This commit is contained in:
Your Name
2026-08-11 09:12:51 +08:00
parent c3ceb0dd0f
commit cfe4c82c90
111 changed files with 26110 additions and 826 deletions
@@ -3,6 +3,8 @@
from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping
from contextlib import suppress
from datetime import date
@@ -102,7 +104,7 @@ class DoctorRepository(Protocol):
def upload_material(
self,
path: str | PathLike[str],
material_type: Literal["image", "file", "tongue_images", "report_files"],
material_type: Literal["image", "video", "file", "tongue_images", "report_files"],
cid: int = 0,
) -> str:
"""Upload one local note material and return its server URI."""
@@ -224,6 +226,9 @@ class DoctorRepository(Protocol):
def cancel_patient_appointment(self, appointment_id: int) -> Any:
"""Cancel an appointment from the patient workspace."""
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
"""Cancel a diagnosis-list appointment through the doctor route."""
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Return an editable or permission-aware readonly diagnosis detail."""
@@ -453,6 +458,39 @@ class DoctorRepository(Protocol):
def add_tracking_note(self, diagnosis_id: int, content: str) -> Any:
"""Append a diagnosis tracking note."""
def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one doctor-entered blood glucose / pressure record."""
def update_blood_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one blood glucose / pressure record."""
def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one daily diet record."""
def update_diet_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one daily diet record."""
def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create one daily exercise record."""
def update_exercise_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update one daily exercise record."""
def check_diagnosis_phone(self, payload: Mapping[str, Any]) -> Any:
"""Check diagnosis phone uniqueness."""
@@ -479,6 +517,72 @@ class DoctorRepository(Protocol):
def cancel_diagnosis_todo(self, todo_id: int) -> Any:
"""Cancel a diagnosis follow-up todo."""
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return persisted audio/video call records and replay URLs."""
def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]:
"""Create the server record used to hold a manually uploaded replay."""
def attach_local_call_recording(
self,
diagnosis_id: int,
file_url: str,
*,
call_record_id: int | None = None,
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Return diagnosis IM messages, defaulting to the fast archive-only path."""
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the server-side IM archive synchronisation job."""
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return per-diagnosis registration / cancellation audit rows."""
def generate_mini_program_qrcode(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Generate the diagnosis or video-login mini-program QR code."""
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the video-login QR variant."""
def generate_diagnosis_qrcode(
self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the diagnosis-confirmation QR variant."""
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Compatibility name for diagnosis registration logs."""
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
"""Create the diagnosis-list generic payment order (not a prescription order)."""
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Generate the payment mini-program QR code for a generic order."""
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
"""Return short-lived call credentials."""
@@ -796,7 +900,7 @@ class RemoteDoctorRepository:
def upload_material(
self,
path: str | PathLike[str],
material_type: Literal["image", "file", "tongue_images", "report_files"],
material_type: Literal["image", "video", "file", "tongue_images", "report_files"],
cid: int = 0,
) -> str:
"""Upload a local note material and return only its server reference."""
@@ -1437,6 +1541,13 @@ class RemoteDoctorRepository:
return self.client.post("firstvisit.myPatient/cancelAppointment", {"id": appointment_id})
def cancel_diagnosis_appointment(self, appointment_id: int) -> Any:
"""Cancel through the exact route used by the admin diagnosis list."""
if appointment_id <= 0:
raise ValueError("appointment_id must be positive")
return self.client.post("doctor.appointment/cancel", {"id": appointment_id})
def patient_detail(self, diagnosis_id: int) -> dict[str, Any]:
"""Compatibility name for permission-aware readonly diagnosis details."""
@@ -1516,6 +1627,11 @@ class RemoteDoctorRepository:
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any:
"""Set the diagnosis revisit-statistics starting offset."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if offset not in range(0, 21):
raise ValueError("revisit_slot_start_offset must be between 0 and 20")
return self.client.post(
"tcm.diagnosis/setRevisitSlotStartOffset",
{"id": diagnosis_id, "revisit_slot_start_offset": offset},
@@ -1623,13 +1739,66 @@ class RemoteDoctorRepository:
def add_tracking_note(self, diagnosis_id: int, content: str) -> Any:
"""Append a textual daily tracking note."""
if not content.strip():
raise ValueError("tracking_content is required")
clean_content = content.strip()
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if not clean_content or len(clean_content) > 1000:
raise ValueError("tracking_content must contain 1 to 1000 characters")
return self.client.post(
"tcm.diagnosis/addTrackingNote",
{"diagnosis_id": diagnosis_id, "tracking_content": content.strip()},
{"diagnosis_id": diagnosis_id, "tracking_content": clean_content},
)
def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a blood record using the fields consumed by ``DailyMatrix``."""
return self.client.post("tcm.bloodRecord/add", _daily_record_body("blood", payload, fields))
def update_blood_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update a blood record without weakening add-time validation."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.bloodRecord/edit", _daily_record_body("blood", body, {}))
def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a breakfast/lunch/dinner daily record."""
return self.client.post("tcm.dietRecord/add", _daily_record_body("diet", payload, fields))
def update_diet_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update a diet record."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.dietRecord/edit", _daily_record_body("diet", body, {}))
def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any:
"""Create a validated exercise record."""
return self.client.post(
"tcm.exerciseRecord/add", _daily_record_body("exercise", payload, fields)
)
def update_exercise_record(
self,
record: int | Mapping[str, Any],
changes: Mapping[str, Any] | None = None,
**fields: Any,
) -> Any:
"""Update an exercise record."""
body = _identified_body(record, changes, fields)
return self.client.post("tcm.exerciseRecord/edit", _daily_record_body("exercise", body, {}))
def list_diagnosis_todos(
self,
diagnosis_id: int,
@@ -1660,11 +1829,19 @@ class RemoteDoctorRepository:
def add_diagnosis_todo(self, diagnosis_id: int, content: str, remind_time: int) -> Any:
"""Create a follow-up todo using a Unix-seconds reminder."""
clean_content = content.strip()
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if not clean_content or len(clean_content) > 500:
raise ValueError("todo content must contain 1 to 500 characters")
if remind_time <= int(time.time()) + 30:
raise ValueError("remind_time must be at least 30 seconds in the future")
return self.client.post(
"tcm.diagnosisTodo/add",
{
"diagnosis_id": diagnosis_id,
"content": content.strip(),
"content": clean_content,
"remind_time": remind_time,
},
)
@@ -1672,8 +1849,204 @@ class RemoteDoctorRepository:
def cancel_diagnosis_todo(self, todo_id: int) -> Any:
"""Cancel an outstanding diagnosis todo."""
if todo_id <= 0:
raise ValueError("todo_id must be positive")
return self.client.post("tcm.diagnosisTodo/cancel", {"id": todo_id})
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Load the confirmed replay list endpoint."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return _mapping_rows(
self.client.get("tcm.diagnosis/getCallRecords", {"diagnosis_id": diagnosis_id})
)
def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]:
"""Create a synthetic call record before a toolbar video upload."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
result = self.client.post(
"tcm.diagnosis/createManualCallRecord", {"diagnosis_id": diagnosis_id}
)
return dict(_require_mapping(result, "tcm.diagnosis/createManualCallRecord"))
def attach_local_call_recording(
self,
diagnosis_id: int,
file_url: str,
*,
call_record_id: int | None = None,
) -> Any:
"""Attach an uploaded replay URI to the selected call record."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_url = file_url.strip()
if not clean_url:
raise ValueError("file_url is required")
body: dict[str, Any] = {"diagnosis_id": diagnosis_id, "file_url": clean_url}
if call_record_id is not None:
if call_record_id <= 0:
raise ValueError("call_record_id must be positive")
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Load archive-only messages unless the caller explicitly requests live merging."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
payload = self.client.get(
"tcm.diagnosis/getImChatMessages",
{"diagnosis_id": diagnosis_id, "only_archived": int(only_archived)},
)
return dict(_require_mapping(payload, "tcm.diagnosis/getImChatMessages"))
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the confirmed shutdown-function archive sync endpoint."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return self.client.post("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": diagnosis_id})
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return registration/cancellation logs from ``guahaoLogList``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return _mapping_rows(self.client.get("tcm.diagnosis/guahaoLogList", {"id": diagnosis_id}))
def generate_mini_program_qrcode(
self, payload: Mapping[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
"""Generate either QR variant using the server's shared DTO."""
body = _body(payload, fields)
for key in ("patient_id", "share_user_id"):
if int(body.get(key) or 0) <= 0:
raise ValueError(f"{key} must be positive")
page = str(body.get("mini_program_path") or "").strip()
if page == "pages/login/login":
if int(body.get("doctor_id") or 0) <= 0:
raise ValueError("doctor_id must be positive for a video QR code")
# The current server validator mistakenly inspects diagnosis_id for this case;
# mirror the admin DTO until that server contract is fixed.
body.setdefault("diagnosis_id", body["doctor_id"])
elif int(body.get("diagnosis_id") or 0) <= 0:
raise ValueError("diagnosis_id must be positive")
payload_result = self.client.post("tcm.diagnosis/generateMiniProgramQrcode", body)
result = dict(_require_mapping(payload_result, "tcm.diagnosis/generateMiniProgramQrcode"))
if not str(result.get("qrcode_url") or "").strip():
raise ApiProtocolError(
"tcm.diagnosis/generateMiniProgramQrcode returned no qrcode_url",
data=result,
)
return result
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the video QR with the admin's confirmed login-page DTO."""
return self.generate_mini_program_qrcode(
diagnosis_id=doctor_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
mini_program_path="pages/login/login",
)
def generate_diagnosis_qrcode(
self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, Any]:
"""Generate the diagnosis-confirmation QR."""
return self.generate_mini_program_qrcode(
diagnosis_id=diagnosis_id,
doctor_id=doctor_id,
patient_id=patient_id,
share_user_id=share_user_id,
)
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Compatibility name used by the diagnosis-list menu."""
return self.list_diagnosis_appointment_logs(diagnosis_id)
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
"""Create the generic order used by the diagnosis-list action."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
if order_type not in range(1, 9):
raise ValueError("order_type must be between 1 and 8")
if amount <= 0:
raise ValueError("amount must be positive")
payload = self.client.post(
"order.order/create",
{
"patient_id": patient_id,
"order_type": order_type,
"amount": round(float(amount), 2),
"remark": remark.strip(),
},
)
return dict(_require_mapping(payload, "order.order/create"))
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Generate the QR shown immediately after a generic order is created."""
clean_order_no = order_no.strip()
if not clean_order_no:
raise ValueError("order_no is required")
payload = self.client.post(
"tcm.diagnosis/generateOrderQrcode", {"order_no": clean_order_no}
)
result = dict(_require_mapping(payload, "tcm.diagnosis/generateOrderQrcode"))
if not str(result.get("qrcode_url") or "").strip():
raise ApiProtocolError(
"tcm.diagnosis/generateOrderQrcode returned no qrcode_url", data=result
)
return result
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket:
"""Obtain short-lived Tencent credentials for a consultation call."""
@@ -1767,15 +2140,17 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
def _material_kind(
material_type: str,
) -> Literal["image", "file"]:
"""Map reception concepts to the two audited upload endpoints."""
) -> Literal["image", "video", "file"]:
"""Map workstation concepts to the three audited upload endpoints."""
value = material_type.strip().lower()
if value in {"image", "tongue_images"}:
return "image"
if value == "video":
return "video"
if value in {"file", "report_files"}:
return "file"
raise ValueError("material_type must be image/file or tongue_images/report_files")
raise ValueError("material_type must be image/video/file or tongue_images/report_files")
def _is_local_material_reference(value: str) -> bool:
@@ -1910,6 +2285,111 @@ def _body(
return result
_RECORD_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_RECORD_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
def _daily_record_body(
kind: Literal["blood", "diet", "exercise"],
payload: Mapping[str, Any] | None,
fields: Mapping[str, Any],
) -> dict[str, Any]:
"""Validate the exact DTOs used by the admin ``DailyMatrix`` dialogs."""
body = _body(payload, fields)
for key in ("diagnosis_id", "patient_id"):
value = _to_int(body.get(key), 0)
if value <= 0:
raise ValueError(f"{key} must be positive")
body[key] = value
record_date = str(body.get("record_date") or "").strip()
if not _RECORD_DATE_PATTERN.fullmatch(record_date):
raise ValueError("record_date must use YYYY-MM-DD")
parsed_date = date.fromisoformat(record_date)
if parsed_date > date.today():
raise ValueError("record_date cannot be in the future")
body["record_date"] = record_date
if kind == "blood":
record_time = str(body.get("record_time") or "").strip()
if record_time and not _RECORD_TIME_PATTERN.fullmatch(record_time):
raise ValueError("record_time must use HH:MM")
body["record_time"] = record_time
limits = {
"fasting_blood_sugar": 50.0,
"postprandial_blood_sugar": 50.0,
"other_blood_sugar": 50.0,
"systolic_pressure": 300.0,
"diastolic_pressure": 200.0,
}
has_content = False
for key, maximum in limits.items():
raw = body.get(key)
if raw in (None, ""):
continue
try:
number = float(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be numeric") from exc
if number < 0 or number > maximum:
raise ValueError(f"{key} must be between 0 and {maximum:g}")
body[key] = number
has_content = has_content or number > 0
for key in ("western_medicine", "insulin", "remark"):
clean = str(body.get(key) or "").strip()
body[key] = clean
has_content = has_content or bool(clean)
if not has_content:
raise ValueError("a blood record must contain at least one measurement or note")
return body
if kind == "diet":
has_content = False
for meal in ("breakfast", "lunch", "dinner"):
foods_key = f"{meal}_foods"
foods = str(body.get(foods_key) or body.get(meal) or "").strip()
body[foods_key] = foods
has_content = has_content or bool(foods)
images_key = f"{meal}_images"
raw_images = body.get(images_key) or []
if not isinstance(raw_images, (list, tuple)):
raise ValueError(f"{images_key} must be an array")
if len(raw_images) > 3:
raise ValueError(f"{images_key} cannot contain more than 3 images")
body[images_key] = _server_materials(raw_images, images_key)
has_content = has_content or bool(body[images_key])
body["note"] = str(body.get("note") or "").strip()
has_content = has_content or bool(body["note"])
if not has_content:
raise ValueError("a diet record must contain food, an image or a note")
return body
exercise_type = str(body.get("exercise_type") or "").strip()
if not exercise_type:
raise ValueError("exercise_type is required")
duration = _to_int(body.get("duration"), 0)
if duration < 1 or duration > 300:
raise ValueError("duration must be between 1 and 300 minutes")
intensity = _to_int(body.get("intensity"), 0)
if intensity not in {1, 2, 3}:
raise ValueError("intensity must be 1, 2 or 3")
raw_images = body.get("images") or []
if not isinstance(raw_images, (list, tuple)):
raise ValueError("images must be an array")
if len(raw_images) > 3:
raise ValueError("images cannot contain more than 3 items")
body.update(
{
"exercise_type": exercise_type,
"duration": duration,
"intensity": intensity,
"images": _server_materials(raw_images, "images"),
"note": str(body.get("note") or "").strip(),
}
)
return body
def _identified_body(
value: int | Mapping[str, Any],
changes: Mapping[str, Any] | None,