1561 lines
63 KiB
Python
1561 lines
63 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""企业微信聊天归档 API。
|
|
|
|
路由独立注册在 ``/api/v2/archive`` 下,不改动现有模型、用户和桌面端同步接口。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
from starlette.responses import FileResponse, RedirectResponse
|
|
|
|
from archive_store import ALL_TENANTS, ArchiveStore
|
|
from zyt_patient_client import (
|
|
ZytPatientClient, ZytPatientError, ZytPatientSessionExpired, ZytPatientPermissionDenied,
|
|
ZytPatientIpRestricted,
|
|
)
|
|
from zyt_order_client import (
|
|
ZytOrderClient, ZytOrderError, ZytOrderSessionExpired,
|
|
ZytOrderPermissionDenied, ZytOrderNotFound, ZytOrderTrackingUnavailable,
|
|
ZytOrderIpRestricted, sanitize_order, sanitize_tracking,
|
|
)
|
|
|
|
|
|
ZYT_PATIENT_LIST_PERMISSION = "tcm.diagnosis/lists"
|
|
# 每个订单动作在 ZYT 侧真正校验的权限。桌面代理按动作要求同样的权限,避免本地
|
|
# 放行之后再被上游拒绝,也避免只有列表权限的账号看到物流轨迹。
|
|
ZYT_ORDER_LIST_PERMISSIONS = frozenset({"firstvisit.mypatient/orders"})
|
|
ZYT_ORDER_DETAIL_PERMISSIONS = frozenset({
|
|
"firstvisit.mypatient/orderdetail",
|
|
"tcm.prescriptionorder/detail",
|
|
})
|
|
ZYT_ORDER_TRACKING_PERMISSIONS = frozenset({"tcm.prescriptionorder/logisticstrace"})
|
|
ZYT_ORDER_PERMISSIONS = (
|
|
ZYT_ORDER_LIST_PERMISSIONS | ZYT_ORDER_DETAIL_PERMISSIONS | ZYT_ORDER_TRACKING_PERMISSIONS
|
|
)
|
|
ORDER_AUDIT_ACTION_PREFIX = "archive.order."
|
|
ORDER_AUDIT_LABELS = {
|
|
"archive.order.list": "查询订单列表",
|
|
"archive.order.detail": "查询订单详情",
|
|
"archive.order.tracking": "查询物流轨迹",
|
|
"archive.order.audit_status": "查询审核状态",
|
|
"archive.order.summary": "刷新订单概览",
|
|
}
|
|
|
|
|
|
class StorageBody(BaseModel):
|
|
bucket: str = ""
|
|
region: str = ""
|
|
custom_domain: str = ""
|
|
media_prefix: str = "archive/media"
|
|
export_prefix: str = "archive/exports"
|
|
release_prefix: str = "desktop/releases"
|
|
encryption_mode: str = "AES256"
|
|
secret_id: str = ""
|
|
secret_key: str = ""
|
|
enabled: bool = False
|
|
|
|
|
|
class MediaPrepareBody(BaseModel):
|
|
sha256: str
|
|
size_bytes: int
|
|
mime_type: str = "application/octet-stream"
|
|
original_filename: str = "file"
|
|
|
|
|
|
class MultipartPartBody(BaseModel):
|
|
part_number: int = Field(ge=1, le=10000)
|
|
etag: str = Field(min_length=1, max_length=512)
|
|
|
|
|
|
class MediaMultipartCompleteBody(BaseModel):
|
|
upload_id: str = Field(min_length=1, max_length=2048)
|
|
parts: list[MultipartPartBody] = Field(min_length=1, max_length=10000)
|
|
|
|
|
|
class ImportBody(BaseModel):
|
|
source_account: dict[str, Any]
|
|
messages: list[dict[str, Any]] = Field(min_length=1, max_length=5000)
|
|
batch_id: str = ""
|
|
source_table: str = "message_table"
|
|
checkpoint: Any = None
|
|
|
|
|
|
class MetadataBody(BaseModel):
|
|
source_account: dict[str, Any]
|
|
people: list[dict[str, Any]] = Field(default_factory=list, max_length=5000)
|
|
conversations: list[dict[str, Any]] = Field(default_factory=list, max_length=5000)
|
|
|
|
|
|
class DesktopCheckpointBody(BaseModel):
|
|
source_account: dict[str, Any]
|
|
source_table: str = "message_table"
|
|
checkpoint: dict[str, Any]
|
|
|
|
|
|
class MediaAccessBody(BaseModel):
|
|
media_ids: list[str] = Field(min_length=1, max_length=200)
|
|
expires: int = Field(default=300, ge=60, le=900)
|
|
|
|
|
|
class ExportBody(BaseModel):
|
|
formats: list[str] = Field(default_factory=lambda: ["sql", "xlsx"])
|
|
filters: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class IdentityBody(BaseModel):
|
|
identity_type: str
|
|
scope_id: str = ""
|
|
external_id: str
|
|
verified: bool = True
|
|
|
|
|
|
class PatientBindingBody(BaseModel):
|
|
patient_id: int = Field(gt=0)
|
|
diagnosis_id: int = Field(default=0, ge=0)
|
|
relation_type: str = Field(default="self", min_length=2, max_length=16)
|
|
|
|
|
|
class DesktopPatientBindingBody(PatientBindingBody):
|
|
external_account_id: str = Field(min_length=1, max_length=191)
|
|
conversation_external_id: str = Field(min_length=1, max_length=512)
|
|
person_id: str = Field(default="", max_length=64)
|
|
|
|
|
|
def _http_error(exc: Exception) -> HTTPException:
|
|
if isinstance(exc, HTTPException):
|
|
return exc
|
|
if isinstance(exc, KeyError):
|
|
return HTTPException(status_code=404, detail=str(exc).strip("'"))
|
|
if isinstance(exc, ValueError):
|
|
return HTTPException(status_code=400, detail=str(exc))
|
|
if isinstance(exc, (ZytPatientIpRestricted, ZytOrderIpRestricted)):
|
|
# 不是会话过期:ZYT 把 token 绑在桌面电脑的出口 IP 上,重新验证只会再绑
|
|
# 回电脑,下一次查询照样被拒。也不能用 401/403——桌面端会把非患者路径
|
|
# 的 401/403 当成退出登录、把患者路径的 403 当成没有权限。
|
|
# 这句话是给客服和管理员看的;订单接口会经订单 MCP 交给模型,要走
|
|
# _order_http_error 换成中性口径。
|
|
subject = "订单" if isinstance(exc, ZytOrderIpRestricted) else "患者"
|
|
return HTTPException(status_code=409, detail={
|
|
"code": "zyt_ip_restricted",
|
|
"message": (
|
|
f"{subject}信息暂时查不到:ZYT 的登录 IP 校验拒绝了客服服务器的查询,"
|
|
"重新验证账号也无效,请联系管理员在 ZYT 中放行客服服务器 IP。"
|
|
),
|
|
})
|
|
if isinstance(exc, ZytPatientSessionExpired):
|
|
return HTTPException(status_code=409, detail={
|
|
"code": "zyt_session_expired",
|
|
"message": "ZYT 患者查询登录已过期,请重新验证账号。",
|
|
})
|
|
if isinstance(exc, ZytPatientPermissionDenied):
|
|
return HTTPException(status_code=403, detail=str(exc))
|
|
if isinstance(exc, ZytOrderSessionExpired):
|
|
return HTTPException(status_code=409, detail={
|
|
"code": "zyt_session_expired",
|
|
"message": "ZYT 订单查询登录已过期,请重新验证账号。",
|
|
})
|
|
if isinstance(exc, ZytOrderPermissionDenied):
|
|
return HTTPException(status_code=403, detail=str(exc))
|
|
if isinstance(exc, ZytOrderNotFound):
|
|
return HTTPException(status_code=404, detail={
|
|
"code": "order_not_found",
|
|
"message": str(exc) or "订单不存在或不属于当前绑定患者",
|
|
})
|
|
if isinstance(exc, ZytOrderTrackingUnavailable):
|
|
# 没有单号或物流服务失败都不是权限问题;用明确的 code 让 MCP 和界面
|
|
# 告诉客服“正在核实”,而不是把它当成通用 502。
|
|
return HTTPException(
|
|
status_code=409 if exc.code == "tracking_number_missing" else 502,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
)
|
|
if isinstance(exc, ZytOrderError):
|
|
return HTTPException(status_code=502, detail=str(exc))
|
|
if isinstance(exc, ZytPatientError):
|
|
return HTTPException(status_code=502, detail=str(exc))
|
|
if isinstance(exc, RuntimeError):
|
|
return HTTPException(status_code=409, detail=str(exc))
|
|
return HTTPException(status_code=502, detail=f"外部存储操作失败:{exc}")
|
|
|
|
|
|
# 订单 MCP 只把 detail.code / detail.message 原样交给模型。“请管理员放行客服
|
|
# 服务器 IP”“请重新验证账号”都是给内部人员的处理办法,模型照着回给患者只会让
|
|
# 人摸不着头脑,所以订单接口的 message 用和其它订单错误一样的口径,原来的说明
|
|
# 挪到 staff_message 给桌面端界面展示。桌面端按 code 判断要不要重新验证,不看文案。
|
|
ORDER_IP_RESTRICTED_MESSAGE = "订单信息暂时查不到,请告知客户正在核实,稍后由人工回复。"
|
|
_STAFF_ONLY_ORDER_CODES = frozenset({"zyt_ip_restricted", "zyt_session_expired"})
|
|
|
|
|
|
def _order_http_error(exc: Exception) -> HTTPException:
|
|
"""订单 MCP 读的订单接口(/desktop/patient-orders 列表、详情、物流、审核状态)用的 `_http_error`。
|
|
|
|
只改 ZYT 会话类的两种(IP 校验、登录过期):绑定校验里患者接口出的同类错误
|
|
也一样会从订单接口返回。患者检索/绑定、订单概览这些只给客服看的接口仍直接用 `_http_error`。
|
|
"""
|
|
error = _http_error(exc)
|
|
detail = error.detail
|
|
if isinstance(detail, dict) and detail.get("code") in _STAFF_ONLY_ORDER_CODES:
|
|
return HTTPException(status_code=error.status_code, detail={
|
|
"code": detail["code"],
|
|
"message": ORDER_IP_RESTRICTED_MESSAGE,
|
|
"staff_message": str(detail.get("message") or ""),
|
|
})
|
|
return error
|
|
|
|
|
|
def register_archive_routes(
|
|
app: FastAPI,
|
|
database: Any,
|
|
current: Callable[..., Any],
|
|
require: Callable[..., Any],
|
|
client_ip: Callable[[Request], str],
|
|
current_desktop: Callable[..., Any],
|
|
# 返回值有两种形状:一个租户,或者一组租户(管理员选「全部」且只被授权到
|
|
# 若干客户端账号时)。两种都由 `archive_store.tenant_scope` 统一翻译。
|
|
resolve_admin_tenant: Callable[[Any, int | None], "str | list[str]"],
|
|
zyt_api_url: Callable[[], str] | None = None,
|
|
zyt_patient_searcher: Callable[[str, str, int, int], dict[str, Any]] | None = None,
|
|
) -> ArchiveStore:
|
|
"""将聊天归档路由附加到现有 FastAPI 应用。"""
|
|
|
|
store = ArchiveStore(database)
|
|
store.initialize()
|
|
app.state.archive_store = store
|
|
|
|
def single_tenant(value: Any) -> str:
|
|
"""写路径要的那**一个**租户。
|
|
|
|
看数据可以跨账号(「全部」),但绑患者、发导出任务不行:这条记录要记在
|
|
谁名下,得有个确切答案。这里把它挡在最前面并说清楚该怎么办——否则会一路
|
|
走到 `safe_scope`,报一句"租户只能包含字母、数字、下划线和连字符",
|
|
对着界面上的下拉框完全看不懂。
|
|
"""
|
|
if isinstance(value, str) and value != ALL_TENANTS:
|
|
return value
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属",
|
|
)
|
|
export_slot = asyncio.Lock()
|
|
|
|
async def run_export(job_id: str) -> None:
|
|
# Queued jobs wait asynchronously, never occupying the API worker pool.
|
|
async with export_slot:
|
|
await asyncio.to_thread(store.run_export_job, job_id)
|
|
|
|
|
|
def _desktop_patient_access(account: Any) -> dict[str, Any]:
|
|
try:
|
|
raw_permissions = account["zyt_permissions_json"]
|
|
except (KeyError, IndexError):
|
|
raw_permissions = "[]"
|
|
try:
|
|
parsed_permissions = json.loads(str(raw_permissions or "[]"))
|
|
except (TypeError, ValueError):
|
|
parsed_permissions = []
|
|
permissions = {
|
|
str(item).strip().lower()
|
|
for item in parsed_permissions
|
|
if str(item).strip()
|
|
} if isinstance(parsed_permissions, list) else set()
|
|
try:
|
|
is_root = bool(int(account["zyt_root"] or 0))
|
|
except (KeyError, IndexError, TypeError, ValueError):
|
|
is_root = False
|
|
try:
|
|
known = bool(int(account["zyt_permissions_known"] or 0))
|
|
except (KeyError, IndexError, TypeError, ValueError):
|
|
known = False
|
|
allowed = is_root or "*" in permissions or ZYT_PATIENT_LIST_PERMISSION in permissions
|
|
if allowed:
|
|
reason = ""
|
|
elif known:
|
|
reason = "当前登录的 ZYT 账号没有患者列表权限"
|
|
else:
|
|
reason = "暂未取得当前 ZYT 账号的权限信息,请退出软件后重新登录"
|
|
return {
|
|
"can_search": allowed,
|
|
"can_bind": allowed,
|
|
"can_unbind": allowed,
|
|
"is_root": is_root,
|
|
"known": known,
|
|
"required_permission": ZYT_PATIENT_LIST_PERMISSION,
|
|
"reason": reason,
|
|
}
|
|
|
|
def _require_desktop_patient_access(account: Any) -> dict[str, Any]:
|
|
access = _desktop_patient_access(account)
|
|
if not access["can_search"]:
|
|
raise HTTPException(status_code=403, detail=str(access["reason"]))
|
|
return access
|
|
|
|
def _context_with_access(
|
|
context: dict[str, Any], access: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
result = dict(context)
|
|
result["permissions"] = access
|
|
return result
|
|
|
|
async def _search_patients_for_tenant(
|
|
tenant: str, keyword: str, page_no: int, page_size: int
|
|
) -> dict[str, Any]:
|
|
token = await asyncio.to_thread(store.zyt_token, tenant)
|
|
if zyt_patient_searcher is not None:
|
|
result = await asyncio.to_thread(
|
|
zyt_patient_searcher,
|
|
token,
|
|
str(keyword or ""),
|
|
int(page_no),
|
|
int(page_size),
|
|
)
|
|
else:
|
|
if zyt_api_url is None:
|
|
raise RuntimeError("ZYT 患者接口地址未配置")
|
|
client = ZytPatientClient(zyt_api_url(), token, tenant=tenant)
|
|
result = await asyncio.to_thread(
|
|
client.search_patients,
|
|
str(keyword or ""),
|
|
page_no=int(page_no),
|
|
page_size=int(page_size),
|
|
)
|
|
if not isinstance(result, dict):
|
|
raise ZytPatientError("ZYT 患者查询结果格式不正确")
|
|
items = await asyncio.to_thread(store.cache_patients, list(result.get("items") or []), tenant)
|
|
return {
|
|
"items": items,
|
|
"total": int(result.get("total") or len(items)),
|
|
"page_no": int(result.get("page_no") or page_no),
|
|
"page_size": int(result.get("page_size") or page_size),
|
|
}
|
|
|
|
async def _patient_visible_to_tenant(
|
|
tenant: str, patient_id: int
|
|
) -> dict[str, Any] | None:
|
|
"""绑定前重新确认该患者仍在当前 ZYT 账号的数据范围内。"""
|
|
|
|
token = await asyncio.to_thread(store.zyt_token, tenant)
|
|
if zyt_patient_searcher is not None:
|
|
result = await asyncio.to_thread(
|
|
zyt_patient_searcher, token, str(int(patient_id)), 1, 50
|
|
)
|
|
items = list(result.get("items") or []) if isinstance(result, dict) else []
|
|
return next(
|
|
(
|
|
dict(item)
|
|
for item in items
|
|
if isinstance(item, dict)
|
|
and int(item.get("patient_id") or 0) == int(patient_id)
|
|
),
|
|
None,
|
|
)
|
|
if zyt_api_url is None:
|
|
raise RuntimeError("ZYT 患者接口地址未配置")
|
|
client = ZytPatientClient(zyt_api_url(), token, tenant=tenant)
|
|
return await asyncio.to_thread(client.get_patient, int(patient_id))
|
|
|
|
async def _diagnosis_visible_to_patient(
|
|
tenant: str, patient_id: int, diagnosis_id: int
|
|
) -> dict[str, Any] | None:
|
|
"""Verify the selected diagnosis belongs to the selected patient.
|
|
|
|
``archive_patient_shadow`` stores only the latest diagnosis, while a
|
|
patient can have several historical diagnoses. Read raw rows with an
|
|
exact patient filter through the authenticated ZYT list endpoint; the
|
|
normal keyword search only matches name/phone and deduplicates patients.
|
|
"""
|
|
try:
|
|
patient_id = int(patient_id)
|
|
diagnosis_id = int(diagnosis_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if patient_id <= 0 or diagnosis_id <= 0:
|
|
return None
|
|
token = await asyncio.to_thread(store.zyt_token, tenant)
|
|
if zyt_patient_searcher is not None:
|
|
result = await asyncio.to_thread(
|
|
zyt_patient_searcher, token, str(diagnosis_id), 1, 50
|
|
)
|
|
else:
|
|
if zyt_api_url is None:
|
|
raise RuntimeError("ZYT 患者接口地址未配置")
|
|
client = ZytPatientClient(zyt_api_url(), token, tenant=tenant)
|
|
return await asyncio.to_thread(
|
|
client.get_patient_diagnosis, patient_id, diagnosis_id
|
|
)
|
|
if not isinstance(result, dict):
|
|
raise ZytPatientError("ZYT 患者查询结果格式不正确")
|
|
for item in list(result.get("items") or []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
try:
|
|
item_patient_id = int(item.get("patient_id") or 0)
|
|
item_diagnosis_id = int(item.get("diagnosis_id") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if item_patient_id == patient_id and item_diagnosis_id == diagnosis_id:
|
|
return dict(item)
|
|
return None
|
|
|
|
def _desktop_order_access(
|
|
account: Any, required: frozenset[str] = ZYT_ORDER_LIST_PERMISSIONS
|
|
) -> dict[str, Any]:
|
|
"""订单查询权限只允许只读业务权限,不能由模型自行提升。
|
|
|
|
``required`` 是该动作在 ZYT 侧会检查的全部权限;缺任何一个都在本地拒绝。
|
|
"""
|
|
try:
|
|
raw_permissions = account["zyt_permissions_json"]
|
|
except (KeyError, IndexError):
|
|
raw_permissions = "[]"
|
|
try:
|
|
parsed_permissions = json.loads(str(raw_permissions or "[]"))
|
|
except (TypeError, ValueError):
|
|
parsed_permissions = []
|
|
permissions = {
|
|
str(item).strip().lower()
|
|
for item in parsed_permissions
|
|
if str(item).strip()
|
|
} if isinstance(parsed_permissions, list) else set()
|
|
try:
|
|
is_root = bool(int(account["zyt_root"] or 0))
|
|
except (KeyError, IndexError, TypeError, ValueError):
|
|
is_root = False
|
|
try:
|
|
known = bool(int(account["zyt_permissions_known"] or 0))
|
|
except (KeyError, IndexError, TypeError, ValueError):
|
|
known = False
|
|
full_access = is_root or "*" in permissions
|
|
allowed = full_access or required <= permissions
|
|
reason = "" if allowed else (
|
|
"当前登录的 ZYT 账号没有患者订单查看权限"
|
|
if known else "暂未取得当前 ZYT 账号的权限信息,请退出软件后重新登录"
|
|
)
|
|
return {
|
|
"can_query": allowed,
|
|
"can_list": full_access or ZYT_ORDER_LIST_PERMISSIONS <= permissions,
|
|
"can_detail": full_access or ZYT_ORDER_DETAIL_PERMISSIONS <= permissions,
|
|
"can_track": full_access or ZYT_ORDER_TRACKING_PERMISSIONS <= permissions,
|
|
"is_root": is_root,
|
|
"known": known,
|
|
"required_permissions": sorted(required),
|
|
"reason": reason,
|
|
}
|
|
|
|
def _require_desktop_order_access(
|
|
account: Any, required: frozenset[str] = ZYT_ORDER_LIST_PERMISSIONS
|
|
) -> dict[str, Any]:
|
|
access = _desktop_order_access(account, required)
|
|
if not access["can_query"]:
|
|
raise HTTPException(status_code=403, detail=str(access["reason"]))
|
|
return access
|
|
|
|
async def _bound_patient_for_order(
|
|
account: Any, external_account_id: str, conversation_external_id: str, person_id: str = ""
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""Resolve the current conversation and return its active primary binding."""
|
|
context = await asyncio.to_thread(
|
|
store.conversation_patient_context,
|
|
str(external_account_id or "").strip(),
|
|
str(conversation_external_id or "").strip(),
|
|
str(account["tenant_id"]),
|
|
person_id=str(person_id or "").strip(),
|
|
)
|
|
person = context.get("person") if isinstance(context, dict) else None
|
|
bindings = list((person or {}).get("patient_bindings") or []) if isinstance(person, dict) else []
|
|
if not bindings and isinstance(context, dict):
|
|
bindings = list(context.get("patient_bindings") or [])
|
|
if not isinstance(person, dict) or not person.get("id"):
|
|
if isinstance(context, dict) and context.get("requires_person_selection"):
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "person_selection_required",
|
|
"message": "当前会话有多个联系人,请先在桌面端选择联系人并绑定患者",
|
|
})
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "patient_binding_required",
|
|
"message": str((context or {}).get("reason") or "当前会话没有可绑定的联系人"),
|
|
})
|
|
if not bindings:
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "patient_binding_required",
|
|
"message": "当前会话尚未绑定患者,请先绑定患者",
|
|
})
|
|
binding = dict(bindings[0])
|
|
try:
|
|
patient_id = int(binding.get("patient_id") or 0)
|
|
except (TypeError, ValueError):
|
|
patient_id = 0
|
|
if patient_id <= 0:
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "patient_binding_invalid",
|
|
"message": "当前患者绑定已失效,请重新绑定",
|
|
})
|
|
# Older bindings may not have persisted diagnosis_id. Resolve it through
|
|
# the authenticated ZYT patient list before any order query, otherwise a
|
|
# broad order list could expose another patient's data.
|
|
try:
|
|
diagnosis_id = int(binding.get("diagnosis_id") or 0)
|
|
except (TypeError, ValueError):
|
|
diagnosis_id = 0
|
|
if diagnosis_id <= 0:
|
|
visible = await _patient_visible_to_tenant(str(account["tenant_id"]), patient_id)
|
|
try:
|
|
diagnosis_id = int((visible or {}).get("diagnosis_id") or 0)
|
|
except (TypeError, ValueError):
|
|
diagnosis_id = 0
|
|
if diagnosis_id <= 0:
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "patient_binding_invalid",
|
|
"message": "当前患者绑定缺少有效诊单,请重新绑定",
|
|
})
|
|
binding["diagnosis_id"] = diagnosis_id
|
|
# Do not trust a diagnosis ID persisted by an older client or supplied
|
|
# by a caller. It must still resolve to this patient in the current
|
|
# authenticated ZYT data scope before any order request is made.
|
|
verified = await _diagnosis_visible_to_patient(
|
|
str(account["tenant_id"]), patient_id, diagnosis_id
|
|
)
|
|
if verified is None:
|
|
raise HTTPException(status_code=409, detail={
|
|
"code": "patient_binding_invalid",
|
|
"message": "当前患者绑定的诊单不属于该患者,请重新绑定",
|
|
})
|
|
return context, binding
|
|
|
|
def _order_client(tenant: str) -> ZytOrderClient:
|
|
if zyt_api_url is None:
|
|
raise RuntimeError("ZYT 订单接口地址未配置")
|
|
token = store.zyt_token(str(tenant))
|
|
return ZytOrderClient(zyt_api_url(), token, tenant=str(tenant))
|
|
|
|
@staticmethod
|
|
def _query_time() -> str:
|
|
return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
def _desktop_order_diagnosis_id(row: dict[str, Any]) -> int:
|
|
try:
|
|
return int(row.get("diagnosis_id") or row.get("diagnosisId") or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
async def _order_for_binding(
|
|
client: ZytOrderClient, binding: dict[str, Any], order_id: int
|
|
) -> dict[str, Any]:
|
|
order = await asyncio.to_thread(client.get_order_detail, int(order_id))
|
|
diagnosis_id = _desktop_order_diagnosis_id(order)
|
|
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
|
|
if diagnosis_id <= 0 or (bound_diagnosis > 0 and diagnosis_id != bound_diagnosis):
|
|
raise ZytOrderNotFound("订单不存在或不属于当前绑定患者")
|
|
return order
|
|
|
|
def _audit_order_query(
|
|
account: Any, request: Request, action: str, binding: dict[str, Any],
|
|
order_id: int = 0, *, conversation_external_id: str = "", outcome: str = "ok",
|
|
) -> None:
|
|
try:
|
|
database.audit(
|
|
int(account["id"]),
|
|
action,
|
|
f"patient={int(binding.get('patient_id') or 0)} order={int(order_id or 0)} "
|
|
f"outcome={str(outcome or 'ok')[:40]} conv={str(conversation_external_id or '')[:200]}",
|
|
client_ip(request),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _order_query_logs(account: Any, conversation_external_id: str, limit: int = 8) -> list[dict[str, Any]]:
|
|
"""当前会话最近的订单查询留痕,给桌面端展示“MCP 查询日志”。"""
|
|
conversation = str(conversation_external_id or "").strip()
|
|
if not conversation:
|
|
return []
|
|
try:
|
|
rows = database.audit_entries_for_user(
|
|
int(account["id"]), ORDER_AUDIT_ACTION_PREFIX,
|
|
detail_contains=f"conv={conversation}", limit=limit,
|
|
)
|
|
except Exception:
|
|
return []
|
|
logs = []
|
|
for row in rows:
|
|
detail = str(row.get("detail") or "")
|
|
order_match = re.search(r"\border=(\d+)", detail)
|
|
outcome_match = re.search(r"\boutcome=(\S+)", detail)
|
|
action = str(row.get("action") or "")
|
|
logs.append({
|
|
"time": str(row.get("created_at") or ""),
|
|
"action": action,
|
|
"label": ORDER_AUDIT_LABELS.get(action, action),
|
|
"order_id": int(order_match.group(1)) if order_match else 0,
|
|
"outcome": outcome_match.group(1) if outcome_match else "ok",
|
|
})
|
|
return logs
|
|
|
|
async def _tracking_for_order(
|
|
client: ZytOrderClient, order: dict[str, Any], order_id: int, queried_at: str
|
|
) -> tuple[dict[str, Any] | None, dict[str, str]]:
|
|
"""Return (latest_tracking, error). Never invents a status when upstream fails."""
|
|
phone = str(order.get("recipient_phone") or order.get("patient_phone") or "")
|
|
try:
|
|
tracking = await asyncio.to_thread(
|
|
client.get_tracking, int(order_id), phone_tail=re.sub(r"\D", "", phone)[-4:]
|
|
)
|
|
except ZytOrderTrackingUnavailable as exc:
|
|
return None, {"code": exc.code, "message": str(exc)}
|
|
except ZytOrderIpRestricted:
|
|
# 整个 ZYT 查询被 IP 校验挡住,不是物流问题;交给 _order_http_error 统一说明。
|
|
raise
|
|
except ZytOrderError as exc:
|
|
return None, {"code": "tracking_unavailable", "message": str(exc)}
|
|
return sanitize_tracking(tracking, queried_at=queried_at), {}
|
|
|
|
def _order_list_hint(items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""把“单笔直接查、多笔让客户选”的规则做成数据,不靠模型自觉。"""
|
|
count = len(items)
|
|
if count == 0:
|
|
return {
|
|
"order_count": 0, "selection_required": False, "single_order_id": 0,
|
|
"hint": "该患者名下没有查到订单记录:如实告诉客户系统里暂时没有查到,说明会人工核实后回复;不要向客户索要订单号。",
|
|
}
|
|
if count == 1:
|
|
return {
|
|
"order_count": 1, "selection_required": False,
|
|
"single_order_id": int(items[0].get("order_id") or 0),
|
|
"hint": "只有一笔订单,可以直接告知该订单的状态和物流。",
|
|
}
|
|
return {
|
|
"order_count": count, "selection_required": True, "single_order_id": 0,
|
|
"hint": "查到多笔订单,只能向客户说明各笔的下单时间和状态,让客户确认是哪一笔后再查物流。",
|
|
}
|
|
|
|
@app.get("/api/v2/archive/stats")
|
|
def archive_stats(
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:read")),
|
|
) -> dict[str, Any]:
|
|
return store.stats(resolve_admin_tenant(principal, account_id))
|
|
|
|
@app.get("/api/v2/archive/conversations")
|
|
def archive_conversations(
|
|
limit: int = 50,
|
|
cursor: str = "",
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:content:read")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return store.conversations(
|
|
limit, cursor, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/conversations/{conversation_id}/messages")
|
|
def archive_messages(
|
|
conversation_id: str,
|
|
limit: int = 100,
|
|
cursor: str = "",
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:content:read")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return store.messages(
|
|
conversation_id,
|
|
limit,
|
|
cursor,
|
|
resolve_admin_tenant(principal, account_id),
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/imports/messages")
|
|
def archive_import_messages(
|
|
body: ImportBody,
|
|
request: Request,
|
|
principal: Any = Depends(require("im:import")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return store.import_messages(
|
|
body.model_dump(), principal.id, client_ip(request)
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/imports/metadata")
|
|
def archive_import_metadata(
|
|
body: MetadataBody,
|
|
request: Request,
|
|
principal: Any = Depends(require("im:import")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return store.sync_metadata(
|
|
body.model_dump(), principal.id, client_ip(request)
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/checkpoint")
|
|
def desktop_archive_checkpoint(
|
|
external_account_id: str,
|
|
source_table: str = "message_table",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
if not str(external_account_id or "").strip():
|
|
raise HTTPException(status_code=400, detail="账号标识不能为空")
|
|
return {
|
|
"checkpoint": store.source_checkpoint(
|
|
external_account_id, source_table, str(account["tenant_id"])
|
|
),
|
|
"capabilities": {"message_origin_updates": True}
|
|
}
|
|
|
|
@app.post("/api/v2/archive/desktop/checkpoint")
|
|
def desktop_archive_advance_checkpoint(
|
|
body: DesktopCheckpointBody,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"checkpoint": store.advance_source_checkpoint(
|
|
body.source_account,
|
|
body.source_table,
|
|
body.checkpoint,
|
|
str(account["tenant_id"]),
|
|
)
|
|
}
|
|
|
|
@app.get("/api/v2/archive/desktop/pending-attachments")
|
|
def desktop_pending_attachments(
|
|
external_account_id: str,
|
|
limit: int = 500,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
if not str(external_account_id or "").strip():
|
|
raise HTTPException(status_code=400, detail="账号标识不能为空")
|
|
return {
|
|
"source_message_ids": store.claim_pending_attachment_source_ids(
|
|
external_account_id, limit, str(account["tenant_id"])
|
|
)
|
|
}
|
|
|
|
@app.post("/api/v2/archive/desktop/imports/messages")
|
|
def desktop_archive_import_messages(
|
|
body: ImportBody,
|
|
request: Request,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
values = body.model_dump()
|
|
values["tenant_id"] = str(account["tenant_id"])
|
|
return store.import_messages(values, None, client_ip(request))
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/desktop/imports/metadata")
|
|
def desktop_archive_import_metadata(
|
|
body: MetadataBody,
|
|
request: Request,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
values = body.model_dump()
|
|
values["tenant_id"] = str(account["tenant_id"])
|
|
return store.sync_metadata(values, None, client_ip(request))
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/people")
|
|
def archive_people(
|
|
limit: int = 100,
|
|
keyword: str = "",
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:identity:read")),
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"items": store.people(
|
|
limit, keyword, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
}
|
|
|
|
@app.get("/api/v2/archive/people/{person_id}")
|
|
def archive_person(
|
|
person_id: str,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:identity:read")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"person": store.person_detail(
|
|
person_id, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/people/{person_id}/identities")
|
|
def bind_archive_identity(
|
|
person_id: str,
|
|
body: IdentityBody,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:identity:write")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"person": store.bind_identity(
|
|
person_id,
|
|
body.model_dump(),
|
|
principal.id,
|
|
client_ip(request),
|
|
single_tenant(resolve_admin_tenant(principal, account_id)),
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/patients/search")
|
|
async def search_zyt_patients(
|
|
keyword: str,
|
|
page_no: int = 1,
|
|
page_size: int = 20,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:patient:bind")),
|
|
) -> dict[str, Any]:
|
|
tenant = single_tenant(
|
|
await asyncio.to_thread(resolve_admin_tenant, principal, account_id)
|
|
)
|
|
try:
|
|
return await _search_patients_for_tenant(
|
|
tenant, keyword, page_no, page_size
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-context")
|
|
def desktop_patient_context(
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
access = _desktop_patient_access(account)
|
|
if not access["can_search"]:
|
|
return {
|
|
"available": False,
|
|
"reason": str(access["reason"]),
|
|
"people": [],
|
|
"patient_bindings": [],
|
|
"permissions": access,
|
|
}
|
|
try:
|
|
context = store.conversation_patient_context(
|
|
external_account_id,
|
|
conversation_external_id,
|
|
str(account["tenant_id"]),
|
|
person_id=person_id,
|
|
)
|
|
return _context_with_access(context, access)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-orders")
|
|
async def desktop_patient_orders(
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
keyword: str = "",
|
|
page_no: int = 1,
|
|
page_size: int = 20,
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
access = _require_desktop_order_access(account, ZYT_ORDER_LIST_PERMISSIONS)
|
|
try:
|
|
context, binding = await _bound_patient_for_order(
|
|
account, external_account_id, conversation_external_id, person_id
|
|
)
|
|
page_no = max(1, int(page_no))
|
|
page_size = max(1, min(int(page_size), 50))
|
|
keyword = str(keyword or "").strip()[:80]
|
|
client = _order_client(str(account["tenant_id"]))
|
|
result = await asyncio.to_thread(
|
|
client.list_orders,
|
|
diagnosis_id=int(binding.get("diagnosis_id") or 0),
|
|
keyword=keyword, page_no=page_no, page_size=page_size,
|
|
start_date=str(start_date or "").strip(), end_date=str(end_date or "").strip(),
|
|
)
|
|
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
|
|
rows = [
|
|
row for row in list(result.get("items") or [])
|
|
if isinstance(row, dict)
|
|
and (bound_diagnosis <= 0 or _desktop_order_diagnosis_id(row) == bound_diagnosis)
|
|
]
|
|
queried_at = _query_time()
|
|
items = [sanitize_order(row, queried_at=queried_at) for row in rows]
|
|
hint = _order_list_hint(items)
|
|
latest_tracking = None
|
|
tracking_error: dict[str, str] = {}
|
|
# 只有一笔订单时直接把物流带回去,模型不需要再猜要不要查、查哪一笔。
|
|
if hint["single_order_id"] and access["can_track"]:
|
|
only = items[0]
|
|
if only.get("has_tracking_number"):
|
|
latest_tracking, tracking_error = await _tracking_for_order(
|
|
client, rows[0], hint["single_order_id"], queried_at
|
|
)
|
|
else:
|
|
tracking_error = {
|
|
"code": "tracking_number_missing",
|
|
"message": "该订单还没有快递单号,请告知客户正在核实发货情况",
|
|
}
|
|
_audit_order_query(
|
|
account, request, "archive.order.list", binding,
|
|
conversation_external_id=conversation_external_id,
|
|
outcome=f"count={len(items)}",
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"patient": {
|
|
"patient_id": int(binding.get("patient_id") or 0),
|
|
"diagnosis_id": bound_diagnosis,
|
|
},
|
|
"items": items,
|
|
# Never expose the upstream unfiltered count.
|
|
"total": len(items),
|
|
"page_no": page_no,
|
|
"page_size": page_size,
|
|
"queried_at": queried_at,
|
|
"binding_id": str(binding.get("id") or ""),
|
|
"context_available": bool(context.get("available")),
|
|
**hint,
|
|
"latest_tracking": latest_tracking,
|
|
"tracking_error": tracking_error,
|
|
}
|
|
except Exception as exc:
|
|
raise _order_http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-orders/summary")
|
|
async def desktop_patient_order_summary(
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
"""桌面端会话面板用的概览:能力状态、最近订单、最新物流和查询留痕。
|
|
|
|
与其它订单接口不同,这里对“未绑定 / 无权限”不抛错,而是把原因返回给界面
|
|
展示;真正的订单数据仍然只在绑定有效且有权限时才会查询。
|
|
"""
|
|
access = _desktop_order_access(account, ZYT_ORDER_LIST_PERMISSIONS)
|
|
base: dict[str, Any] = {
|
|
"ok": True,
|
|
"capability": {
|
|
"enabled": False,
|
|
"bound": False,
|
|
"reason": str(access["reason"] or ""),
|
|
"can_track": bool(access["can_track"]),
|
|
"can_detail": bool(access["can_detail"]),
|
|
},
|
|
"recent_orders": [],
|
|
"latest_tracking": None,
|
|
"tracking_error": {},
|
|
"logs": _order_query_logs(account, conversation_external_id),
|
|
"queried_at": _query_time(),
|
|
}
|
|
if not access["can_query"]:
|
|
return base
|
|
try:
|
|
_context, binding = await _bound_patient_for_order(
|
|
account, external_account_id, conversation_external_id, person_id
|
|
)
|
|
except HTTPException as exc:
|
|
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
|
|
base["capability"]["reason"] = str(detail.get("message") or "当前会话尚未绑定患者")
|
|
base["capability"]["code"] = str(detail.get("code") or "")
|
|
return base
|
|
except Exception as exc:
|
|
# 概览只给客服面板用(订单 MCP 不调这个接口),保留给客服看的完整说明。
|
|
raise _http_error(exc) from exc
|
|
base["capability"].update({"enabled": True, "bound": True, "reason": ""})
|
|
try:
|
|
client = _order_client(str(account["tenant_id"]))
|
|
result = await asyncio.to_thread(
|
|
client.list_orders,
|
|
diagnosis_id=int(binding.get("diagnosis_id") or 0), page_no=1, page_size=5,
|
|
)
|
|
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
|
|
rows = [
|
|
row for row in list(result.get("items") or [])
|
|
if isinstance(row, dict)
|
|
and (bound_diagnosis <= 0 or _desktop_order_diagnosis_id(row) == bound_diagnosis)
|
|
][:3]
|
|
queried_at = _query_time()
|
|
items = [sanitize_order(row, queried_at=queried_at) for row in rows]
|
|
base["recent_orders"] = items
|
|
base["queried_at"] = queried_at
|
|
shipped = next(
|
|
(index for index, item in enumerate(items) if item.get("has_tracking_number")),
|
|
None,
|
|
)
|
|
if shipped is not None and access["can_track"]:
|
|
base["latest_tracking"], base["tracking_error"] = await _tracking_for_order(
|
|
client, rows[shipped], int(items[shipped]["order_id"]), queried_at
|
|
)
|
|
if base["latest_tracking"] is not None:
|
|
base["latest_tracking"]["order_id"] = int(items[shipped]["order_id"])
|
|
_audit_order_query(
|
|
account, request, "archive.order.summary", binding,
|
|
conversation_external_id=conversation_external_id,
|
|
outcome=f"count={len(items)}",
|
|
)
|
|
base["logs"] = _order_query_logs(account, conversation_external_id)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
return base
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}")
|
|
async def desktop_patient_order_detail(
|
|
order_id: int,
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
_require_desktop_order_access(account, ZYT_ORDER_DETAIL_PERMISSIONS)
|
|
try:
|
|
_context, binding = await _bound_patient_for_order(
|
|
account, external_account_id, conversation_external_id, person_id
|
|
)
|
|
client = _order_client(str(account["tenant_id"]))
|
|
try:
|
|
order = await _order_for_binding(client, binding, order_id)
|
|
except ZytOrderNotFound:
|
|
_audit_order_query(
|
|
account, request, "archive.order.detail", binding, order_id,
|
|
conversation_external_id=conversation_external_id, outcome="rejected",
|
|
)
|
|
raise
|
|
queried_at = _query_time()
|
|
_audit_order_query(
|
|
account, request, "archive.order.detail", binding, order_id,
|
|
conversation_external_id=conversation_external_id,
|
|
)
|
|
return {"ok": True, "order": sanitize_order(order, queried_at=queried_at)}
|
|
except Exception as exc:
|
|
raise _order_http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}/tracking")
|
|
async def desktop_patient_order_tracking(
|
|
order_id: int,
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
_require_desktop_order_access(
|
|
account, ZYT_ORDER_DETAIL_PERMISSIONS | ZYT_ORDER_TRACKING_PERMISSIONS
|
|
)
|
|
try:
|
|
_context, binding = await _bound_patient_for_order(
|
|
account, external_account_id, conversation_external_id, person_id
|
|
)
|
|
client = _order_client(str(account["tenant_id"]))
|
|
order = await _order_for_binding(client, binding, order_id)
|
|
queried_at = _query_time()
|
|
safe = sanitize_order(order, queried_at=queried_at)
|
|
if not safe.get("has_tracking_number"):
|
|
_audit_order_query(
|
|
account, request, "archive.order.tracking", binding, order_id,
|
|
conversation_external_id=conversation_external_id,
|
|
outcome="tracking_number_missing",
|
|
)
|
|
raise ZytOrderTrackingUnavailable(
|
|
"该订单还没有快递单号,请告知客户正在核实发货情况",
|
|
code="tracking_number_missing",
|
|
)
|
|
latest_tracking, tracking_error = await _tracking_for_order(
|
|
client, order, order_id, queried_at
|
|
)
|
|
if latest_tracking is None:
|
|
_audit_order_query(
|
|
account, request, "archive.order.tracking", binding, order_id,
|
|
conversation_external_id=conversation_external_id,
|
|
outcome=str(tracking_error.get("code") or "tracking_unavailable"),
|
|
)
|
|
raise ZytOrderTrackingUnavailable(
|
|
str(tracking_error.get("message") or "物流服务暂时查不到轨迹"),
|
|
code=str(tracking_error.get("code") or "tracking_unavailable"),
|
|
)
|
|
_audit_order_query(
|
|
account, request, "archive.order.tracking", binding, order_id,
|
|
conversation_external_id=conversation_external_id,
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"order_id": int(order_id),
|
|
"order_no": safe.get("order_no", ""),
|
|
"express_company": safe.get("express_company", ""),
|
|
"tracking_number_masked": safe.get("tracking_number_masked", ""),
|
|
"fulfillment_status": safe.get("fulfillment_status", ""),
|
|
"latest_tracking": latest_tracking,
|
|
"queried_at": queried_at,
|
|
}
|
|
except Exception as exc:
|
|
raise _order_http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}/audit-status")
|
|
async def desktop_patient_order_audit_status(
|
|
order_id: int,
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
_require_desktop_order_access(account, ZYT_ORDER_DETAIL_PERMISSIONS)
|
|
try:
|
|
_context, binding = await _bound_patient_for_order(
|
|
account, external_account_id, conversation_external_id, person_id
|
|
)
|
|
client = _order_client(str(account["tenant_id"]))
|
|
order = await _order_for_binding(client, binding, order_id)
|
|
queried_at = _query_time()
|
|
safe = sanitize_order(order, queried_at=queried_at)
|
|
_audit_order_query(
|
|
account, request, "archive.order.audit_status", binding, order_id,
|
|
conversation_external_id=conversation_external_id,
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"order_id": int(order_id),
|
|
"order_no": safe.get("order_no", ""),
|
|
"prescription_audit": safe["prescription_audit"],
|
|
"payment_audit": safe["payment_audit"],
|
|
"fulfillment_status": safe["fulfillment_status"],
|
|
"queried_at": queried_at,
|
|
}
|
|
except Exception as exc:
|
|
raise _order_http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/desktop/patients/search")
|
|
async def desktop_search_zyt_patients(
|
|
keyword: str,
|
|
page_no: int = 1,
|
|
page_size: int = 20,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
_require_desktop_patient_access(account)
|
|
try:
|
|
return await _search_patients_for_tenant(
|
|
str(account["tenant_id"]), keyword, page_no, page_size
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.put("/api/v2/archive/desktop/patient-binding")
|
|
async def desktop_bind_archive_patient(
|
|
body: DesktopPatientBindingBody,
|
|
request: Request,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
tenant = str(account["tenant_id"])
|
|
access = _require_desktop_patient_access(account)
|
|
try:
|
|
visible_patient = await _patient_visible_to_tenant(tenant, body.patient_id)
|
|
if visible_patient is None:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="当前 ZYT 账号无权访问该患者,不能建立绑定",
|
|
)
|
|
if body.diagnosis_id:
|
|
verified_diagnosis = await _diagnosis_visible_to_patient(
|
|
tenant, body.patient_id, body.diagnosis_id
|
|
)
|
|
if verified_diagnosis is None:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="所选诊单不属于该患者,不能建立绑定",
|
|
)
|
|
context = await asyncio.to_thread(store.conversation_patient_context,
|
|
body.external_account_id,
|
|
body.conversation_external_id,
|
|
tenant,
|
|
person_id=body.person_id,
|
|
)
|
|
person = context.get("person")
|
|
if not isinstance(person, dict) or not person.get("id"):
|
|
if context.get("requires_person_selection"):
|
|
raise ValueError("群聊中有多个联系人,请先选择需要绑定的联系人")
|
|
raise ValueError(str(context.get("reason") or "当前会话没有可绑定的联系人"))
|
|
binding = await asyncio.to_thread(store.bind_patient,
|
|
str(person["id"]),
|
|
body.model_dump(),
|
|
None,
|
|
client_ip(request),
|
|
tenant,
|
|
)
|
|
return {
|
|
"binding": binding,
|
|
"context": _context_with_access(
|
|
await asyncio.to_thread(store.conversation_patient_context,
|
|
body.external_account_id,
|
|
body.conversation_external_id,
|
|
tenant,
|
|
person_id=str(person["id"]),
|
|
),
|
|
access,
|
|
),
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.delete("/api/v2/archive/desktop/patient-binding")
|
|
def desktop_unbind_archive_patient(
|
|
request: Request,
|
|
external_account_id: str,
|
|
conversation_external_id: str,
|
|
binding_id: str,
|
|
person_id: str = "",
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
tenant = str(account["tenant_id"])
|
|
access = _require_desktop_patient_access(account)
|
|
try:
|
|
context = store.conversation_patient_context(
|
|
external_account_id,
|
|
conversation_external_id,
|
|
tenant,
|
|
person_id=person_id,
|
|
)
|
|
person = context.get("person")
|
|
if not isinstance(person, dict) or not person.get("id"):
|
|
raise ValueError("当前会话没有选中可解除绑定的联系人")
|
|
store.unbind_patient(
|
|
str(person["id"]), binding_id, None, client_ip(request), tenant
|
|
)
|
|
return {
|
|
"context": _context_with_access(
|
|
store.conversation_patient_context(
|
|
external_account_id,
|
|
conversation_external_id,
|
|
tenant,
|
|
person_id=str(person["id"]),
|
|
),
|
|
access,
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.put("/api/v2/archive/people/{person_id}/patient-bindings")
|
|
def bind_archive_patient(
|
|
person_id: str,
|
|
body: PatientBindingBody,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:patient:bind")),
|
|
) -> dict[str, Any]:
|
|
tenant = single_tenant(resolve_admin_tenant(principal, account_id))
|
|
try:
|
|
binding = store.bind_patient(
|
|
person_id,
|
|
body.model_dump(),
|
|
principal.id,
|
|
client_ip(request),
|
|
tenant,
|
|
)
|
|
return {
|
|
"binding": binding,
|
|
"person": store.person_detail(person_id, tenant),
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.delete(
|
|
"/api/v2/archive/people/{person_id}/patient-bindings/{binding_id}"
|
|
)
|
|
def unbind_archive_patient(
|
|
person_id: str,
|
|
binding_id: str,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:patient:unbind")),
|
|
) -> dict[str, Any]:
|
|
tenant = single_tenant(resolve_admin_tenant(principal, account_id))
|
|
try:
|
|
store.unbind_patient(
|
|
person_id,
|
|
binding_id,
|
|
principal.id,
|
|
client_ip(request),
|
|
tenant,
|
|
)
|
|
return {"person": store.person_detail(person_id, tenant)}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/storage")
|
|
def archive_storage(
|
|
_: Any = Depends(require("im:storage:read")),
|
|
) -> dict[str, Any]:
|
|
return {"storage": store.storage_config()}
|
|
|
|
@app.put("/api/v2/archive/storage")
|
|
def save_archive_storage(
|
|
body: StorageBody,
|
|
request: Request,
|
|
principal: Any = Depends(require("im:storage:write")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"storage": store.save_storage_config(
|
|
body.model_dump(), principal.id, client_ip(request)
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/storage/test")
|
|
def test_archive_storage(
|
|
request: Request,
|
|
principal: Any = Depends(require("im:storage:test")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
result = store.test_storage()
|
|
database.audit(
|
|
principal.id,
|
|
"archive.storage.test",
|
|
f"ok={int(bool(result.get('ok')))} bucket={result.get('bucket', '')}",
|
|
client_ip(request),
|
|
)
|
|
return {"result": result}
|
|
except Exception as exc:
|
|
database.audit(
|
|
principal.id,
|
|
"archive.storage.test",
|
|
f"ok=0 error={str(exc)[:500]}",
|
|
client_ip(request),
|
|
)
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/media")
|
|
def archive_media(
|
|
limit: int = 100,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:content:read")),
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"items": store.media_items(
|
|
limit, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
}
|
|
|
|
@app.post("/api/v2/archive/media/access-urls")
|
|
def archive_media_access_urls(
|
|
body: MediaAccessBody,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:content:read")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
items = store.media_download_urls(
|
|
body.media_ids,
|
|
body.expires,
|
|
resolve_admin_tenant(principal, account_id),
|
|
)
|
|
database.audit(
|
|
principal.id,
|
|
"archive.media.access",
|
|
f"count={len(items)}",
|
|
client_ip(request),
|
|
)
|
|
return {"items": items}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/media/prepare")
|
|
def prepare_archive_media(
|
|
body: MediaPrepareBody,
|
|
_: Any = Depends(require("im:import")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return store.prepare_media(body.model_dump())
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/desktop/media/prepare")
|
|
def desktop_prepare_archive_media(
|
|
body: MediaPrepareBody,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
values = body.model_dump()
|
|
values["tenant_id"] = str(account["tenant_id"])
|
|
return store.prepare_media(values)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/media/{media_id}/complete")
|
|
def complete_archive_media(
|
|
media_id: str,
|
|
_: Any = Depends(require("im:import")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {"media": store.complete_media(media_id)}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/media/{media_id}/multipart-complete")
|
|
def complete_archive_multipart_media(
|
|
media_id: str,
|
|
body: MediaMultipartCompleteBody,
|
|
_: Any = Depends(require("im:import")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"media": store.complete_multipart_media(
|
|
media_id,
|
|
body.upload_id,
|
|
[part.model_dump() for part in body.parts],
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/desktop/media/{media_id}/complete")
|
|
def desktop_complete_archive_media(
|
|
media_id: str,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"media": store.complete_media(media_id, str(account["tenant_id"]))
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/desktop/media/{media_id}/multipart-complete")
|
|
def desktop_complete_archive_multipart_media(
|
|
media_id: str,
|
|
body: MediaMultipartCompleteBody,
|
|
account: Any = Depends(current_desktop),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"media": store.complete_multipart_media(
|
|
media_id,
|
|
body.upload_id,
|
|
[part.model_dump() for part in body.parts],
|
|
str(account["tenant_id"]),
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/media/{media_id}/view")
|
|
def view_archive_media(
|
|
media_id: str,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:content:read")),
|
|
) -> RedirectResponse:
|
|
try:
|
|
url = store.media_download_url(
|
|
media_id, tenant_id=resolve_admin_tenant(principal, account_id)
|
|
)
|
|
database.audit(
|
|
principal.id, "archive.media.view", f"media={media_id}", client_ip(request)
|
|
)
|
|
return RedirectResponse(url=url, status_code=307)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.post("/api/v2/archive/exports")
|
|
def create_archive_export(
|
|
body: ExportBody,
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:export:create")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
job = store.create_export_job(
|
|
body.formats,
|
|
body.filters,
|
|
principal.id,
|
|
client_ip(request),
|
|
single_tenant(resolve_admin_tenant(principal, account_id)),
|
|
)
|
|
background.add_task(run_export, job["id"])
|
|
return {"job": job}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/exports")
|
|
def archive_exports(
|
|
limit: int = 100,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:export")),
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"jobs": store.export_jobs(
|
|
limit, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
}
|
|
|
|
@app.get("/api/v2/archive/exports/{job_id}")
|
|
def archive_export(
|
|
job_id: str,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:export")),
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return {
|
|
"job": store.export_job(
|
|
job_id, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
}
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
@app.get("/api/v2/archive/export-files/{file_id}/download")
|
|
def download_archive_export(
|
|
file_id: str,
|
|
request: Request,
|
|
account_id: int | None = None,
|
|
principal: Any = Depends(require("im:export:download")),
|
|
):
|
|
try:
|
|
item = store.export_file(
|
|
file_id, resolve_admin_tenant(principal, account_id)
|
|
)
|
|
database.audit(
|
|
principal.id,
|
|
"archive.export.download",
|
|
f"file={file_id} job={item['job_id']}",
|
|
client_ip(request),
|
|
)
|
|
if item["storage_status"] == "cos":
|
|
return RedirectResponse(
|
|
url=store.export_download_url(file_id), status_code=307
|
|
)
|
|
path = Path(item["local_path"]).resolve()
|
|
root = store.export_root.resolve()
|
|
if not path.is_relative_to(root) or not path.is_file():
|
|
raise KeyError("导出文件已不在本机")
|
|
return FileResponse(
|
|
path, filename=item["file_name"], media_type="application/octet-stream"
|
|
)
|
|
except Exception as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
return store
|