This commit is contained in:
Your Name
2026-08-12 18:09:57 +08:00
parent a1092c02c3
commit b2eb18c75f
391 changed files with 1666 additions and 638 deletions
@@ -76,6 +76,7 @@ _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"
@@ -798,6 +799,9 @@ class DiagnosisDialog(QDialog):
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))
@@ -826,6 +830,8 @@ class DiagnosisDialog(QDialog):
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()
@@ -1631,6 +1637,9 @@ class DiagnosisDialog(QDialog):
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))
@@ -1895,6 +1904,7 @@ class DiagnosisDialog(QDialog):
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
@@ -2176,6 +2186,7 @@ class DiagnosisDialog(QDialog):
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
@@ -3012,7 +3023,9 @@ class DiagnosisDialog(QDialog):
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")
@@ -3025,6 +3038,7 @@ class DiagnosisDialog(QDialog):
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:
@@ -3343,13 +3357,106 @@ class DiagnosisDialog(QDialog):
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)
)
table.setCellWidget(row_index, 6, button)
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(
@@ -3360,6 +3467,55 @@ class DiagnosisDialog(QDialog):
)
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):
@@ -76,6 +76,7 @@ from ..widgets import (
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
@@ -542,6 +543,59 @@ def _duplicate_herb_names(herbs: Sequence[Mapping[str, Any]]) -> list[str]:
return duplicates
class MultiSelectComboBox(QComboBox):
"""Compact fixed-option multi-select matching Element Plus ``multiple`` selects."""
def __init__(self, options: Sequence[str], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setEditable(True)
if self.lineEdit() is not None:
self.lineEdit().setReadOnly(True)
self.lineEdit().setPlaceholderText("请选择忌口内容(可多选)")
for option in options:
self.addItem(option, option)
self.setItemData(self.count() - 1, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
self.view().pressed.connect(self._toggle_item)
# QComboBox applies the clicked item's text after ``pressed``. Refresh
# on the next event-loop tick so the field always shows every checked
# value instead of only the last clicked one.
self.currentIndexChanged.connect(lambda _index: QTimer.singleShot(0, self._refresh_text))
self._refresh_text()
def _toggle_item(self, index: Any) -> None:
row = index.row()
checked = self.itemData(row, Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked
self.setItemData(
row,
Qt.CheckState.Unchecked if checked else Qt.CheckState.Checked,
Qt.ItemDataRole.CheckStateRole,
)
self._refresh_text()
def set_values(self, values: Iterable[Any]) -> None:
selected = {str(value).strip() for value in values if str(value).strip()}
for row in range(self.count()):
self.setItemData(
row,
Qt.CheckState.Checked
if str(self.itemData(row)).strip() in selected
else Qt.CheckState.Unchecked,
Qt.ItemDataRole.CheckStateRole,
)
self._refresh_text()
def values(self) -> list[str]:
return [
str(self.itemData(row))
for row in range(self.count())
if self.itemData(row, Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked
]
def _refresh_text(self) -> None:
if self.lineEdit() is not None:
self.lineEdit().setText("".join(self.values()))
class RemoteMedicineComboBox(QComboBox):
"""Editable remote medicine selector retaining both id and canonical name."""
@@ -889,6 +943,10 @@ class HerbEditor(QWidget):
self.rows_changed.emit()
def set_rows(self, herbs: Iterable[Any], *, locked: bool = False) -> None:
herb_rows = list(herbs)
locked = locked or any(
_bool(first_value(herb, "locked", default=False)) for herb in herb_rows
)
self._suspend_changes = True
self.clear()
self._locked = locked
@@ -904,7 +962,7 @@ class HerbEditor(QWidget):
)
else:
self.lock_banner.clear()
for herb in herbs:
for herb in herb_rows:
row_locked = locked or _bool(first_value(herb, "locked", default=False))
self.add_row(herb, locked=row_locked)
self._suspend_changes = False
@@ -1576,6 +1634,8 @@ class _PrescriptionSectionNavigator:
class PrescriptionEditorDialog(QDialog):
"""Full issued-prescription add/edit form matching the admin DTO."""
diagnosis_requested = Signal(int)
PRESCRIPTION_TYPES = ("浓缩水丸", "饮片", "颗粒", "丸剂", "散剂", "膏方", "汤剂")
DIETARY_OPTIONS = (
"辛辣食物",
@@ -1600,6 +1660,7 @@ class PrescriptionEditorDialog(QDialog):
*,
mode: str = "add",
current_user: Any = None,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -1607,7 +1668,15 @@ class PrescriptionEditorDialog(QDialog):
self.prescription = prescription
self.mode = mode
self.current_user = current_user
self.permissions = (
permissions
if permissions is not None
else getattr(parent, "permissions", None)
if parent is not None
else None
)
self._source = _mapping(prescription)
self._linked_order_generation = 0
self._prescribing_creator_id = _int(
first_value(
prescription,
@@ -1644,6 +1713,10 @@ class PrescriptionEditorDialog(QDialog):
self.body_layout.setContentsMargins(24, 20, 24, 28)
self.body_layout.setSpacing(18)
self.context_banner = MessageBanner()
self.context_banner.setObjectName("PrescriptionEditorContextBanner")
self.body_layout.addWidget(self.context_banner)
self.patient_section = self._build_patient_section()
self.diagnosis_section = self._build_diagnosis_section()
self.herbs_section = self._build_herbs_section()
@@ -1684,6 +1757,7 @@ class PrescriptionEditorDialog(QDialog):
self.tabs = _PrescriptionSectionNavigator(self)
self._load_values()
self._load_editor_context()
def _build_header(self) -> QFrame:
header = QFrame()
@@ -1701,6 +1775,14 @@ class PrescriptionEditorDialog(QDialog):
subtitle.setObjectName("PrescriptionDrawerSubtitle")
copy.addWidget(subtitle)
layout.addLayout(copy, 1)
diagnosis_id = _int(self._source.get("diagnosis_id"), 0)
self.diagnosis_button = QPushButton("查看患者诊单详情")
self.diagnosis_button.setObjectName("PrescriptionDrawerDiagnosisButton")
self.diagnosis_button.setVisible(diagnosis_id > 0)
self.diagnosis_button.clicked.connect(
lambda _checked=False: self.diagnosis_requested.emit(diagnosis_id)
)
layout.addWidget(self.diagnosis_button, 0, Qt.AlignmentFlag.AlignVCenter)
mode_badge = QLabel("新增" if self.mode == "add" else "编辑")
mode_badge.setObjectName("PrescriptionDrawerMode")
layout.addWidget(mode_badge, 0, Qt.AlignmentFlag.AlignVCenter)
@@ -1800,20 +1882,20 @@ class PrescriptionEditorDialog(QDialog):
section, layout = self._section("诊断信息", "辨证信息随处方一并留档")
grid = QGridLayout()
self._prepare_grid(grid, 2)
self.pulse = QLineEdit()
self.pulse.setPlaceholderText("请输入面象")
self.tongue = QLineEdit()
self.tongue.setPlaceholderText("请输入")
self.tongue.setPlaceholderText("请输入")
self.tongue_image = QLineEdit()
self.tongue_image.setPlaceholderText("补充舌象详情")
self.tongue_image.setPlaceholderText("请输入舌象")
self.pulse = QLineEdit()
self.pulse.setPlaceholderText("请输入脉象")
self.pulse_condition = QLineEdit()
self.pulse_condition.setPlaceholderText("补充脉象详情")
self.pulse_condition.setPlaceholderText("请输入脉象详情")
self.clinical_diagnosis = QTextEdit()
self.clinical_diagnosis.setPlaceholderText("请输入临床诊断")
self.clinical_diagnosis.setMaximumHeight(86)
self._place_field(grid, 0, 0, "面象", self.pulse)
self._place_field(grid, 0, 1, "舌象", self.tongue)
self._place_field(grid, 1, 0, "舌象详情", self.tongue_image)
self._place_field(grid, 0, 0, "面象", self.tongue)
self._place_field(grid, 0, 1, "舌象", self.tongue_image)
self._place_field(grid, 1, 0, "脉象", self.pulse)
self._place_field(grid, 1, 1, "脉象详情", self.pulse_condition)
self._place_field(
grid,
@@ -1871,6 +1953,17 @@ class PrescriptionEditorDialog(QDialog):
actions.addWidget(self.add_aux_button)
self.import_library_button = QPushButton("从处方库导入")
self.import_library_button.clicked.connect(self._import_library)
library_default = self.permissions is None
self.import_library_button.setVisible(
any(
has_permission(self.permissions, code, default=library_default)
for code in (
"cf.prescription/add",
"cf.prescription/edit",
"tcm.prescriptionLibrary/lists",
)
)
)
actions.addWidget(self.import_library_button)
self.paste_button = QPushButton("导入药方")
self.paste_button.clicked.connect(self._paste_herbs)
@@ -1885,6 +1978,10 @@ class PrescriptionEditorDialog(QDialog):
)
self.herbs.rows_changed.connect(self._update_herb_summary)
layout.addWidget(self.herbs)
self.linked_order_banner = MessageBanner()
self.linked_order_banner.setObjectName("PrescriptionLinkedOrderBanner")
self.linked_order_banner.hide()
layout.addWidget(self.linked_order_banner)
return section
def _build_usage_section(self) -> QFrame:
@@ -1898,7 +1995,6 @@ class PrescriptionEditorDialog(QDialog):
self.dose_count = QSpinBox()
self.dose_count.setRange(1, 999)
self.dose_unit = QComboBox()
self.dose_unit.setEditable(True)
self.dose_unit.addItems(["", "", "", "", "", "", ""])
self._place_field(grid, 0, 0, "处方类型", self.prescription_type, required=True)
self._place_field(grid, 0, 1, "剂数", self.dose_count)
@@ -1910,7 +2006,6 @@ class PrescriptionEditorDialog(QDialog):
self.dosage_amount.setRange(0, 100000)
self.dosage_amount.setDecimals(2)
self.dosage_unit = QComboBox()
self.dosage_unit.setEditable(True)
self.dosage_unit.addItems(["g", "ml"])
self.dosage_bag_count = QSpinBox()
self.dosage_bag_count.setRange(1, 99)
@@ -1922,7 +2017,10 @@ class PrescriptionEditorDialog(QDialog):
self.usage_days = QSpinBox()
self.usage_days.setRange(1, 365)
self._place_field(grid, 2, 0, "单次用量", self.dosage_amount)
self._place_field(grid, 2, 1, "用量单位", self.dosage_unit)
self._main_dosage_unit_field = self._place_field(
grid, 2, 1, "用量单位", self.dosage_unit
)
self._main_dosage_unit_field.hide()
self._main_bag_field = self._place_field(grid, 2, 2, "每次袋数", self.dosage_bag_count)
self._main_decoction_field = self._place_field(grid, 3, 0, "代煎", self.need_decoction)
self._main_bags_field = self._place_field(grid, 3, 1, "每贴出包数", self.bags_per_dose)
@@ -1932,15 +2030,12 @@ class PrescriptionEditorDialog(QDialog):
self.usage_instruction.setMaxLength(200)
self._place_field(grid, 4, 1, "用法", self.usage_instruction, column_span=2)
self.usage_time = QComboBox()
self.usage_time.setEditable(True)
self.usage_time.addItems(["饭前", "饭后", "饭中", "空腹", "睡前", "晨起", "随时"])
self.usage_way = QComboBox()
self.usage_way.setEditable(True)
self.usage_way.addItems(
["温水送服", "开水冲服", "黄酒送服", "淡盐水送服", "米汤送服", "嚼服", "含化"]
)
self.dietary_taboo = QLineEdit()
self.dietary_taboo.setPlaceholderText("多项用逗号分隔")
self.dietary_taboo = MultiSelectComboBox(self.DIETARY_OPTIONS)
self._place_field(grid, 5, 0, "服用时间", self.usage_time)
self._place_field(grid, 5, 1, "服用方式", self.usage_way)
self._place_field(grid, 5, 2, "忌口", self.dietary_taboo)
@@ -1972,6 +2067,10 @@ class PrescriptionEditorDialog(QDialog):
self._aux_name_field = self._place_field(
grid, 8, 2, "辅方模板名", self.aux_prescription_name
)
# The admin editor keeps this value as import metadata; it is not a
# user-editable usage field. Preserve it in the DTO without exposing
# an extra workstation-only control.
self._aux_name_field.hide()
self._aux_decoction_field = self._place_field(
grid, 9, 0, "辅方代煎", self.aux_need_decoction
)
@@ -1985,7 +2084,6 @@ class PrescriptionEditorDialog(QDialog):
self._aux_usage_fields = (
self._aux_dosage_field,
self._aux_bag_field,
self._aux_name_field,
self._aux_decoction_field,
self._aux_bags_field,
self._aux_times_field,
@@ -2050,6 +2148,11 @@ class PrescriptionEditorDialog(QDialog):
self.herb_summary.setText(
f"{len(self.herbs.rows)} 味(主方 {main_count} / 辅方 {aux_count}"
" · 可手动添加、从处方库或粘贴药方批量录入"
+ (
" · 存在重复药名,请核对是否需要合并剂量"
if _duplicate_herb_names(self.herbs.values())
else ""
)
)
locked = self.herbs.locked
self.rp_lock_label.setVisible(locked)
@@ -2093,6 +2196,7 @@ class PrescriptionEditorDialog(QDialog):
self.prescription_type,
source.get("prescription_type") or "浓缩水丸",
)
self._type_changed()
self.dosage_amount.setValue(_float(source.get("dosage_amount"), 1))
self.dosage_unit.setCurrentText(
str(
@@ -2111,11 +2215,12 @@ class PrescriptionEditorDialog(QDialog):
self.usage_time.setCurrentText(str(source.get("usage_time") or "饭后"))
self.usage_way.setCurrentText(str(source.get("usage_way") or "温水送服"))
dietary = source.get("dietary_taboo") or ""
self.dietary_taboo.setText(
"".join(str(item) for item in dietary)
dietary_values = (
[str(item) for item in dietary]
if isinstance(dietary, (list, tuple))
else str(dietary)
else [item for item in re.split(r"[,,、]", str(dietary)) if item.strip()]
)
self.dietary_taboo.set_values(dietary_values)
self.usage_notes.setPlainText(str(source.get("usage_notes") or ""))
aux = source.get("aux_usage")
aux = dict(aux) if isinstance(aux, Mapping) else {}
@@ -2143,18 +2248,144 @@ class PrescriptionEditorDialog(QDialog):
self.visit_no.setReadOnly(True)
self._update_herb_summary()
def _load_editor_context(self) -> None:
"""Show the same edit/review context exposed by the admin drawer."""
messages: list[str] = []
if self.mode == "edit":
messages.append("保存修改后,处方将重新进入待审核。")
if _bool(self._source.get("is_system_auto")):
messages.append("当前为空白处方;保存后将转为手工处方。")
voided = _bool(self._source.get("void_status"))
rejected = _int(self._source.get("audit_status"), -1) == 2
if voided and rejected:
messages.append("当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。")
elif voided:
messages.append("当前处方已作废;保存后将取消作废并重新进入待审核。")
elif rejected:
messages.append("当前处方已驳回;保存后将清除驳回并重新进入待审核。")
if _bool(self._source.get("business_prescription_audit_rejected")):
remark = str(self._source.get("business_prescription_audit_remark") or "").strip()
message = (
"业务订单侧处方审核已驳回;保存后关联业务订单的处方和支付单审核会重置为待审。"
)
if remark:
message += f" 驳回意见:{remark}"
messages.append(message)
if messages:
self.context_banner.show_message("\n".join(messages), "warning")
else:
self.context_banner.hide()
prescription_id = _int(self._source.get("id"), 0)
list_orders = getattr(self.repository, "list_prescription_orders", None)
if self.mode != "edit" or prescription_id <= 0 or not callable(list_orders):
self.linked_order_banner.hide()
return
self._linked_order_generation += 1
generation = self._linked_order_generation
run_async(
lambda: list_orders(page_no=1, page_size=5, prescription_id=prescription_id),
on_success=lambda result: self._linked_order_loaded(result, generation),
on_error=lambda _error: self._linked_order_failed(generation),
)
def _linked_order_loaded(self, result: Any, generation: int) -> None:
if generation != self._linked_order_generation:
return
rows = page_items(result)
if not rows:
self.linked_order_banner.show_message(
"当前处方暂无关联业务订单,或当前账号无订单列表权限。",
"info",
)
return
row = rows[0]
order_no = str(first_value(row, "order_no", default="") or "").strip() or ""
medication_days = first_value(row, "medication_days", default=None)
days_text = f"{medication_days}" if _int(medication_days, 0) > 0 else "— 订单未填写"
assistant_remark = (
str(first_value(row, "remark_assistant", default="") or "").strip() or "— 无"
)
self.linked_order_banner.show_message(
"业务订单要求(请与本处方保持一致)\n"
f"最近关联订单:{order_no} 服用天数:{days_text}\n"
f"医助备注:{assistant_remark}",
"warning",
)
def _linked_order_failed(self, generation: int) -> None:
if generation == self._linked_order_generation:
self.linked_order_banner.show_message(
"当前处方暂无关联业务订单,或当前账号无订单列表权限。",
"info",
)
def _type_changed(self, _index: Any = None) -> None:
value = self.prescription_type.currentData()
if value == "饮片":
self.dosage_unit.setCurrentText("ml")
if self.dosage_amount.value() <= 0:
self.dosage_amount.setValue(50)
self.dosage_amount.setDecimals(0)
self.dosage_amount.setRange(50, 250)
self.dosage_amount.setSingleStep(50)
self.dosage_amount.setValue(50)
self.dosage_bag_count.setRange(1, 5)
self.dosage_bag_count.setValue(1)
self.need_decoction.setChecked(False)
self.bags_per_dose.setRange(1, 9)
self.bags_per_dose.setValue(1)
self.aux_dosage_amount.setDecimals(0)
self.aux_dosage_amount.setRange(50, 250)
self.aux_dosage_amount.setSingleStep(50)
self.aux_dosage_amount.setValue(50)
self.aux_dosage_bag_count.setRange(1, 5)
self.aux_dosage_bag_count.setValue(1)
self.aux_need_decoction.setChecked(False)
self.aux_bags_per_dose.setRange(1, 9)
self.aux_bags_per_dose.setValue(1)
elif value == "浓缩水丸":
self.dosage_unit.setCurrentText("g")
if self.dosage_amount.value() <= 0:
self.dosage_amount.setValue(1)
self.dosage_amount.setDecimals(0)
self.dosage_amount.setRange(1, 10)
self.dosage_amount.setSingleStep(1)
self.dosage_amount.setValue(1)
self.dosage_bag_count.setRange(1, 5)
self.dosage_bag_count.setValue(1)
self.need_decoction.setChecked(False)
self.bags_per_dose.setRange(1, 9)
self.bags_per_dose.setValue(1)
self.aux_dosage_amount.setDecimals(0)
self.aux_dosage_amount.setRange(1, 10)
self.aux_dosage_amount.setSingleStep(1)
self.aux_dosage_amount.setValue(5)
self.aux_dosage_bag_count.setRange(1, 5)
self.aux_dosage_bag_count.setValue(1)
self.aux_need_decoction.setChecked(False)
self.aux_bags_per_dose.setRange(1, 9)
self.aux_bags_per_dose.setValue(1)
else:
self.dosage_unit.setCurrentText("g")
self.dosage_amount.setDecimals(2)
self.dosage_amount.setRange(0, 100000)
self.dosage_amount.setSingleStep(1)
self.dosage_amount.setValue(0)
self.dosage_bag_count.setRange(1, 5)
self.dosage_bag_count.setValue(1)
self.need_decoction.setChecked(False)
self.bags_per_dose.setRange(1, 9)
self.bags_per_dose.setValue(1)
self.aux_dosage_amount.setDecimals(2)
self.aux_dosage_amount.setRange(0, 100000)
self.aux_dosage_amount.setSingleStep(1)
self.aux_dosage_amount.setValue(1)
self.aux_dosage_bag_count.setRange(1, 5)
self.aux_dosage_bag_count.setValue(1)
self.aux_need_decoction.setChecked(False)
self.aux_bags_per_dose.setRange(1, 9)
self.aux_bags_per_dose.setValue(1)
self.aux_times_per_day.setValue(3)
self.aux_usage_days.setValue(7)
self.aux_prescription_name.clear()
self._apply_type_visibility()
def _apply_type_visibility(self) -> None:
@@ -2263,11 +2494,7 @@ class PrescriptionEditorDialog(QDialog):
"usage_instruction": self.usage_instruction.text().strip(),
"usage_time": self.usage_time.currentText().strip(),
"usage_way": self.usage_way.currentText().strip(),
"dietary_taboo": [
item.strip()
for item in re.split(r"[,,、]", self.dietary_taboo.text())
if item.strip()
],
"dietary_taboo": self.dietary_taboo.values(),
"usage_notes": self.usage_notes.toPlainText().strip(),
"doctor_name": self.doctor_name.text().strip(),
"doctor_signature": self.signature.data_url(),
@@ -2309,16 +2536,42 @@ class PrescriptionEditorDialog(QDialog):
self.validation.show_message(message, "warning")
widget.setFocus()
return
length_checks = (
(payload["clinical_diagnosis"], 500, "临床诊断最多 500 个字符。", self.clinical_diagnosis),
(payload["usage_instruction"], 200, "用法最多 200 个字符。", self.usage_instruction),
(payload["usage_notes"], 200, "其他说明最多 200 个字符。", self.usage_notes),
)
for value, limit, message, widget in length_checks:
if len(str(value)) > limit:
self.validation.show_message(message, "warning")
widget.setFocus()
return
prescription_type = str(payload["prescription_type"] or "")
has_aux = any(_formula(herb.get("formula_type")) == "辅方" for herb in payload["herbs"])
dosage_values = [float(payload["dosage_amount"])]
if has_aux:
dosage_values.append(float(payload["aux_usage"]["dosage_amount"]))
if prescription_type == "浓缩水丸" and any(
value not in {float(item) for item in range(1, 11)} for value in dosage_values
):
self.validation.show_message("浓缩水丸单次用量只能选择 110g。", "warning")
self.dosage_amount.setFocus()
return
if prescription_type == "饮片" and any(
value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0}
for value in dosage_values
):
self.validation.show_message(
"饮片单次用量只能选择 50、100、120、150、180、200 或 250ml。",
"warning",
)
self.dosage_amount.setFocus()
return
herbs = payload["herbs"]
if not herbs:
self.tabs.setCurrentIndex(1)
self.validation.show_message("请至少添加一味药材。", "warning")
return
duplicate_names = _duplicate_herb_names(herbs)
if duplicate_names:
self.tabs.setCurrentIndex(1)
self.validation.show_message("药材不可重复:" + "".join(duplicate_names), "warning")
return
for index, herb in enumerate(herbs, 1):
if not str(herb.get("name") or "").strip():
self.tabs.setCurrentIndex(1)
@@ -3115,6 +3368,43 @@ class PrescriptionDetailDialog(QDialog):
pdf_button.clicked.connect(self.choose_pdf_path)
actions.addWidget(pdf_button)
root.addLayout(actions)
self.status_banner = MessageBanner()
self.status_banner.setObjectName("PrescriptionDetailStatusBanner")
status_lines: list[str] = []
if _bool(first_value(prescription, "void_status", "is_void", default=False)):
void_by = display_text(first_value(prescription, "void_by_name", default=""), "")
void_time = display_text(first_value(prescription, "void_time", default=""), "")
status_lines.append(f"当前处方已作废 作废人:{void_by} 作废时间:{void_time}")
audit_status = _int(first_value(prescription, "audit_status", default=-1), -1)
audit_label = {0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit_status, "未知")
status_lines.append(f"消费者处方审核:{audit_label}")
audit_remark = str(first_value(prescription, "audit_remark", default="") or "").strip()
audit_by = str(first_value(prescription, "audit_by_name", default="") or "").strip()
audit_time = str(first_value(prescription, "audit_time", default="") or "").strip()
if audit_by or audit_time or audit_remark:
status_lines.append(
f"审核人:{audit_by or ''} 审核时间:{audit_time or ''}"
+ (f" 审核意见:{audit_remark}" if audit_remark else "")
)
if _bool(first_value(prescription, "business_prescription_audit_rejected", default=False)):
business_remark = str(
first_value(prescription, "business_prescription_audit_remark", default="") or ""
).strip()
status_lines.append(
"业务订单处方审核:已驳回"
+ (f" 驳回意见:{business_remark}" if business_remark else "")
)
self.status_banner.show_message(
"\n".join(status_lines),
"danger"
if _bool(first_value(prescription, "void_status", default=False))
or audit_status == 2
or _bool(
first_value(prescription, "business_prescription_audit_rejected", default=False)
)
else "info",
)
root.addWidget(self.status_banner)
self.preview = _PrescriptionPaperPreview(prescription)
self.preview.setObjectName("PrescriptionPaperPreview")
self.preview.setOpenExternalLinks(False)
@@ -228,10 +228,19 @@ def _status_value(row: Any) -> int:
return _as_int(first_value(row, "status", default=0))
def prescription_action_label(row: Any) -> str:
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
return "查看" if audit == 1 and voided != 1 else "开方"
def prescription_action_label(row: Any) -> str:
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
explicit = first_value(row, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0
or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看" if audit == 1 and voided != 1 else "编辑处方"
def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str:
@@ -1177,31 +1186,64 @@ class AppointmentsPage(QWidget):
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
if generation != self._prescription_generation:
return
self.banner.clear()
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
dialog = PrescriptionDetailDialog(
existing,
can_open_diagnosis=_canonical_allowed(
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
),
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
if approved and not voided:
return
answer = QMessageBox.question(
self,
"新建处方",
"已展示当前挂号的处方。是否继续新建一张处方?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.StandardButton.Yes:
return
self._begin_case_record_load(row)
self.banner.clear()
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
dialog = PrescriptionDetailDialog(
existing,
can_open_diagnosis=_canonical_allowed(
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
),
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
return
self._begin_case_record_load(row)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
if prescription_id <= 0:
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
return
dialog = PrescriptionEditorDialog(
self.repository,
prescription,
mode="edit",
current_user=self.current_user,
parent=self,
)
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
if diagnosis_signal is not None:
diagnosis_signal.connect(self._open_diagnosis_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
frozen_payload = MappingProxyType(dialog.payload())
self._mutation_pending = True
self._action_generation += 1
generation = self._action_generation
self.banner.show_message("正在保存处方…", "info")
run_async(
lambda: invoke(
self.repository,
"update_prescription",
prescription=prescription_id,
changes=frozen_payload,
),
on_success=lambda _result: self._prescription_updated(generation),
on_error=lambda error: self._action_error(error, generation),
on_finished=lambda: self._mutation_finished(generation),
)
def _prescription_updated(self, generation: int) -> None:
if generation != self._action_generation:
return
self.banner.show_message("处方已保存并重新进入待审核。", "success")
self.refresh(silent=True)
def _begin_case_record_load(self, row: Any) -> None:
snapshot = deepcopy(row)
@@ -1262,8 +1304,10 @@ class AppointmentsPage(QWidget):
"visit_no": build_prescription_visit_no(
diagnosis_id=diagnosis_id, appointment_id=appointment_id
),
"tongue": authoritative("tongue", "tongue_coating", default=""),
"pulse": authoritative("pulse", default=""),
"tongue": authoritative("tongue", "tongue_coating", default=""),
"tongue_image": authoritative("tongue_image", default=""),
"pulse": authoritative("pulse", default=""),
"pulse_condition": authoritative("pulse_condition", default=""),
"clinical_diagnosis": build_prescription_clinical_diagnosis(
diagnosis, patient, record, case_record
),
@@ -1278,14 +1322,17 @@ class AppointmentsPage(QWidget):
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
seed = self._prescription_seed(record, case_record)
dialog = PrescriptionEditorDialog(
dialog = PrescriptionEditorDialog(
self.repository,
seed,
mode="add",
current_user=self.current_user,
parent=self,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
parent=self,
)
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
if diagnosis_signal is not None:
diagnosis_signal.connect(self._open_diagnosis_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
payload["diagnosis_id"] = seed["diagnosis_id"]
@@ -270,10 +270,19 @@ def is_diagnosis_confirmed(record: Any) -> bool:
return _as_bool(first_value(record, "diagnosis_confirmed", "confirmed", default=False))
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
return "查看处方" if audit == 1 and voided != 1 else "开方"
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit = first_value(record, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0
or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
def can_void_prescription(record: Any) -> bool:
@@ -2692,35 +2701,56 @@ class ConsultationsPage(QWidget):
def _prescription_loaded(self, existing: Any, record: Any, mode: str, generation: int) -> None:
if generation != self._prescription_generation:
return
self.banner.clear()
self._last_prescription = existing
if mode == "void":
self._confirm_void(existing)
return
if existing is not None:
detail = PrescriptionDetailDialog(
existing,
can_open_diagnosis=_canonical_allowed(
self.permissions, "tcm.diagnosis/readonlyDetail"
),
parent=self,
)
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
if not approved or voided:
answer = QMessageBox.question(
self,
"新建处方",
"已展示当前挂号的处方。是否继续新建一张处方?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
self._begin_case_record_load(record)
return
self._begin_case_record_load(record)
self.banner.clear()
self._last_prescription = existing
if mode == "void":
self._confirm_void(existing)
return
if existing is not None:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
detail = PrescriptionDetailDialog(
existing,
can_open_diagnosis=_canonical_allowed(
self.permissions, "tcm.diagnosis/readonlyDetail"
),
parent=self,
)
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
return
self._begin_case_record_load(record)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
if prescription_id <= 0:
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
return
dialog = PrescriptionEditorDialog(
self.repository,
prescription,
mode="edit",
current_user=self.current_user,
parent=self,
)
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
if diagnosis_signal is not None:
diagnosis_signal.connect(self._open_diagnosis_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
frozen_payload = MappingProxyType(dialog.payload())
self._run_mutation(
lambda: invoke(
self.repository,
"update_prescription",
prescription=prescription_id,
changes=frozen_payload,
),
"处方已保存并重新进入待审核。",
)
def _begin_case_record_load(self, record: Any) -> None:
record_snapshot = deepcopy(record)
@@ -2789,8 +2819,10 @@ class ConsultationsPage(QWidget):
"visit_no": build_prescription_visit_no(
diagnosis_id=diagnosis_id, appointment_id=appointment_id
),
"tongue": authoritative("tongue", "tongue_coating", default=""),
"pulse": authoritative("pulse", default=""),
"tongue": authoritative("tongue", "tongue_coating", default=""),
"tongue_image": authoritative("tongue_image", default=""),
"pulse": authoritative("pulse", default=""),
"pulse_condition": authoritative("pulse_condition", default=""),
"clinical_diagnosis": build_prescription_clinical_diagnosis(
diagnosis, patient, record, case_record
),
@@ -2804,14 +2836,17 @@ class ConsultationsPage(QWidget):
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
seed = self._prescription_seed(record, case_record)
dialog = PrescriptionEditorDialog(
dialog = PrescriptionEditorDialog(
self.repository,
seed,
mode="add",
current_user=self.current_user,
parent=self,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
parent=self,
)
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
if diagnosis_signal is not None:
diagnosis_signal.connect(self._open_diagnosis_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
payload["diagnosis_id"] = seed["diagnosis_id"]
@@ -69,10 +69,8 @@ def _truthy(value: Any) -> bool:
def prescription_status(row: Any) -> tuple[str, str]:
"""Return the admin-equivalent combined status label and visual kind."""
"""Return the admin audit-column status; void state has its own column."""
if _int(first_value(row, "void_status", "is_void"), 0) == 1:
return "已作废", "danger"
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
return "已驳回", "danger"
status = _int(first_value(row, "audit_status", "status", default=0), 0)
@@ -718,6 +716,7 @@ class PrescriptionsPage(QWidget):
current_user=self.current_user,
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(dialog.payload(), None)
@@ -739,6 +738,7 @@ class PrescriptionsPage(QWidget):
current_user=self.current_user,
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(
dialog.payload(),
+89 -1
View File
@@ -3,12 +3,13 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from PySide6.QtWidgets import QApplication, QDialog, QLabel
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -174,6 +175,93 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert prescription_action_label(approved) == "查看"
pending = Appointment.from_dict(
{
"id": 103,
"prescription_audit_status": 0,
"prescription_void_status": 0,
"has_prescription": 1,
}
)
assert prescription_action_label(pending) == "编辑处方"
def test_appointment_pending_prescription_uses_full_edit_contract(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
updated: list[tuple[int, dict[str, Any]]] = []
existing = {
"id": 81,
"diagnosis_id": 501,
"audit_status": 0,
"void_status": 0,
"patient_name": "林晓岚",
}
class Repository:
def update_prescription(
self, prescription: int, changes: dict[str, Any]
) -> dict[str, Any]:
updated.append((prescription, dict(changes)))
return {"id": prescription}
class AcceptedEditor:
def __init__(self, _repository: Any, source: Any, **kwargs: Any) -> None:
assert source is existing
assert kwargs["mode"] == "edit"
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"id": 81, "clinical_diagnosis": "脾气虚"}
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
result = function()
except Exception as error:
callbacks["on_error"](error)
else:
callbacks["on_success"](result)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(appointments_module, "PrescriptionEditorDialog", AcceptedEditor)
monkeypatch.setattr(appointments_module, "run_async", run_immediately)
page = AppointmentsPage(Repository(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "refresh", lambda **_kwargs: None)
page._prescription_loaded(existing, {"id": 101}, page._prescription_generation)
assert updated == [(81, {"id": 81, "clinical_diagnosis": "脾气虚"})]
page.close()
application.processEvents()
def test_appointment_prescription_seed_keeps_admin_observation_fields(
application: QApplication,
) -> None:
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
seed = page._prescription_seed(
{"id": 101, "appointment_id": 101, "diagnosis_id": 501},
{
"id": 501,
"patient_name": "林晓岚",
"tongue": "面象哨兵",
"tongue_image": "舌象哨兵",
"pulse": "脉象哨兵",
"pulse_condition": "脉象详情哨兵",
},
)
assert seed["tongue"] == "面象哨兵"
assert seed["tongue_image"] == "舌象哨兵"
assert seed["pulse"] == "脉象哨兵"
assert seed["pulse_condition"] == "脉象详情哨兵"
page.close()
application.processEvents()
def test_appointments_page_default_query_is_today_pending(
application: QApplication,
+89 -11
View File
@@ -132,10 +132,20 @@ def test_nested_appointments_confirmation_and_prescription_labels() -> None:
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 0})
== "查看处方"
)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 1})
== ""
)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 1})
== "编辑处"
)
assert (
prescription_action_label(
{
"has_prescription": 1,
"prescription_audit_status": 0,
"prescription_void_status": 0,
}
)
== "编辑处方"
)
def test_default_query_matches_admin_today_and_page_size_contract(
@@ -313,7 +323,7 @@ def test_prescription_query_error_is_fail_closed_without_diagnosis_fallback(
application.processEvents()
def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
@@ -321,8 +331,16 @@ def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
calls: list[tuple[str, int]] = []
created: list[dict[str, Any]] = []
dialog_seeds: list[dict[str, Any]] = []
case_record = {
"diagnosis": {"id": 501, "patient_name": "林晓岚", "chief_complaint": "咳嗽"},
case_record = {
"diagnosis": {
"id": 501,
"patient_name": "林晓岚",
"chief_complaint": "咳嗽",
"tongue": "面象哨兵",
"tongue_image": "舌象哨兵",
"pulse": "脉象哨兵",
"pulse_condition": "脉象详情哨兵",
},
"patient": {"id": 301, "gender": 2, "age": 36},
}
@@ -370,10 +388,70 @@ def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
assert created[0]["appointment_id"] == 202
assert created[0]["case_record"] == case_record
assert created[0]["case_record"] is not case_record
assert dialog_seeds[0]["case_record"] == case_record
assert dialog_seeds[0]["case_record"] is not case_record
page.close()
application.processEvents()
assert dialog_seeds[0]["case_record"] == case_record
assert dialog_seeds[0]["case_record"] is not case_record
assert dialog_seeds[0]["tongue"] == "面象哨兵"
assert dialog_seeds[0]["tongue_image"] == "舌象哨兵"
assert dialog_seeds[0]["pulse"] == "脉象哨兵"
assert dialog_seeds[0]["pulse_condition"] == "脉象详情哨兵"
page.close()
application.processEvents()
def test_existing_pending_prescription_is_edited_in_place_not_duplicated(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
updated: list[tuple[int, dict[str, Any]]] = []
created: list[dict[str, Any]] = []
existing = {
"id": 901,
"diagnosis_id": 501,
"patient_name": "林晓岚",
"audit_status": 0,
"void_status": 0,
"clinical_diagnosis": "气虚",
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
}
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 202
return existing
def update_prescription(
self, prescription: int, changes: dict[str, Any]
) -> dict[str, Any]:
updated.append((prescription, dict(changes)))
return {"id": prescription}
def create_prescription(self, prescription: dict[str, Any]) -> dict[str, Any]:
created.append(dict(prescription))
return {"id": 999}
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
class AcceptedEditor:
def __init__(self, _repository: Any, source: Any, **kwargs: Any) -> None:
assert source is existing
assert kwargs["mode"] == "edit"
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"id": 901, "clinical_diagnosis": "气血两虚"}
monkeypatch.setattr(consultations_module, "PrescriptionEditorDialog", AcceptedEditor)
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page._begin_prescription_load(_row(appointment_id=202), mode="open")
assert updated == [(901, {"id": 901, "clinical_diagnosis": "气血两虚"})]
assert created == []
page.close()
application.processEvents()
def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
@@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtWidgets import QApplication, QTabWidget, QWidget
from PySide6.QtWidgets import QApplication, QDialog, QTabWidget, QWidget
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
@@ -31,9 +31,10 @@ def _seed() -> dict[str, object]:
"gender": 0,
"age": 34,
"visit_no": "1K00000401",
"tongue": "舌淡红、苔薄白",
"pulse": "面色少华",
"pulse_condition": "脉细",
"tongue": "面色少华",
"tongue_image": "舌淡红、苔薄白",
"pulse": "脉细",
"pulse_condition": "沉取无力",
"clinical_diagnosis": "气阴两虚",
"doctor_name": "陈医生",
"herbs": [
@@ -116,7 +117,7 @@ def test_drawer_uses_full_width_on_a_narrow_host(application: QApplication) -> N
application.processEvents()
def test_duplicate_guard_and_admin_dto_survive_the_visual_restructure(
def test_duplicate_warning_and_admin_dto_survive_the_visual_restructure(
application: QApplication,
) -> None:
source = _seed()
@@ -138,11 +139,10 @@ def test_duplicate_guard_and_admin_dto_survive_the_visual_restructure(
assert payload["herbs"][0]["formula_type"] == "主方"
assert payload["herbs"][1]["formula_type"] == "辅方"
assert all(bool(row.property("duplicate")) for row in editor.herbs.rows)
assert "存在重复药名" in editor.herb_summary.text()
editor.accept()
assert editor.result() == 0
assert editor.tabs.currentIndex() == 1
assert "药材不可重复:黄芪" in editor.validation.label.text()
assert editor.result() == QDialog.DialogCode.Accepted
editor.close()
application.processEvents()
+77 -4
View File
@@ -129,6 +129,31 @@ def test_canonical_permission_helper_rejects_dot_aliases_and_accepts_wildcards()
assert not widgets.has_permission({}, "cf.prescription/edit")
def test_diagnosis_prescription_edit_uses_admin_edit_permission(
application: QApplication,
) -> None:
repository = SimpleNamespace(
create_prescription=lambda **_kwargs: {},
update_prescription=lambda **_kwargs: {},
)
create_only = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/chufang"]),
)
assert create_only._can_prescribe
assert not create_only._can_edit_prescription
create_only.close()
edit_only = DiagnosisDialog(
repository,
permissions=PermissionSet(["cf.prescription/edit"]),
)
assert not edit_only._can_prescribe
assert edit_only._can_edit_prescription
edit_only.close()
application.processEvents()
def test_diagnosis_masks_sensitive_fields_and_loads_exact_context_orders(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -190,7 +215,7 @@ def test_diagnosis_edit_checks_unique_identity_and_saves_expanded_dto(
application.processEvents()
def test_duplicate_herbs_are_rejected_for_templates_and_issued_prescriptions(
def test_duplicate_herbs_match_admin_warning_for_issued_prescriptions(
application: QApplication,
) -> None:
herbs = [
@@ -216,9 +241,9 @@ def test_duplicate_herbs_are_rejected_for_templates_and_issued_prescriptions(
editor.clinical_diagnosis.setPlainText("气虚")
editor.signature._has_strokes = True
editor.herbs.set_rows(herbs, locked=False)
assert "存在重复药名" in editor.herb_summary.text()
editor.accept()
assert editor.result() == QDialog.DialogCode.Rejected
assert "药材不可重复:黄芪" in editor.validation.label.text()
assert editor.result() == QDialog.DialogCode.Accepted
template.close()
editor.close()
application.processEvents()
@@ -358,7 +383,7 @@ def test_diagnosis_detail_requires_permission_and_ignores_stale_target(
shown: list[int] = []
class FakeDiagnosisDetailDialog:
def __init__(self, detail: dict[str, Any], _parent: Any) -> None:
def __init__(self, detail: dict[str, Any], _parent: Any, **_kwargs: Any) -> None:
shown.append(detail["id"])
def exec(self) -> int:
@@ -384,3 +409,51 @@ def test_diagnosis_detail_requires_permission_and_ignores_stale_target(
denied.close()
allowed.close()
application.processEvents()
def test_diagnosis_prescription_view_fetches_full_detail_and_ignores_stale_row(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
requested: list[int] = []
callbacks: list[tuple[Any, dict[str, Any]]] = []
shown: list[int] = []
class Repository:
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
requested.append(prescription_id)
return {
"id": prescription_id,
"patient_name": f"患者{prescription_id}",
"doctor_signature": "data:image/png;base64,detail",
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
}
def queue_async(function: Any, **options: Any) -> object:
callbacks.append((function, options))
return object()
class FakePrescriptionDetailDialog:
def __init__(self, detail: dict[str, Any], **_kwargs: Any) -> None:
assert detail["doctor_signature"].endswith("detail")
assert detail["herbs"][0]["name"] == "黄芪"
shown.append(detail["id"])
def exec(self) -> int:
return 0
monkeypatch.setattr(diagnosis_module, "run_async", queue_async)
monkeypatch.setattr(dialog_module, "PrescriptionDetailDialog", FakePrescriptionDetailDialog)
dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["*"]))
dialog._view_prescription({"id": 11, "herbs": []})
dialog._view_prescription({"id": 12, "herbs": []})
old_function, old_options = callbacks[0]
old_options["on_success"](old_function())
assert shown == []
new_function, new_options = callbacks[1]
new_options["on_success"](new_function())
assert requested == [11, 12]
assert shown == [12]
dialog.close()
application.processEvents()
+109 -1
View File
@@ -10,6 +10,7 @@ import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.prescription import (
@@ -88,7 +89,7 @@ def test_admin_status_and_action_guards_are_exact() -> None:
assert prescription_status(pending) == ("待审核", "warning")
assert prescription_status(approved) == ("已通过", "success")
assert prescription_status(rejected) == ("已驳回", "danger")
assert prescription_status(voided) == ("作废", "danger")
assert prescription_status(voided) == ("通过", "success")
assert can_patch_patient(pending)
assert can_create_order(pending)
assert can_audit(pending)
@@ -325,6 +326,8 @@ def test_editor_builds_complete_add_payload(
editor.clinical_diagnosis.setPlainText("脾气虚")
editor.herbs.rows[0].medicine.set_value(31, "黄芪")
editor.herbs.rows[0].dosage.setValue(15)
editor.dietary_taboo.set_values(["辛辣食物", "浓茶"])
assert editor.dietary_taboo.lineEdit().text() == "辛辣食物、浓茶"
editor.signature._has_strokes = True
payload = editor.payload()
@@ -341,12 +344,117 @@ def test_editor_builds_complete_add_payload(
}
]
assert payload["doctor_name"] == "周医生"
assert payload["dietary_taboo"] == ["辛辣食物", "浓茶"]
assert payload["doctor_signature"].startswith("data:image/png;base64,")
assert isinstance(payload["aux_usage"], dict)
editor.close()
application.processEvents()
def test_editor_matches_admin_four_observation_fields_and_edit_context(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_prescription_orders(self, **filters: Any) -> dict[str, Any]:
calls.append(filters)
return {
"lists": [
{
"order_no": "PO-901",
"medication_days": 14,
"remark_assistant": "按两周疗程复核",
}
],
"count": 1,
}
source = {
"id": 91,
"diagnosis_id": 501,
"patient_name": "林晓岚",
"visit_no": "1K00000501",
"tongue": "面象哨兵",
"tongue_image": "舌象哨兵",
"pulse": "脉象哨兵",
"pulse_condition": "脉象详情哨兵",
"clinical_diagnosis": "气阴两虚",
"doctor_name": "周医生",
"audit_status": 2,
"void_status": 1,
"is_system_auto": 1,
"business_prescription_audit_rejected": 1,
"business_prescription_audit_remark": "订单审核意见",
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
}
editor = PrescriptionEditorDialog(Repository(), source, mode="edit")
assert editor.tongue.text() == "面象哨兵"
assert editor.tongue_image.text() == "舌象哨兵"
assert editor.pulse.text() == "脉象哨兵"
assert editor.pulse_condition.text() == "脉象详情哨兵"
payload = editor.payload()
assert payload["tongue"] == "面象哨兵"
assert payload["tongue_image"] == "舌象哨兵"
assert payload["pulse"] == "脉象哨兵"
assert payload["pulse_condition"] == "脉象详情哨兵"
assert "取消作废、清除驳回" in editor.context_banner.label.text()
assert "空白处方" in editor.context_banner.label.text()
assert calls == [{"page_no": 1, "page_size": 5, "prescription_id": 91}]
assert "PO-901" in editor.linked_order_banner.label.text()
assert "14 天" in editor.linked_order_banner.label.text()
assert "按两周疗程复核" in editor.linked_order_banner.label.text()
editor.close()
application.processEvents()
def test_editor_preserves_global_lock_and_admin_type_constraints(
application: QApplication,
) -> None:
editor = PrescriptionEditorDialog(
SimpleNamespace(),
mode="add",
current_user=SimpleNamespace(id=7, name="周医生"),
permissions=PermissionSet([]),
)
editor.herbs.set_rows(
[
{
"medicine_id": 31,
"name": "酸枣仁",
"dosage": 12,
"formula_type": "辅方",
"locked": True,
},
{"medicine_id": 32, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
],
locked=False,
)
assert editor.herbs.locked
assert all(row.locked for row in editor.herbs.rows)
assert not editor.add_main_button.isEnabled()
assert editor.import_library_button.isHidden()
assert editor._aux_name_field.isHidden()
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("饮片"))
assert editor.dosage_amount.minimum() == 50
assert editor.dosage_amount.maximum() == 250
assert editor.dosage_amount.value() == 50
assert editor.bags_per_dose.maximum() == 9
assert editor.aux_dosage_amount.value() == 50
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("浓缩水丸"))
assert editor.dosage_amount.minimum() == 1
assert editor.dosage_amount.maximum() == 10
assert editor.dosage_amount.value() == 1
assert editor.dosage_bag_count.maximum() == 5
assert editor.aux_dosage_amount.value() == 5
editor.close()
application.processEvents()
def test_audit_reject_requires_remark(
application: QApplication,
) -> None: