5129 lines
216 KiB
Python
5129 lines
216 KiB
Python
"""Patient diagnosis detail/editor used by the scoped patient workspace."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sqlite3
|
||
from collections.abc import Mapping, Sequence
|
||
from contextlib import suppress
|
||
from typing import Any
|
||
|
||
from PySide6.QtCore import QDate, QDateTime, QEvent, QPoint, QRect, Qt, QTimer, QUrl, Signal
|
||
from PySide6.QtGui import QDesktopServices
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QBoxLayout,
|
||
QDialog,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QInputDialog,
|
||
QLabel,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSpinBox,
|
||
QStackedLayout,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||
from ..diagnosis_drawer import (
|
||
DIAGNOSIS_QSS,
|
||
CaseGrid,
|
||
ChatPanel,
|
||
DailyRecordPanel,
|
||
DiagnosisChoiceButtons,
|
||
DiagnosisComboBox,
|
||
DiagnosisDateEdit,
|
||
DiagnosisLineEdit,
|
||
DiagnosisNumberEdit,
|
||
DiagnosisSwitch,
|
||
DiagnosisTabWidget,
|
||
DiagnosisTextEdit,
|
||
ExpandableDiagnosisTextEdit,
|
||
LoadingOverlay,
|
||
MessageStrip,
|
||
NotesTimeline,
|
||
RecordTable,
|
||
SaveStateButton,
|
||
readonly_card,
|
||
repolish,
|
||
section_heading,
|
||
set_status_tag,
|
||
set_tag_item,
|
||
)
|
||
from ..diagnosis_editors import DailyRecordEditorDialog
|
||
from ..diagnosis_media import (
|
||
ImagePreviewDialog,
|
||
RecordingPlaybackCell,
|
||
RecordingPlayerDialog,
|
||
safe_image_sources,
|
||
)
|
||
from ..infinite_list import InfiniteList
|
||
from ..theme import main_window_target_rect
|
||
from ..widgets import (
|
||
display_text,
|
||
first_value,
|
||
friendly_error,
|
||
get_value,
|
||
has_permission,
|
||
invoke,
|
||
page_items,
|
||
page_total,
|
||
run_async,
|
||
)
|
||
from .issued_prescription_ai import can_open_issued_ai, present_issued_prescription_ai
|
||
from .local_audio_queue import LocalAudioQueueDialog
|
||
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
|
||
|
||
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
|
||
_PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders"
|
||
_ORDER_DETAIL_PERMISSION = "tcm.prescriptionOrder/detail"
|
||
_ORDER_LOGS_PERMISSION = "tcm.prescriptionOrder/logs"
|
||
_DAILY_PERMISSION = "tcm.diagnosis/dailyRecord"
|
||
_PRESCRIPTION_PERMISSION = "tcm.diagnosis/chufang"
|
||
_PRESCRIPTION_EDIT_PERMISSION = "cf.prescription/edit"
|
||
_OFFSET_PERMISSION = "tcm.diagnosis/setRevisitSlotStartOffset"
|
||
_VIDEO_PERMISSION = "tcm.diagnosis/huifang"
|
||
_CHAT_PERMISSION = "tcm.diagnosis/chat"
|
||
_NOTE_ADD_PERMISSION = "doctor.appointment/addDoctorNote"
|
||
_NOTE_DELETE_PERMISSION = "doctor.appointment/deleteDoctorNoteImage"
|
||
_PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$")
|
||
_ID_CARD_PATTERN = re.compile(
|
||
r"^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$"
|
||
)
|
||
|
||
_DIAGNOSIS_FIELDS = (
|
||
("患者姓名", "patient_name", "患者姓名"),
|
||
("手机号", "phone", "11 位手机号"),
|
||
("身份证号", "id_card", "18 位身份证号(可选)"),
|
||
("性别", "gender", "1=男,0=女"),
|
||
("年龄", "age", "患者年龄"),
|
||
("诊断日期", "diagnosis_date", "YYYY-MM-DD"),
|
||
("诊断类型", "diagnosis_type", "初诊/复诊类型"),
|
||
("证型", "syndrome_type", "中医证型"),
|
||
("糖尿病类型", "diabetes_type", "糖尿病分型"),
|
||
("糖尿病发现年", "diabetes_discovery_year", "发现年份或病程描述"),
|
||
("当地医院诊断", "local_hospital_diagnosis", "多项以顿号分隔"),
|
||
("当地医院", "local_hospital_name", "当地就诊医院"),
|
||
("婚姻状态", "marital_status", "婚姻状态"),
|
||
("身高", "height", "cm"),
|
||
("体重", "weight", "kg"),
|
||
("地区", "region", "所在地区"),
|
||
("收缩压", "systolic_pressure", "mmHg"),
|
||
("舒张压", "diastolic_pressure", "mmHg"),
|
||
("空腹血糖", "fasting_blood_sugar", "mmol/L"),
|
||
("主诉", "chief_complaint", "患者此次就诊的主要诉求"),
|
||
("现病史", "present_illness", "主要症状、持续时间与变化"),
|
||
("症状", "symptoms", "当前主要症状"),
|
||
("既往史", "past_history", "既往史;多项可用顿号分隔"),
|
||
("外伤史", "trauma_history", "0/1 或具体说明"),
|
||
("手术史", "surgery_history", "0/1 或具体说明"),
|
||
("过敏史", "allergy_history", "0/1 或具体说明"),
|
||
("家族史", "family_history", "0/1 或具体说明"),
|
||
("妊娠史", "pregnancy_history", "0/1 或具体说明"),
|
||
("食欲", "appetite", "多项以顿号分隔"),
|
||
("饮水", "water_intake", "饮水情况"),
|
||
("饮食", "diet_condition", "多项以顿号分隔"),
|
||
("体重变化", "weight_change", "体重变化"),
|
||
("身体感觉", "body_feeling", "多项以顿号分隔"),
|
||
("睡眠", "sleep_condition", "多项以顿号分隔"),
|
||
("眼部", "eye_condition", "多项以顿号分隔"),
|
||
("头部感觉", "head_feeling", "多项以顿号分隔"),
|
||
("出汗", "sweat_condition", "多项以顿号分隔"),
|
||
("皮肤", "skin_condition", "多项以顿号分隔"),
|
||
("小便", "urine_condition", "多项以顿号分隔"),
|
||
("大便", "stool_condition", "多项以顿号分隔"),
|
||
("肾脏情况", "kidney_condition", "多项以顿号分隔"),
|
||
("脂肪肝程度", "fatty_liver_degree", "脂肪肝程度"),
|
||
("舌象", "tongue", "舌质、舌苔等观察"),
|
||
("舌苔", "tongue_coating", "舌苔记录"),
|
||
("脉象", "pulse", "脉象记录"),
|
||
("临床诊断", "clinical_diagnosis", "填写临床诊断"),
|
||
("治则", "treatment_principle", "治疗原则"),
|
||
("处方", "prescription", "处方摘要"),
|
||
("处方意见", "prescription_opinion", "辨证与处方意见"),
|
||
("医嘱", "doctor_advice", "医生嘱托"),
|
||
("当前用药", "current_medications", "患者当前用药"),
|
||
("补充备注", "remark", "其他需要记录的信息"),
|
||
("状态", "status", "启用或禁用"),
|
||
("渠道来源", "create_source", "诊单创建来源"),
|
||
("统计端就诊卡", "show_card", "是否在统计端显示"),
|
||
)
|
||
|
||
_LIST_FIELDS = {
|
||
"local_hospital_diagnosis",
|
||
"appetite",
|
||
"diet_condition",
|
||
"body_feeling",
|
||
"sleep_condition",
|
||
"eye_condition",
|
||
"head_feeling",
|
||
"sweat_condition",
|
||
"skin_condition",
|
||
"urine_condition",
|
||
"stool_condition",
|
||
"kidney_condition",
|
||
"past_history",
|
||
}
|
||
_INTEGER_FIELDS = {
|
||
"gender",
|
||
"age",
|
||
"marital_status",
|
||
"systolic_pressure",
|
||
"diastolic_pressure",
|
||
"trauma_history",
|
||
"surgery_history",
|
||
"allergy_history",
|
||
"family_history",
|
||
"pregnancy_history",
|
||
"status",
|
||
"show_card",
|
||
}
|
||
# fasting_blood_sugar stays free text (supports ranges like "6-9").
|
||
_FLOAT_FIELDS = {"height", "weight"}
|
||
_PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"}
|
||
|
||
_ORDER_OFFSET_HELP = (
|
||
"在实单诊次序号上叠加偏移量。设为 0(默认):第 1 笔实单计为一诊;"
|
||
"设为 1:第 1 笔实单计为二诊;设为 2:第 1 笔实单计为三诊——"
|
||
"若有 5 笔实单且偏移 2,则统计上相当于计至七诊(5+2)。"
|
||
)
|
||
|
||
_ORDER_DETAIL_QSS = """
|
||
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
||
QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; }
|
||
QFrame#DiagnosisOrderDetailDrawer {
|
||
background: #F7F7F7;
|
||
border: 0;
|
||
border-left: 1px solid #F0F0F0;
|
||
}
|
||
QFrame#DiagnosisOrderDetailHeader {
|
||
background: #FFFFFF;
|
||
border: 0;
|
||
border-bottom: 1px solid #F0F0F0;
|
||
}
|
||
QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
|
||
QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
|
||
QLabel#DiagnosisOrderReadonlyBadge {
|
||
color: #1A1C1F;
|
||
background: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 4px;
|
||
padding: 3px 7px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
|
||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
|
||
QFrame[orderAmountCard="true"] {
|
||
background: #FFFFFF;
|
||
border: 1px solid #F0F0F0;
|
||
border-radius: 9px;
|
||
}
|
||
QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; }
|
||
QLabel[orderAmountValue="true"] {
|
||
color: #1A1C1F;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
QLabel[orderAmountTone="danger"] { color: #BE4B58; }
|
||
QLabel[orderAmountTone="success"] { color: #287B65; }
|
||
QLabel[orderAmountTone="warning"] { color: #A9691D; }
|
||
QFrame[orderDetailSection="true"] {
|
||
background: #FFFFFF;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 10px;
|
||
}
|
||
QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
|
||
QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
|
||
QFrame[orderField="true"] {
|
||
background: #F7F7F7;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 7px;
|
||
}
|
||
QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
|
||
QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
|
||
QLabel[orderEmptyState="true"] {
|
||
color: #606163;
|
||
background: #F7F7F7;
|
||
border: 1px dashed #E4E4E5;
|
||
border-radius: 5px;
|
||
padding: 18px 12px;
|
||
font-size: 12px;
|
||
}
|
||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
|
||
QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
|
||
QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
|
||
QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
|
||
QFrame#DiagnosisOrderDetailFooter {
|
||
background: #FFFFFF;
|
||
border: 0;
|
||
border-top: 1px solid #F0F0F0;
|
||
}
|
||
"""
|
||
|
||
_FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ...]], ...] = (
|
||
(
|
||
"基本信息",
|
||
(
|
||
(("诊单ID", "__diagnosis_id", 24, "line"),),
|
||
(
|
||
("姓名", "patient_name", 12, "line"),
|
||
("身份证号", "id_card", 12, "line"),
|
||
),
|
||
(("手机号", "phone", 24, "line"),),
|
||
(
|
||
("性别", "gender", 12, "gender"),
|
||
("年龄", "age", 12, "age_number"),
|
||
),
|
||
),
|
||
),
|
||
(
|
||
"生命体征",
|
||
(
|
||
(
|
||
("婚姻", "marital_status", 12, "marital"),
|
||
("身高", "height", 12, "height_number"),
|
||
),
|
||
(
|
||
("体重", "weight", 12, "weight_number"),
|
||
("地区", "region", 12, "line"),
|
||
),
|
||
(
|
||
("高压", "systolic_pressure", 12, "systolic_number"),
|
||
("低压", "diastolic_pressure", 12, "diastolic_number"),
|
||
),
|
||
(
|
||
("空腹血糖", "fasting_blood_sugar", 12, "line"),
|
||
("诊断类型", "diagnosis_type", 12, "diagnosis_type"),
|
||
),
|
||
(
|
||
("状态", "status", 12, "status"),
|
||
("渠道", "create_source", 12, "create_source"),
|
||
),
|
||
(("统计端就诊卡", "show_card", 12, "show_card"),),
|
||
(("在用药物", "current_medications", 24, "expandable_textarea3"),),
|
||
),
|
||
),
|
||
(
|
||
"主诉",
|
||
(
|
||
(
|
||
("当地诊断日期", "diagnosis_date", 12, "date"),
|
||
("糖尿病病史", "diabetes_discovery_year", 12, "line"),
|
||
),
|
||
(("当地医院诊断结果", "local_hospital_diagnosis", 24, "local_diagnosis"),),
|
||
(("当地医院名称", "local_hospital_name", 24, "line"),),
|
||
),
|
||
),
|
||
(
|
||
"现病史",
|
||
(
|
||
(("口腔感觉", "appetite", 24, "appetite_choices"),),
|
||
(("每日饮水量", "water_intake", 24, "water_intake_choices"),),
|
||
(("近一个月体重变化", "weight_change", 24, "weight_change_choices"),),
|
||
(("脂肪肝程度", "fatty_liver_degree", 24, "fatty_liver_degree_choices"),),
|
||
(("饮食情况", "diet_condition", 24, "diet_condition_choices"),),
|
||
(("肢体感觉", "body_feeling", 24, "body_feeling_choices"),),
|
||
(("睡眠情况", "sleep_condition", 24, "sleep_condition_choices"),),
|
||
(("眼睛情况", "eye_condition", 24, "eye_condition_choices"),),
|
||
(("头部感觉", "head_feeling", 24, "head_feeling_choices"),),
|
||
(("出汗情况", "sweat_condition", 24, "sweat_condition_choices"),),
|
||
(("皮肤情况", "skin_condition", 24, "skin_condition_choices"),),
|
||
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
|
||
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
|
||
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
|
||
(("其他补充", "symptoms", 24, "expandable_textarea3"),),
|
||
),
|
||
),
|
||
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
|
||
(
|
||
"其他病史",
|
||
(
|
||
(
|
||
("外伤史", "trauma_history", 12, "boolean"),
|
||
("手术史", "surgery_history", 12, "boolean"),
|
||
),
|
||
(
|
||
("过敏史", "allergy_history", 12, "boolean"),
|
||
("家族史", "family_history", 12, "boolean"),
|
||
),
|
||
(("妊娠史", "pregnancy_history", 12, "boolean"),),
|
||
),
|
||
),
|
||
("诊断信息", ((("病史补充", "remark", 24, "textarea2"),),)),
|
||
)
|
||
|
||
_CHOICE_DICTIONARIES: dict[str, tuple[str, bool]] = {
|
||
"appetite": ("appetite", True),
|
||
"water_intake": ("water_intake", False),
|
||
"diet_condition": ("diet_condition", True),
|
||
"weight_change": ("weight_change", False),
|
||
"body_feeling": ("body_feeling", True),
|
||
"sleep_condition": ("sleep_condition", True),
|
||
"eye_condition": ("eye_condition", True),
|
||
"head_feeling": ("head_feeling", True),
|
||
"sweat_condition": ("sweat_condition", True),
|
||
"skin_condition": ("skin_condition", True),
|
||
"urine_condition": ("urine_condition", True),
|
||
"stool_condition": ("stool_condition", True),
|
||
"kidney_condition": ("kidney_condition", True),
|
||
"fatty_liver_degree": ("fatty_liver_degree", False),
|
||
"past_history": ("past_history", True),
|
||
}
|
||
|
||
_TAB_DEFINITIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||
("basic", "病历", ()),
|
||
("notes", "医生备注", ()),
|
||
("daily", "日常记录", ("tcm.diagnosis/dailyRecord",)),
|
||
("prescription", "处方", ("tcm.diagnosis/chufang",)),
|
||
("orders", "业务订单", (_PATIENT_ORDERS_PERMISSION,)),
|
||
("video", "视频录制回放", ("tcm.diagnosis/huifang",)),
|
||
("chat", "聊天", ("tcm.diagnosis/chat",)),
|
||
(
|
||
"assign",
|
||
"指派医助记录",
|
||
("tcm.diagnosis/assign", "tcm.diagnosis/detail"),
|
||
),
|
||
("appointment", "挂号记录", ("doctor.appointment/lists",)),
|
||
)
|
||
|
||
|
||
def _mask_phone(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
return f"{text[:3]}****{text[-4:]}" if len(text) == 11 else ("***" if text else "")
|
||
|
||
|
||
def _mask_id_card(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
if len(text) == 18:
|
||
return f"{text[:6]}********{text[-4:]}"
|
||
if len(text) == 15:
|
||
return f"{text[:6]}*****{text[-4:]}"
|
||
return "***" if text else ""
|
||
|
||
|
||
def _truthy(value: Any) -> bool:
|
||
if isinstance(value, str):
|
||
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
||
return bool(value)
|
||
|
||
|
||
def _editor_text(value: Any) -> str:
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
return "、".join(str(item) for item in value)
|
||
return "" if value is None else str(value)
|
||
|
||
|
||
def _diagnosis_gender_text(value: Any, default: str = "—") -> str:
|
||
"""Render the diagnosis API's documented 1/0 male/female contract."""
|
||
|
||
normalized = str(value).strip().lower() if value not in (None, "") else ""
|
||
if normalized in {"1", "m", "male", "男"}:
|
||
return "男"
|
||
if normalized in {"0", "2", "f", "female", "女"}:
|
||
return "女"
|
||
return display_text(value, default)
|
||
|
||
|
||
def _duplicate_found(value: Any) -> bool:
|
||
for key in ("exists", "duplicate", "data.exists", "data.duplicate"):
|
||
candidate = get_value(value, key, None)
|
||
if candidate is not None:
|
||
if isinstance(candidate, str):
|
||
return candidate.strip().lower() not in {"", "0", "false", "no", "off"}
|
||
return bool(candidate)
|
||
return False
|
||
|
||
|
||
def _field_value(key: str, text: str, original: Any) -> Any:
|
||
value = text.strip()
|
||
if isinstance(original, Sequence) and not isinstance(original, (str, bytes, bytearray)):
|
||
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
|
||
if isinstance(original, bool):
|
||
return value.lower() not in {"", "0", "false", "no", "off"}
|
||
if isinstance(original, int) or (original in (None, "") and key in _INTEGER_FIELDS):
|
||
return _int(value, 0) if value else ""
|
||
if isinstance(original, float) or (original in (None, "") and key in _FLOAT_FIELDS):
|
||
try:
|
||
return float(value) if value else ""
|
||
except ValueError:
|
||
return value
|
||
if key in _LIST_FIELDS:
|
||
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
|
||
return value
|
||
|
||
|
||
def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any:
|
||
"""Call the first repository method present while keeping keyword tolerance."""
|
||
|
||
for name in names:
|
||
if callable(getattr(repository, name, None)):
|
||
return invoke(repository, name, **kwargs)
|
||
return invoke(repository, names[0], **kwargs)
|
||
|
||
|
||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||
return value if isinstance(value, Mapping) else {}
|
||
|
||
|
||
def _dictionary_choices(value: Any, dictionary_type: str) -> list[tuple[str, Any]]:
|
||
candidates: Any = value
|
||
if isinstance(value, Mapping):
|
||
candidates = value.get(dictionary_type, value.get("lists", value.get("data", [])))
|
||
if isinstance(candidates, Mapping):
|
||
candidates = candidates.get(dictionary_type, candidates.get("lists", []))
|
||
rows = (
|
||
list(candidates)
|
||
if isinstance(candidates, Sequence) and not isinstance(candidates, (str, bytes, bytearray))
|
||
else []
|
||
)
|
||
choices: list[tuple[str, Any]] = []
|
||
for row in rows:
|
||
label = first_value(row, "name", "label", "text", default="")
|
||
value = first_value(row, "value", "id", default="")
|
||
if label not in (None, "") and value not in (None, ""):
|
||
choices.append((str(label), value))
|
||
return choices
|
||
|
||
|
||
def _diagnosis_type_value(value: Any) -> str:
|
||
normalized = str(value or "").strip()
|
||
canonical = {
|
||
"1": "first_visit",
|
||
"2": "follow_up",
|
||
"3": "consultation",
|
||
"初诊": "first_visit",
|
||
"复诊": "follow_up",
|
||
"会诊": "consultation",
|
||
}.get(normalized, normalized)
|
||
return canonical if canonical in {"first_visit", "follow_up", "consultation"} else ""
|
||
|
||
|
||
def _int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
_MISSING = object()
|
||
|
||
|
||
def _raw_value(source: Any, *keys: str, default: Any = _MISSING) -> Any:
|
||
"""Return the first present field while preserving explicit zero/false values."""
|
||
|
||
for key in keys:
|
||
value = get_value(source, key, _MISSING)
|
||
if value is not _MISSING and value is not None and value != "":
|
||
return value
|
||
return default
|
||
|
||
|
||
def _detail_rows(source: Any, *keys: str) -> tuple[list[Any], bool]:
|
||
"""Read a returned list field and report whether that field actually existed."""
|
||
|
||
for key in keys:
|
||
value = get_value(source, key, _MISSING)
|
||
if value is _MISSING:
|
||
continue
|
||
if value is None:
|
||
return [], True
|
||
if isinstance(value, Mapping):
|
||
for nested_key in ("lists", "items", "rows", "results", "data", "traces"):
|
||
nested = value.get(nested_key, _MISSING)
|
||
if isinstance(nested, Sequence) and not isinstance(nested, (str, bytes, bytearray)):
|
||
return list(nested), True
|
||
return [], True
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
return list(value), True
|
||
return [], True
|
||
return [], False
|
||
|
||
|
||
def _number(value: Any) -> float | None:
|
||
if value is _MISSING or value is None or value == "":
|
||
return None
|
||
if isinstance(value, bool):
|
||
return float(value)
|
||
try:
|
||
return float(str(value).replace("¥", "").replace("¥", "").replace(",", "").strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _money_text(value: Any) -> str:
|
||
amount = _number(value)
|
||
return "—" if amount is None else f"¥{amount:,.2f}"
|
||
|
||
|
||
def _fulfillment_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
labels = {
|
||
1: "待双审通过",
|
||
2: "待发货",
|
||
3: "已完成",
|
||
4: "已取消",
|
||
5: "已发货",
|
||
6: "已签收",
|
||
7: "进行中",
|
||
8: "暂不制药",
|
||
9: "拒收",
|
||
10: "退款",
|
||
11: "保留药方",
|
||
12: "制药缓发",
|
||
}
|
||
numeric = _int(value, -1)
|
||
return labels.get(numeric, display_text(value))
|
||
|
||
|
||
def _pay_status_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
labels = {1: "待支付", 2: "已支付", 3: "已取消", 4: "已退款", 5: "待审核"}
|
||
return labels.get(_int(value, -1), display_text(value))
|
||
|
||
|
||
def _fee_type_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
labels = {
|
||
1: "挂号费",
|
||
2: "问诊费",
|
||
3: "药品费用",
|
||
4: "首付费用",
|
||
5: "尾款费用",
|
||
6: "其他费用",
|
||
7: "全部费用",
|
||
8: "驼奶费用",
|
||
}
|
||
return labels.get(_int(value, -1), display_text(value))
|
||
|
||
|
||
def _express_company_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
normalized = str(value).strip().lower()
|
||
return {
|
||
"sf": "顺丰速运",
|
||
"jd": "京东快递",
|
||
"jt": "极兔速递",
|
||
"jtexpress": "极兔速递",
|
||
"auto": "自动识别",
|
||
}.get(normalized, display_text(value))
|
||
|
||
|
||
def _audit_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
return {0: "待审核", 1: "审核通过", 2: "已驳回"}.get(_int(value, -1), display_text(value))
|
||
|
||
|
||
def _log_action_text(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
normalized = str(value).strip()
|
||
labels = {
|
||
"create": "创建",
|
||
"edit": "编辑",
|
||
"audit_rx_approve": "处方审核",
|
||
"audit_rx_reject": "处方审核",
|
||
"audit_pay_approve": "支付审核",
|
||
"audit_pay_reject": "支付审核",
|
||
"fill_tracking": "填快递单",
|
||
"ship": "确认发货",
|
||
"withdraw": "撤销",
|
||
"link_pay_order": "关联支付单",
|
||
"completion_request": "完单申请",
|
||
"auto_complete": "自动完成",
|
||
"revoke_rx_audit": "撤回处方审核",
|
||
"revoke_pay_audit": "撤回支付审核",
|
||
"gancao_submit": "甘草下单",
|
||
"ej_pharmacy_submit": "洛阳药房下单",
|
||
"ej_pharmacy_callback": "洛阳药房状态",
|
||
"patch_rx_patient": "处方患者信息",
|
||
"patch_rx_usage": "服用参数",
|
||
"update_amount": "修改订单金额",
|
||
"complete": "完成订单",
|
||
"refund": "退款",
|
||
"manual_log": "手工备注",
|
||
"assign_assistant": "改派医助",
|
||
"add_pay_order": "补齐支付单",
|
||
"set_ship_mode": "发货类型",
|
||
}
|
||
return labels.get(normalized, normalized)
|
||
|
||
|
||
def _display_collection(value: Any) -> str:
|
||
if value is _MISSING or value in (None, ""):
|
||
return "—"
|
||
if isinstance(value, Mapping):
|
||
for key in ("name", "label", "text", "path"):
|
||
item = value.get(key)
|
||
if item not in (None, ""):
|
||
return str(item)
|
||
return "—"
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
parts = [_display_collection(item) for item in value]
|
||
return " / ".join(part for part in parts if part != "—") or "—"
|
||
return display_text(value)
|
||
|
||
|
||
def _visit_slot_label(offset: int) -> str:
|
||
slot = max(0, int(offset)) + 1
|
||
chinese = ("", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十")
|
||
return f"{chinese[slot]}诊" if slot <= 10 else f"第{slot}诊"
|
||
|
||
|
||
class OrderDetailDrawer(QDialog):
|
||
"""Modal overlay whose readonly content panel occupies 80% of its owner."""
|
||
|
||
drawer_ratio = 0.8
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("DiagnosisOrderDetailOverlay")
|
||
self.setWindowTitle("业务订单详情")
|
||
self.setModal(True)
|
||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setStyleSheet(DIAGNOSIS_QSS + _ORDER_DETAIL_QSS)
|
||
root = QHBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
root.setSpacing(0)
|
||
self.scrim = QFrame()
|
||
self.scrim.setObjectName("DiagnosisOrderDetailScrim")
|
||
root.addWidget(self.scrim, 1)
|
||
self.drawer_panel = QFrame()
|
||
self.drawer_panel.setObjectName("DiagnosisOrderDetailDrawer")
|
||
self.drawer_panel.setProperty("readonly", True)
|
||
self.drawer_panel.setProperty("drawerRatio", self.drawer_ratio)
|
||
self.content_layout = QVBoxLayout(self.drawer_panel)
|
||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.content_layout.setSpacing(0)
|
||
root.addWidget(self.drawer_panel, 0)
|
||
self._owner = parent
|
||
if self._owner is not None:
|
||
self._owner.installEventFilter(self)
|
||
QTimer.singleShot(0, self.sync_to_owner)
|
||
|
||
def sync_to_owner(self) -> None:
|
||
target = main_window_target_rect(self)
|
||
if target is not None:
|
||
self.setGeometry(target)
|
||
self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio)))
|
||
return
|
||
owner = self._owner
|
||
if owner is None:
|
||
self.resize(1152, 720)
|
||
width = round(self.width() * self.drawer_ratio)
|
||
else:
|
||
origin = owner.mapToGlobal(QPoint(0, 0))
|
||
self.setGeometry(origin.x(), origin.y(), owner.width(), owner.height())
|
||
width = round(owner.width() * self.drawer_ratio)
|
||
self.drawer_panel.setFixedWidth(max(1, width))
|
||
|
||
def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||
owner = getattr(self, "_owner", None)
|
||
if watched is owner and event.type() in {
|
||
QEvent.Type.Move,
|
||
QEvent.Type.Resize,
|
||
QEvent.Type.Show,
|
||
QEvent.Type.WindowStateChange,
|
||
}:
|
||
QTimer.singleShot(0, self.sync_to_owner)
|
||
return super().eventFilter(watched, event)
|
||
|
||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||
self.sync_to_owner()
|
||
super().showEvent(event)
|
||
|
||
def closeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||
if self._owner is not None:
|
||
with suppress(RuntimeError):
|
||
self._owner.removeEventFilter(self)
|
||
super().closeEvent(event)
|
||
|
||
|
||
class DiagnosisDialog(QDialog):
|
||
"""Diagnosis readonly page plus edit/view-only right drawer.
|
||
|
||
The public ``open_for`` contract and all repository calls remain compatible
|
||
with the earlier dialog. ``editable=False`` alone keeps the standalone
|
||
readonly card flow; list/detail “查看” should call :meth:`open_view_only`
|
||
so it matches admin ``openViewOnly`` (same 病历 form, chips stay selected).
|
||
"""
|
||
|
||
saved = Signal()
|
||
_local_audio_upload_completed = Signal(int, int)
|
||
|
||
def __init__(
|
||
self,
|
||
repository: Any,
|
||
parent: QWidget | None = None,
|
||
*,
|
||
permissions: Any = None,
|
||
embedded: bool = False,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.repository = repository
|
||
self._embedded = bool(embedded)
|
||
self.permissions = (
|
||
permissions
|
||
if permissions is not None
|
||
else getattr(parent, "permissions", None)
|
||
if parent is not None
|
||
else None
|
||
)
|
||
self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False)
|
||
self._can_patient_orders = has_permission(
|
||
self.permissions, _PATIENT_ORDERS_PERMISSION, default=False
|
||
)
|
||
self._can_order_detail = has_permission(
|
||
self.permissions, _ORDER_DETAIL_PERMISSION, default=False
|
||
)
|
||
self._can_order_logs = has_permission(
|
||
self.permissions, _ORDER_LOGS_PERMISSION, default=False
|
||
)
|
||
self._can_daily_mutate = has_permission(
|
||
self.permissions, _DAILY_PERMISSION, default=False
|
||
) and all(
|
||
callable(getattr(self.repository, name, None))
|
||
for name in (
|
||
"add_blood_record",
|
||
"add_diet_record",
|
||
"add_exercise_record",
|
||
"add_tracking_note",
|
||
"add_diagnosis_todo",
|
||
"cancel_diagnosis_todo",
|
||
)
|
||
)
|
||
self._can_daily_edit = has_permission(
|
||
self.permissions, _DAILY_PERMISSION, default=False
|
||
) and all(
|
||
callable(getattr(self.repository, name, None))
|
||
for name in (
|
||
"update_blood_record",
|
||
"update_diet_record",
|
||
"update_exercise_record",
|
||
)
|
||
)
|
||
self._can_prescribe = has_permission(
|
||
self.permissions, _PRESCRIPTION_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "create_prescription", None))
|
||
self._can_edit_prescription = has_permission(
|
||
self.permissions, _PRESCRIPTION_EDIT_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "update_prescription", None))
|
||
self._can_offset = has_permission(
|
||
self.permissions, _OFFSET_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None))
|
||
self._can_video_upload = has_permission(
|
||
self.permissions, _VIDEO_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "upload_call_recording", None))
|
||
self._can_chat_sync = has_permission(
|
||
self.permissions, _CHAT_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "sync_im_chat_messages", None))
|
||
self._can_note_add = has_permission(
|
||
self.permissions, _NOTE_ADD_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "add_doctor_note", None))
|
||
self._can_note_delete = has_permission(
|
||
self.permissions, _NOTE_DELETE_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "delete_doctor_note_image", None))
|
||
self._diagnosis_id = 0
|
||
self._patient_id = 0
|
||
self._editable = False
|
||
self._authoritative_detail_loaded = False
|
||
self._saving = False
|
||
self._load_mode = "readonly"
|
||
self._generation = 0
|
||
self._dictionary_requested_generation = -1
|
||
self._dictionary_error_message = ""
|
||
self._save_generation = 0
|
||
self._orders_generation = 0
|
||
self._order_detail_generation = 0
|
||
self._daily_mutation_generation = 0
|
||
self._notes_mutation_generation = 0
|
||
self._media_generation = 0
|
||
self._prescription_detail_generation = 0
|
||
self._prescription_detail_target = 0
|
||
self._offset_generation = 0
|
||
self._tab_generations: dict[str, int] = {key: 0 for key, _label, _codes in _TAB_DEFINITIONS}
|
||
self._loaded_tabs: set[str] = set()
|
||
self._loading_tabs: set[str] = set()
|
||
self._daily_todo_status: int | None = None
|
||
self._recording_players: list[RecordingPlayerDialog] = []
|
||
self._image_preview: ImagePreviewDialog | None = None
|
||
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
||
self._last_order_detail_dialog: QDialog | None = None
|
||
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
||
self._local_audio_upload_manager: Any = None
|
||
self._local_audio_upload_listener: Any = None
|
||
self._video_reload_pending = False
|
||
self._orders_page = 1
|
||
self._orders_page_size = 10
|
||
self._orders_total = 0
|
||
self._saved_order_offset = 0
|
||
self._detail: Any = None
|
||
self._field_originals: dict[str, Any] = {}
|
||
self._standalone_readonly = True
|
||
self._view_only = False
|
||
self._basic_locked = False
|
||
self._tab_pages: dict[str, QWidget] = {}
|
||
self._readonly_sections: dict[str, QWidget] = {}
|
||
self._form_rows: list[tuple[QBoxLayout, list[QWidget], QWidget | None]] = []
|
||
self._form_narrow: bool | None = None
|
||
self._choice_fields: dict[str, DiagnosisChoiceButtons] = {}
|
||
self._table_registry: dict[str, list[RecordTable]] = {
|
||
"daily": [],
|
||
"prescription": [],
|
||
"orders": [],
|
||
"video": [],
|
||
"assign": [],
|
||
"appointment": [],
|
||
}
|
||
self._notes_timelines: list[NotesTimeline] = []
|
||
self._chat_panels: list[ChatPanel] = []
|
||
self._daily_panels: list[DailyRecordPanel] = []
|
||
self._owner = None
|
||
self._owner_filter_installed = False
|
||
self._local_audio_upload_completed.connect(
|
||
self._local_audio_upload_succeeded,
|
||
type=Qt.ConnectionType.QueuedConnection,
|
||
)
|
||
|
||
self.setObjectName("DiagnosisDialogRoot")
|
||
if self._embedded:
|
||
self.setWindowFlags(Qt.WindowType.Widget)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
|
||
self.setMinimumSize(0, 0)
|
||
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||
else:
|
||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setMinimumSize(760, 520)
|
||
self.resize(1024, 640)
|
||
self.setWindowTitle("患者信息详情")
|
||
self.setStyleSheet(DIAGNOSIS_QSS)
|
||
|
||
self.view_stack = QStackedLayout(self)
|
||
self.view_stack.setContentsMargins(0, 0, 0, 0)
|
||
self.view_stack.setStackingMode(QStackedLayout.StackingMode.StackOne)
|
||
self.readonly_page = self._build_readonly_page()
|
||
self.drawer_overlay = self._build_drawer_overlay()
|
||
self.view_stack.addWidget(self.readonly_page)
|
||
self.view_stack.addWidget(self.drawer_overlay)
|
||
|
||
self.compatibility_host = QWidget(self)
|
||
self.compatibility_host.hide()
|
||
self.notes_table = QTableWidget(0, 3, self.compatibility_host)
|
||
self.notes_table.setHorizontalHeaderLabels(["时间", "医生", "内容"])
|
||
self._build_compatibility_editors()
|
||
self.refresh_permissions()
|
||
|
||
def _build_readonly_page(self) -> QFrame:
|
||
page = QFrame()
|
||
page.setObjectName("DiagnosisReadonlyPage")
|
||
page.setProperty("mode", "readonly")
|
||
root = QVBoxLayout(page)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
root.setSpacing(0)
|
||
self.readonly_header = QWidget(page)
|
||
self.readonly_header.setObjectName("DiagnosisReadonlyHeader")
|
||
header_layout = QVBoxLayout(self.readonly_header)
|
||
header_layout.setContentsMargins(16, 16, 16, 0)
|
||
header_layout.setSpacing(0)
|
||
self.readonly_hero = self._build_readonly_hero()
|
||
header_layout.addWidget(self.readonly_hero)
|
||
root.addWidget(self.readonly_header)
|
||
self.readonly_scroll = QScrollArea()
|
||
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
||
self.readonly_scroll.setWidgetResizable(True)
|
||
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
|
||
content = QWidget()
|
||
self.readonly_content_layout = QVBoxLayout(content)
|
||
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
||
self.readonly_content_layout.setSpacing(16)
|
||
self.readonly_error = QFrame()
|
||
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
||
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
||
error_layout = QVBoxLayout(self.readonly_error)
|
||
error_layout.setContentsMargins(18, 40, 18, 40)
|
||
self.readonly_error_label = QLabel("诊单不存在或无权访问")
|
||
self.readonly_error_label.setObjectName("DiagnosisEmptyState")
|
||
self.readonly_error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.readonly_error_label.setWordWrap(True)
|
||
error_layout.addWidget(self.readonly_error_label)
|
||
self.readonly_retry_button = QPushButton("重试加载")
|
||
self.readonly_retry_button.setProperty("variant", "primary")
|
||
self.readonly_retry_button.setFixedHeight(36)
|
||
self.readonly_retry_button.clicked.connect(self._retry_detail)
|
||
error_layout.addWidget(self.readonly_retry_button, 0, Qt.AlignmentFlag.AlignCenter)
|
||
self.readonly_error.hide()
|
||
self.readonly_content_layout.addWidget(self.readonly_error)
|
||
self.readonly_patient_card = self._build_readonly_patient_card()
|
||
self.readonly_content_layout.addWidget(self.readonly_patient_card)
|
||
self.case_grid = CaseGrid()
|
||
self.readonly_content_layout.addWidget(self.case_grid)
|
||
|
||
daily = self._new_daily_panel("DiagnosisReadonlyDailyPanel")
|
||
self._add_readonly_section("daily", "日常记录", daily)
|
||
notes = NotesTimeline(editable=False)
|
||
notes.open_attachment_requested.connect(self._open_safe_resource)
|
||
notes.preview_images_requested.connect(self._preview_note_images)
|
||
self._notes_timelines.append(notes)
|
||
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
||
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
||
self._add_readonly_section("orders", "业务订单", orders)
|
||
video = self._new_table("video", "DiagnosisReadonlyVideoTable")
|
||
self._add_readonly_section("video", "视频录制回放记录", video)
|
||
chat = ChatPanel()
|
||
chat.sync_requested.connect(self._sync_chat_archive)
|
||
chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True))
|
||
chat.open_attachment_requested.connect(self._open_safe_resource)
|
||
self._chat_panels.append(chat)
|
||
self._add_readonly_section("chat", "聊天记录", chat)
|
||
assignments = self._new_table("assign", "DiagnosisReadonlyAssignmentsTable")
|
||
self._add_readonly_section("assign", "指派医助记录", assignments)
|
||
appointments = self._new_table("appointment", "DiagnosisReadonlyAppointmentsTable")
|
||
self._add_readonly_section("appointment", "挂号记录", appointments)
|
||
self.readonly_content_layout.addStretch(1)
|
||
self.readonly_scroll.setWidget(content)
|
||
root.addWidget(self.readonly_scroll)
|
||
self.readonly_loading = LoadingOverlay(page)
|
||
return page
|
||
|
||
def _build_readonly_hero(self) -> QFrame:
|
||
hero = QFrame()
|
||
hero.setObjectName("DiagnosisReadonlyHero")
|
||
layout = QGridLayout(hero)
|
||
layout.setContentsMargins(16, 12, 16, 12)
|
||
layout.setHorizontalSpacing(12)
|
||
layout.setVerticalSpacing(8)
|
||
self.readonly_hero_layout = layout
|
||
self.readonly_hero_left = QWidget()
|
||
left_layout = QHBoxLayout(self.readonly_hero_left)
|
||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||
left_layout.setSpacing(12)
|
||
self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回")
|
||
self.readonly_back_button.setObjectName("DiagnosisReadonlyBack")
|
||
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.readonly_back_button.setStyleSheet(
|
||
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
||
"color:#1A1C1F;font-size:13px;font-weight:500;}"
|
||
"QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-radius:6px;}"
|
||
)
|
||
self.readonly_back_button.clicked.connect(self.reject)
|
||
left_layout.addWidget(self.readonly_back_button)
|
||
title = QLabel("患者信息详情")
|
||
title.setObjectName("DiagnosisReadonlyTitle")
|
||
left_layout.addWidget(title)
|
||
left_layout.addStretch(1)
|
||
self.readonly_hero_right = QWidget()
|
||
right_layout = QHBoxLayout(self.readonly_hero_right)
|
||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||
right_layout.setSpacing(12)
|
||
self.readonly_hero_name = QLabel("患者")
|
||
self.readonly_hero_name.setObjectName("DiagnosisReadonlyPatientName")
|
||
right_layout.addWidget(self.readonly_hero_name)
|
||
self.readonly_hero_meta = QLabel("—")
|
||
self.readonly_hero_meta.setObjectName("DiagnosisReadonlyHeroMeta")
|
||
right_layout.addWidget(self.readonly_hero_meta)
|
||
self.readonly_status = QLabel("从未打卡")
|
||
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
||
self.readonly_status.setProperty("severity", "neutral")
|
||
right_layout.addWidget(self.readonly_status)
|
||
self.readonly_close_button = QPushButton("×")
|
||
self.readonly_close_button.setObjectName("DiagnosisCloseButton")
|
||
self.readonly_close_button.setToolTip("关闭诊单详情")
|
||
self.readonly_close_button.setAccessibleName("关闭诊单详情")
|
||
self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.readonly_close_button.clicked.connect(self.reject)
|
||
right_layout.addWidget(self.readonly_close_button)
|
||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||
layout.setColumnStretch(0, 1)
|
||
return hero
|
||
|
||
def _build_readonly_patient_card(self) -> QFrame:
|
||
card = QFrame()
|
||
card.setObjectName("DiagnosisReadonlyPatientCard")
|
||
card.setProperty("diagnosisReadonlyCard", True)
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(18, 18, 18, 18)
|
||
layout.setSpacing(14)
|
||
heading_row = QHBoxLayout()
|
||
heading_row.setContentsMargins(0, 0, 0, 0)
|
||
heading_row.setSpacing(12)
|
||
heading = QLabel("患者信息")
|
||
heading.setObjectName("DiagnosisReadonlyCardTitle")
|
||
heading_row.addWidget(heading, 1)
|
||
self.readonly_ai_button = QPushButton("AI 报告", card)
|
||
self.readonly_ai_button.setProperty("variant", "secondary")
|
||
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.readonly_ai_button.clicked.connect(self._open_ai_report)
|
||
heading_row.addWidget(self.readonly_ai_button, 0)
|
||
self.readonly_prescription_ai_button = QPushButton("处方 AI 对照", card)
|
||
self.readonly_prescription_ai_button.setProperty("variant", "secondary")
|
||
self.readonly_prescription_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
|
||
self.readonly_prescription_ai_button.clicked.connect(self._open_prescription_ai_report)
|
||
heading_row.addWidget(self.readonly_prescription_ai_button, 0)
|
||
layout.addLayout(heading_row)
|
||
patient_hero = QFrame()
|
||
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
|
||
hero_layout = QVBoxLayout(patient_hero)
|
||
hero_layout.setContentsMargins(18, 16, 18, 16)
|
||
hero_layout.setSpacing(3)
|
||
self.readonly_patient_title = QLabel("患者")
|
||
self.readonly_patient_title.setObjectName("DiagnosisReadonlyPatientTitle")
|
||
hero_layout.addWidget(self.readonly_patient_title)
|
||
self.summary_fields: dict[str, QLabel] = {}
|
||
for parts in (
|
||
(("", "phone"), (" · ", "gender_age")),
|
||
(("", "body"), (" · ", "region")),
|
||
(("预约:", "appointment"),),
|
||
(("", "staff"),),
|
||
(("状态:", "appointment_status"), (" · ", "prescription_status")),
|
||
(("备注:", "remark"),),
|
||
):
|
||
row = QWidget()
|
||
row_layout = QHBoxLayout(row)
|
||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||
row_layout.setSpacing(0)
|
||
for prefix, key in parts:
|
||
if prefix:
|
||
prefix_label = QLabel(prefix)
|
||
prefix_label.setProperty("diagnosisPatientMeta", True)
|
||
prefix_label.setSizePolicy(
|
||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred
|
||
)
|
||
prefix_label.setMinimumWidth(prefix_label.sizeHint().width())
|
||
row_layout.addWidget(prefix_label)
|
||
value = QLabel("—")
|
||
value.setProperty("diagnosisPatientMeta", True)
|
||
value.setWordWrap(False)
|
||
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
value.setSizePolicy(
|
||
QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred
|
||
)
|
||
row_layout.addWidget(value)
|
||
self.summary_fields[key] = value
|
||
row_layout.addStretch(1)
|
||
hero_layout.addWidget(row)
|
||
layout.addWidget(patient_hero)
|
||
self.summary_fields["patient"] = self.readonly_patient_title
|
||
for key in ("id_card", "pressure", "blood_sugar"):
|
||
self.summary_fields[key] = QLabel("—", card)
|
||
self.summary_fields[key].hide()
|
||
return card
|
||
|
||
def _add_readonly_section(self, key: str, title: str, body: QWidget) -> None:
|
||
card = readonly_card(title, f"DiagnosisReadonlySection_{key}", body)
|
||
self._readonly_sections[key] = card
|
||
self.readonly_content_layout.addWidget(card)
|
||
|
||
def _build_drawer_overlay(self) -> QFrame:
|
||
overlay = QFrame()
|
||
overlay.setObjectName("DiagnosisDrawerOverlay")
|
||
layout = QHBoxLayout(overlay)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
layout.addStretch(1)
|
||
self.drawer_panel = QFrame()
|
||
self.drawer_panel.setObjectName("DiagnosisDrawer")
|
||
self.drawer_panel.setProperty("mode", "edit")
|
||
self.drawer_panel.setFixedWidth(614)
|
||
panel_layout = QVBoxLayout(self.drawer_panel)
|
||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||
panel_layout.setSpacing(0)
|
||
panel_layout.addWidget(self._build_drawer_header())
|
||
self.drawer_banner = MessageStrip()
|
||
self.drawer_banner.setContentsMargins(16, 8, 16, 0)
|
||
self.drawer_banner.action_requested.connect(self._retry_current)
|
||
panel_layout.addWidget(self.drawer_banner)
|
||
self.tabs = DiagnosisTabWidget()
|
||
self.tabs.setObjectName("DiagnosisDrawerTabs")
|
||
self._build_drawer_tabs()
|
||
self.tabs.currentChanged.connect(self._tab_changed)
|
||
panel_layout.addWidget(self.tabs, 1)
|
||
panel_layout.addWidget(self._build_drawer_footer())
|
||
self.drawer_loading = LoadingOverlay(self.drawer_panel)
|
||
layout.addWidget(self.drawer_panel)
|
||
return overlay
|
||
|
||
def _build_drawer_header(self) -> QFrame:
|
||
header = QFrame()
|
||
header.setObjectName("DiagnosisDrawerHeader")
|
||
layout = QHBoxLayout(header)
|
||
layout.setContentsMargins(16, 13, 16, 13)
|
||
layout.setSpacing(10)
|
||
identity = QVBoxLayout()
|
||
identity.setSpacing(4)
|
||
title_row = QHBoxLayout()
|
||
title_row.setSpacing(10)
|
||
self.title_label = QLabel("编辑诊单")
|
||
self.title_label.setObjectName("DiagnosisDrawerTitle")
|
||
title_row.addWidget(self.title_label)
|
||
self.mode_label = QLabel("编辑")
|
||
self.mode_label.setObjectName("DiagnosisDrawerModeBadge")
|
||
self.mode_label.setProperty("kind", "warning")
|
||
title_row.addWidget(self.mode_label)
|
||
title_row.addStretch(1)
|
||
identity.addLayout(title_row)
|
||
self.meta_label = QLabel("正在准备诊单…")
|
||
self.meta_label.setObjectName("DiagnosisDrawerMeta")
|
||
identity.addWidget(self.meta_label)
|
||
layout.addLayout(identity, 1)
|
||
close = QPushButton("×")
|
||
close.setObjectName("DiagnosisCloseButton")
|
||
close.setToolTip("关闭")
|
||
close.clicked.connect(self.reject)
|
||
layout.addWidget(close, 0, Qt.AlignmentFlag.AlignTop)
|
||
return header
|
||
|
||
def _build_drawer_footer(self) -> QFrame:
|
||
footer = QFrame()
|
||
footer.setObjectName("DiagnosisDrawerFooter")
|
||
layout = QHBoxLayout(footer)
|
||
layout.setContentsMargins(20, 14, 20, 18)
|
||
layout.setSpacing(12)
|
||
layout.addStretch(1)
|
||
self.close_button = QPushButton("关闭")
|
||
self.close_button.setObjectName("DiagnosisFooterCloseButton")
|
||
self.close_button.clicked.connect(self.reject)
|
||
layout.addWidget(self.close_button)
|
||
self.save_button = SaveStateButton("保存病历")
|
||
self.save_button.setObjectName("DiagnosisSaveButton")
|
||
self.save_button.clicked.connect(self._save)
|
||
layout.addWidget(self.save_button)
|
||
return footer
|
||
|
||
def _build_drawer_tabs(self) -> None:
|
||
self._tab_pages["basic"] = self._build_basic_tab()
|
||
self._tab_pages["notes"] = self._build_notes_tab()
|
||
self._tab_pages["daily"] = self._wrap_tab(
|
||
"DiagnosisTabDaily", self._new_daily_panel("DiagnosisDailyPanel")
|
||
)
|
||
self._tab_pages["prescription"] = self._build_prescription_tab()
|
||
self._tab_pages["orders"] = self._build_orders_tab()
|
||
self._tab_pages["video"] = self._build_video_tab()
|
||
chat = ChatPanel()
|
||
chat.sync_requested.connect(self._sync_chat_archive)
|
||
chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True))
|
||
chat.open_attachment_requested.connect(self._open_safe_resource)
|
||
self._chat_panels.append(chat)
|
||
self._tab_pages["chat"] = self._wrap_tab("DiagnosisTabChat", chat)
|
||
self.assign_table = self._new_table("assign", "DiagnosisTableAssignments")
|
||
self._tab_pages["assign"] = self._wrap_tab("DiagnosisTabAssign", self.assign_table)
|
||
self.appointment_table = self._new_table("appointment", "DiagnosisTableAppointments")
|
||
self._tab_pages["appointment"] = self._wrap_tab(
|
||
"DiagnosisTabAppointment", self.appointment_table
|
||
)
|
||
|
||
def _build_basic_tab(self) -> QScrollArea:
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName("DiagnosisDrawerBody")
|
||
scroll.setProperty("diagnosisTabKey", "basic")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
scroll.verticalScrollBar().setSingleStep(28)
|
||
scroll.verticalScrollBar().setPageStep(120)
|
||
content = QWidget()
|
||
content.setObjectName("DiagnosisTabBasic")
|
||
content.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||
content.setMinimumWidth(0)
|
||
content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
layout = QVBoxLayout(content)
|
||
layout.setContentsMargins(20, 16, 20, 20)
|
||
layout.setSpacing(8)
|
||
self.privacy_banner = QFrame()
|
||
self.privacy_banner.setObjectName("DiagnosisPrivacyBanner")
|
||
privacy_layout = QHBoxLayout(self.privacy_banner)
|
||
privacy_layout.setContentsMargins(12, 9, 12, 9)
|
||
self.privacy_label = QLabel("存在未完成的业务订单,患者基本信息不可修改")
|
||
self.privacy_label.setWordWrap(True)
|
||
self.privacy_label.setObjectName("DiagnosisPrivacyText")
|
||
privacy_layout.addWidget(self.privacy_label)
|
||
self.privacy_banner.hide()
|
||
layout.addWidget(self.privacy_banner)
|
||
self.form_host = QFrame()
|
||
self.form_host.setObjectName("DiagnosisFormHost")
|
||
self.form_host.setProperty("locked", False)
|
||
self.form_host.setMinimumWidth(0)
|
||
form_layout = QVBoxLayout(self.form_host)
|
||
form_layout.setContentsMargins(0, 0, 0, 8)
|
||
form_layout.setSpacing(12)
|
||
self.edit_fields: dict[str, Any] = {}
|
||
self.display_fields: dict[str, Any] = {}
|
||
for section_key, (title, rows) in enumerate(_FORM_SECTIONS):
|
||
form_layout.addWidget(section_heading(title, f"DiagnosisSection_{section_key}"))
|
||
for row_index, row_fields in enumerate(rows):
|
||
row_host = QWidget()
|
||
row_host.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||
)
|
||
row_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, row_host)
|
||
row_layout.setContentsMargins(0, 0, 0, 6 if row_index == len(rows) - 1 else 10)
|
||
row_layout.setSpacing(16)
|
||
fields: list[QWidget] = []
|
||
occupied = 0
|
||
for label, key, span, field_kind in row_fields:
|
||
editor = self._ensure_editor(key, field_kind)
|
||
if isinstance(editor, DiagnosisNumberEdit):
|
||
display = editor.wrap_with_unit()
|
||
elif isinstance(editor, ExpandableDiagnosisTextEdit):
|
||
display = editor.wrap_with_expander(label)
|
||
else:
|
||
display = editor
|
||
container = self._field_container(label, display, span, editor=editor)
|
||
row_layout.addWidget(container, span)
|
||
fields.append(container)
|
||
occupied += span
|
||
remainder: QWidget | None = None
|
||
if occupied < 24:
|
||
remainder = QWidget()
|
||
remainder.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||
)
|
||
row_layout.addWidget(remainder, 24 - occupied)
|
||
self._form_rows.append((row_layout, fields, remainder))
|
||
form_layout.addWidget(row_host)
|
||
layout.addWidget(self.form_host)
|
||
layout.addStretch(1)
|
||
scroll.setWidget(content)
|
||
return scroll
|
||
|
||
def _field_container(
|
||
self,
|
||
caption: str,
|
||
display: QWidget,
|
||
span: int,
|
||
*,
|
||
editor: QWidget | None = None,
|
||
) -> QWidget:
|
||
# `display` may wrap a number edit with an external unit label; stacked
|
||
# detection must use the semantic editor when provided.
|
||
semantic = editor if editor is not None else display
|
||
container = QWidget()
|
||
container.setMinimumWidth(0)
|
||
container.setProperty("diagnosisFieldSpan", span)
|
||
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
policy.setHorizontalStretch(max(1, span))
|
||
# Choice/flow editors need height-for-width; propagate so the form row grows.
|
||
if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth():
|
||
policy.setHeightForWidth(True)
|
||
container.setSizePolicy(policy)
|
||
# Multi-option chips sit under the label so wrapped rows stay visible.
|
||
stacked = isinstance(semantic, DiagnosisChoiceButtons) or (
|
||
hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth()
|
||
)
|
||
layout = QBoxLayout(
|
||
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight,
|
||
container,
|
||
)
|
||
layout.setContentsMargins(0, 2, 0, 2)
|
||
layout.setSpacing(6 if stacked else 8)
|
||
label = QLabel(caption)
|
||
label.setObjectName("DiagnosisFieldLabel")
|
||
label.setProperty("diagnosisFieldLabel", True)
|
||
if stacked:
|
||
label.setMinimumWidth(0)
|
||
label.setMaximumWidth(16777215)
|
||
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
else:
|
||
label.setFixedWidth(100)
|
||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||
label.setWordWrap(False)
|
||
layout.addWidget(label, 0, Qt.AlignmentFlag.AlignTop)
|
||
display.setMinimumWidth(0)
|
||
editor_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth():
|
||
editor_policy.setHeightForWidth(True)
|
||
if not isinstance(semantic, DiagnosisSwitch):
|
||
display.setSizePolicy(editor_policy)
|
||
layout.addWidget(display, 1)
|
||
container.setProperty("diagnosisFieldStacked", stacked)
|
||
return container
|
||
|
||
def _ensure_editor(self, key: str, field_kind: str) -> QWidget:
|
||
if key in self.edit_fields:
|
||
return self.edit_fields[key]
|
||
if key in self.display_fields:
|
||
return self.display_fields[key]
|
||
if field_kind == "gender":
|
||
editor: Any = DiagnosisChoiceButtons(
|
||
(("男", 1), ("女", 0)), multiple=False, columns=2
|
||
)
|
||
elif field_kind == "marital":
|
||
editor = DiagnosisComboBox((("未婚", 0), ("已婚", 1), ("离异", 2)))
|
||
elif field_kind == "diagnosis_type":
|
||
editor = DiagnosisComboBox(
|
||
(("初诊", "first_visit"), ("复诊", "follow_up"), ("会诊", "consultation")),
|
||
allow_custom=False,
|
||
)
|
||
elif field_kind == "status":
|
||
editor = DiagnosisChoiceButtons(
|
||
(("启用", 1), ("禁用", 0)), multiple=False, columns=2
|
||
)
|
||
elif field_kind == "show_card":
|
||
editor = DiagnosisSwitch()
|
||
elif field_kind == "create_source":
|
||
editor = DiagnosisComboBox(
|
||
(
|
||
("未设置", ""),
|
||
("后台建档", "admin"),
|
||
("小程序完整建档", "mnp"),
|
||
("小程序手机号快捷建档", "mnp_daily"),
|
||
)
|
||
)
|
||
elif field_kind == "age_number":
|
||
editor = DiagnosisNumberEdit(maximum=150)
|
||
elif field_kind == "height_number":
|
||
editor = DiagnosisNumberEdit(maximum=300, decimals=1, step=0.5, suffix="cm")
|
||
elif field_kind == "weight_number":
|
||
editor = DiagnosisNumberEdit(maximum=500, decimals=1, step=0.1, suffix="kg")
|
||
elif field_kind == "systolic_number":
|
||
editor = DiagnosisNumberEdit(maximum=250, suffix="mmHg")
|
||
elif field_kind == "diastolic_number":
|
||
editor = DiagnosisNumberEdit(maximum=150, suffix="mmHg")
|
||
elif field_kind == "date":
|
||
editor = DiagnosisDateEdit()
|
||
elif field_kind == "local_diagnosis":
|
||
editor = DiagnosisChoiceButtons(
|
||
(("糖尿病", "糖尿病"), ("消渴病", "消渴病"), ("糖尿病前期", "糖尿病前期")),
|
||
multiple=True,
|
||
columns=3,
|
||
)
|
||
self._choice_fields[key] = editor
|
||
elif field_kind == "boolean":
|
||
editor = DiagnosisChoiceButtons(
|
||
(("有", 1), ("无", 0)), multiple=False, columns=2
|
||
)
|
||
elif field_kind.endswith("_choices"):
|
||
multiple = _CHOICE_DICTIONARIES.get(key, (key, True))[1]
|
||
editor = DiagnosisChoiceButtons(
|
||
(),
|
||
multiple=multiple,
|
||
columns=4 if multiple else 5,
|
||
)
|
||
self._choice_fields[key] = editor
|
||
elif field_kind.startswith(("textarea", "expandable_textarea")):
|
||
rows = int(field_kind[-1])
|
||
editor = (
|
||
ExpandableDiagnosisTextEdit(rows)
|
||
if field_kind.startswith("expandable_")
|
||
else DiagnosisTextEdit(rows)
|
||
)
|
||
else:
|
||
editor = DiagnosisLineEdit()
|
||
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
|
||
if key.startswith("__"):
|
||
editor.setReadOnly(True)
|
||
self.display_fields[key] = editor
|
||
else:
|
||
placeholder = next(
|
||
(item[2] for item in _DIAGNOSIS_FIELDS if item[1] == key),
|
||
"",
|
||
)
|
||
if hasattr(editor, "setPlaceholderText"):
|
||
editor.setPlaceholderText(placeholder)
|
||
self.edit_fields[key] = editor
|
||
return editor
|
||
|
||
def _build_compatibility_editors(self) -> None:
|
||
for _caption, key, placeholder in _DIAGNOSIS_FIELDS:
|
||
if key in self.edit_fields:
|
||
continue
|
||
editor = DiagnosisTextEdit(2, self.compatibility_host)
|
||
editor.setObjectName(f"DiagnosisField_{key}")
|
||
editor.setPlaceholderText(placeholder)
|
||
editor.hide()
|
||
self.edit_fields[key] = editor
|
||
|
||
def _build_notes_tab(self) -> QWidget:
|
||
timeline = NotesTimeline(editable=True)
|
||
timeline.add_requested.connect(self._add_doctor_note)
|
||
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
||
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
||
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
||
timeline.preview_images_requested.connect(self._preview_note_images)
|
||
self.drawer_notes_timeline = timeline
|
||
self._notes_timelines.append(timeline)
|
||
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
||
|
||
def _build_prescription_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
page.setObjectName("DiagnosisTabPrescription")
|
||
layout = QVBoxLayout(page)
|
||
layout.setContentsMargins(16, 14, 16, 18)
|
||
layout.setSpacing(12)
|
||
toolbar = QHBoxLayout()
|
||
title = QLabel("处方记录")
|
||
title.setObjectName("DiagnosisDailySectionTitle")
|
||
toolbar.addWidget(title)
|
||
hint = QLabel("开方后进入真实审核与订单流程")
|
||
hint.setObjectName("DiagnosisDialogGuidance")
|
||
toolbar.addWidget(hint)
|
||
toolbar.addStretch(1)
|
||
self.prescribe_button = QPushButton("开方")
|
||
self.prescribe_button.setProperty("variant", "primary")
|
||
self.prescribe_button.clicked.connect(self._open_new_prescription)
|
||
toolbar.addWidget(self.prescribe_button)
|
||
layout.addLayout(toolbar)
|
||
layout.addWidget(self._new_table("prescription", "DiagnosisPrescriptionTable"), 1)
|
||
return page
|
||
|
||
def _build_video_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
page.setObjectName("DiagnosisTabVideo")
|
||
layout = QVBoxLayout(page)
|
||
layout.setContentsMargins(16, 14, 16, 18)
|
||
layout.setSpacing(12)
|
||
toolbar = QHBoxLayout()
|
||
hint = QLabel("每条通话记录可包含多个回放地址;录音转写完成后可查看本次面诊对话文字。")
|
||
hint.setObjectName("DiagnosisDialogGuidance")
|
||
hint.setWordWrap(True)
|
||
toolbar.addWidget(hint, 1)
|
||
toolbar.addStretch(1)
|
||
self.local_audio_queue_button = QPushButton("本机录音文件")
|
||
self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton")
|
||
self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态")
|
||
self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue)
|
||
toolbar.addWidget(self.local_audio_queue_button)
|
||
self.video_upload_button = QPushButton("上传本地回放")
|
||
self.video_upload_button.setProperty("variant", "primary")
|
||
self.video_upload_button.clicked.connect(
|
||
lambda _checked=False: self._upload_call_recording(None)
|
||
)
|
||
toolbar.addWidget(self.video_upload_button)
|
||
layout.addLayout(toolbar)
|
||
layout.addWidget(self._new_table("video", "DiagnosisVideoTable"), 1)
|
||
return page
|
||
|
||
def _build_orders_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
page.setObjectName("DiagnosisTabOrders")
|
||
layout = QVBoxLayout(page)
|
||
layout.setContentsMargins(16, 14, 16, 18)
|
||
layout.setSpacing(12)
|
||
toolbar = QFrame()
|
||
toolbar.setObjectName("DiagnosisOrderOffsetBar")
|
||
toolbar_layout = QHBoxLayout(toolbar)
|
||
toolbar_layout.setContentsMargins(14, 10, 14, 10)
|
||
label = QLabel("复诊统计起始偏移")
|
||
label.setObjectName("DiagnosisOrderOffsetLabel")
|
||
label.setToolTip(_ORDER_OFFSET_HELP)
|
||
toolbar_layout.addWidget(label)
|
||
help_button = QPushButton("?")
|
||
help_button.setObjectName("DiagnosisOrderOffsetHelp")
|
||
help_button.setAccessibleName("查看复诊统计起始偏移说明")
|
||
help_button.setToolTip(_ORDER_OFFSET_HELP)
|
||
help_button.setFixedSize(22, 22)
|
||
help_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||
toolbar_layout.addWidget(help_button)
|
||
self.order_offset_help = help_button
|
||
self.order_offset = QSpinBox()
|
||
self.order_offset.setRange(0, 20)
|
||
self.order_offset.setSingleStep(1)
|
||
self.order_offset.setFixedWidth(120)
|
||
self.order_offset.setEnabled(False)
|
||
self.order_offset.setToolTip(_ORDER_OFFSET_HELP)
|
||
self.order_offset.valueChanged.connect(self._order_offset_changed)
|
||
toolbar_layout.addWidget(self.order_offset)
|
||
self.order_offset_preview = QLabel("第 1 笔实单计为一诊")
|
||
self.order_offset_preview.setObjectName("DiagnosisOrderOffsetPreview")
|
||
self.order_offset_preview.setToolTip(_ORDER_OFFSET_HELP)
|
||
toolbar_layout.addWidget(self.order_offset_preview)
|
||
offset_save = QPushButton("保存")
|
||
offset_save.setProperty("variant", "primary")
|
||
self.order_offset_save = offset_save
|
||
offset_save.clicked.connect(self._save_order_offset)
|
||
toolbar_layout.addWidget(offset_save)
|
||
toolbar_layout.addStretch(1)
|
||
layout.addWidget(toolbar)
|
||
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
||
layout.addWidget(self.orders_table, 1)
|
||
self.orders_list = InfiniteList(self._orders_page_size, page)
|
||
for table in self._table_registry["orders"]:
|
||
self.orders_list.bind(table)
|
||
self._orders_footer_layout = layout
|
||
layout.addWidget(self.orders_list)
|
||
return page
|
||
|
||
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName(object_name)
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
content = QWidget()
|
||
layout = QVBoxLayout(content)
|
||
layout.setContentsMargins(16, 14, 16, 18)
|
||
layout.setSpacing(12)
|
||
layout.addWidget(body)
|
||
layout.addStretch(1)
|
||
scroll.setWidget(content)
|
||
return scroll
|
||
|
||
def _new_daily_panel(self, object_name: str) -> DailyRecordPanel:
|
||
panel = DailyRecordPanel()
|
||
panel.setObjectName(object_name)
|
||
panel.range_requested.connect(
|
||
lambda start, end: self._load_daily_range(start, end, force=True)
|
||
)
|
||
panel.refresh_requested.connect(lambda: self._ensure_tab_loaded("daily", force=True))
|
||
panel.todo_status_requested.connect(self._daily_todo_filter_changed)
|
||
panel.daily_add_requested.connect(self._add_daily_record)
|
||
panel.daily_edit_requested.connect(self._edit_daily_record)
|
||
panel.todo_add_requested.connect(self._add_diagnosis_todo)
|
||
panel.todo_cancel_requested.connect(self._cancel_diagnosis_todo)
|
||
self._daily_panels.append(panel)
|
||
return panel
|
||
|
||
def _new_table(self, kind: str, object_name: str) -> RecordTable:
|
||
definitions: dict[str, tuple[tuple[str, int], ...]] = {
|
||
"daily": (("日期", 120), ("指标", 150), ("记录", 180), ("记录人", 110)),
|
||
"prescription": (
|
||
("就诊日期", 120),
|
||
("就诊编号", 120),
|
||
("诊断", 180),
|
||
("处方摘要", 220),
|
||
("医生", 90),
|
||
("状态", 140),
|
||
("操作", 120),
|
||
),
|
||
"orders": (
|
||
("订单编号", 200),
|
||
("全局诊次", 96),
|
||
("计入统计", 96),
|
||
("金额", 120),
|
||
("医生", 110),
|
||
("医助", 110),
|
||
("履约状态", 110),
|
||
("创建时间", 170),
|
||
("操作", 100),
|
||
),
|
||
"video": (
|
||
("录制回放", 440),
|
||
("房间号", 180),
|
||
("开始时间", 170),
|
||
("结束时间", 170),
|
||
("通话类型", 100),
|
||
("时长", 110),
|
||
("状态", 90),
|
||
("录制 / 文字", 120),
|
||
("操作", 130),
|
||
),
|
||
"assign": (
|
||
("操作时间", 175),
|
||
("原医助", 120),
|
||
("新医助", 120),
|
||
("继承", 72),
|
||
("快照·业务单创建人", 150),
|
||
("快照·创建时间", 190),
|
||
("操作人", 110),
|
||
("操作账号", 120),
|
||
("IP", 130),
|
||
),
|
||
"appointment": (
|
||
("ID", 72),
|
||
("状态", 100),
|
||
("患者(挂号人)", 150),
|
||
("挂号医生", 110),
|
||
("挂号助理", 110),
|
||
("预约日期", 130),
|
||
("时段", 80),
|
||
("类型", 100),
|
||
("渠道", 110),
|
||
("确认诊单", 92),
|
||
("开方", 80),
|
||
("备注", 120),
|
||
("创建时间", 165),
|
||
),
|
||
}
|
||
table = RecordTable(object_name, definitions[kind])
|
||
table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||
self._table_registry[kind].append(table)
|
||
return table
|
||
|
||
def _tab_allowed(self, codes: tuple[str, ...]) -> bool:
|
||
return not codes or has_permission(self.permissions, codes, default=True)
|
||
|
||
def refresh_permissions(self, permissions: Any | None = None) -> None:
|
||
if permissions is not None:
|
||
self.permissions = permissions
|
||
self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False)
|
||
self._can_patient_orders = has_permission(
|
||
self.permissions, _PATIENT_ORDERS_PERMISSION, default=False
|
||
)
|
||
self._can_order_detail = has_permission(
|
||
self.permissions, _ORDER_DETAIL_PERMISSION, default=False
|
||
)
|
||
self._can_order_logs = has_permission(
|
||
self.permissions, _ORDER_LOGS_PERMISSION, default=False
|
||
)
|
||
self._can_daily_mutate = has_permission(
|
||
self.permissions, _DAILY_PERMISSION, default=False
|
||
) and all(
|
||
callable(getattr(self.repository, name, None))
|
||
for name in (
|
||
"add_blood_record",
|
||
"add_diet_record",
|
||
"add_exercise_record",
|
||
"add_tracking_note",
|
||
"add_diagnosis_todo",
|
||
"cancel_diagnosis_todo",
|
||
)
|
||
)
|
||
self._can_daily_edit = has_permission(
|
||
self.permissions, _DAILY_PERMISSION, default=False
|
||
) and all(
|
||
callable(getattr(self.repository, name, None))
|
||
for name in (
|
||
"update_blood_record",
|
||
"update_diet_record",
|
||
"update_exercise_record",
|
||
)
|
||
)
|
||
self._can_prescribe = has_permission(
|
||
self.permissions, _PRESCRIPTION_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "create_prescription", None))
|
||
self._can_edit_prescription = has_permission(
|
||
self.permissions, _PRESCRIPTION_EDIT_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "update_prescription", None))
|
||
self._can_offset = has_permission(
|
||
self.permissions, _OFFSET_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None))
|
||
self._can_video_upload = has_permission(
|
||
self.permissions, _VIDEO_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "upload_call_recording", None))
|
||
self._can_chat_sync = has_permission(
|
||
self.permissions, _CHAT_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "sync_im_chat_messages", None))
|
||
self._can_note_add = has_permission(
|
||
self.permissions, _NOTE_ADD_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "add_doctor_note", None))
|
||
self._can_note_delete = has_permission(
|
||
self.permissions, _NOTE_DELETE_PERMISSION, default=False
|
||
) and callable(getattr(self.repository, "delete_doctor_note_image", None))
|
||
if hasattr(self, "readonly_ai_button"):
|
||
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
||
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
||
if hasattr(self, "readonly_prescription_ai_button"):
|
||
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
|
||
self.readonly_prescription_ai_button.setEnabled(self._diagnosis_id > 0)
|
||
previous_key = self._current_tab_key()
|
||
allowed_tabs = [
|
||
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
||
]
|
||
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
|
||
key for key, _label in allowed_tabs
|
||
]:
|
||
blocked = self.tabs.blockSignals(True)
|
||
self.tabs.clear()
|
||
target = 0
|
||
for key, label in allowed_tabs:
|
||
index = self.tabs.addTab(self._tab_pages[key], label)
|
||
self.tabs.tabBar().setTabData(index, key)
|
||
if key == previous_key:
|
||
target = index
|
||
self.tabs.setCurrentIndex(target)
|
||
self.tabs.blockSignals(blocked)
|
||
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||
for key, section in self._readonly_sections.items():
|
||
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
||
for panel in self._daily_panels:
|
||
panel.set_editable(
|
||
self._editable and self._can_daily_mutate,
|
||
record_editable=self._editable and self._can_daily_edit,
|
||
)
|
||
for timeline in self._notes_timelines:
|
||
timeline.set_editable(
|
||
self._editable and timeline is self.drawer_notes_timeline and self._can_note_add,
|
||
can_delete=(
|
||
self._editable
|
||
and timeline is self.drawer_notes_timeline
|
||
and self._can_note_delete
|
||
),
|
||
)
|
||
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
|
||
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
|
||
self.local_audio_queue_button.setVisible(self._can_video_upload)
|
||
self.local_audio_queue_button.setEnabled(
|
||
self._diagnosis_id > 0 and self._can_video_upload
|
||
)
|
||
offset_visible = self._editable and self._can_offset
|
||
self.order_offset.setVisible(offset_visible)
|
||
self.order_offset_save.setVisible(offset_visible)
|
||
self._sync_order_offset_actions()
|
||
for panel in self._chat_panels:
|
||
panel.sync_button.setVisible(self._can_chat_sync)
|
||
self._sync_save_button()
|
||
if self._current_tab_key() != previous_key:
|
||
self._tab_changed(self.tabs.currentIndex())
|
||
|
||
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
||
self.drawer_banner.show_message(text, kind, action_text)
|
||
if self._standalone_readonly and kind == "danger":
|
||
self.readonly_error_label.setText(text)
|
||
self.readonly_error.show()
|
||
|
||
def _clear_message(self) -> None:
|
||
if self._dictionary_error_message and self._authoritative_detail_loaded:
|
||
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||
return
|
||
self.drawer_banner.clear()
|
||
self.readonly_error.hide()
|
||
|
||
def _set_loading(self, loading: bool) -> None:
|
||
overlay = self.readonly_loading if self._standalone_readonly else self.drawer_loading
|
||
overlay.setVisible(loading)
|
||
if loading:
|
||
overlay.sync_geometry()
|
||
|
||
def _reflow_form(self, viewport_width: int) -> None:
|
||
"""Adapt the diagnosis form to the live drawer content width.
|
||
|
||
Wide/medium drawers keep a stable two-column grid. Only phone-narrow
|
||
drawers stack fields and flip labels above editors.
|
||
"""
|
||
|
||
content_w = max(240, int(viewport_width) - 56)
|
||
mode = "grid" if content_w >= 520 else "stack"
|
||
narrow = mode == "stack"
|
||
if narrow == self._form_narrow:
|
||
return
|
||
self._form_narrow = narrow
|
||
for row_layout, fields, remainder in self._form_rows:
|
||
row_layout.setDirection(
|
||
QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight
|
||
)
|
||
row_layout.setSpacing(12 if narrow else 16)
|
||
if remainder is not None:
|
||
remainder.setVisible(not narrow)
|
||
for container in fields:
|
||
self._orient_field_container(container, stacked=narrow)
|
||
|
||
def _orient_field_container(self, container: QWidget, *, stacked: bool) -> None:
|
||
# Choice fields stay label-above permanently so wrapped chips never clip.
|
||
if bool(container.property("diagnosisFieldStacked")):
|
||
return
|
||
layout = container.layout()
|
||
if not isinstance(layout, QBoxLayout):
|
||
return
|
||
label = container.findChild(QLabel, "DiagnosisFieldLabel")
|
||
layout.setDirection(
|
||
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight
|
||
)
|
||
layout.setSpacing(6 if stacked else 8)
|
||
if label is None:
|
||
return
|
||
if stacked:
|
||
label.setMinimumWidth(0)
|
||
label.setMaximumWidth(16777215)
|
||
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||
else:
|
||
label.setFixedWidth(100)
|
||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||
|
||
def _sync_host_geometry(self) -> None:
|
||
if self._embedded:
|
||
return
|
||
target = main_window_target_rect(self)
|
||
if target is not None:
|
||
self.setGeometry(target)
|
||
return
|
||
owner = self._owner
|
||
if owner is None:
|
||
if self.width() < 760 or self.height() < 520:
|
||
self.resize(1024, 640)
|
||
return
|
||
if owner is self:
|
||
return
|
||
origin = owner.mapToGlobal(QPoint(0, 0))
|
||
self.setGeometry(QRect(origin, owner.size()))
|
||
|
||
def _update_drawer_geometry(self) -> None:
|
||
if not hasattr(self, "drawer_panel"):
|
||
return
|
||
drawer_width = self.width() if self.width() <= 768 else max(456, round(self.width() * 0.60))
|
||
self.drawer_panel.setFixedWidth(drawer_width)
|
||
# Reflow against the actual drawer content width, not the fullscreen overlay.
|
||
self._reflow_form(drawer_width)
|
||
self._sync_choice_field_heights()
|
||
|
||
def _reflow_readonly_hero(self) -> None:
|
||
if not hasattr(self, "readonly_hero_layout"):
|
||
return
|
||
layout = self.readonly_hero_layout
|
||
layout.removeWidget(self.readonly_hero_left)
|
||
layout.removeWidget(self.readonly_hero_right)
|
||
if self.width() < 900:
|
||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||
layout.addWidget(self.readonly_hero_right, 1, 0, Qt.AlignmentFlag.AlignLeft)
|
||
else:
|
||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||
|
||
def _install_owner_filter(self) -> None:
|
||
if self._embedded:
|
||
return
|
||
if self._owner is not None and not self._owner_filter_installed:
|
||
self._owner.installEventFilter(self)
|
||
self._owner_filter_installed = True
|
||
|
||
def _rebind_owner(self) -> None:
|
||
"""Resolve the live Shell window for every open/show cycle."""
|
||
|
||
if self._embedded:
|
||
self._owner = None
|
||
self._owner_filter_installed = False
|
||
return
|
||
parent = self.parentWidget()
|
||
candidate = parent.window() if parent is not None else None
|
||
if candidate is self:
|
||
candidate = None
|
||
if candidate is not self._owner:
|
||
if self._owner is not None and self._owner_filter_installed:
|
||
with suppress(RuntimeError):
|
||
self._owner.removeEventFilter(self)
|
||
self._owner = candidate
|
||
self._owner_filter_installed = False
|
||
self._install_owner_filter()
|
||
|
||
def resizeEvent(self, event: Any) -> None:
|
||
self._update_drawer_geometry()
|
||
self._reflow_readonly_hero()
|
||
if hasattr(self, "readonly_loading"):
|
||
self.readonly_loading.sync_geometry()
|
||
if hasattr(self, "drawer_loading"):
|
||
self.drawer_loading.sync_geometry()
|
||
super().resizeEvent(event)
|
||
|
||
def showEvent(self, event: Any) -> None:
|
||
if not self._embedded:
|
||
self._rebind_owner()
|
||
self._sync_host_geometry()
|
||
self._update_drawer_geometry()
|
||
self._reflow_readonly_hero()
|
||
super().showEvent(event)
|
||
self._queue_owner_sync()
|
||
|
||
def _queue_owner_sync(self) -> None:
|
||
generation = self._generation
|
||
QTimer.singleShot(0, lambda: self._sync_owner_if_current(generation))
|
||
|
||
def _sync_owner_if_current(self, generation: int) -> None:
|
||
if generation == self._generation and self.isVisible():
|
||
self._sync_host_geometry()
|
||
|
||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||
if watched is self._owner and event.type() in {
|
||
QEvent.Type.Resize,
|
||
QEvent.Type.Move,
|
||
QEvent.Type.Show,
|
||
}:
|
||
self._sync_host_geometry()
|
||
self._update_drawer_geometry()
|
||
self._queue_owner_sync()
|
||
elif watched is self._owner and event.type() == QEvent.Type.Close:
|
||
self._invalidate_requests()
|
||
QTimer.singleShot(0, self.reject)
|
||
return super().eventFilter(watched, event)
|
||
|
||
def reject(self) -> None:
|
||
self._invalidate_requests()
|
||
super().reject()
|
||
|
||
def done(self, result: int) -> None:
|
||
self._invalidate_requests()
|
||
if self._owner is not None and self._owner_filter_installed:
|
||
with suppress(RuntimeError):
|
||
self._owner.removeEventFilter(self)
|
||
self._owner_filter_installed = False
|
||
super().done(result)
|
||
|
||
def _open_ai_report(self) -> None:
|
||
if not can_open_diagnosis_ai_report(self.permissions):
|
||
return
|
||
if self._diagnosis_id <= 0:
|
||
return
|
||
detail = self._detail if isinstance(self._detail, Mapping) else {}
|
||
diagnosis = get_value(detail, "diagnosis", None)
|
||
row = dict(diagnosis) if isinstance(diagnosis, Mapping) else dict(detail)
|
||
row.update(
|
||
{
|
||
"id": self._diagnosis_id,
|
||
"diagnosis_id": self._diagnosis_id,
|
||
"patient_name": first_value(
|
||
row,
|
||
"patient_name",
|
||
"name",
|
||
default=self.readonly_patient_title.text()
|
||
if hasattr(self, "readonly_patient_title")
|
||
else "患者",
|
||
),
|
||
}
|
||
)
|
||
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
||
|
||
def _open_prescription_ai_report(self) -> None:
|
||
if self._diagnosis_id > 0:
|
||
present_issued_prescription_ai(self.repository, self.permissions, self, diagnosis_id=self._diagnosis_id)
|
||
|
||
def open_view_only(
|
||
self,
|
||
diagnosis_id: int,
|
||
*,
|
||
seed: Any = None,
|
||
modeless: bool = False,
|
||
) -> None:
|
||
self.open_for(
|
||
diagnosis_id,
|
||
editable=False,
|
||
seed=seed,
|
||
view_only=True,
|
||
modeless=modeless,
|
||
)
|
||
|
||
def open_for(
|
||
self,
|
||
diagnosis_id: int,
|
||
*,
|
||
editable: bool = False,
|
||
seed: Any = None,
|
||
authoritative_detail: Any = None,
|
||
view_only: bool = False,
|
||
modeless: bool = False,
|
||
auto_show: bool = True,
|
||
) -> None:
|
||
"""Prepare a diagnosis view, optionally showing it immediately."""
|
||
|
||
self._rebind_owner()
|
||
for player in list(self._recording_players):
|
||
with suppress(RuntimeError):
|
||
player.close()
|
||
self._recording_players.clear()
|
||
self._stop_inline_recordings()
|
||
if self._last_order_detail_dialog is not None:
|
||
with suppress(RuntimeError):
|
||
self._last_order_detail_dialog.close()
|
||
self._last_order_detail_dialog = None
|
||
if self._local_audio_dialog is not None:
|
||
with suppress(RuntimeError):
|
||
self._local_audio_dialog.close()
|
||
self._local_audio_dialog = None
|
||
self._diagnosis_id = int(diagnosis_id)
|
||
self._patient_id = 0
|
||
self._editable = bool(
|
||
editable and has_permission(self.permissions, "tcm.diagnosis/edit", default=True)
|
||
)
|
||
self._view_only = bool(view_only and not self._editable)
|
||
self._standalone_readonly = not self._editable and not self._view_only
|
||
self._load_mode = (
|
||
"readonly" if self._standalone_readonly else "edit" if self._editable else "view_only"
|
||
)
|
||
self._authoritative_detail_loaded = False
|
||
self._saving = False
|
||
self._dictionary_error_message = ""
|
||
self._generation += 1
|
||
self._save_generation += 1
|
||
self._orders_generation += 1
|
||
self._order_detail_generation += 1
|
||
self._daily_mutation_generation += 1
|
||
self._notes_mutation_generation += 1
|
||
self._media_generation += 1
|
||
self._prescription_detail_generation += 1
|
||
self._offset_generation += 1
|
||
for key in self._tab_generations:
|
||
self._tab_generations[key] += 1
|
||
self._loaded_tabs.clear()
|
||
self._loading_tabs.clear()
|
||
self._video_reload_pending = False
|
||
self._daily_todo_status = None
|
||
self._orders_page = 1
|
||
self._orders_total = 0
|
||
self.orders_list.reset()
|
||
footer_layout = (
|
||
self._readonly_sections["orders"].layout()
|
||
if self._standalone_readonly
|
||
else self._orders_footer_layout
|
||
)
|
||
footer_layout.addWidget(self.orders_list)
|
||
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||
self.save_button.set_state("idle")
|
||
self.refresh_permissions()
|
||
self.view_stack.setCurrentWidget(
|
||
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
||
)
|
||
self.setModal(False if self._embedded else not modeless and not self._standalone_readonly)
|
||
self.setWindowTitle(
|
||
"患者信息详情"
|
||
if self._standalone_readonly
|
||
else "编辑诊单"
|
||
if self._editable
|
||
else "诊单详情"
|
||
)
|
||
self.title_label.setText("编辑诊单" if self._editable else "诊单详情")
|
||
self.mode_label.setText("编辑" if self._editable else "只读")
|
||
self.mode_label.setProperty("kind", "warning" if self._editable else "info")
|
||
repolish(self.mode_label)
|
||
self.drawer_panel.setProperty("mode", "edit" if self._editable else "viewOnly")
|
||
repolish(self.drawer_panel)
|
||
self.close_button.setText("取消" if self._editable else "关闭")
|
||
for timeline in self._notes_timelines:
|
||
timeline.set_editable(
|
||
self._editable and timeline is self.drawer_notes_timeline and self._can_note_add,
|
||
can_delete=(
|
||
self._editable
|
||
and timeline is self.drawer_notes_timeline
|
||
and self._can_note_delete
|
||
),
|
||
)
|
||
self._clear_tables()
|
||
self._clear_message()
|
||
if self._detail is not None:
|
||
self._render(self._detail, [], [])
|
||
self._sync_form_interactivity()
|
||
self._sync_save_button()
|
||
self._sync_host_geometry()
|
||
if auto_show:
|
||
self.show()
|
||
if not self._embedded:
|
||
self.raise_()
|
||
if authoritative_detail is not None:
|
||
diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail
|
||
patient = get_value(authoritative_detail, "patient", None) or {}
|
||
self._patient_id = _int(
|
||
first_value(
|
||
diagnosis,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
default=first_value(patient, "patient_id", "id", default=0),
|
||
),
|
||
0,
|
||
)
|
||
self._authoritative_detail_loaded = True
|
||
self._show_authoritative_content(True)
|
||
self._clear_message()
|
||
self._set_loading(False)
|
||
if self._standalone_readonly:
|
||
self._load_visible_readonly_sections()
|
||
else:
|
||
self._ensure_tab_loaded(self._current_tab_key())
|
||
return
|
||
self._show_message("正在加载权威诊单详情…", "info")
|
||
self._set_loading(True)
|
||
self._start_detail_load()
|
||
|
||
def _start_detail_load(self) -> None:
|
||
self._generation += 1
|
||
generation = self._generation
|
||
diagnosis_id_snapshot = self._diagnosis_id
|
||
mode_snapshot = self._load_mode
|
||
run_async(
|
||
lambda: self._load_bundle(diagnosis_id_snapshot, mode_snapshot),
|
||
on_success=lambda result: self._apply_bundle(result, generation),
|
||
on_error=lambda error: self._load_error(error, generation),
|
||
on_finished=lambda: self._load_finished(generation),
|
||
)
|
||
|
||
def _load_bundle(self, diagnosis_id: int, mode: str) -> dict[str, Any]:
|
||
readonly = mode == "readonly"
|
||
detail_names = (
|
||
("diagnosis_readonly_detail", "patient_detail", "get_diagnosis_detail")
|
||
if readonly
|
||
else ("get_diagnosis_detail",)
|
||
)
|
||
detail = _invoke_first(
|
||
self.repository,
|
||
detail_names,
|
||
diagnosis_id=diagnosis_id,
|
||
id=diagnosis_id,
|
||
readonly=readonly,
|
||
)
|
||
if detail in (None, "", [], {}):
|
||
raise LookupError("诊单不存在或无权访问")
|
||
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
|
||
patient = get_value(detail, "patient", None) or {}
|
||
patient_id = _int(
|
||
first_value(
|
||
diagnosis,
|
||
"patient_id",
|
||
"source_patient_id",
|
||
default=first_value(patient, "patient_id", "id", default=0),
|
||
),
|
||
0,
|
||
)
|
||
return {"detail": detail, "patient_id": patient_id}
|
||
|
||
def _load_dictionary_choices(self) -> dict[str, Any]:
|
||
"""Load optional editor labels without extending the authoritative-detail gate."""
|
||
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
||
dictionary_errors: list[str] = []
|
||
dictionary_types = sorted(
|
||
{
|
||
"diagnosis_type",
|
||
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
||
}
|
||
)
|
||
if callable(getattr(self.repository, "get_dictionaries", None)):
|
||
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
|
||
dictionaries = {
|
||
key: _dictionary_choices(get_value(raw, key, []), key)
|
||
for key in dictionary_types
|
||
}
|
||
elif callable(getattr(self.repository, "get_dictionary", None)):
|
||
for dictionary_type in dictionary_types:
|
||
try:
|
||
raw = invoke(
|
||
self.repository,
|
||
"get_dictionary",
|
||
dictionary_type=dictionary_type,
|
||
)
|
||
dictionaries[dictionary_type] = _dictionary_choices(raw, dictionary_type)
|
||
except Exception as error:
|
||
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
||
return {
|
||
"dictionaries": dictionaries,
|
||
"dictionary_errors": dictionary_errors,
|
||
}
|
||
|
||
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
|
||
if self._standalone_readonly or not self._authoritative_detail_loaded:
|
||
return
|
||
generation = self._generation
|
||
if not force and self._dictionary_requested_generation == generation:
|
||
return
|
||
self._dictionary_requested_generation = generation
|
||
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
|
||
self._dictionary_error_message = ""
|
||
if clear_error:
|
||
self._clear_message()
|
||
run_async(
|
||
self._load_dictionary_choices,
|
||
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
|
||
on_error=lambda error: self._dictionary_choices_loaded(
|
||
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
|
||
),
|
||
)
|
||
|
||
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
|
||
if generation != self._generation or not self._authoritative_detail_loaded:
|
||
return
|
||
# A clinician may already have edited fields while the optional labels load.
|
||
# Update only choices; re-rendering the detail would overwrite that draft.
|
||
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
|
||
errors = get_value(result, "dictionary_errors", []) or []
|
||
if errors:
|
||
self._dictionary_requested_generation = -1
|
||
self._dictionary_error_message = ";".join(str(item) for item in errors)
|
||
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||
|
||
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
||
filters: dict[str, Any] = {
|
||
"context_diagnosis_id": diagnosis_id,
|
||
"scene": "diagnosis_edit",
|
||
}
|
||
if patient_id > 0:
|
||
filters["patient_id"] = patient_id
|
||
return _invoke_first(
|
||
self.repository,
|
||
("list_prescription_orders",),
|
||
page_no=page,
|
||
page_size=self._orders_page_size,
|
||
**filters,
|
||
)
|
||
|
||
def _apply_bundle(self, result: Any, generation: int) -> None:
|
||
if generation != self._generation:
|
||
return
|
||
detail = get_value(result, "detail", None)
|
||
self._detail = detail
|
||
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
||
self._authoritative_detail_loaded = True
|
||
self._show_authoritative_content(True)
|
||
self._render(detail, [], [])
|
||
self._sync_form_interactivity()
|
||
self._sync_save_button()
|
||
self._set_loading(False)
|
||
errors = get_value(result, "dictionary_errors", []) or []
|
||
if errors:
|
||
self._show_message(";".join(str(item) for item in errors), "warning")
|
||
else:
|
||
self._clear_message()
|
||
if self._standalone_readonly:
|
||
self._load_visible_readonly_sections()
|
||
else:
|
||
self._ensure_tab_loaded(self._current_tab_key())
|
||
|
||
def _load_error(self, error: Exception, generation: int) -> None:
|
||
if generation == self._generation:
|
||
self._authoritative_detail_loaded = False
|
||
self._detail = None
|
||
self._clear_authoritative_content()
|
||
self._sync_form_interactivity()
|
||
self._sync_save_button()
|
||
message = friendly_error(error)
|
||
self._show_message(message, "danger", action_text="重试")
|
||
self.readonly_error_label.setText(message)
|
||
self.readonly_error.show()
|
||
|
||
def _load_finished(self, generation: int) -> None:
|
||
if generation == self._generation:
|
||
self._set_loading(False)
|
||
|
||
def _apply_dictionary_choices(
|
||
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
|
||
) -> None:
|
||
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
||
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
||
labels = {
|
||
normalized: str(label)
|
||
for label, value in dictionaries.get("diagnosis_type", [])
|
||
if (normalized := _diagnosis_type_value(value))
|
||
}
|
||
diagnosis_type_editor.set_choices(
|
||
tuple(
|
||
(labels.get(value, fallback), value)
|
||
for fallback, value in (
|
||
("初诊", "first_visit"),
|
||
("复诊", "follow_up"),
|
||
("会诊", "consultation"),
|
||
)
|
||
),
|
||
preserve=preserve,
|
||
)
|
||
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
||
if dictionary_type not in dictionaries:
|
||
continue
|
||
editor = self._choice_fields.get(key)
|
||
choices = list(dictionaries.get(dictionary_type, []))
|
||
if not _multiple:
|
||
choices.insert(0, ("无", ""))
|
||
if editor is not None:
|
||
editor.set_choices(choices, preserve=preserve)
|
||
# Defer until the drawer has a real width so wrapped chips get height.
|
||
QTimer.singleShot(0, self._sync_choice_field_heights)
|
||
|
||
def _sync_choice_field_heights(self) -> None:
|
||
for editor in self._choice_fields.values():
|
||
if hasattr(editor, "_sync_flow_height"):
|
||
editor._sync_flow_height()
|
||
if hasattr(self, "form_host"):
|
||
self.form_host.updateGeometry()
|
||
self.form_host.adjustSize()
|
||
|
||
def _show_authoritative_content(self, visible: bool) -> None:
|
||
self.readonly_patient_card.setVisible(visible)
|
||
self.case_grid.setVisible(visible)
|
||
for section in self._readonly_sections.values():
|
||
section.setVisible(visible)
|
||
if visible:
|
||
self.readonly_error.hide()
|
||
self.refresh_permissions()
|
||
|
||
def _clear_authoritative_content(self) -> None:
|
||
self._field_originals.clear()
|
||
for field in self.edit_fields.values():
|
||
field.setPlainText("")
|
||
field.setProperty("invalid", False)
|
||
field.setReadOnly(True)
|
||
repolish(field)
|
||
for field in self.display_fields.values():
|
||
field.setPlainText("")
|
||
self.meta_label.setText(f"诊单 #{self._diagnosis_id} · 详情加载失败")
|
||
self.case_grid.set_data({}, {})
|
||
for label in self.summary_fields.values():
|
||
label.setText("—")
|
||
self._clear_tables()
|
||
self._show_authoritative_content(False)
|
||
|
||
def _sync_form_interactivity(self) -> None:
|
||
for key, field in self.edit_fields.items():
|
||
locked = (
|
||
not self._authoritative_detail_loaded
|
||
or not self._editable
|
||
or (key in _PATIENT_BASIC_FIELDS and self._basic_locked)
|
||
or (key in {"phone", "id_card"} and not self._can_phone_plain)
|
||
)
|
||
field.setReadOnly(locked)
|
||
repolish(field)
|
||
|
||
def _current_tab_key(self) -> str:
|
||
index = self.tabs.currentIndex()
|
||
return str(self.tabs.tabBar().tabData(index) or "basic") if index >= 0 else "basic"
|
||
|
||
def _sync_save_button(self) -> None:
|
||
visible = self._editable and self._current_tab_key() == "basic"
|
||
self.save_button.setVisible(visible)
|
||
self.save_button.setEnabled(
|
||
visible and self._authoritative_detail_loaded and not self._saving
|
||
)
|
||
if self._saving:
|
||
self.save_button.set_state("loading")
|
||
elif self.save_button.state == "loading":
|
||
self.save_button.set_state("idle")
|
||
|
||
def _retry_detail(self) -> None:
|
||
if self._diagnosis_id <= 0:
|
||
return
|
||
self._authoritative_detail_loaded = False
|
||
self.readonly_error.hide()
|
||
self._clear_message()
|
||
self._show_message("正在重新加载权威诊单详情…", "info")
|
||
self._set_loading(True)
|
||
self._sync_form_interactivity()
|
||
self._sync_save_button()
|
||
self._start_detail_load()
|
||
|
||
def _retry_current(self) -> None:
|
||
if not self._authoritative_detail_loaded:
|
||
self._retry_detail()
|
||
return
|
||
if (self._dictionary_error_message
|
||
and self.drawer_banner.label.text() == self._dictionary_error_message):
|
||
self._ensure_dictionary_loaded(force=True)
|
||
return
|
||
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
||
|
||
def _invalidate_requests(self) -> None:
|
||
self._close_image_preview()
|
||
self._stop_watching_local_audio_uploads()
|
||
self._video_reload_pending = False
|
||
self._generation += 1
|
||
self._save_generation += 1
|
||
self._orders_generation += 1
|
||
self.orders_list.invalidate()
|
||
self._order_detail_generation += 1
|
||
self._daily_mutation_generation += 1
|
||
self._notes_mutation_generation += 1
|
||
self._media_generation += 1
|
||
self._prescription_detail_generation += 1
|
||
self._offset_generation += 1
|
||
for key in self._tab_generations:
|
||
self._tab_generations[key] += 1
|
||
self._loading_tabs.clear()
|
||
self._saving = False
|
||
self.save_button.set_state("idle")
|
||
for player in list(getattr(self, "_recording_players", [])):
|
||
with suppress(RuntimeError):
|
||
player.close()
|
||
self._recording_players.clear()
|
||
self._stop_inline_recordings()
|
||
order_dialog = getattr(self, "_last_order_detail_dialog", None)
|
||
if order_dialog is not None:
|
||
with suppress(RuntimeError):
|
||
order_dialog.close()
|
||
self._last_order_detail_dialog = None
|
||
local_audio_dialog = getattr(self, "_local_audio_dialog", None)
|
||
if local_audio_dialog is not None:
|
||
with suppress(RuntimeError):
|
||
local_audio_dialog.close()
|
||
self._local_audio_dialog = None
|
||
if hasattr(self, "readonly_loading"):
|
||
self.readonly_loading.hide()
|
||
if hasattr(self, "drawer_loading"):
|
||
self.drawer_loading.hide()
|
||
for panel in getattr(self, "_daily_panels", []):
|
||
panel.set_loading(False)
|
||
|
||
def _render(self, detail: Any, appointments: Sequence[Any], assignments: Sequence[Any]) -> None:
|
||
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
|
||
patient = get_value(detail, "patient", None) or {}
|
||
appointment = get_value(detail, "appointment", None) or {}
|
||
patient_name = first_value(
|
||
diagnosis,
|
||
"patient_name",
|
||
"name",
|
||
default=first_value(patient, "patient_name", "name", default="未命名患者"),
|
||
)
|
||
gender = _diagnosis_gender_text(
|
||
first_value(
|
||
diagnosis,
|
||
"gender_desc",
|
||
"gender",
|
||
default=first_value(patient, "gender_desc", "gender"),
|
||
)
|
||
)
|
||
age = display_text(first_value(diagnosis, "age", default=first_value(patient, "age")))
|
||
self.meta_label.setText(f"{patient_name} · 诊单 #{self._diagnosis_id}")
|
||
self.readonly_hero_name.setText(display_text(patient_name))
|
||
self.readonly_hero_meta.setText(f"{gender} · {age}岁")
|
||
self.readonly_patient_title.setText(display_text(patient_name))
|
||
self.summary_fields["patient"].setText(display_text(patient_name))
|
||
raw_phone = first_value(
|
||
diagnosis,
|
||
"phone",
|
||
"patient_phone",
|
||
default=first_value(
|
||
patient,
|
||
"phone",
|
||
"patient_phone",
|
||
default=first_value(appointment, "patient_phone"),
|
||
),
|
||
)
|
||
rendered_phone = raw_phone if self._can_phone_plain else _mask_phone(raw_phone)
|
||
self.summary_fields["phone"].setText(display_text(rendered_phone))
|
||
id_card = first_value(
|
||
diagnosis,
|
||
"id_card",
|
||
default=first_value(patient, "id_card", default=""),
|
||
)
|
||
self.summary_fields["id_card"].setText(
|
||
display_text(id_card if self._can_phone_plain else _mask_id_card(id_card))
|
||
)
|
||
self.summary_fields["gender_age"].setText(f"{gender} / {age}岁")
|
||
self.summary_fields["body"].setText(
|
||
f"{display_text(first_value(diagnosis, 'height', default=first_value(patient, 'height')))} cm / "
|
||
f"{display_text(first_value(diagnosis, 'weight', default=first_value(patient, 'weight')))} kg"
|
||
)
|
||
self.summary_fields["region"].setText(
|
||
display_text(first_value(diagnosis, "region", default=first_value(patient, "region")))
|
||
)
|
||
appointment_text = " ".join(
|
||
part
|
||
for part in (
|
||
display_text(first_value(appointment, "appointment_date"), ""),
|
||
display_text(
|
||
first_value(appointment, "appointment_time", "appointment_time_text"), ""
|
||
),
|
||
display_text(first_value(appointment, "period", "period_text"), ""),
|
||
)
|
||
if part
|
||
)
|
||
self.summary_fields["appointment"].setText(appointment_text or "暂无预约")
|
||
self.summary_fields["staff"].setText(
|
||
f"医生:{display_text(first_value(appointment, 'doctor_name'))} "
|
||
f"客服:{display_text(first_value(appointment, 'assistant_name'))}"
|
||
)
|
||
self.summary_fields["appointment_status"].setText(
|
||
display_text(
|
||
first_value(
|
||
appointment,
|
||
"status_desc",
|
||
"status_text",
|
||
default=first_value(diagnosis, "appointment_status_desc", "appointment_status"),
|
||
)
|
||
)
|
||
)
|
||
has_prescription = _truthy(
|
||
first_value(diagnosis, "has_prescription", "is_prescribed", default=False)
|
||
)
|
||
self.summary_fields["prescription_status"].setText(
|
||
"已开方" if has_prescription else "未开方"
|
||
)
|
||
self.summary_fields["remark"].setText(
|
||
display_text(
|
||
first_value(appointment, "remark", default=first_value(diagnosis, "remark"))
|
||
)
|
||
)
|
||
self.summary_fields["pressure"].setText(
|
||
f"{display_text(first_value(diagnosis, 'systolic_pressure'))} / "
|
||
f"{display_text(first_value(diagnosis, 'diastolic_pressure'))} mmHg"
|
||
)
|
||
self.summary_fields["blood_sugar"].setText(
|
||
f"{display_text(first_value(diagnosis, 'fasting_blood_sugar'))} mmol/L"
|
||
)
|
||
offset = max(
|
||
0,
|
||
min(
|
||
20,
|
||
_int(
|
||
first_value(
|
||
diagnosis,
|
||
"revisit_slot_start_offset",
|
||
"revisitSlotStartOffset",
|
||
default=0,
|
||
),
|
||
0,
|
||
),
|
||
),
|
||
)
|
||
blocked = self.order_offset.blockSignals(True)
|
||
self.order_offset.setValue(offset)
|
||
self.order_offset.blockSignals(blocked)
|
||
self._saved_order_offset = offset
|
||
self._order_offset_changed(offset)
|
||
self._render_unserved(detail, diagnosis)
|
||
self.case_grid.set_data(diagnosis, patient)
|
||
if not self._can_phone_plain:
|
||
for label in self.case_grid.value_labels.get("phone", []):
|
||
label.setText(display_text(_mask_phone(raw_phone)))
|
||
for label in self.case_grid.value_labels.get("id_card", []):
|
||
label.setText(display_text(_mask_id_card(id_card)))
|
||
self.display_fields["__diagnosis_id"].setPlainText(str(self._diagnosis_id))
|
||
|
||
basic_locked = _truthy(
|
||
first_value(diagnosis, "patient_basic_locked", default=False)
|
||
) or not _truthy(first_value(diagnosis, "can_edit_patient_basic", default=True))
|
||
self._basic_locked = basic_locked
|
||
self.form_host.setProperty("locked", basic_locked)
|
||
repolish(self.form_host)
|
||
self.privacy_banner.setVisible((self._editable or self._view_only) and basic_locked)
|
||
latest_order = first_value(diagnosis, "latest_prescription_order", default=None)
|
||
if latest_order:
|
||
order_id = first_value(latest_order, "id", "order_no")
|
||
order_status = first_value(
|
||
latest_order, "fulfillment_status_text", "status_text", "status"
|
||
)
|
||
self.privacy_label.setText(
|
||
f"最近业务订单 #{display_text(order_id)} 状态为「{display_text(order_status)}」,"
|
||
"患者基本信息不可修改"
|
||
)
|
||
else:
|
||
self.privacy_label.setText("存在未完成的业务订单,患者基本信息不可修改")
|
||
self._field_originals.clear()
|
||
for key, field in self.edit_fields.items():
|
||
raw = first_value(
|
||
diagnosis,
|
||
key,
|
||
default=first_value(
|
||
patient,
|
||
key,
|
||
default=first_value(
|
||
diagnosis,
|
||
"patient_phone" if key == "phone" else key,
|
||
default="",
|
||
),
|
||
),
|
||
)
|
||
if key == "appetite" and raw in (None, "", [], ()):
|
||
raw = first_value(diagnosis, "oral_condition", "mouth_condition", default="")
|
||
if key == "diagnosis_type":
|
||
raw = _diagnosis_type_value(raw) or _diagnosis_type_value(
|
||
first_value(diagnosis, "consultation_type", default="")
|
||
)
|
||
if key == "gender":
|
||
gender_token = str(raw).strip().lower() if raw not in (None, "") else ""
|
||
if gender_token in {"2", "f", "female", "女"}:
|
||
raw = 0
|
||
elif gender_token in {"1", "m", "male", "男"}:
|
||
raw = 1
|
||
self._field_originals[key] = raw
|
||
if key == "phone" and not self._can_phone_plain:
|
||
rendered = _mask_phone(raw)
|
||
elif key == "id_card" and not self._can_phone_plain:
|
||
rendered = _mask_id_card(raw)
|
||
else:
|
||
rendered = _editor_text(raw)
|
||
field.setPlainText(rendered)
|
||
field.setProperty("invalid", False)
|
||
field.setReadOnly(
|
||
not self._editable
|
||
or (key in _PATIENT_BASIC_FIELDS and basic_locked)
|
||
or (key in {"phone", "id_card"} and not self._can_phone_plain)
|
||
)
|
||
repolish(field)
|
||
|
||
self._fill_notes([])
|
||
latest_appointments = list(appointments)
|
||
if not latest_appointments and appointment:
|
||
latest_appointments = [appointment]
|
||
self._fill_appointments(latest_appointments)
|
||
self._fill_assignments(assignments)
|
||
|
||
def _render_unserved(self, detail: Any, diagnosis: Any) -> None:
|
||
value = get_value(detail, "unserved_days", None)
|
||
if value is None:
|
||
value = first_value(diagnosis, "unserved_days", default=None)
|
||
if value in (None, ""):
|
||
self.readonly_status.setText("从未打卡")
|
||
severity = "neutral"
|
||
else:
|
||
days = _int(value, 0)
|
||
self.readonly_status.setText(f"未服务 {days} 天")
|
||
severity = "success" if days <= 2 else "warning" if days <= 6 else "danger"
|
||
self.readonly_status.setProperty("severity", severity)
|
||
repolish(self.readonly_status)
|
||
|
||
@staticmethod
|
||
def _status_kind(value: Any) -> str:
|
||
text = str(value or "")
|
||
if any(token in text for token in ("失败", "驳回", "作废", "取消", "异常")):
|
||
return "danger"
|
||
if any(token in text for token in ("待", "未", "过号", "爽约")):
|
||
return "warning"
|
||
if any(token in text for token in ("已", "完成", "成功", "确认")):
|
||
return "success"
|
||
return "info"
|
||
|
||
def _semanticize(self, table: RecordTable, column: int) -> None:
|
||
for row in range(table.rowCount()):
|
||
item = table.item(row, column)
|
||
if item is not None:
|
||
set_status_tag(
|
||
table,
|
||
row,
|
||
column,
|
||
item.text(),
|
||
self._status_kind(item.text()),
|
||
)
|
||
|
||
def _tab_changed(self, _index: int) -> None:
|
||
self._sync_save_button()
|
||
if self._authoritative_detail_loaded and not self._standalone_readonly:
|
||
key = self._current_tab_key()
|
||
if key == "video":
|
||
self._reload_video_records(self._diagnosis_id)
|
||
else:
|
||
self._ensure_tab_loaded(key)
|
||
|
||
def _load_visible_readonly_sections(self) -> None:
|
||
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||
for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"):
|
||
if self._tab_allowed(permission_map.get(key, ())):
|
||
self._ensure_tab_loaded(key)
|
||
|
||
def _set_tab_loading(self, key: str) -> None:
|
||
if key == "notes":
|
||
for timeline in self._notes_timelines:
|
||
timeline.set_notes([])
|
||
timeline.set_state("正在加载医生备注…")
|
||
elif key == "daily":
|
||
for panel in self._daily_panels:
|
||
panel.set_loading(True)
|
||
elif key == "chat":
|
||
for panel in self._chat_panels:
|
||
panel.set_unavailable("正在加载聊天归档…")
|
||
elif key in self._table_registry:
|
||
for table in self._table_registry[key]:
|
||
table.set_matrix([])
|
||
table.set_empty_text("正在加载…")
|
||
|
||
def _set_tab_error(self, key: str, text: str) -> None:
|
||
message = f"加载失败:{text}"
|
||
if key == "notes":
|
||
for timeline in self._notes_timelines:
|
||
timeline.set_state(message)
|
||
elif key == "daily":
|
||
for panel in self._daily_panels:
|
||
panel.set_error(message)
|
||
elif key == "chat":
|
||
for panel in self._chat_panels:
|
||
panel.set_unavailable(message)
|
||
elif key in self._table_registry:
|
||
for table in self._table_registry[key]:
|
||
table.set_empty_text(message)
|
||
|
||
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
||
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||
return
|
||
if key == "basic":
|
||
self._ensure_dictionary_loaded(force=force)
|
||
return
|
||
if key == "daily":
|
||
source = next(
|
||
(panel for panel in self._daily_panels if panel.isVisibleTo(self)),
|
||
self._daily_panels[0],
|
||
)
|
||
start, end = source.current_range()
|
||
self._load_daily_range(start, end, force=force)
|
||
return
|
||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||
return
|
||
if key == "orders":
|
||
self._load_orders()
|
||
return
|
||
self._tab_generations[key] += 1
|
||
generation = self._tab_generations[key]
|
||
diagnosis_id = self._diagnosis_id
|
||
self._loading_tabs.add(key)
|
||
self._set_tab_loading(key)
|
||
run_async(
|
||
lambda: self._query_tab(key, diagnosis_id),
|
||
on_success=lambda result: self._apply_tab_result(key, result, diagnosis_id, generation),
|
||
on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation),
|
||
)
|
||
|
||
def _query_tab(self, key: str, diagnosis_id: int) -> Any:
|
||
if key == "notes":
|
||
return _invoke_first(self.repository, ("get_doctor_notes",), diagnosis_id=diagnosis_id)
|
||
if key == "prescription":
|
||
return _invoke_first(
|
||
self.repository,
|
||
("list_prescriptions_by_diagnosis",),
|
||
diagnosis_id=diagnosis_id,
|
||
)
|
||
if key == "orders":
|
||
return self._query_orders(diagnosis_id, self._patient_id, 1)
|
||
if key == "assign":
|
||
return _invoke_first(
|
||
self.repository,
|
||
("assign_history",),
|
||
diagnosis_id=diagnosis_id,
|
||
page_no=1,
|
||
page_size=50,
|
||
)
|
||
if key == "appointment":
|
||
return _invoke_first(
|
||
self.repository,
|
||
("appointment_history",),
|
||
diagnosis_id=diagnosis_id,
|
||
page_no=1,
|
||
page_size=500,
|
||
)
|
||
if key == "video":
|
||
names = tuple(
|
||
name
|
||
for name in ("list_call_records", "get_call_records")
|
||
if callable(getattr(self.repository, name, None))
|
||
)
|
||
if not names:
|
||
return {"unsupported": "桌面端尚未接入视频回放列表接口。"}
|
||
return _invoke_first(self.repository, names, diagnosis_id=diagnosis_id)
|
||
if key == "chat":
|
||
names = tuple(
|
||
name
|
||
for name in ("list_im_chat_messages", "get_im_chat_messages", "list_chat_records")
|
||
if callable(getattr(self.repository, name, None))
|
||
)
|
||
if not names:
|
||
return {"unsupported": "桌面端尚未接入 IM 聊天归档接口。"}
|
||
return _invoke_first(
|
||
self.repository,
|
||
names,
|
||
diagnosis_id=diagnosis_id,
|
||
only_archived=True,
|
||
)
|
||
return []
|
||
|
||
def _apply_tab_result(
|
||
self,
|
||
key: str,
|
||
result: Any,
|
||
diagnosis_id: int,
|
||
generation: int,
|
||
) -> None:
|
||
if (
|
||
generation != self._tab_generations[key]
|
||
or diagnosis_id != self._diagnosis_id
|
||
or not self._authoritative_detail_loaded
|
||
):
|
||
return
|
||
self._loading_tabs.discard(key)
|
||
self._loaded_tabs.add(key)
|
||
unsupported = get_value(result, "unsupported", "")
|
||
if unsupported:
|
||
if key == "chat":
|
||
for panel in self._chat_panels:
|
||
panel.set_unavailable(str(unsupported))
|
||
else:
|
||
for table in self._table_registry.get(key, []):
|
||
table.set_matrix([])
|
||
table.set_empty_text(str(unsupported))
|
||
elif key == "notes":
|
||
self._fill_notes(page_items(result))
|
||
elif key == "prescription":
|
||
self._fill_prescriptions(page_items(result))
|
||
elif key == "orders":
|
||
rows = page_items(result)
|
||
self._orders_page = self.orders_list.page
|
||
self._orders_total = page_total(result, len(rows))
|
||
self._fill_orders(rows)
|
||
elif key == "assign":
|
||
self._fill_assignments(page_items(result))
|
||
elif key == "appointment":
|
||
self._fill_appointments(page_items(result))
|
||
elif key == "video":
|
||
self._fill_video(page_items(result))
|
||
elif key == "chat":
|
||
rows = page_items(result)
|
||
for panel in self._chat_panels:
|
||
panel.set_messages(rows)
|
||
if not self._standalone_readonly and self._current_tab_key() == key:
|
||
self._clear_message()
|
||
if key == "video":
|
||
self._flush_video_reload_if_pending(diagnosis_id)
|
||
|
||
def _tab_load_error(
|
||
self,
|
||
key: str,
|
||
error: Exception,
|
||
diagnosis_id: int,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._loading_tabs.discard(key)
|
||
text = friendly_error(error)
|
||
self._set_tab_error(key, text)
|
||
if not self._standalone_readonly and self._current_tab_key() == key:
|
||
self._show_message(text, "danger", action_text="重试")
|
||
if key == "video":
|
||
self._flush_video_reload_if_pending(diagnosis_id)
|
||
|
||
def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None:
|
||
key = "daily"
|
||
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||
return
|
||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||
return
|
||
self._tab_generations[key] += 1
|
||
generation = self._tab_generations[key]
|
||
diagnosis_id = self._diagnosis_id
|
||
status = self._daily_todo_status
|
||
self._loading_tabs.add(key)
|
||
for panel in self._daily_panels:
|
||
panel.set_range(start, end)
|
||
self._set_tab_loading(key)
|
||
|
||
def load() -> dict[str, Any]:
|
||
window = _invoke_first(
|
||
self.repository,
|
||
("get_tracking_window",),
|
||
diagnosis_id=diagnosis_id,
|
||
start_date=start,
|
||
end_date=end,
|
||
)
|
||
notes = _invoke_first(
|
||
self.repository,
|
||
("list_tracking_notes",),
|
||
diagnosis_id=diagnosis_id,
|
||
)
|
||
todos: Any = []
|
||
if callable(getattr(self.repository, "list_diagnosis_todos", None)):
|
||
todos = invoke(
|
||
self.repository,
|
||
"list_diagnosis_todos",
|
||
diagnosis_id=diagnosis_id,
|
||
page_no=1,
|
||
page_size=15,
|
||
status=status,
|
||
)
|
||
return {"window": window, "notes": notes, "todos": todos}
|
||
|
||
run_async(
|
||
load,
|
||
on_success=lambda result: self._apply_daily_result(result, diagnosis_id, generation),
|
||
on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation),
|
||
)
|
||
|
||
def _apply_daily_result(self, result: Any, diagnosis_id: int, generation: int) -> None:
|
||
key = "daily"
|
||
if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._loading_tabs.discard(key)
|
||
self._loaded_tabs.add(key)
|
||
window = get_value(result, "window", {}) or {}
|
||
notes = page_items(get_value(result, "notes", []) or [])
|
||
todo_result = get_value(result, "todos", []) or []
|
||
todos = page_items(todo_result)
|
||
total = page_total(todo_result, len(todos))
|
||
for panel in self._daily_panels:
|
||
panel.set_data(window, notes)
|
||
panel.set_todos(todos, total)
|
||
if not self._standalone_readonly and self._current_tab_key() == key:
|
||
self._clear_message()
|
||
|
||
def _daily_todo_filter_changed(self, status: Any) -> None:
|
||
self._daily_todo_status = None if status is None else _int(status)
|
||
source = next(
|
||
(panel for panel in self._daily_panels if panel.isVisibleTo(self)),
|
||
self._daily_panels[0],
|
||
)
|
||
start, end = source.current_range()
|
||
self._load_daily_range(start, end, force=True)
|
||
|
||
def _add_daily_record(self, kind: str) -> None:
|
||
if not self._editable or not self._can_daily_mutate:
|
||
self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning")
|
||
return
|
||
if self._patient_id <= 0:
|
||
self._show_message("诊单缺少患者 ID,无法新增日常记录。", "danger")
|
||
return
|
||
record_date = QDate.currentDate().toString("yyyy-MM-dd")
|
||
base: dict[str, Any] = {
|
||
"diagnosis_id": self._diagnosis_id,
|
||
"patient_id": self._patient_id,
|
||
"record_date": record_date,
|
||
}
|
||
if kind in {"blood", "diet", "exercise"}:
|
||
editor = DailyRecordEditorDialog(kind, base, parent=self)
|
||
if isinstance(editor, QWidget):
|
||
editor.setStyleSheet(DIAGNOSIS_QSS)
|
||
if editor.exec() == QDialog.DialogCode.Accepted:
|
||
self._submit_daily_record(kind, editor.payload())
|
||
return
|
||
if kind == "tracking":
|
||
content, accepted = QInputDialog.getMultiLineText(
|
||
self, "新增跟踪备注", "备注内容(最多 1000 字)"
|
||
)
|
||
if accepted:
|
||
self._submit_daily_record(kind, {"content": content})
|
||
|
||
def _edit_daily_record(self, kind: str, record: Any) -> None:
|
||
if not self._editable or not self._can_daily_edit:
|
||
self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning")
|
||
return
|
||
if kind not in {"blood", "diet", "exercise"}:
|
||
return
|
||
source = dict(record) if isinstance(record, Mapping) else {}
|
||
record_id = _int(first_value(source, "id"), 0)
|
||
if record_id <= 0:
|
||
self._show_message("该记录缺少 ID,无法安全编辑。", "warning")
|
||
return
|
||
source_diagnosis_id = _int(first_value(source, "diagnosis_id"), 0)
|
||
source_patient_id = _int(first_value(source, "patient_id"), 0)
|
||
if source_diagnosis_id not in {0, self._diagnosis_id} or source_patient_id not in {
|
||
0,
|
||
self._patient_id,
|
||
}:
|
||
self._show_message("记录归属与当前诊单不一致,已拒绝编辑。", "danger")
|
||
return
|
||
source.update(
|
||
{
|
||
"id": record_id,
|
||
"diagnosis_id": self._diagnosis_id,
|
||
"patient_id": self._patient_id,
|
||
}
|
||
)
|
||
editor = DailyRecordEditorDialog(kind, source, parent=self)
|
||
if isinstance(editor, QWidget):
|
||
editor.setStyleSheet(DIAGNOSIS_QSS)
|
||
if editor.exec() == QDialog.DialogCode.Accepted:
|
||
self._submit_daily_record(kind, editor.payload())
|
||
|
||
def _submit_daily_record(self, kind: str, payload: Mapping[str, Any]) -> None:
|
||
add_operations = {
|
||
"blood": "add_blood_record",
|
||
"diet": "add_diet_record",
|
||
"exercise": "add_exercise_record",
|
||
"tracking": "add_tracking_note",
|
||
}
|
||
update_operations = {
|
||
"blood": "update_blood_record",
|
||
"diet": "update_diet_record",
|
||
"exercise": "update_exercise_record",
|
||
}
|
||
frozen = dict(payload)
|
||
if kind != "tracking":
|
||
payload_diagnosis_id = _int(frozen.get("diagnosis_id"), 0)
|
||
payload_patient_id = _int(frozen.get("patient_id"), 0)
|
||
if payload_diagnosis_id != self._diagnosis_id or payload_patient_id != self._patient_id:
|
||
self._show_message("日常记录归属与当前诊单不一致,已拒绝提交。", "danger")
|
||
return
|
||
record_id = _int(frozen.get("id"), 0)
|
||
updating = record_id > 0 and kind in update_operations
|
||
if updating and (not self._editable or not self._can_daily_edit):
|
||
self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning")
|
||
return
|
||
if not updating and (not self._editable or not self._can_daily_mutate):
|
||
self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning")
|
||
return
|
||
method = update_operations.get(kind) if updating else add_operations.get(kind)
|
||
if method is None or not callable(getattr(self.repository, method, None)):
|
||
self._show_message("该日常记录接口不可用。", "danger")
|
||
return
|
||
diagnosis_id = self._diagnosis_id
|
||
|
||
def operation() -> Any:
|
||
if kind == "tracking":
|
||
return invoke(
|
||
self.repository,
|
||
method,
|
||
diagnosis_id=diagnosis_id,
|
||
content=str(frozen.get("content") or ""),
|
||
)
|
||
if updating:
|
||
return invoke(self.repository, method, record=frozen)
|
||
return invoke(self.repository, method, payload=frozen)
|
||
|
||
self._run_daily_mutation(operation, "日常记录已更新。" if updating else "日常记录已保存。")
|
||
|
||
def _add_diagnosis_todo(self) -> None:
|
||
if not self._editable or not self._can_daily_mutate:
|
||
return
|
||
content, accepted = QInputDialog.getMultiLineText(self, "新增待办", "待办内容(1–500 字)")
|
||
if not accepted:
|
||
return
|
||
default_time = QDateTime.currentDateTime().addSecs(3600).toString("yyyy-MM-dd HH:mm")
|
||
time_text, accepted = QInputDialog.getText(
|
||
self, "新增待办", "提醒时间(YYYY-MM-DD HH:mm)", text=default_time
|
||
)
|
||
if not accepted:
|
||
return
|
||
remind_at = QDateTime.fromString(time_text.strip(), "yyyy-MM-dd HH:mm")
|
||
if not remind_at.isValid():
|
||
self._show_message("提醒时间格式不正确。", "warning")
|
||
return
|
||
self._run_daily_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"add_diagnosis_todo",
|
||
diagnosis_id=self._diagnosis_id,
|
||
content=content,
|
||
remind_time=remind_at.toSecsSinceEpoch(),
|
||
),
|
||
"待办已新增。",
|
||
)
|
||
|
||
def _cancel_diagnosis_todo(self, todo_id: int) -> None:
|
||
if not self._editable or not self._can_daily_mutate or todo_id <= 0:
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"取消待办",
|
||
"确定取消这条待办吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._run_daily_mutation(
|
||
lambda: invoke(self.repository, "cancel_diagnosis_todo", todo_id=todo_id),
|
||
"待办已取消。",
|
||
)
|
||
|
||
def _run_daily_mutation(self, operation: Any, success_text: str) -> None:
|
||
self._daily_mutation_generation += 1
|
||
generation = self._daily_mutation_generation
|
||
diagnosis_id = self._diagnosis_id
|
||
self._show_message("正在提交…", "info")
|
||
run_async(
|
||
operation,
|
||
on_success=lambda _result: self._daily_mutation_success(
|
||
success_text, diagnosis_id, generation
|
||
),
|
||
on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "daily"),
|
||
)
|
||
|
||
def _daily_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None:
|
||
if generation != self._daily_mutation_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._show_message(text, "success")
|
||
self._loaded_tabs.discard("daily")
|
||
source = next(
|
||
(panel for panel in self._daily_panels if panel.isVisibleTo(self)),
|
||
self._daily_panels[0],
|
||
)
|
||
self._load_daily_range(*source.current_range(), force=True)
|
||
|
||
def _mutation_error(
|
||
self,
|
||
error: Exception,
|
||
diagnosis_id: int,
|
||
generation: int,
|
||
kind: str,
|
||
) -> None:
|
||
current = {
|
||
"daily": self._daily_mutation_generation,
|
||
"notes": self._notes_mutation_generation,
|
||
"media": self._media_generation,
|
||
"offset": self._offset_generation,
|
||
}[kind]
|
||
if generation == current and diagnosis_id == self._diagnosis_id:
|
||
self._show_message(friendly_error(error), "danger")
|
||
|
||
def _add_doctor_note(self) -> None:
|
||
if not self._editable or not self._can_note_add:
|
||
return
|
||
content, accepted = QInputDialog.getMultiLineText(
|
||
self, "新增 / 追加诊疗笔记", "笔记内容(最多 500 字)"
|
||
)
|
||
if not accepted:
|
||
return
|
||
self._run_notes_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"add_doctor_note",
|
||
diagnosis_id=self._diagnosis_id,
|
||
content=content,
|
||
),
|
||
"诊疗笔记已保存。",
|
||
)
|
||
|
||
def _upload_doctor_note_material(self, image_type: str) -> None:
|
||
if (
|
||
image_type not in {"tongue_images", "report_files"}
|
||
or not self._editable
|
||
or not self._can_note_add
|
||
):
|
||
return
|
||
if not all(
|
||
callable(getattr(self.repository, name, None))
|
||
for name in ("upload_material", "add_doctor_note")
|
||
):
|
||
self._show_message("附件上传接口不可用。", "warning")
|
||
return
|
||
caption = "选择舌象图片" if image_type == "tongue_images" else "选择检查报告"
|
||
file_filter = (
|
||
"图片 (*.png *.jpg *.jpeg *.webp)"
|
||
if image_type == "tongue_images"
|
||
else "报告文件 (*.pdf *.png *.jpg *.jpeg *.doc *.docx);;所有文件 (*)"
|
||
)
|
||
path, _selected = QFileDialog.getOpenFileName(self, caption, "", file_filter)
|
||
if not path:
|
||
return
|
||
diagnosis_id = self._diagnosis_id
|
||
|
||
def operation() -> Any:
|
||
reference = invoke(
|
||
self.repository,
|
||
"upload_material",
|
||
path=path,
|
||
material_type=image_type,
|
||
)
|
||
kwargs: dict[str, Any] = {
|
||
"diagnosis_id": diagnosis_id,
|
||
"content": "",
|
||
image_type: [str(reference)],
|
||
}
|
||
return invoke(self.repository, "add_doctor_note", **kwargs)
|
||
|
||
self._run_notes_mutation(operation, "附件已上传并写入诊疗笔记。")
|
||
|
||
def _delete_doctor_note_attachment(
|
||
self, note_id: int, image_type: str, image_path: str
|
||
) -> None:
|
||
if (
|
||
not self._editable
|
||
or not self._can_note_delete
|
||
or note_id <= 0
|
||
or image_type not in {"tongue_images", "report_files"}
|
||
or not callable(getattr(self.repository, "delete_doctor_note_image", None))
|
||
):
|
||
return
|
||
answer = QMessageBox.warning(
|
||
self,
|
||
"删除附件",
|
||
"只删除当前附件,诊疗笔记正文会保留。确定继续吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._run_notes_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"delete_doctor_note_image",
|
||
note_id=note_id,
|
||
image_type=image_type,
|
||
image_path=image_path,
|
||
),
|
||
"附件已删除。",
|
||
)
|
||
|
||
def _run_notes_mutation(self, operation: Any, success_text: str) -> None:
|
||
self._notes_mutation_generation += 1
|
||
generation = self._notes_mutation_generation
|
||
diagnosis_id = self._diagnosis_id
|
||
self._show_message("正在更新诊疗笔记…", "info")
|
||
run_async(
|
||
operation,
|
||
on_success=lambda _result: self._notes_mutation_success(
|
||
success_text, diagnosis_id, generation
|
||
),
|
||
on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "notes"),
|
||
)
|
||
|
||
def _notes_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None:
|
||
if generation != self._notes_mutation_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._show_message(text, "success")
|
||
self._loaded_tabs.discard("notes")
|
||
self._ensure_tab_loaded("notes", force=True)
|
||
|
||
def _open_new_prescription(self) -> None:
|
||
if not self._editable or not self._can_prescribe:
|
||
self._show_message("当前账号无开方权限或接口不可用。", "warning")
|
||
return
|
||
from .prescription import (
|
||
PrescriptionEditorDialog,
|
||
build_prescription_clinical_diagnosis,
|
||
build_prescription_visit_no,
|
||
)
|
||
|
||
detail = self._detail or {}
|
||
diagnosis = get_value(detail, "diagnosis", None) or detail
|
||
appointment = get_value(detail, "appointment", None) or {}
|
||
case_record = first_value(diagnosis, "case_record", default={}) or {}
|
||
diagnosis_id = self._diagnosis_id
|
||
appointment_id = _int(
|
||
first_value(appointment, "id", "appointment_id", default=0)
|
||
or first_value(diagnosis, "appointment_id", default=0),
|
||
0,
|
||
)
|
||
seed = {
|
||
"diagnosis_id": diagnosis_id,
|
||
"appointment_id": appointment_id,
|
||
"case_record": dict(case_record) if isinstance(case_record, Mapping) else {},
|
||
"patient_name": first_value(diagnosis, "patient_name", "name"),
|
||
"gender": _int(first_value(diagnosis, "gender"), 0),
|
||
"age": _int(first_value(diagnosis, "age"), 0),
|
||
"phone": first_value(diagnosis, "phone", default=""),
|
||
"visit_no": build_prescription_visit_no(
|
||
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
||
),
|
||
"tongue": first_value(diagnosis, "tongue", "tongue_coating"),
|
||
"tongue_image": first_value(diagnosis, "tongue_image"),
|
||
"pulse": first_value(diagnosis, "pulse"),
|
||
"pulse_condition": first_value(diagnosis, "pulse_condition"),
|
||
"clinical_diagnosis": build_prescription_clinical_diagnosis(diagnosis, case_record),
|
||
"doctor_name": first_value(
|
||
appointment, "doctor_name", default=first_value(diagnosis, "doctor_name")
|
||
),
|
||
}
|
||
dialog = PrescriptionEditorDialog(
|
||
self.repository,
|
||
seed,
|
||
mode="add",
|
||
current_user=getattr(self.parentWidget(), "current_user", None),
|
||
parent=self,
|
||
)
|
||
dialog.diagnosis_requested.connect(self._open_current_diagnosis_detail)
|
||
dialog.setObjectName("DiagnosisPrescriptionEditor")
|
||
dialog.setStyleSheet(DIAGNOSIS_QSS)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
payload = dialog.payload()
|
||
payload.update(
|
||
{
|
||
"diagnosis_id": diagnosis_id,
|
||
"appointment_id": appointment_id,
|
||
"case_record": seed["case_record"],
|
||
}
|
||
)
|
||
self._run_media_mutation(
|
||
lambda: invoke(self.repository, "create_prescription", prescription=payload),
|
||
"处方已开具并提交审核。",
|
||
"prescription",
|
||
)
|
||
|
||
def _upload_call_recording(self, call_record_id: int | None = None) -> None:
|
||
if not self._editable or not self._can_video_upload:
|
||
return
|
||
if call_record_id is not None and call_record_id <= 0:
|
||
self._show_message("通话记录 ID 无效,无法追加回放。", "warning")
|
||
return
|
||
path, _selected = QFileDialog.getOpenFileName(
|
||
self,
|
||
"上传本地视频回放",
|
||
"",
|
||
"视频 (*.mp4 *.mov *.m4v *.webm);;所有文件 (*)",
|
||
)
|
||
if not path:
|
||
return
|
||
self._run_media_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"upload_call_recording",
|
||
path=path,
|
||
diagnosis_id=self._diagnosis_id,
|
||
call_record_id=call_record_id,
|
||
),
|
||
"视频回放已追加到指定通话记录。"
|
||
if call_record_id is not None
|
||
else "视频回放已上传并创建通话记录。",
|
||
"video",
|
||
)
|
||
|
||
def _open_local_audio_queue(self) -> None:
|
||
if self._diagnosis_id <= 0 or not self._can_video_upload:
|
||
self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning")
|
||
return
|
||
current = self._local_audio_dialog
|
||
if current is not None and current.isVisible():
|
||
current.raise_()
|
||
current.activateWindow()
|
||
return
|
||
try:
|
||
dialog = LocalAudioQueueDialog(
|
||
self.repository,
|
||
self._diagnosis_id,
|
||
self,
|
||
)
|
||
except (OSError, RuntimeError, sqlite3.Error) as error:
|
||
self._show_message(f"无法打开本机录音文件:{error}", "danger")
|
||
return
|
||
self._local_audio_dialog = dialog
|
||
opened_diagnosis_id = self._diagnosis_id
|
||
self._watch_local_audio_uploads(dialog.manager)
|
||
|
||
def clear_dialog(_result: int) -> None:
|
||
if self._local_audio_dialog is dialog:
|
||
self._local_audio_dialog = None
|
||
|
||
dialog.finished.connect(clear_dialog)
|
||
dialog.open()
|
||
# Opening the queue commonly follows a background upload that completed
|
||
# before this dialog existed, so also refresh the server-backed rows now.
|
||
self._reload_video_records(opened_diagnosis_id)
|
||
|
||
def _local_audio_upload_succeeded(
|
||
self,
|
||
diagnosis_id: int,
|
||
_call_record_id: int,
|
||
) -> None:
|
||
if self._local_audio_upload_manager is None:
|
||
return
|
||
self._reload_video_records(diagnosis_id)
|
||
|
||
def _reload_video_records(self, diagnosis_id: int) -> None:
|
||
if int(diagnosis_id) != self._diagnosis_id:
|
||
return
|
||
if "video" in self._loading_tabs:
|
||
self._video_reload_pending = True
|
||
return
|
||
self._video_reload_pending = False
|
||
self._loaded_tabs.discard("video")
|
||
self._ensure_tab_loaded("video", force=True)
|
||
|
||
def _watch_local_audio_uploads(self, manager: Any) -> None:
|
||
if self._local_audio_upload_manager is manager:
|
||
return
|
||
self._stop_watching_local_audio_uploads()
|
||
|
||
def notify(record: Any) -> None:
|
||
with suppress(RuntimeError):
|
||
self._local_audio_upload_completed.emit(
|
||
int(record.diagnosis_id),
|
||
int(record.call_record_id or 0),
|
||
)
|
||
|
||
manager.add_upload_listener(notify)
|
||
self._local_audio_upload_manager = manager
|
||
self._local_audio_upload_listener = notify
|
||
|
||
def _stop_watching_local_audio_uploads(self) -> None:
|
||
manager = self._local_audio_upload_manager
|
||
listener = self._local_audio_upload_listener
|
||
self._local_audio_upload_manager = None
|
||
self._local_audio_upload_listener = None
|
||
if manager is not None and listener is not None:
|
||
with suppress(Exception):
|
||
manager.remove_upload_listener(listener)
|
||
|
||
def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None:
|
||
if not self._video_reload_pending or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._video_reload_pending = False
|
||
QTimer.singleShot(
|
||
0,
|
||
lambda expected_id=diagnosis_id: self._reload_video_records(expected_id),
|
||
)
|
||
|
||
def _sync_chat_archive(self) -> None:
|
||
if not self._can_chat_sync or self._diagnosis_id <= 0:
|
||
self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning")
|
||
return
|
||
self._run_media_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"sync_im_chat_messages",
|
||
diagnosis_id=self._diagnosis_id,
|
||
),
|
||
"已提交聊天归档同步,请稍后重新加载。",
|
||
"chat",
|
||
)
|
||
|
||
def _run_media_mutation(self, operation: Any, success_text: str, reload_key: str) -> None:
|
||
self._media_generation += 1
|
||
generation = self._media_generation
|
||
diagnosis_id = self._diagnosis_id
|
||
self._show_message("正在提交…", "info")
|
||
run_async(
|
||
operation,
|
||
on_success=lambda _result: self._media_mutation_success(
|
||
success_text, reload_key, diagnosis_id, generation
|
||
),
|
||
on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "media"),
|
||
)
|
||
|
||
def _media_mutation_success(
|
||
self,
|
||
text: str,
|
||
reload_key: str,
|
||
diagnosis_id: int,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._media_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._show_message(text, "success")
|
||
self._loaded_tabs.discard(reload_key)
|
||
if reload_key == "chat":
|
||
QTimer.singleShot(
|
||
800,
|
||
lambda: (
|
||
self._ensure_tab_loaded("chat", force=True)
|
||
if generation == self._media_generation
|
||
else None
|
||
),
|
||
)
|
||
else:
|
||
self._ensure_tab_loaded(reload_key, force=True)
|
||
|
||
def _order_offset_changed(self, value: int) -> None:
|
||
self.order_offset_preview.setText(f"第 1 笔实单计为{_visit_slot_label(value)}")
|
||
self._sync_order_offset_actions()
|
||
|
||
def _sync_order_offset_actions(self) -> None:
|
||
if not hasattr(self, "order_offset_save"):
|
||
return
|
||
can_edit = self._editable and self._can_offset
|
||
dirty = self.order_offset.value() != self._saved_order_offset
|
||
saving = self.order_offset_save.text() == "保存中…"
|
||
self.order_offset.setEnabled(can_edit and not saving)
|
||
self.order_offset_save.setEnabled(can_edit and dirty and not saving)
|
||
|
||
def _save_order_offset(self) -> None:
|
||
if not self._editable or not self._can_offset or self._diagnosis_id <= 0:
|
||
return
|
||
self._offset_generation += 1
|
||
generation = self._offset_generation
|
||
diagnosis_id = self._diagnosis_id
|
||
offset = self.order_offset.value()
|
||
self.order_offset.setEnabled(False)
|
||
self.order_offset_save.setEnabled(False)
|
||
self.order_offset_save.setText("保存中…")
|
||
self._show_message("正在保存复诊统计起始偏移…", "info")
|
||
run_async(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"set_revisit_slot_start_offset",
|
||
diagnosis_id=diagnosis_id,
|
||
offset=offset,
|
||
),
|
||
on_success=lambda _result: self._offset_success(diagnosis_id, offset, generation),
|
||
on_error=lambda error: self._offset_error(error, diagnosis_id, generation),
|
||
)
|
||
|
||
def _offset_success(self, diagnosis_id: int, offset: int, generation: int) -> None:
|
||
if generation != self._offset_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._saved_order_offset = offset
|
||
self.order_offset_save.setText("保存")
|
||
self._sync_order_offset_actions()
|
||
self._show_message("保存成功", "success")
|
||
self._ensure_tab_loaded("orders", force=True)
|
||
|
||
def _offset_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
||
if generation != self._offset_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self.order_offset_save.setText("保存")
|
||
self._sync_order_offset_actions()
|
||
self._mutation_error(error, diagnosis_id, generation, "offset")
|
||
|
||
def _close_image_preview(self) -> None:
|
||
preview, self._image_preview = self._image_preview, None
|
||
if preview is not None:
|
||
with suppress(RuntimeError):
|
||
preview.close()
|
||
|
||
def _image_preview_finished(self, _result: int) -> None:
|
||
if self.sender() is self._image_preview:
|
||
self._image_preview = None
|
||
|
||
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
|
||
"""Keep the diagnosis open while viewing the clicked note's image group."""
|
||
if not safe_image_sources(sources):
|
||
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
|
||
return
|
||
self._close_image_preview()
|
||
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
|
||
self._image_preview = preview
|
||
preview.finished.connect(self._image_preview_finished)
|
||
preview.open()
|
||
|
||
def _open_safe_resource(self, target: str) -> None:
|
||
url = QUrl(str(target).strip())
|
||
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||
QMessageBox.warning(self, "无法打开", "仅允许打开 HTTP(S) 服务端附件或回放。")
|
||
return
|
||
if not QDesktopServices.openUrl(url):
|
||
QMessageBox.warning(self, "无法打开", "系统未能打开该安全外部链接。")
|
||
|
||
def _clear_tables(self) -> None:
|
||
self.notes_table.setRowCount(0)
|
||
empty_text = {
|
||
"daily": "切换到日常记录后加载数据。",
|
||
"prescription": "切换到处方后加载病历记录。",
|
||
"orders": "切换到业务订单后加载数据。",
|
||
"video": "切换到视频录制回放后检查桌面端接口。",
|
||
"assign": "切换到指派记录后加载数据。",
|
||
"appointment": "切换到挂号记录后加载数据。",
|
||
}
|
||
for kind, tables in self._table_registry.items():
|
||
for table in tables:
|
||
table.set_matrix([])
|
||
table.set_empty_text(empty_text.get(kind, "暂无数据"))
|
||
for timeline in self._notes_timelines:
|
||
timeline.set_state("暂无医生备注")
|
||
timeline.set_notes([])
|
||
for panel in self._chat_panels:
|
||
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
||
for panel in self._daily_panels:
|
||
panel.clear()
|
||
self.orders_list.update_state(1, 0)
|
||
|
||
@staticmethod
|
||
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
||
for column, value in enumerate(values):
|
||
item = QTableWidgetItem(display_text(value))
|
||
item.setToolTip(item.text() if len(item.text()) > 18 else "")
|
||
table.setItem(row, column, item)
|
||
|
||
def _fill_notes(self, rows: Sequence[Any]) -> None:
|
||
self.notes_table.setRowCount(len(rows))
|
||
for index, row in enumerate(rows):
|
||
self._set_row(
|
||
self.notes_table,
|
||
index,
|
||
(
|
||
first_value(row, "create_time", "created_at", "time"),
|
||
first_value(row, "doctor_name", "creator_name", default="医生"),
|
||
first_value(row, "content", "note", "remark"),
|
||
),
|
||
)
|
||
for timeline in self._notes_timelines:
|
||
timeline.empty.setText("暂无医生备注")
|
||
timeline.set_notes(rows)
|
||
|
||
def _fill_appointments(self, rows: Sequence[Any]) -> None:
|
||
matrix = [
|
||
(
|
||
first_value(row, "id", "appointment_id"),
|
||
first_value(row, "status_desc", "status_text", "status"),
|
||
"\n".join(
|
||
part
|
||
for part in (
|
||
display_text(first_value(row, "patient_name"), ""),
|
||
display_text(
|
||
first_value(row, "patient_phone", "phone")
|
||
if self._can_phone_plain
|
||
else _mask_phone(first_value(row, "patient_phone", "phone")),
|
||
"",
|
||
),
|
||
)
|
||
if part
|
||
),
|
||
first_value(row, "doctor_name"),
|
||
first_value(row, "assistant_name"),
|
||
first_value(row, "appointment_date"),
|
||
first_value(row, "appointment_time", "period"),
|
||
appointment_type_description(appointment_type_value(row)),
|
||
" / ".join(
|
||
part
|
||
for part in (
|
||
display_text(first_value(row, "channel_name", "channel_source"), ""),
|
||
display_text(first_value(row, "channel_source_detail"), ""),
|
||
)
|
||
if part
|
||
),
|
||
"已确认"
|
||
if _truthy(first_value(row, "is_confirmed", "confirmed", default=False))
|
||
else "未确认",
|
||
"已开方"
|
||
if _truthy(first_value(row, "has_prescription", "is_prescribed", default=False))
|
||
else "未开方",
|
||
first_value(row, "remark"),
|
||
first_value(row, "create_time", "created_at"),
|
||
)
|
||
for row in rows
|
||
]
|
||
for table in self._table_registry["appointment"]:
|
||
table.set_empty_text("暂无挂号记录。")
|
||
table.set_matrix(matrix)
|
||
self._semanticize(table, 1)
|
||
self._semanticize(table, 9)
|
||
self._semanticize(table, 10)
|
||
|
||
def _fill_assignments(self, rows: Sequence[Any]) -> None:
|
||
matrix = [
|
||
(
|
||
first_value(row, "create_time_text", "create_time"),
|
||
first_value(row, "from_assistant_name", default="—"),
|
||
first_value(row, "to_assistant_name", "assistant_name", default="—"),
|
||
"是" if _truthy(first_value(row, "is_inherit", default=False)) else "否",
|
||
first_value(row, "snapshot_order_creator_name", "order_creator_name"),
|
||
first_value(row, "snapshot_order_create_time", "order_create_time"),
|
||
first_value(row, "operator_name"),
|
||
first_value(row, "operator_account"),
|
||
first_value(row, "ip"),
|
||
)
|
||
for row in rows
|
||
]
|
||
for table in self._table_registry["assign"]:
|
||
table.set_empty_text("暂无指派记录。")
|
||
table.set_matrix(matrix)
|
||
for row_index in range(table.rowCount()):
|
||
item = table.item(row_index, 3)
|
||
if item is not None:
|
||
set_tag_item(item, "success" if item.text() == "是" else "neutral")
|
||
|
||
@staticmethod
|
||
def _action_button(text: str, tooltip: str = "") -> QPushButton:
|
||
button = QPushButton(text)
|
||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
button.setToolTip(tooltip)
|
||
button.setProperty("variant", "link")
|
||
return button
|
||
|
||
def _fill_prescriptions(self, rows: Sequence[Any]) -> None:
|
||
matrix: list[tuple[Any, ...]] = []
|
||
for row in rows:
|
||
herbs = first_value(row, "herbs", default=[]) or []
|
||
summary = first_value(row, "prescription_summary", "summary", default="")
|
||
if (
|
||
not summary
|
||
and isinstance(herbs, Sequence)
|
||
and not isinstance(herbs, (str, bytes, bytearray))
|
||
):
|
||
summary = "、".join(
|
||
f"{display_text(first_value(herb, 'name'), '')}"
|
||
f"{display_text(first_value(herb, 'dosage'), '')}克"
|
||
for herb in list(herbs)[:3]
|
||
)
|
||
if len(herbs) > 3:
|
||
summary += "…"
|
||
voided = _truthy(first_value(row, "void_status", default=False))
|
||
matrix.append(
|
||
(
|
||
first_value(row, "prescription_date", "visit_date", "create_time"),
|
||
first_value(row, "visit_no", "sn", "id"),
|
||
first_value(row, "clinical_diagnosis", "diagnosis"),
|
||
summary,
|
||
first_value(row, "doctor_name"),
|
||
"已作废"
|
||
if voided
|
||
else first_value(row, "status_text", "audit_status_text", default="正常"),
|
||
"",
|
||
)
|
||
)
|
||
for table in self._table_registry["prescription"]:
|
||
table.set_empty_text("暂无病历记录,开方后会自动显示。")
|
||
table.set_matrix(matrix)
|
||
self._semanticize(table, 5)
|
||
for row_index, row in enumerate(rows):
|
||
actions = QWidget()
|
||
actions_layout = QHBoxLayout(actions)
|
||
actions_layout.setContentsMargins(0, 0, 0, 0)
|
||
actions_layout.setSpacing(6)
|
||
button = self._action_button("查看", "打开真实处方详情")
|
||
button.clicked.connect(
|
||
lambda _checked=False, source=row: self._view_prescription(source)
|
||
)
|
||
actions_layout.addWidget(button)
|
||
approved = _int(first_value(row, "audit_status", "status", default=-1), -1) == 1
|
||
voided = _truthy(first_value(row, "void_status", "is_void", default=False))
|
||
can_edit = (
|
||
self._editable
|
||
and self._can_edit_prescription
|
||
and (not approved or voided)
|
||
)
|
||
if can_edit:
|
||
edit_button = self._action_button("编辑", "使用完整处方表单编辑")
|
||
edit_button.clicked.connect(
|
||
lambda _checked=False, source=row: self._edit_prescription(source)
|
||
)
|
||
actions_layout.addWidget(edit_button)
|
||
actions_layout.addStretch(1)
|
||
table.setCellWidget(row_index, 6, actions)
|
||
|
||
def _view_prescription(self, prescription: Any) -> None:
|
||
prescription_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0)
|
||
method = getattr(self.repository, "get_prescription", None)
|
||
if prescription_id <= 0 or not callable(method):
|
||
self._show_message("无法加载完整处方详情。", "warning")
|
||
return
|
||
self._prescription_detail_generation += 1
|
||
generation = self._prescription_detail_generation
|
||
self._prescription_detail_target = prescription_id
|
||
self._show_message("正在加载完整处方详情…", "info")
|
||
run_async(
|
||
lambda: method(prescription_id),
|
||
on_success=lambda detail: self._prescription_detail_loaded(
|
||
detail, prescription_id, generation, edit=False
|
||
),
|
||
on_error=lambda error: self._prescription_detail_error(
|
||
error, prescription_id, generation
|
||
),
|
||
)
|
||
|
||
def _edit_prescription(self, prescription: Any) -> None:
|
||
if (
|
||
not self._editable
|
||
or not self._can_edit_prescription
|
||
):
|
||
self._show_message("当前账号无处方编辑权限或接口不可用。", "warning")
|
||
return
|
||
prescription_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0)
|
||
if prescription_id <= 0:
|
||
self._show_message("处方编号不完整,无法编辑。", "warning")
|
||
return
|
||
approved = _int(first_value(prescription, "audit_status", "status", default=-1), -1) == 1
|
||
voided = _truthy(first_value(prescription, "void_status", "is_void", default=False))
|
||
if approved and not voided:
|
||
self._show_message("审核通过且未作废的处方只能查看。", "warning")
|
||
return
|
||
method = getattr(self.repository, "get_prescription", None)
|
||
if not callable(method):
|
||
self._show_message("无法加载完整处方详情。", "warning")
|
||
return
|
||
self._prescription_detail_generation += 1
|
||
generation = self._prescription_detail_generation
|
||
self._prescription_detail_target = prescription_id
|
||
self._show_message("正在准备编辑处方…", "info")
|
||
run_async(
|
||
lambda: method(prescription_id),
|
||
on_success=lambda detail: self._prescription_detail_loaded(
|
||
detail, prescription_id, generation, edit=True
|
||
),
|
||
on_error=lambda error: self._prescription_detail_error(
|
||
error, prescription_id, generation
|
||
),
|
||
)
|
||
|
||
def _prescription_detail_loaded(
|
||
self,
|
||
prescription: Any,
|
||
prescription_id: int,
|
||
generation: int,
|
||
*,
|
||
edit: bool,
|
||
) -> None:
|
||
if (
|
||
generation != self._prescription_detail_generation
|
||
or prescription_id != self._prescription_detail_target
|
||
):
|
||
return
|
||
actual_id = _int(first_value(prescription, "id", "prescription_id", default=0), 0)
|
||
if actual_id != prescription_id:
|
||
self._show_message("服务端处方与当前行不一致,已停止打开。", "danger")
|
||
return
|
||
self._clear_message()
|
||
if edit:
|
||
self._open_prescription_edit_dialog(prescription, prescription_id)
|
||
return
|
||
from .prescription import PrescriptionDetailDialog
|
||
|
||
dialog = PrescriptionDetailDialog(
|
||
prescription,
|
||
can_open_diagnosis=False,
|
||
can_open_orders=False,
|
||
parent=self,
|
||
)
|
||
dialog.exec()
|
||
|
||
def _prescription_detail_error(
|
||
self, error: Exception, prescription_id: int, generation: int
|
||
) -> None:
|
||
if (
|
||
generation == self._prescription_detail_generation
|
||
and prescription_id == self._prescription_detail_target
|
||
):
|
||
self._show_message(friendly_error(error), "danger")
|
||
|
||
def _open_prescription_edit_dialog(self, prescription: Any, prescription_id: int) -> None:
|
||
from .prescription import PrescriptionEditorDialog
|
||
|
||
dialog = PrescriptionEditorDialog(
|
||
self.repository,
|
||
prescription,
|
||
mode="edit",
|
||
current_user=getattr(self.parentWidget(), "current_user", None),
|
||
parent=self,
|
||
)
|
||
dialog.diagnosis_requested.connect(self._open_current_diagnosis_detail)
|
||
dialog.setObjectName("DiagnosisPrescriptionEditor")
|
||
dialog.setStyleSheet(DIAGNOSIS_QSS)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
payload = dialog.payload()
|
||
self._run_media_mutation(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"update_prescription",
|
||
prescription=prescription_id,
|
||
changes=payload,
|
||
),
|
||
"处方已保存并重新进入待审核。",
|
||
"prescription",
|
||
)
|
||
|
||
def _open_current_diagnosis_detail(self, diagnosis_id: int) -> None:
|
||
if diagnosis_id <= 0 or diagnosis_id != self._diagnosis_id or not self._detail:
|
||
self._show_message("当前处方关联的诊单详情不可用。", "warning")
|
||
return
|
||
from .prescription import DiagnosisDetailDialog
|
||
|
||
DiagnosisDetailDialog(
|
||
self._detail,
|
||
self,
|
||
repository=self.repository,
|
||
permissions=self.permissions,
|
||
).exec()
|
||
|
||
def _stop_inline_recordings(self) -> None:
|
||
for cell in list(getattr(self, "_inline_recording_cells", [])):
|
||
with suppress(RuntimeError):
|
||
cell.stop()
|
||
self._inline_recording_cells.clear()
|
||
|
||
@staticmethod
|
||
def _call_transcript_text(row: Any) -> str:
|
||
raw = first_value(
|
||
row,
|
||
"transcript_text",
|
||
"call_transcript",
|
||
"conversation_text",
|
||
"transcript",
|
||
default="",
|
||
)
|
||
if isinstance(raw, Mapping):
|
||
raw = first_value(raw, "text", "full_text", "content", default="")
|
||
text = str(raw or "").strip()
|
||
if text:
|
||
return text
|
||
segments = first_value(row, "transcript_segments", "segments", default=[]) or []
|
||
if isinstance(segments, Sequence) and not isinstance(
|
||
segments, (str, bytes, bytearray)
|
||
):
|
||
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
|
||
lines: list[str] = []
|
||
for segment in segments:
|
||
content = str(first_value(segment, "text", "sourceText", default="") or "").strip()
|
||
if not content:
|
||
continue
|
||
role = str(
|
||
first_value(segment, "speaker_role", "speakerRole", default="unknown")
|
||
or "unknown"
|
||
)
|
||
lines.append(f"{labels.get(role, '未知说话人')}:{content}")
|
||
return "\n".join(lines)
|
||
return ""
|
||
|
||
def _view_call_transcript(self, call_record_id: int, transcript: str) -> None:
|
||
dialog = QDialog(self)
|
||
dialog.setObjectName("DiagnosisCallTranscriptDialog")
|
||
dialog.setWindowTitle(f"面诊对话文字 · 通话记录 #{call_record_id}")
|
||
dialog.resize(680, 520)
|
||
layout = QVBoxLayout(dialog)
|
||
layout.setContentsMargins(20, 18, 20, 18)
|
||
layout.setSpacing(12)
|
||
title = QLabel("录音转写对话")
|
||
title.setObjectName("DiagnosisDailySectionTitle")
|
||
layout.addWidget(title)
|
||
hint = QLabel("以下内容由语音自动转写,仅作为面诊记录辅助,请由医生核对。")
|
||
hint.setObjectName("DiagnosisDialogGuidance")
|
||
hint.setWordWrap(True)
|
||
layout.addWidget(hint)
|
||
content = QPlainTextEdit(dialog)
|
||
content.setObjectName("DiagnosisCallTranscriptText")
|
||
content.setReadOnly(True)
|
||
content.setPlainText(transcript)
|
||
layout.addWidget(content, 1)
|
||
close_button = QPushButton("关闭")
|
||
close_button.setProperty("variant", "primary")
|
||
close_button.clicked.connect(dialog.accept)
|
||
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
|
||
dialog.exec()
|
||
|
||
def _fill_video(self, rows: Sequence[Any]) -> None:
|
||
self._stop_inline_recordings()
|
||
matrix: list[tuple[Any, ...]] = []
|
||
row_urls: list[list[str]] = []
|
||
row_audio_urls: list[list[str]] = []
|
||
record_ids: list[int] = []
|
||
transcripts: list[str] = []
|
||
for row in rows:
|
||
raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or []
|
||
if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence):
|
||
raw_urls = [raw_urls]
|
||
direct = str(first_value(row, "recording_url", "url", default="") or "").strip()
|
||
normalized: list[str] = []
|
||
for candidate in ([direct] if direct else []) + list(raw_urls):
|
||
url = str(candidate or "").strip()
|
||
if url and url not in normalized:
|
||
normalized.append(url)
|
||
row_urls.append(normalized)
|
||
raw_audio_urls = first_value(
|
||
row,
|
||
"local_audio_urls_list",
|
||
"local_audio_urls",
|
||
default=[],
|
||
) or []
|
||
if isinstance(raw_audio_urls, str) or not isinstance(
|
||
raw_audio_urls, Sequence
|
||
):
|
||
raw_audio_urls = [raw_audio_urls]
|
||
normalized_audio: list[str] = []
|
||
for candidate in raw_audio_urls:
|
||
url = str(candidate or "").strip()
|
||
if url and url not in normalized_audio:
|
||
normalized_audio.append(url)
|
||
row_audio_urls.append(normalized_audio)
|
||
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
|
||
transcript = self._call_transcript_text(row)
|
||
transcripts.append(transcript)
|
||
call_type = _raw_value(row, "call_type_text")
|
||
if call_type is _MISSING:
|
||
raw_call_type = _raw_value(row, "call_type")
|
||
if raw_call_type is _MISSING:
|
||
call_type = "—"
|
||
else:
|
||
call_type = "语音" if _int(raw_call_type, 2) == 1 else "视频"
|
||
status = _raw_value(row, "status_text")
|
||
if status is _MISSING:
|
||
raw_status = _raw_value(row, "status")
|
||
status = {
|
||
1: "进行中",
|
||
2: "已结束",
|
||
3: "未接听",
|
||
4: "已取消",
|
||
}.get(_int(raw_status, -1), "—")
|
||
recording_status = first_value(
|
||
row, "recording_status_text", "record_status_text", "record_status"
|
||
)
|
||
local_audio_status = first_value(
|
||
row,
|
||
"local_audio_status_text",
|
||
default="本机录音已保存" if normalized_audio else "无本机录音",
|
||
)
|
||
transcript_status = first_value(
|
||
row,
|
||
"transcription_status_text",
|
||
"transcript_status_text",
|
||
default="文字已生成" if transcript else "未生成文字",
|
||
)
|
||
room_id = str(
|
||
first_value(
|
||
row,
|
||
"room_id_text",
|
||
"room_id",
|
||
"room_no",
|
||
default="",
|
||
)
|
||
or ""
|
||
).strip()
|
||
matrix.append(
|
||
(
|
||
"" if normalized else "暂无录制回放",
|
||
room_id or "历史记录未保存",
|
||
first_value(row, "start_time_text", "start_time"),
|
||
first_value(row, "end_time_text", "end_time"),
|
||
call_type,
|
||
first_value(row, "duration_text", "duration"),
|
||
status,
|
||
(
|
||
f"云端视频:{display_text(recording_status)}\n"
|
||
f"本机录音:{display_text(local_audio_status)}\n"
|
||
f"转写文字:{display_text(transcript_status)}"
|
||
),
|
||
"",
|
||
)
|
||
)
|
||
for table in self._table_registry["video"]:
|
||
table.set_empty_text("暂无通话记录。")
|
||
table.set_matrix(matrix)
|
||
self._semanticize(table, 6)
|
||
playback_rows: list[tuple[int, RecordingPlaybackCell]] = []
|
||
for row_index, urls in enumerate(row_urls):
|
||
if urls:
|
||
playback = RecordingPlaybackCell(
|
||
urls,
|
||
record_id=record_ids[row_index],
|
||
render_owner=self,
|
||
owner_generation=self._tab_generations["video"],
|
||
)
|
||
playback.fallback_requested.connect(self._open_recording_player)
|
||
self._inline_recording_cells.append(playback)
|
||
playback_rows.append((row_index, playback))
|
||
table.setCellWidget(row_index, 0, playback)
|
||
item = table.item(row_index, 0)
|
||
if item is not None:
|
||
item.setText("")
|
||
call_record_id = record_ids[row_index]
|
||
actions: list[QPushButton] = []
|
||
audio_urls = row_audio_urls[row_index]
|
||
if audio_urls and call_record_id > 0:
|
||
play_audio = self._action_button(
|
||
"播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音"
|
||
)
|
||
play_audio.setObjectName("DiagnosisLocalAudioPlayback")
|
||
play_audio.setProperty("callRecordId", call_record_id)
|
||
play_audio.clicked.connect(
|
||
lambda _checked=False, target=audio_urls[0]: self._open_recording_player(
|
||
target
|
||
)
|
||
)
|
||
actions.append(play_audio)
|
||
transcript = transcripts[row_index]
|
||
if transcript and call_record_id > 0:
|
||
view_transcript = self._action_button(
|
||
"查看文字", f"查看通话记录 #{call_record_id} 的录音转写"
|
||
)
|
||
view_transcript.setObjectName("DiagnosisVideoTranscriptView")
|
||
view_transcript.setProperty("callRecordId", call_record_id)
|
||
view_transcript.clicked.connect(
|
||
lambda _checked=False, selected=call_record_id, text=transcript: self._view_call_transcript(
|
||
selected, text
|
||
)
|
||
)
|
||
actions.append(view_transcript)
|
||
if self._editable and self._can_video_upload and call_record_id > 0:
|
||
upload = self._action_button(
|
||
"追加回放", f"上传并绑定通话记录 #{call_record_id}"
|
||
)
|
||
upload.setProperty("variant", "secondary")
|
||
upload.setObjectName("DiagnosisVideoRowUpload")
|
||
upload.setProperty("callRecordId", call_record_id)
|
||
upload.setMinimumHeight(28)
|
||
upload.setMaximumHeight(30)
|
||
upload.setSizePolicy(
|
||
QSizePolicy.Policy.Preferred,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
upload.clicked.connect(
|
||
lambda _checked=False, selected=call_record_id: self._upload_call_recording(
|
||
selected
|
||
)
|
||
)
|
||
actions.append(upload)
|
||
if actions:
|
||
action_host = QWidget()
|
||
action_host.setObjectName("DiagnosisVideoRowUploadCell")
|
||
action_layout = QVBoxLayout(action_host)
|
||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||
action_layout.setSpacing(2)
|
||
for action in actions:
|
||
action_layout.addWidget(action, 0, Qt.AlignmentFlag.AlignCenter)
|
||
table.setCellWidget(row_index, 8, action_host)
|
||
upload_item = table.item(row_index, 8)
|
||
if upload_item is not None:
|
||
upload_item.setText("")
|
||
else:
|
||
table.setItem(row_index, 8, QTableWidgetItem("—"))
|
||
table.resizeRowsToContents()
|
||
# A fixed-height action button in another column makes Qt's content
|
||
# resize pass collapse the whole section to roughly 30 px. Restore
|
||
# the playback cell's measured layout height after that final pass;
|
||
# retaining the item hint also documents the row's semantic size for
|
||
# delegates and future table-layout changes.
|
||
for row_index, playback in playback_rows:
|
||
required_height = playback.required_table_row_height()
|
||
item = table.item(row_index, 0)
|
||
if item is not None:
|
||
hint = playback.sizeHint()
|
||
hint.setHeight(required_height)
|
||
item.setSizeHint(hint)
|
||
table.setRowHeight(row_index, required_height)
|
||
|
||
def _open_recording_player(self, target: str) -> None:
|
||
player = RecordingPlayerDialog(target, parent=self)
|
||
self._recording_players.append(player)
|
||
|
||
def release(*_args: Any) -> None:
|
||
with suppress(ValueError):
|
||
self._recording_players.remove(player)
|
||
|
||
player.destroyed.connect(release)
|
||
player.show()
|
||
|
||
def _fill_orders(self, rows: Sequence[Any]) -> None:
|
||
matrix: list[tuple[Any, ...]] = []
|
||
for row in rows:
|
||
visit_sequence = _raw_value(
|
||
row,
|
||
"global_visit_seq",
|
||
"global_visit_sequence",
|
||
"revisit_slot",
|
||
"visit_sequence",
|
||
)
|
||
counts = _raw_value(
|
||
row,
|
||
"counts_for_revisit_rate",
|
||
"statistics_count",
|
||
"quantity",
|
||
"count",
|
||
)
|
||
fulfillment = _raw_value(row, "fulfillment_status_text", "status_text")
|
||
if fulfillment is _MISSING:
|
||
fulfillment = _fulfillment_text(_raw_value(row, "fulfillment_status", "status"))
|
||
visit_text = display_text(visit_sequence)
|
||
if visit_sequence is not _MISSING and not visit_text.endswith("诊"):
|
||
visit_text = f"{visit_text}诊"
|
||
matrix.append(
|
||
(
|
||
first_value(row, "order_no", "sn", "id"),
|
||
"—" if visit_sequence is _MISSING else visit_text,
|
||
"—" if counts is _MISSING else ("是" if _truthy(counts) else "否"),
|
||
_money_text(_raw_value(row, "amount")),
|
||
first_value(row, "doctor_name"),
|
||
first_value(row, "assistant_name"),
|
||
fulfillment,
|
||
first_value(row, "create_time", "created_at"),
|
||
"",
|
||
)
|
||
)
|
||
for table in self._table_registry["orders"]:
|
||
table.set_empty_text("暂无业务订单。")
|
||
table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,))
|
||
self._semanticize(table, 6)
|
||
for row_index, values in enumerate(matrix):
|
||
counts_text = str(values[2])
|
||
if counts_text in {"是", "否"}:
|
||
set_status_tag(
|
||
table,
|
||
row_index,
|
||
2,
|
||
counts_text,
|
||
"success" if counts_text == "是" else "neutral",
|
||
)
|
||
for row_index, row in enumerate(rows):
|
||
order_id = _int(first_value(row, "id", "order_id"), 0)
|
||
item = table.item(row_index, 0)
|
||
if item is not None:
|
||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||
if (
|
||
self._can_order_detail
|
||
and order_id > 0
|
||
and callable(getattr(self.repository, "get_prescription_order", None))
|
||
):
|
||
button = self._action_button("查看详情", "加载真实业务订单详情")
|
||
button.clicked.connect(
|
||
lambda _checked=False, selected=order_id: self._view_order(selected)
|
||
)
|
||
table.setCellWidget(row_index, 8, button)
|
||
elif not self._can_order_detail:
|
||
table.setItem(row_index, 8, QTableWidgetItem("无详情权限"))
|
||
else:
|
||
table.setItem(row_index, 8, QTableWidgetItem("详情不可用"))
|
||
|
||
def _view_order(self, order_id: int) -> None:
|
||
if not self._can_order_detail:
|
||
self._show_message("当前账号无业务订单详情权限。", "warning")
|
||
return
|
||
if order_id <= 0 or not callable(getattr(self.repository, "get_prescription_order", None)):
|
||
self._show_message("当前订单没有可用的详情接口。", "warning")
|
||
return
|
||
self._order_detail_generation += 1
|
||
generation = self._order_detail_generation
|
||
diagnosis_id = self._diagnosis_id
|
||
self._show_message("正在加载订单详情…", "info")
|
||
run_async(
|
||
lambda: invoke(
|
||
self.repository,
|
||
"get_prescription_order",
|
||
order_id=order_id,
|
||
id=order_id,
|
||
),
|
||
on_success=lambda result: self._order_detail_success(
|
||
result, diagnosis_id, order_id, generation
|
||
),
|
||
on_error=lambda error: self._order_detail_error(error, diagnosis_id, generation),
|
||
)
|
||
|
||
def _order_detail_success(
|
||
self,
|
||
detail: Any,
|
||
diagnosis_id: int,
|
||
order_id: int,
|
||
generation: int,
|
||
) -> None:
|
||
if generation != self._order_detail_generation or diagnosis_id != self._diagnosis_id:
|
||
return
|
||
self._clear_message()
|
||
payload = detail
|
||
if isinstance(detail, Mapping):
|
||
for key in ("data", "detail"):
|
||
nested = detail.get(key)
|
||
if isinstance(nested, Mapping) and any(
|
||
field in nested for field in ("id", "order_no", "prescription_id")
|
||
):
|
||
payload = nested
|
||
break
|
||
dialog = self._build_order_detail_dialog(payload, order_id)
|
||
self._last_order_detail_dialog = dialog
|
||
|
||
def clear_dialog(_result: int) -> None:
|
||
if self._last_order_detail_dialog is dialog:
|
||
self._last_order_detail_dialog = None
|
||
|
||
dialog.finished.connect(clear_dialog)
|
||
dialog.open()
|
||
|
||
def _build_order_detail_dialog(
|
||
self,
|
||
detail: Any,
|
||
order_id: int,
|
||
*,
|
||
host: QWidget | None = None,
|
||
) -> QDialog:
|
||
owner = host if isinstance(host, QWidget) else self if isinstance(self, QWidget) else None
|
||
dialog = OrderDetailDrawer(owner)
|
||
header = QFrame()
|
||
header.setObjectName("DiagnosisOrderDetailHeader")
|
||
header_layout = QHBoxLayout(header)
|
||
header_layout.setContentsMargins(20, 15, 18, 15)
|
||
header_layout.setSpacing(12)
|
||
title_stack = QVBoxLayout()
|
||
title_stack.setSpacing(4)
|
||
heading = QLabel("业务订单详情")
|
||
heading.setObjectName("DiagnosisOrderDetailTitle")
|
||
title_stack.addWidget(heading)
|
||
order_number_value = _raw_value(detail, "order_no", "sn")
|
||
order_number = QLabel(
|
||
f"订单编号:{display_text(order_number_value)}"
|
||
if order_number_value is not _MISSING
|
||
else f"订单ID:#{order_id}"
|
||
)
|
||
order_number.setObjectName("DiagnosisOrderDetailMeta")
|
||
order_number.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
title_stack.addWidget(order_number)
|
||
references: list[str] = []
|
||
gancao_no = _raw_value(detail, "gancao_reciperl_order_no")
|
||
if gancao_no is not _MISSING:
|
||
references.append(f"甘草 {display_text(gancao_no)}")
|
||
pharmacy_no = _raw_value(detail, "ej_pharmacy_order_no")
|
||
if pharmacy_no is not _MISSING:
|
||
references.append(f"洛阳 {display_text(pharmacy_no)}")
|
||
if references:
|
||
reference = QLabel(" · ".join(references))
|
||
reference.setObjectName("DiagnosisOrderDetailMeta")
|
||
reference.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
title_stack.addWidget(reference)
|
||
header_layout.addLayout(title_stack, 1)
|
||
readonly_badge = QLabel("只读")
|
||
readonly_badge.setObjectName("DiagnosisOrderReadonlyBadge")
|
||
header_layout.addWidget(readonly_badge, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
status_value = _raw_value(detail, "fulfillment_status_text", "status_text")
|
||
status_text = (
|
||
display_text(status_value)
|
||
if status_value is not _MISSING
|
||
else _fulfillment_text(_raw_value(detail, "fulfillment_status", "status"))
|
||
)
|
||
if status_text == "—":
|
||
status_text = "状态未返回"
|
||
status = QLabel(status_text)
|
||
status.setObjectName("DiagnosisOrderStatus")
|
||
status.setProperty("diagnosisTag", True)
|
||
status.setProperty("kind", self._status_kind(status_text))
|
||
status.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
header_layout.addWidget(status, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
close_header = QPushButton("关闭")
|
||
close_header.setObjectName("DiagnosisOrderDetailClose")
|
||
close_header.setProperty("variant", "secondary")
|
||
close_header.clicked.connect(dialog.reject)
|
||
header_layout.addWidget(close_header, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
dialog.content_layout.addWidget(header)
|
||
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName("DiagnosisOrderDetailScroll")
|
||
scroll.setWidgetResizable(True)
|
||
content = QWidget()
|
||
body = QVBoxLayout(content)
|
||
body.setContentsMargins(18, 16, 18, 22)
|
||
body.setSpacing(14)
|
||
self._add_order_amount_overview(body, detail)
|
||
self._add_order_prescription_section(body, detail)
|
||
self._add_order_payment_section(body, detail)
|
||
self._add_order_fulfillment_section(body, detail)
|
||
self._add_order_logistics_section(body, detail)
|
||
self._add_order_logs_section(body, detail)
|
||
body.addStretch(1)
|
||
scroll.setWidget(content)
|
||
dialog.content_layout.addWidget(scroll, 1)
|
||
|
||
footer = QFrame()
|
||
footer.setObjectName("DiagnosisOrderDetailFooter")
|
||
actions = QHBoxLayout(footer)
|
||
actions.setContentsMargins(18, 10, 18, 10)
|
||
source_note = QLabel("只读 · 数据来自业务订单详情接口")
|
||
source_note.setObjectName("DiagnosisOrderDetailMeta")
|
||
actions.addWidget(source_note)
|
||
actions.addStretch(1)
|
||
close = QPushButton("关闭")
|
||
close.setProperty("variant", "secondary")
|
||
close.clicked.connect(dialog.reject)
|
||
actions.addWidget(close)
|
||
dialog.content_layout.addWidget(footer)
|
||
dialog.sync_to_owner()
|
||
return dialog
|
||
|
||
def _new_order_section(
|
||
self,
|
||
title: str,
|
||
hint: str = "",
|
||
) -> tuple[QFrame, QVBoxLayout]:
|
||
section = QFrame()
|
||
section.setProperty("orderDetailSection", True)
|
||
section.setObjectName(f"DiagnosisOrderSection{title}")
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(15, 14, 15, 16)
|
||
layout.setSpacing(11)
|
||
heading = QHBoxLayout()
|
||
heading.setSpacing(10)
|
||
title_label = QLabel(title)
|
||
title_label.setProperty("orderSectionTitle", True)
|
||
heading.addWidget(title_label)
|
||
if hint:
|
||
hint_label = QLabel(hint)
|
||
hint_label.setProperty("orderSectionHint", True)
|
||
hint_label.setWordWrap(True)
|
||
heading.addWidget(hint_label, 1)
|
||
else:
|
||
heading.addStretch(1)
|
||
layout.addLayout(heading)
|
||
return section, layout
|
||
|
||
def _add_order_empty(self, layout: QVBoxLayout, text: str) -> QLabel:
|
||
empty = QLabel(text)
|
||
empty.setProperty("orderEmptyState", True)
|
||
empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
empty.setWordWrap(True)
|
||
empty.setMinimumHeight(62)
|
||
layout.addWidget(empty)
|
||
return empty
|
||
|
||
def _add_order_field_grid(
|
||
self,
|
||
layout: QVBoxLayout,
|
||
fields: Sequence[tuple[str, Any]],
|
||
*,
|
||
columns: int = 3,
|
||
) -> None:
|
||
host = QWidget()
|
||
grid = QGridLayout(host)
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(8)
|
||
grid.setVerticalSpacing(8)
|
||
for column in range(columns):
|
||
grid.setColumnStretch(column, 1)
|
||
for index, (caption, raw_value) in enumerate(fields):
|
||
card = QFrame()
|
||
card.setProperty("orderField", True)
|
||
card_layout = QVBoxLayout(card)
|
||
card_layout.setContentsMargins(10, 8, 10, 9)
|
||
card_layout.setSpacing(4)
|
||
label = QLabel(caption)
|
||
label.setProperty("orderFieldLabel", True)
|
||
card_layout.addWidget(label)
|
||
value = QLabel(display_text(raw_value))
|
||
value.setProperty("orderFieldValue", True)
|
||
value.setWordWrap(True)
|
||
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
card_layout.addWidget(value, 1)
|
||
grid.addWidget(card, index // columns, index % columns)
|
||
layout.addWidget(host)
|
||
|
||
def _add_order_amount_overview(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
section, layout = self._new_order_section(
|
||
"金额概览",
|
||
"需代收优先使用后端快照;缺少快照时仅在总额与已付总额都返回后计算。",
|
||
)
|
||
linked_rows, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders")
|
||
total = _number(_raw_value(detail, "amount", "effective_amount"))
|
||
paid = _number(_raw_value(detail, "linked_pay_paid_total", "paid_total"))
|
||
if paid is None and linked_present:
|
||
amounts = [
|
||
_number(_raw_value(row, "amount", "pay_amount", "paid_amount"))
|
||
for row in linked_rows
|
||
]
|
||
if not linked_rows:
|
||
paid = 0.0
|
||
elif all(amount is not None for amount in amounts):
|
||
paid = sum(amount for amount in amounts if amount is not None)
|
||
refund = _number(_raw_value(detail, "refund_amount"))
|
||
agency = _number(_raw_value(detail, "agency_collect_amount"))
|
||
if agency is None and total is not None and paid is not None:
|
||
agency = round((total - paid) * 100) / 100
|
||
cards = (
|
||
("总金额", _money_text(total), "danger"),
|
||
("已付总额", _money_text(paid), "success"),
|
||
("退款金额", _money_text(refund), "danger" if refund and refund > 0 else "neutral"),
|
||
("需代收", _money_text(agency), "warning" if agency and agency > 0 else "neutral"),
|
||
("已付笔数", f"{len(linked_rows)} 笔" if linked_present else "—", "info"),
|
||
)
|
||
host = QWidget()
|
||
grid = QGridLayout(host)
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(8)
|
||
grid.setVerticalSpacing(8)
|
||
for index, (caption, value, tone) in enumerate(cards):
|
||
card = QFrame()
|
||
card.setProperty("orderAmountCard", True)
|
||
card_layout = QVBoxLayout(card)
|
||
card_layout.setContentsMargins(11, 10, 11, 11)
|
||
card_layout.setSpacing(4)
|
||
title = QLabel(caption)
|
||
title.setProperty("orderAmountTitle", True)
|
||
card_layout.addWidget(title)
|
||
amount = QLabel(value)
|
||
amount.setProperty("orderAmountValue", True)
|
||
amount.setProperty("orderAmountTone", tone)
|
||
amount.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
card_layout.addWidget(amount)
|
||
grid.addWidget(card, index // 5, index % 5)
|
||
grid.setColumnStretch(index % 5, 1)
|
||
layout.addWidget(host)
|
||
parent.addWidget(section)
|
||
|
||
def _add_order_prescription_section(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
prescription = _raw_value(detail, "prescription")
|
||
doctor = _raw_value(detail, "doctor_name")
|
||
hint = "" if doctor is _MISSING else f"开方人:{display_text(doctor)}"
|
||
section, layout = self._new_order_section("处方详情", hint)
|
||
error = _raw_value(detail, "prescription_detail_error")
|
||
if error is not _MISSING:
|
||
warning = QLabel(display_text(error))
|
||
warning.setProperty("orderEmptyState", True)
|
||
warning.setProperty("kind", "warning")
|
||
warning.setWordWrap(True)
|
||
layout.addWidget(warning)
|
||
if prescription is _MISSING or prescription is None or prescription == {}:
|
||
self._add_order_empty(layout, "无处方数据(详情接口未返回 prescription)")
|
||
parent.addWidget(section)
|
||
return
|
||
|
||
gender = _raw_value(prescription, "gender_desc")
|
||
if gender is _MISSING:
|
||
raw_gender = _raw_value(prescription, "gender")
|
||
gender = _diagnosis_gender_text(raw_gender) if raw_gender is not _MISSING else "—"
|
||
age = _raw_value(prescription, "age")
|
||
gender_age_parts = [display_text(gender)]
|
||
if age is not _MISSING:
|
||
gender_age_parts.append(f"{display_text(age)}岁")
|
||
|
||
dose_count = _raw_value(detail, "dose_count")
|
||
if dose_count is _MISSING:
|
||
dose_count = _raw_value(prescription, "dose_count")
|
||
dose_unit = _raw_value(detail, "dose_unit")
|
||
if dose_unit is _MISSING:
|
||
dose_unit = _raw_value(prescription, "dose_unit")
|
||
usage_method = _raw_value(prescription, "usage_instruction", "usage_method")
|
||
dose_parts: list[str] = []
|
||
if dose_count is not _MISSING:
|
||
dose_parts.append(
|
||
f"{display_text(dose_count)}{display_text(dose_unit, '') if dose_unit is not _MISSING else ''}"
|
||
)
|
||
if usage_method is not _MISSING:
|
||
dose_parts.append(display_text(usage_method))
|
||
|
||
dosage_amount = _raw_value(prescription, "dosage_amount")
|
||
dosage_parts: list[str] = []
|
||
if dosage_amount is not _MISSING:
|
||
dosage_unit = _raw_value(prescription, "dosage_unit")
|
||
dosage_parts.append(
|
||
f"{display_text(dosage_amount)}{display_text(dosage_unit, '') if dosage_unit is not _MISSING else ''}"
|
||
)
|
||
bag_count = _raw_value(prescription, "dosage_bag_count")
|
||
if bag_count is not _MISSING:
|
||
dosage_parts.append(f"{display_text(bag_count)}袋")
|
||
decoction = _raw_value(prescription, "need_decoction")
|
||
if decoction is not _MISSING:
|
||
dosage_parts.append("代煎" if _truthy(decoction) else "不代煎")
|
||
|
||
usage_parts: list[str] = []
|
||
times = _raw_value(prescription, "times_per_day")
|
||
if times is not _MISSING:
|
||
usage_parts.append(f"每天 {display_text(times)} 次")
|
||
usage_days = _raw_value(prescription, "usage_days")
|
||
if usage_days is not _MISSING:
|
||
usage_parts.append(f"处方开立 {display_text(usage_days)} 天")
|
||
medication_days = _raw_value(detail, "medication_days")
|
||
if medication_days is not _MISSING:
|
||
usage_parts.append(f"订单设置 {display_text(medication_days)} 天")
|
||
|
||
dietary = _display_collection(_raw_value(prescription, "dietary_taboo"))
|
||
void_status = _raw_value(prescription, "void_status")
|
||
prescription_status = (
|
||
"—" if void_status is _MISSING else ("已作废" if _truthy(void_status) else "正常")
|
||
)
|
||
fields = (
|
||
("处方编号", _raw_value(prescription, "sn", "id", default="—")),
|
||
("患者", _raw_value(prescription, "patient_name", default="—")),
|
||
("性别 / 年龄", " · ".join(gender_age_parts)),
|
||
("手机", _raw_value(prescription, "phone", default="—")),
|
||
("处方日期", _raw_value(prescription, "prescription_date", default="—")),
|
||
("医师", _raw_value(prescription, "doctor_name", default="—")),
|
||
("类型", _raw_value(prescription, "prescription_type", default="—")),
|
||
("临床诊断", _raw_value(prescription, "clinical_diagnosis", default="—")),
|
||
("剂数 / 用法", " · ".join(dose_parts) or "—"),
|
||
("处方金额", _money_text(_raw_value(prescription, "amount"))),
|
||
("消费者审核", _audit_text(_raw_value(prescription, "audit_status"))),
|
||
("用量", " · ".join(dosage_parts) or "—"),
|
||
("服用方式", " · ".join(usage_parts) or "—"),
|
||
("忌口", dietary),
|
||
("状态", prescription_status),
|
||
)
|
||
self._add_order_field_grid(layout, fields, columns=3)
|
||
parent.addWidget(section)
|
||
|
||
def _build_order_payment_table(
|
||
self,
|
||
rows: Sequence[Any],
|
||
*,
|
||
present: bool,
|
||
object_name: str,
|
||
absent_text: str,
|
||
empty_text: str,
|
||
) -> RecordTable:
|
||
table = RecordTable(
|
||
object_name,
|
||
(
|
||
("ID", 72),
|
||
("单号", 150),
|
||
("类型", 92),
|
||
("金额", 96),
|
||
("状态", 94),
|
||
("创建人", 88),
|
||
("创建时间", 150),
|
||
),
|
||
)
|
||
matrix: list[tuple[Any, ...]] = []
|
||
for row in rows:
|
||
status = _raw_value(row, "status_desc", "status_text")
|
||
if status is _MISSING:
|
||
status = _pay_status_text(_raw_value(row, "status"))
|
||
order_type = _raw_value(row, "order_type_desc", "fee_type_text")
|
||
if order_type is _MISSING:
|
||
order_type = _fee_type_text(_raw_value(row, "order_type", "fee_type"))
|
||
matrix.append(
|
||
(
|
||
_raw_value(row, "id", default="—"),
|
||
_raw_value(row, "order_no", "sn", default="—"),
|
||
order_type,
|
||
_money_text(_raw_value(row, "amount", "pay_amount", "paid_amount")),
|
||
status,
|
||
_raw_value(row, "creator_name", "admin_name", default="—"),
|
||
_raw_value(row, "create_time", "created_at", default="—"),
|
||
)
|
||
)
|
||
table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,))
|
||
table.set_empty_text(empty_text if present else absent_text)
|
||
table.setMinimumHeight(145)
|
||
table.setMaximumHeight(min(280, max(145, 78 + len(matrix) * 38)))
|
||
for row_index, values in enumerate(matrix):
|
||
set_status_tag(
|
||
table,
|
||
row_index,
|
||
4,
|
||
str(values[4]),
|
||
self._status_kind(values[4]),
|
||
)
|
||
return table
|
||
|
||
def _add_order_payment_section(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
section, layout = self._new_order_section(
|
||
"收款记录",
|
||
"只读模式不展示收款方式;仅呈现详情接口返回的关联关系。",
|
||
)
|
||
linked, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders")
|
||
linked_title = QLabel("关联收款记录")
|
||
linked_title.setProperty("orderFieldValue", True)
|
||
layout.addWidget(linked_title)
|
||
layout.addWidget(
|
||
self._build_order_payment_table(
|
||
linked,
|
||
present=linked_present,
|
||
object_name="DiagnosisOrderLinkedPayments",
|
||
absent_text="详情接口未返回关联收款记录字段",
|
||
empty_text="未关联收款单",
|
||
)
|
||
)
|
||
|
||
unlinked, unlinked_present = _detail_rows(
|
||
detail,
|
||
"unlinked_pay_orders",
|
||
"available_pay_orders",
|
||
)
|
||
unlinked_title = QLabel("未关联的收款记录")
|
||
unlinked_title.setProperty("orderFieldValue", True)
|
||
layout.addWidget(unlinked_title)
|
||
layout.addWidget(
|
||
self._build_order_payment_table(
|
||
unlinked,
|
||
present=unlinked_present,
|
||
object_name="DiagnosisOrderUnlinkedPayments",
|
||
absent_text="详情接口未返回未关联收款记录字段",
|
||
empty_text="无未关联的收款单",
|
||
)
|
||
)
|
||
parent.addWidget(section)
|
||
|
||
def _add_order_fulfillment_section(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
section, layout = self._new_order_section("履约与收货信息")
|
||
address_parts = [
|
||
_raw_value(detail, key)
|
||
for key in (
|
||
"shipping_province",
|
||
"shipping_city",
|
||
"shipping_district",
|
||
"shipping_address",
|
||
)
|
||
]
|
||
full_address = " ".join(
|
||
display_text(part) for part in address_parts if part is not _MISSING
|
||
)
|
||
if not full_address:
|
||
full_address = display_text(
|
||
_raw_value(detail, "recipient_address", "address", default="—")
|
||
)
|
||
creator_parts = [
|
||
_raw_value(detail, "creator_name"),
|
||
_raw_value(detail, "creator_account"),
|
||
_raw_value(detail, "creator_mobile"),
|
||
]
|
||
creator = (
|
||
" · ".join(display_text(part) for part in creator_parts if part is not _MISSING) or "—"
|
||
)
|
||
follow_up = _raw_value(detail, "is_follow_up")
|
||
fulfillment_status = _raw_value(detail, "fulfillment_status_text", "status_text")
|
||
if fulfillment_status is _MISSING:
|
||
fulfillment_status = _fulfillment_text(
|
||
_raw_value(detail, "fulfillment_status", "status")
|
||
)
|
||
medication_days = _raw_value(detail, "medication_days")
|
||
fields: list[tuple[str, Any]] = [
|
||
("诊单ID", _raw_value(detail, "diagnosis_id", default="—")),
|
||
("创建时间", _raw_value(detail, "create_time_text", "create_time", default="—")),
|
||
("业务订单创建人", creator),
|
||
(
|
||
"创建人所属部门",
|
||
_display_collection(
|
||
_raw_value(
|
||
detail,
|
||
"creator_department_paths",
|
||
"creator_department_path",
|
||
"creator_dept_path",
|
||
"creator_department_name",
|
||
)
|
||
),
|
||
),
|
||
("收货人", _raw_value(detail, "recipient_name", "patient_name", default="—")),
|
||
("收货手机", _raw_value(detail, "recipient_phone", "phone", default="—")),
|
||
("收货地址", full_address),
|
||
(
|
||
"复诊",
|
||
"—" if follow_up is _MISSING else ("是" if _truthy(follow_up) else "否"),
|
||
),
|
||
(
|
||
"用药疗程",
|
||
"—" if medication_days is _MISSING else f"{display_text(medication_days)} 天",
|
||
),
|
||
("上次医护", _raw_value(detail, "prev_staff", default="—")),
|
||
("服务渠道", _raw_value(detail, "service_channel", default="—")),
|
||
("服务套餐", _display_collection(_raw_value(detail, "service_package"))),
|
||
("费用类别", _fee_type_text(_raw_value(detail, "fee_type"))),
|
||
("履约状态", fulfillment_status),
|
||
("处方审核", _audit_text(_raw_value(detail, "prescription_audit_status"))),
|
||
("支付审核", _audit_text(_raw_value(detail, "payment_slip_audit_status"))),
|
||
("快递单号", _raw_value(detail, "tracking_number", "express_no", default="—")),
|
||
("快递公司", _express_company_text(_raw_value(detail, "express_company"))),
|
||
("医助备注", _raw_value(detail, "remark_assistant", "remark", default="—")),
|
||
(
|
||
"处方审核意见",
|
||
_raw_value(detail, "prescription_audit_remark", default="—"),
|
||
),
|
||
(
|
||
"支付审核意见",
|
||
_raw_value(detail, "payment_slip_audit_remark", default="—"),
|
||
),
|
||
]
|
||
if has_permission(
|
||
self.permissions,
|
||
"tcm.prescriptionOrder/editRemarkExtra",
|
||
default=False,
|
||
):
|
||
fields.append(("药房备注", _raw_value(detail, "remark_extra", default="—")))
|
||
self._add_order_field_grid(layout, fields, columns=3)
|
||
parent.addWidget(section)
|
||
|
||
def _add_order_timeline_item(
|
||
self,
|
||
layout: QVBoxLayout,
|
||
*,
|
||
timestamp: Any,
|
||
title: Any,
|
||
body: Any,
|
||
) -> None:
|
||
item = QFrame()
|
||
item.setObjectName("DiagnosisOrderTimelineItem")
|
||
item_layout = QGridLayout(item)
|
||
item_layout.setContentsMargins(12, 5, 8, 8)
|
||
item_layout.setHorizontalSpacing(12)
|
||
item_layout.setVerticalSpacing(3)
|
||
time_label = QLabel(display_text(timestamp))
|
||
time_label.setObjectName("DiagnosisOrderTimelineTime")
|
||
time_label.setMinimumWidth(132)
|
||
time_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
item_layout.addWidget(time_label, 0, 0, 2, 1, Qt.AlignmentFlag.AlignTop)
|
||
title_label = QLabel(display_text(title))
|
||
title_label.setObjectName("DiagnosisOrderTimelineTitle")
|
||
title_label.setWordWrap(True)
|
||
item_layout.addWidget(title_label, 0, 1)
|
||
body_label = QLabel(display_text(body))
|
||
body_label.setObjectName("DiagnosisOrderTimelineBody")
|
||
body_label.setWordWrap(True)
|
||
body_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
item_layout.addWidget(body_label, 1, 1)
|
||
item_layout.setColumnStretch(1, 1)
|
||
layout.addWidget(item)
|
||
|
||
def _add_order_logistics_section(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
tracking = _raw_value(detail, "tracking_number", "express_no")
|
||
hint = "" if tracking is _MISSING else display_text(tracking)
|
||
section, layout = self._new_order_section("物流轨迹", hint)
|
||
if tracking is _MISSING:
|
||
self._add_order_empty(layout, "订单详情未返回快递单号,暂无物流轨迹")
|
||
parent.addWidget(section)
|
||
return
|
||
|
||
payload = _raw_value(
|
||
detail,
|
||
"logistics_trace",
|
||
"logistics_trace_payload",
|
||
"tracking_trace",
|
||
"logistics",
|
||
)
|
||
traces, traces_present = _detail_rows(detail, "logistics_traces", "tracking_traces")
|
||
if payload is not _MISSING and isinstance(payload, Mapping):
|
||
nested = payload.get("data")
|
||
if isinstance(nested, Mapping):
|
||
payload = nested
|
||
payload_traces, payload_present = _detail_rows(
|
||
payload, "traces", "trace", "items", "lists"
|
||
)
|
||
if payload_present:
|
||
traces, traces_present = payload_traces, True
|
||
state = _raw_value(payload, "state_text", "status_text", "state")
|
||
carrier = _raw_value(payload, "carrier_label", "express_company")
|
||
metadata: list[tuple[str, Any]] = []
|
||
if state is not _MISSING:
|
||
metadata.append(("物流状态", state))
|
||
if carrier is not _MISSING:
|
||
metadata.append(("承运商", _express_company_text(carrier)))
|
||
if metadata:
|
||
self._add_order_field_grid(layout, metadata, columns=2)
|
||
if not traces_present:
|
||
self._add_order_empty(layout, "详情接口未返回物流轨迹数据")
|
||
elif not traces:
|
||
self._add_order_empty(layout, "暂无轨迹节点记录")
|
||
else:
|
||
for trace in traces:
|
||
self._add_order_timeline_item(
|
||
layout,
|
||
timestamp=_raw_value(trace, "time", "create_time", "timestamp", default="—"),
|
||
title=_raw_value(trace, "state_text", "status", "location", default="物流节点"),
|
||
body=_raw_value(trace, "context", "description", "content", default="—"),
|
||
)
|
||
parent.addWidget(section)
|
||
|
||
def _add_order_logs_section(self, parent: QVBoxLayout, detail: Any) -> None:
|
||
section, layout = self._new_order_section("操作日志")
|
||
if not self._can_order_logs:
|
||
empty = self._add_order_empty(layout, "当前账号无操作日志查看权限")
|
||
empty.setProperty("permissionDenied", True)
|
||
parent.addWidget(section)
|
||
return
|
||
rows, present = _detail_rows(detail, "logs", "operation_logs", "order_logs")
|
||
if not present:
|
||
self._add_order_empty(layout, "详情接口未返回操作日志数据")
|
||
elif not rows:
|
||
self._add_order_empty(layout, "暂无操作日志")
|
||
else:
|
||
for log in rows:
|
||
actor = _raw_value(log, "admin_name", "operator_name", "creator_name")
|
||
action = _raw_value(log, "action_text")
|
||
if action is _MISSING:
|
||
action = _log_action_text(_raw_value(log, "action"))
|
||
title = (
|
||
" · ".join(
|
||
display_text(part) for part in (actor, action) if part is not _MISSING
|
||
)
|
||
or "操作记录"
|
||
)
|
||
self._add_order_timeline_item(
|
||
layout,
|
||
timestamp=_raw_value(log, "create_time", "created_at", default="—"),
|
||
title=title,
|
||
body=_raw_value(log, "summary", "content", "remark", default="—"),
|
||
)
|
||
parent.addWidget(section)
|
||
|
||
def _order_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
||
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
||
self._show_message(friendly_error(error), "danger")
|
||
|
||
def _load_orders(self) -> None:
|
||
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
||
return
|
||
self._tab_generations["orders"] += 1
|
||
generation = self._tab_generations["orders"]
|
||
diagnosis_id = self._diagnosis_id
|
||
patient_id = self._patient_id
|
||
self._loading_tabs.add("orders")
|
||
if not self.orders_list.rows:
|
||
self._set_tab_loading("orders")
|
||
self.orders_list.reload(
|
||
lambda page: self._query_orders(diagnosis_id, patient_id, page),
|
||
apply=lambda result: self._apply_orders_result(
|
||
result, diagnosis_id, generation
|
||
),
|
||
on_error=lambda error: self._tab_load_error(
|
||
"orders", error, diagnosis_id, generation
|
||
),
|
||
runner=run_async,
|
||
query_key=(diagnosis_id, patient_id),
|
||
)
|
||
|
||
def _apply_orders_result(
|
||
self,
|
||
result: Any,
|
||
diagnosis_id: int,
|
||
generation: int,
|
||
) -> None:
|
||
if (
|
||
generation != self._tab_generations["orders"]
|
||
or diagnosis_id != self._diagnosis_id
|
||
or not self._authoritative_detail_loaded
|
||
):
|
||
return
|
||
if self.orders_list.page == 0:
|
||
self._fill_orders([])
|
||
return
|
||
self._apply_tab_result("orders", result, diagnosis_id, generation)
|
||
|
||
def _save(self) -> None:
|
||
if (
|
||
not self._editable
|
||
or self._diagnosis_id <= 0
|
||
or not self._authoritative_detail_loaded
|
||
or self._current_tab_key() != "basic"
|
||
):
|
||
if self._editable and not self._authoritative_detail_loaded:
|
||
self._show_message(
|
||
"权威详情尚未加载成功,禁止保存。请先重试。", "danger", action_text="重试"
|
||
)
|
||
return
|
||
diagnosis_id = self._diagnosis_id
|
||
changes = {
|
||
key: _field_value(key, field.toPlainText(), self._field_originals.get(key))
|
||
for key, field in self.edit_fields.items()
|
||
if key not in {"phone", "id_card"} or self._can_phone_plain
|
||
}
|
||
for key in (
|
||
"patient_name",
|
||
"phone",
|
||
"id_card",
|
||
"gender",
|
||
"age",
|
||
"fasting_blood_sugar",
|
||
"diagnosis_type",
|
||
"local_hospital_name",
|
||
):
|
||
if key in self.edit_fields:
|
||
self.edit_fields[key].setProperty("invalid", False)
|
||
repolish(self.edit_fields[key])
|
||
patient_name = str(changes.get("patient_name") or "").strip()
|
||
if not patient_name:
|
||
self._show_message("请输入患者姓名。", "warning")
|
||
self.edit_fields["patient_name"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["patient_name"])
|
||
self.edit_fields["patient_name"].setFocus()
|
||
return
|
||
phone = str(changes.get("phone") or "").strip()
|
||
if self._can_phone_plain and not _PHONE_PATTERN.fullmatch(phone):
|
||
self._show_message("手机号格式不正确。", "warning")
|
||
self.edit_fields["phone"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["phone"])
|
||
self.edit_fields["phone"].setFocus()
|
||
return
|
||
id_card = str(changes.get("id_card") or "").strip()
|
||
if self._can_phone_plain and id_card and not _ID_CARD_PATTERN.fullmatch(id_card):
|
||
self._show_message("身份证号格式不正确。", "warning")
|
||
self.edit_fields["id_card"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["id_card"])
|
||
self.edit_fields["id_card"].setFocus()
|
||
return
|
||
gender_text = str(self.edit_fields["gender"].toPlainText() or "").strip()
|
||
if gender_text == "":
|
||
self._show_message("请选择性别。", "warning")
|
||
self.edit_fields["gender"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["gender"])
|
||
return
|
||
age_text = str(self.edit_fields["age"].toPlainText() or "").strip()
|
||
if age_text == "":
|
||
self._show_message("请输入年龄。", "warning")
|
||
self.edit_fields["age"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["age"])
|
||
self.edit_fields["age"].setFocus()
|
||
return
|
||
fasting = str(changes.get("fasting_blood_sugar") or "").strip()
|
||
if not fasting:
|
||
self._show_message("请输入空腹血糖。", "warning")
|
||
self.edit_fields["fasting_blood_sugar"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["fasting_blood_sugar"])
|
||
self.edit_fields["fasting_blood_sugar"].setFocus()
|
||
return
|
||
hospital = str(changes.get("local_hospital_name") or "").strip()
|
||
if not hospital:
|
||
self._show_message("请输入当地就诊医院名称。", "warning")
|
||
self.edit_fields["local_hospital_name"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["local_hospital_name"])
|
||
self.edit_fields["local_hospital_name"].setFocus()
|
||
return
|
||
diagnosis_type_raw = str(changes.get("diagnosis_type") or "").strip()
|
||
diagnosis_type = _diagnosis_type_value(diagnosis_type_raw)
|
||
if not diagnosis_type:
|
||
self._show_message("请选择诊断类型。", "warning")
|
||
self.edit_fields["diagnosis_type"].setProperty("invalid", True)
|
||
repolish(self.edit_fields["diagnosis_type"])
|
||
self.edit_fields["diagnosis_type"].setFocus()
|
||
return
|
||
changes["diagnosis_type"] = diagnosis_type
|
||
self._save_generation += 1
|
||
generation = self._save_generation
|
||
self._saving = True
|
||
self._sync_save_button()
|
||
self._show_message("正在保存病历…", "info")
|
||
run_async(
|
||
lambda: self._validate_and_save(diagnosis_id, changes),
|
||
on_success=lambda _result: self._save_success(generation),
|
||
on_error=lambda error: self._save_error(error, generation),
|
||
on_finished=lambda: self._save_finished(generation),
|
||
)
|
||
|
||
def _validate_and_save(self, diagnosis_id: int, changes: Mapping[str, Any]) -> Any:
|
||
if self._can_phone_plain:
|
||
phone = str(changes.get("phone") or "").strip()
|
||
phone_result = _invoke_first(
|
||
self.repository,
|
||
("check_diagnosis_phone",),
|
||
payload={"phone": phone, "id": diagnosis_id},
|
||
)
|
||
if _duplicate_found(phone_result):
|
||
raise ValueError(
|
||
str(
|
||
first_value(
|
||
phone_result, "message", "data.message", default="手机号已存在。"
|
||
)
|
||
)
|
||
)
|
||
id_card = str(changes.get("id_card") or "").strip()
|
||
if id_card:
|
||
id_card_result = _invoke_first(
|
||
self.repository,
|
||
("check_diagnosis_id_card",),
|
||
payload={"id_card": id_card, "id": diagnosis_id},
|
||
)
|
||
if _duplicate_found(id_card_result):
|
||
raise ValueError(
|
||
str(
|
||
first_value(
|
||
id_card_result,
|
||
"message",
|
||
"data.message",
|
||
default="身份证号已存在。",
|
||
)
|
||
)
|
||
)
|
||
return _invoke_first(
|
||
self.repository,
|
||
("update_diagnosis",),
|
||
diagnosis=diagnosis_id,
|
||
changes=dict(changes),
|
||
)
|
||
|
||
def _save_success(self, generation: int) -> None:
|
||
if generation != self._save_generation:
|
||
return
|
||
self.save_button.set_state("success")
|
||
self._show_message("病历已保存。", "success")
|
||
self.saved.emit()
|
||
|
||
def _save_error(self, error: Exception, generation: int) -> None:
|
||
if generation == self._save_generation:
|
||
self.save_button.set_state("error")
|
||
self._show_message(friendly_error(error), "danger")
|
||
|
||
def _save_finished(self, generation: int) -> None:
|
||
if generation == self._save_generation:
|
||
self._saving = False
|
||
self._sync_save_button()
|
||
|
||
|
||
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
|
||
|
||
|
||
def _unwrap_order_detail(detail: Any) -> Any:
|
||
payload = detail
|
||
if isinstance(detail, Mapping):
|
||
for key in ("data", "detail"):
|
||
nested = detail.get(key)
|
||
if isinstance(nested, Mapping) and any(
|
||
field in nested for field in ("id", "order_no", "prescription_id")
|
||
):
|
||
payload = nested
|
||
break
|
||
return payload
|
||
|
||
|
||
def present_order_detail(
|
||
parent: QWidget | None,
|
||
detail: Any,
|
||
*,
|
||
order_id: int = 0,
|
||
permissions: Any = None,
|
||
exec_: bool = True,
|
||
) -> OrderDetailDrawer:
|
||
"""Open the shared prescription-order detail drawer (admin drawer parity).
|
||
|
||
Used by diagnosis「业务订单」、处方「查看关联订单」and 我的患者「订单管理」.
|
||
"""
|
||
|
||
from types import MethodType
|
||
|
||
payload = _unwrap_order_detail(detail)
|
||
resolved_id = int(order_id or 0)
|
||
if resolved_id <= 0:
|
||
raw_id = first_value(payload, "id", "order_id", default=0)
|
||
try:
|
||
resolved_id = int(raw_id or 0)
|
||
except (TypeError, ValueError):
|
||
resolved_id = 0
|
||
|
||
class _OrderDetailShim:
|
||
pass
|
||
|
||
shim = _OrderDetailShim()
|
||
shim.permissions = permissions
|
||
shim._can_order_logs = has_permission(permissions, _ORDER_LOGS_PERMISSION, default=False)
|
||
for name in (
|
||
"_build_order_detail_dialog",
|
||
"_new_order_section",
|
||
"_add_order_empty",
|
||
"_add_order_field_grid",
|
||
"_add_order_amount_overview",
|
||
"_add_order_prescription_section",
|
||
"_build_order_payment_table",
|
||
"_add_order_payment_section",
|
||
"_add_order_fulfillment_section",
|
||
"_add_order_timeline_item",
|
||
"_add_order_logistics_section",
|
||
"_add_order_logs_section",
|
||
):
|
||
setattr(shim, name, MethodType(getattr(DiagnosisDialog, name), shim))
|
||
shim._status_kind = DiagnosisDialog._status_kind
|
||
|
||
dialog = shim._build_order_detail_dialog(payload, resolved_id, host=parent)
|
||
if exec_:
|
||
dialog.exec()
|
||
else:
|
||
dialog.open()
|
||
return dialog
|