Files
zyt/app/src/doctor_workstation/ui/dialogs/prescription.py
T
2026-08-12 18:09:57 +08:00

4157 lines
168 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Prescription-specific dialogs and editors.
The widgets in this module intentionally depend on repository protocols only.
Remote and demo repositories can therefore expose the same canonical method
names without coupling the UI to a concrete service implementation.
"""
from __future__ import annotations
import base64
import html
import json
import re
from collections.abc import Iterable, Mapping, Sequence
from datetime import date, datetime
from pathlib import Path
from typing import Any
from PySide6.QtCore import (
QBuffer,
QByteArray,
QDate,
QIODevice,
QPoint,
QRectF,
Qt,
QTimer,
Signal,
)
from PySide6.QtGui import (
QColor,
QFont,
QImage,
QMouseEvent,
QPageSize,
QPainter,
QPen,
QTextDocument,
)
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QDateEdit,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QTextBrowser,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ..widgets import (
MessageBanner,
display_text,
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
)
PRESCRIPTION_DRAWER_QSS = r"""
QFrame#PrescriptionDrawerSurface {
color: #134E4A;
background-color: #FFFFFF;
border-left: 1px solid #D9E0E7;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QFrame#PrescriptionDrawerHeader {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5E7EB;
}
QLabel#PrescriptionDrawerTitle {
color: #1F2937;
font-size: 20px;
font-weight: 700;
}
QLabel#PrescriptionDrawerSubtitle {
color: #5B7A76;
font-size: 12px;
}
QLabel#PrescriptionDrawerMode {
color: #1677A3;
background-color: #EAF6FB;
border: 1px solid #B9DEEC;
border-radius: 5px;
padding: 4px 9px;
font-size: 12px;
font-weight: 600;
}
QPushButton#PrescriptionDrawerClose {
min-width: 34px;
max-width: 34px;
min-height: 34px;
max-height: 34px;
padding: 0;
color: #5B7A76;
background-color: transparent;
border: 0;
border-radius: 7px;
font-size: 22px;
font-weight: 400;
}
QPushButton#PrescriptionDrawerClose:hover {
color: #134E4A;
background-color: #F2F4F7;
}
QScrollArea#PrescriptionDrawerBody,
QScrollArea#PrescriptionDrawerBody > QWidget > QWidget {
background-color: #FFFFFF;
border: 0;
}
QWidget#PrescriptionDrawerContent {
background-color: #FFFFFF;
}
QFrame#PrescriptionSection {
background-color: #F5F7FA;
border: 0;
border-radius: 7px;
}
QLabel#PrescriptionSectionTitle {
color: #134E4A;
font-size: 15px;
font-weight: 600;
}
QLabel#PrescriptionSectionHint {
color: #5B7A76;
font-size: 12px;
}
QWidget#PrescriptionField {
background-color: transparent;
}
QLabel#PrescriptionFieldLabel {
color: #5B7A76;
font-size: 12px;
font-weight: 500;
}
QLabel#PrescriptionUsageMain {
color: #0891B2;
font-size: 13px;
font-weight: 600;
}
QLabel#PrescriptionUsageAux {
color: #D58B1A;
font-size: 13px;
font-weight: 600;
}
QFrame#PrescriptionRpToolbar {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
QFrame#PrescriptionRpMarker {
background-color: #0891B2;
border: 0;
border-radius: 2px;
}
QLabel#PrescriptionRpHeading {
color: #134E4A;
font-size: 16px;
font-weight: 600;
}
QLabel#PrescriptionRpMeta {
color: #7A8492;
font-size: 12px;
}
QLabel#PrescriptionRpLock {
color: #B42318;
background-color: #FEF3F2;
border: 1px solid #FECDCA;
border-radius: 5px;
padding: 5px 8px;
font-size: 12px;
}
QLabel#PrescriptionFormulaTag {
border-radius: 4px;
padding: 3px 8px;
font-size: 12px;
font-weight: 600;
}
QLabel#PrescriptionFormulaTag[formula="main"] {
color: #0E7490;
background-color: #ECFEFF;
border: 1px solid #A0CFFF;
}
QLabel#PrescriptionFormulaTag[formula="aux"] {
color: #B88230;
background-color: #FDF6EC;
border: 1px solid #F3D19E;
}
QLabel#PrescriptionFormulaEmpty {
color: #A8ABB2;
padding: 10px 2px;
}
QFrame#PrescriptionHerbCard {
background-color: #FFFFFF;
border: 1px solid #D5E5E2;
border-radius: 7px;
}
QFrame#PrescriptionHerbCard[duplicate="true"] {
background-color: #FDF6EC;
border: 1px solid #E6A23C;
}
QLabel#PrescriptionHerbCardTitle {
color: #5B7A76;
font-size: 12px;
}
QPushButton#PrescriptionHerbDelete {
min-height: 30px;
padding: 0 6px;
color: #C45656;
background-color: transparent;
border: 0;
border-radius: 5px;
}
QPushButton#PrescriptionHerbDelete:hover {
color: #F56C6C;
background-color: #FEF0F0;
}
QFrame#PrescriptionDrawerFooter {
background-color: #FFFFFF;
border: 0;
border-top: 1px solid #E5E7EB;
}
QFrame#PrescriptionDrawerSurface QLineEdit,
QFrame#PrescriptionDrawerSurface QTextEdit,
QFrame#PrescriptionDrawerSurface QComboBox,
QFrame#PrescriptionDrawerSurface QDateEdit,
QFrame#PrescriptionDrawerSurface QSpinBox,
QFrame#PrescriptionDrawerSurface QDoubleSpinBox {
min-height: 34px;
color: #134E4A;
background-color: #FFFFFF;
border: 1px solid #D7DCE3;
border-radius: 6px;
padding: 0 9px;
selection-background-color: #D9ECFF;
}
QFrame#PrescriptionDrawerSurface QTextEdit {
padding: 7px 9px;
}
QFrame#PrescriptionDrawerSurface QLineEdit:focus,
QFrame#PrescriptionDrawerSurface QTextEdit:focus,
QFrame#PrescriptionDrawerSurface QComboBox:focus,
QFrame#PrescriptionDrawerSurface QDateEdit:focus,
QFrame#PrescriptionDrawerSurface QSpinBox:focus,
QFrame#PrescriptionDrawerSurface QDoubleSpinBox:focus {
border: 1px solid #0891B2;
}
QFrame#PrescriptionDrawerSurface QLineEdit:read-only {
color: #5B7A76;
background-color: #F0F2F5;
}
QFrame#PrescriptionDrawerSurface QComboBox::drop-down,
QFrame#PrescriptionDrawerSurface QDateEdit::drop-down {
border: 0;
width: 24px;
}
QFrame#PrescriptionDrawerSurface QPushButton {
min-height: 34px;
padding: 0 13px;
color: #0E7490;
background-color: #FFFFFF;
border: 1px solid #A0CFFF;
border-radius: 6px;
font-weight: 600;
}
QFrame#PrescriptionDrawerSurface QPushButton:hover {
color: #FFFFFF;
background-color: #0891B2;
border-color: #0891B2;
}
QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose {
min-width: 34px;
max-width: 34px;
min-height: 34px;
max-height: 34px;
padding: 0;
color: #5B7A76;
background-color: transparent;
border: 0;
border-radius: 7px;
font-size: 22px;
font-weight: 400;
}
QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose:hover {
color: #134E4A;
background-color: #F2F4F7;
border: 0;
}
QFrame#PrescriptionDrawerSurface QPushButton:pressed {
background-color: #0E7490;
border-color: #0E7490;
}
QFrame#PrescriptionDrawerSurface QPushButton:disabled {
color: #A8ABB2;
background-color: #F5F7FA;
border-color: #D5E5E2;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #0891B2;
border-color: #0891B2;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover {
background-color: #0E7490;
border-color: #0E7490;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"] {
color: #5B7A76;
background-color: transparent;
border-color: transparent;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"]:hover {
color: #134E4A;
background-color: #F2F4F7;
}
QFrame#PrescriptionDrawerSurface QScrollBar:vertical {
width: 10px;
margin: 2px;
background-color: transparent;
}
QFrame#PrescriptionDrawerSurface QScrollBar::handle:vertical {
min-height: 32px;
background-color: #C5CBD3;
border-radius: 4px;
}
QFrame#PrescriptionDrawerSurface QScrollBar::add-line:vertical,
QFrame#PrescriptionDrawerSurface QScrollBar::sub-line:vertical {
height: 0;
}
QWidget#PrescriptionSignaturePad {
background-color: #FFFFFF;
border: 1px solid #D7DCE3;
border-radius: 5px;
}
"""
_PRESCRIPTION_DARK_REPLACEMENTS = (
("color: #FFFFFF", "color: #EEF2FF"),
("selection-background-color: #D9ECFF", "selection-background-color: #6675F5"),
("#FFFFFF", "#101626"),
("#F5F7FA", "#151D31"),
("#F2F4F7", "#1B2440"),
("#F0F2F5", "#151D31"),
("#EAF6FB", "#151D31"),
("#FEF3F2", "#151D31"),
("#ECFEFF", "#151D31"),
("#FDF6EC", "#151D31"),
("#FEF0F0", "#1B2440"),
("#D9E0E7", "#29334F"),
("#E5E7EB", "#29334F"),
("#B9DEEC", "#29334F"),
("#E2E8F0", "#29334F"),
("#FECDCA", "#29334F"),
("#A0CFFF", "#29334F"),
("#F3D19E", "#29334F"),
("#D5E5E2", "#29334F"),
("#D7DCE3", "#29334F"),
("#134E4A", "#EEF2FF"),
("#1F2937", "#EEF2FF"),
("#5B7A76", "#9AA7C0"),
("#7A8492", "#9AA7C0"),
("#A8ABB2", "#9AA7C0"),
("#C5CBD3", "#29334F"),
("#1677A3", "#78A7FF"),
("#0891B2", "#6675F5"),
("#0E7490", "#6675F5"),
("#D58B1A", "#E4B967"),
("#B88230", "#E4B967"),
("#E6A23C", "#E4B967"),
("#B42318", "#F07886"),
("#C45656", "#F07886"),
("#F56C6C", "#F07886"),
("background-color: #EEF2FF", "background-color: #101626"),
("background: #EEF2FF", "background: #101626"),
)
# Light is the default application theme. Keep the replacement table available
# for a future explicit dark-mode switch, but do not mutate the light source QSS.
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def _mapping(value: Any) -> dict[str, Any]:
if isinstance(value, Mapping):
return dict(value)
raw = getattr(value, "raw", None)
result = dict(raw) if isinstance(raw, Mapping) else {}
fields = getattr(value, "__dataclass_fields__", {})
for name in fields:
if name != "raw":
result[name] = getattr(value, name, None)
return result
_DIAGNOSIS_TYPE_LABELS = {
"first_visit": "初诊",
"follow_up": "复诊",
"consultation": "会诊",
"1": "初诊",
"2": "复诊",
}
def build_prescription_visit_no(*, diagnosis_id: int = 0, appointment_id: int = 0) -> str:
"""Match admin ``tcm-prescription`` / backend: ``1K`` + 8-digit id.
Prefer appointment id when present (挂号列表开方); otherwise diagnosis id
(诊单详情开方 / 系统代开).
"""
seed = int(appointment_id or 0)
if seed <= 0:
seed = int(diagnosis_id or 0)
return f"1K{seed:08d}" if seed > 0 else ""
def build_prescription_clinical_diagnosis(*sources: Any) -> str:
"""Match admin ``buildClinicalDiagnosis``: symptoms first, then typed labels.
Never surface raw enum tokens such as ``follow_up`` as the diagnosis text.
"""
merged: dict[str, Any] = {}
for source in sources:
if source is None:
continue
merged.update(_mapping(source))
symptoms = str(first_value(merged, "symptoms", default="") or "").strip()
if symptoms:
return symptoms
clinical = str(first_value(merged, "clinical_diagnosis", default="") or "").strip()
if clinical and clinical not in _DIAGNOSIS_TYPE_LABELS:
return clinical
# Seeds sometimes put diagnosis_type enum into clinical_diagnosis; treat as type.
type_hint = clinical if clinical in _DIAGNOSIS_TYPE_LABELS else ""
type_text = str(
first_value(merged, "diagnosis_type_text", "diagnosis_type_desc", default="") or ""
).strip()
if not type_text:
raw_type = str(first_value(merged, "diagnosis_type", default="") or "").strip() or type_hint
type_text = _DIAGNOSIS_TYPE_LABELS.get(raw_type, "")
elif type_text in _DIAGNOSIS_TYPE_LABELS:
type_text = _DIAGNOSIS_TYPE_LABELS[type_text]
syndrome = str(
first_value(
merged,
"syndrome_type_text",
"syndrome_type_desc",
"syndrome_type",
default="",
)
or ""
).strip()
if syndrome in _DIAGNOSIS_TYPE_LABELS:
syndrome = ""
parts = [part for part in (type_text, syndrome) if part]
return " ".join(parts)
def _formula(value: Any) -> str:
text = str(value or "").strip().lower()
return "辅方" if text in {"2", "aux", "auxiliary", "secondary", "辅方"} else "主方"
def _repository_action(repository: Any, names: str | Sequence[str], **kwargs: Any) -> Any:
"""Call the first available canonical/compatibility repository method."""
candidates = (names,) if isinstance(names, str) else tuple(names)
for name in candidates:
if callable(getattr(repository, name, None)):
return invoke(repository, name, **kwargs)
raise AttributeError(f"repository has none of: {', '.join(candidates)}")
def _set_combo_data(combo: QComboBox, value: Any, fallback: int = 0) -> None:
index = combo.findData(value)
if index < 0:
text = str(value or "")
index = combo.findText(text)
combo.setCurrentIndex(index if index >= 0 else fallback)
def _duplicate_herb_names(herbs: Sequence[Mapping[str, Any]]) -> list[str]:
seen: set[str] = set()
duplicates: list[str] = []
for herb in herbs:
name = str(herb.get("name") or herb.get("medicine_name") or "").strip()
key = "".join(name.split()).casefold()
if key and key in seen and name not in duplicates:
duplicates.append(name)
seen.add(key)
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."""
search_failed = Signal(str)
def __init__(
self,
repository: Any,
*,
medicine_id: Any = None,
name: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self._generation = 0
self._selected_id = _int(medicine_id, 0) or None
# The admin selector preserves legacy rows whose API payload has a name
# but no medicine id. New/changed text must still be selected from the
# remote master-data list rather than submitted as arbitrary input.
self._legacy_name = str(name or "").strip() if self._selected_id is None else ""
self.setEditable(True)
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.setMaxVisibleItems(14)
self.setMinimumWidth(190)
self.lineEdit().setPlaceholderText("搜索药材名称或拼音首字母")
self.lineEdit().textEdited.connect(self._queue_search)
self.currentIndexChanged.connect(self._selection_changed)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(250)
self._timer.timeout.connect(self._search)
if name:
self.addItem(str(name), self._selected_id)
self.setCurrentIndex(0)
self.setEditText(str(name))
@property
def medicine_id(self) -> int | None:
data = self.currentData()
value = _int(data, 0)
return value or self._selected_id
@property
def medicine_name(self) -> str:
return self.currentText().strip()
@property
def has_valid_selection(self) -> bool:
name = self.medicine_name
return bool(self.medicine_id) or bool(self._legacy_name and name == self._legacy_name)
def set_value(self, medicine_id: Any, name: Any) -> None:
self._selected_id = _int(medicine_id, 0) or None
canonical_name = str(name or "").strip()
self._legacy_name = canonical_name if self._selected_id is None else ""
self.blockSignals(True)
self.clear()
if canonical_name:
self.addItem(canonical_name, self._selected_id)
self.setCurrentIndex(0)
self.setEditText(canonical_name)
self.blockSignals(False)
def showPopup(self) -> None:
if self.count() <= 1:
self._queue_search(self.currentText())
self._timer.stop()
self._search()
super().showPopup()
def _queue_search(self, _text: str) -> None:
self._selected_id = None
self._legacy_name = ""
self._timer.start()
def _search(self) -> None:
query = self.currentText().strip()
self._generation += 1
generation = self._generation
run_async(
lambda: _repository_action(
self.repository,
"list_medicines",
name=query,
page_no=1,
page_size=100,
status=1,
),
on_success=lambda result: self._apply_options(result, query, generation),
on_error=lambda error: self._search_error(error, generation),
)
def _apply_options(self, result: Any, query: str, generation: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
current_text = self.currentText().strip()
self.blockSignals(True)
self.clear()
for row in rows:
medicine_id = _int(first_value(row, "id", "medicine_id"), 0)
name = str(first_value(row, "name", "medicine_name", default="")).strip()
if not medicine_id or not name:
continue
supplier = str(first_value(row, "supplier", default="")).strip()
unit = str(first_value(row, "unit", default="")).strip()
label = name
meta = " · ".join(part for part in (supplier, unit, f"ID {medicine_id}") if part)
if meta:
label = f"{name} {meta}"
self.addItem(label, {"id": medicine_id, "name": name})
self.setEditText(current_text or query)
self.blockSignals(False)
def _search_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.search_failed.emit(friendly_error(error))
def _selection_changed(self, index: int) -> None:
data = self.itemData(index)
if isinstance(data, Mapping):
self._selected_id = _int(data.get("id"), 0) or None
name = str(data.get("name") or "").strip()
if name:
self._legacy_name = ""
self.setEditText(name)
else:
self._selected_id = _int(data, 0) or self._selected_id
class HerbRowWidget(QFrame):
remove_requested = Signal(object)
def __init__(
self,
repository: Any,
herb: Any = None,
*,
formula_type: str = "主方",
locked: bool = False,
show_formula: bool = False,
card_mode: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._card_mode = card_mode
self.setObjectName("PrescriptionHerbCard" if card_mode else "SubtleCard")
self.setProperty("duplicate", False)
self._locked = locked
self.formula_combo = QComboBox()
self.formula_combo.addItem("主方", "主方")
self.formula_combo.addItem("辅方", "辅方")
_set_combo_data(
self.formula_combo,
_formula(first_value(herb, "formula_type", default=formula_type)),
)
self.formula_combo.setVisible(show_formula and not card_mode)
self.formula_combo.setEnabled(not locked)
self.medicine = RemoteMedicineComboBox(
repository,
medicine_id=first_value(herb, "medicine_id", "id", default=None),
name=str(first_value(herb, "name", "medicine_name", default="")),
)
self.medicine.setEnabled(not locked)
self.dosage = QDoubleSpinBox()
self.dosage.setRange(0, 99999)
self.dosage.setDecimals(1)
self.dosage.setSingleStep(0.5)
self.dosage.setSuffix(" g")
self.dosage.setValue(_float(first_value(herb, "dosage", "amount", default=0)))
self.dosage.setEnabled(not locked)
self.dosage.setMaximumWidth(130)
self.remove_button = QPushButton("删除")
self.remove_button.setProperty("variant", "ghost")
self.remove_button.setVisible(not locked)
self.remove_button.clicked.connect(lambda: self.remove_requested.emit(self))
if card_mode:
self._build_card_layout()
else:
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 7, 10, 7)
layout.setSpacing(8)
layout.addWidget(self.formula_combo)
layout.addWidget(self.medicine, 1)
layout.addWidget(self.dosage)
layout.addWidget(self.remove_button)
def _build_card_layout(self) -> None:
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 9, 10, 9)
layout.setSpacing(7)
self.card_title = QLabel()
self.card_title.setObjectName("PrescriptionHerbCardTitle")
layout.addWidget(self.card_title)
self.medicine.setMinimumWidth(0)
self.medicine.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
layout.addWidget(self.medicine)
dose_row = QHBoxLayout()
dose_row.setContentsMargins(0, 0, 0, 0)
dose_row.setSpacing(5)
self.dosage.setMaximumWidth(16777215)
self.dosage.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
dose_row.addWidget(self.dosage, 1)
self.remove_button.setObjectName("PrescriptionHerbDelete")
dose_row.addWidget(self.remove_button)
layout.addLayout(dose_row)
def set_card_position(self, position: int) -> None:
if not self._card_mode:
return
formula = _formula(self.formula_combo.currentData())
self.card_title.setText(f"{formula} {position}")
def set_duplicate(self, duplicate: bool) -> None:
if bool(self.property("duplicate")) == duplicate:
return
self.setProperty("duplicate", duplicate)
self.style().unpolish(self)
self.style().polish(self)
@property
def locked(self) -> bool:
return self._locked
def value(self) -> dict[str, Any]:
result: dict[str, Any] = {
"name": self.medicine.medicine_name,
"dosage": self.dosage.value(),
"formula_type": self.formula_combo.currentData(),
}
if self.medicine.medicine_id:
result["medicine_id"] = self.medicine.medicine_id
if self._locked:
result["locked"] = True
return result
class HerbEditor(QWidget):
"""Scrollable main/auxiliary herb row editor."""
rows_changed = Signal()
def __init__(
self,
repository: Any,
*,
show_formula: bool = False,
grid_mode: bool = False,
show_actions: bool = True,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.show_formula = show_formula
self.grid_mode = grid_mode
self._show_actions = show_actions
self.rows: list[HerbRowWidget] = []
self._locked = False
self._suspend_changes = False
self._grid_columns = 4
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(8)
self.actions_host = QWidget()
actions = QHBoxLayout(self.actions_host)
actions.setContentsMargins(0, 0, 0, 0)
self.main_button = QPushButton(" 添加主方药材")
self.main_button.setProperty("variant", "secondary")
self.main_button.clicked.connect(lambda: self.add_row(formula_type="主方"))
actions.addWidget(self.main_button)
self.aux_button = QPushButton(" 添加辅方药材")
self.aux_button.setProperty("variant", "secondary")
self.aux_button.clicked.connect(lambda: self.add_row(formula_type="辅方"))
self.aux_button.setVisible(show_formula)
actions.addWidget(self.aux_button)
actions.addStretch(1)
self.actions_host.setVisible(show_actions)
root.addWidget(self.actions_host)
self.lock_banner = MessageBanner()
root.addWidget(self.lock_banner)
if grid_mode:
self._build_grid_editor(root)
else:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMinimumHeight(220)
host = QWidget()
self.rows_layout = QVBoxLayout(host)
self.rows_layout.setContentsMargins(0, 0, 4, 0)
self.rows_layout.setSpacing(7)
self.rows_layout.addStretch(1)
scroll.setWidget(host)
root.addWidget(scroll, 1)
def _build_grid_editor(self, root: QVBoxLayout) -> None:
main_head = QHBoxLayout()
main_tag = QLabel("主方")
main_tag.setObjectName("PrescriptionFormulaTag")
main_tag.setProperty("formula", "main")
main_head.addWidget(main_tag, 0, Qt.AlignmentFlag.AlignLeft)
main_head.addStretch(1)
root.addLayout(main_head)
self.main_grid = QGridLayout()
self.main_grid.setContentsMargins(0, 0, 0, 0)
self.main_grid.setHorizontalSpacing(12)
self.main_grid.setVerticalSpacing(12)
root.addLayout(self.main_grid)
self.main_empty = QLabel("暂无主方药材")
self.main_empty.setObjectName("PrescriptionFormulaEmpty")
root.addWidget(self.main_empty)
aux_head = QHBoxLayout()
aux_head.setContentsMargins(0, 8, 0, 0)
aux_tag = QLabel("辅方")
aux_tag.setObjectName("PrescriptionFormulaTag")
aux_tag.setProperty("formula", "aux")
aux_head.addWidget(aux_tag, 0, Qt.AlignmentFlag.AlignLeft)
aux_head.addStretch(1)
root.addLayout(aux_head)
self.aux_grid = QGridLayout()
self.aux_grid.setContentsMargins(0, 0, 0, 0)
self.aux_grid.setHorizontalSpacing(12)
self.aux_grid.setVerticalSpacing(12)
root.addLayout(self.aux_grid)
self.aux_empty = QLabel("暂无辅方药材")
self.aux_empty.setObjectName("PrescriptionFormulaEmpty")
root.addWidget(self.aux_empty)
@property
def locked(self) -> bool:
return self._locked
def clear(self) -> None:
for row in self.rows:
if self.grid_mode:
self.main_grid.removeWidget(row)
self.aux_grid.removeWidget(row)
row.deleteLater()
self.rows.clear()
if self.grid_mode:
self._reflow_rows()
if not self._suspend_changes:
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
self.main_button.setEnabled(not locked)
self.aux_button.setEnabled(not locked)
if locked:
if self.grid_mode:
self.lock_banner.clear()
else:
self.lock_banner.show_message(
"该处方含“禁用修改”模板,药材已锁定;可重新导入模板覆盖。",
"warning",
)
else:
self.lock_banner.clear()
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
if self.grid_mode:
self._reflow_rows()
self._refresh_duplicate_state()
self.rows_changed.emit()
def add_row(
self,
herb: Any = None,
*,
formula_type: str = "主方",
locked: bool | None = None,
) -> HerbRowWidget:
row = HerbRowWidget(
self.repository,
herb,
formula_type=formula_type,
locked=self._locked if locked is None else locked,
show_formula=self.show_formula,
card_mode=self.grid_mode,
)
row.remove_requested.connect(self.remove_row)
row.medicine.editTextChanged.connect(self._row_value_changed)
row.formula_combo.currentIndexChanged.connect(self._row_formula_changed)
self.rows.append(row)
if self.grid_mode:
self._reflow_rows()
else:
self.rows_layout.insertWidget(max(0, self.rows_layout.count() - 1), row)
self._refresh_duplicate_state()
if not self._suspend_changes:
self.rows_changed.emit()
return row
def remove_row(self, row: HerbRowWidget) -> None:
if self._locked or row.locked:
return
if row in self.rows:
self.rows.remove(row)
if self.grid_mode:
self.main_grid.removeWidget(row)
self.aux_grid.removeWidget(row)
row.deleteLater()
if self.grid_mode:
self._reflow_rows()
self._refresh_duplicate_state()
self.rows_changed.emit()
def values(self) -> list[dict[str, Any]]:
values = [row.value() for row in self.rows]
return [value for value in values if value["name"]]
def _row_value_changed(self, _value: Any = None) -> None:
self._refresh_duplicate_state()
if not self._suspend_changes:
self.rows_changed.emit()
def _row_formula_changed(self, _index: int) -> None:
if self.grid_mode:
self._reflow_rows()
self._row_value_changed()
def _refresh_duplicate_state(self) -> None:
names: dict[str, int] = {}
for row in self.rows:
key = "".join(row.medicine.medicine_name.split()).casefold()
if key:
names[key] = names.get(key, 0) + 1
for row in self.rows:
key = "".join(row.medicine.medicine_name.split()).casefold()
row.set_duplicate(bool(key and names.get(key, 0) > 1))
def _reflow_rows(self) -> None:
if not self.grid_mode:
return
for layout in (self.main_grid, self.aux_grid):
while layout.count():
layout.takeAt(0)
main_rows = [
row for row in self.rows if _formula(row.formula_combo.currentData()) == "主方"
]
aux_rows = [row for row in self.rows if _formula(row.formula_combo.currentData()) == "辅方"]
for layout, rows in ((self.main_grid, main_rows), (self.aux_grid, aux_rows)):
for index, row in enumerate(rows):
row.set_card_position(index + 1)
layout.addWidget(row, index // self._grid_columns, index % self._grid_columns)
for column in range(4):
layout.setColumnStretch(column, 1 if column < self._grid_columns else 0)
self.main_empty.setVisible(not main_rows)
self.aux_empty.setVisible(not aux_rows)
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
if not self.grid_mode:
return
width = max(1, event.size().width())
columns = 4 if width >= 880 else 3 if width >= 650 else 2 if width >= 420 else 1
if columns != self._grid_columns:
self._grid_columns = columns
self._reflow_rows()
class SignaturePad(QWidget):
"""Small handwritten signature surface serialised as a PNG data URL."""
changed = Signal()
_PAD_BG = QColor("#FFFFFF")
_PAD_BORDER = QColor("#D7DCE3")
_PAD_INK = QColor("#1F2937")
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("PrescriptionSignaturePad")
self.setMinimumSize(320, 150)
self.setCursor(Qt.CursorShape.CrossCursor)
self._image = QImage(1000, 300, QImage.Format.Format_ARGB32_Premultiplied)
self._image.fill(self._PAD_BG)
self._drawing = False
self._last = QPoint()
self._source = ""
self._has_strokes = False
def clear(self) -> None:
self._image.fill(self._PAD_BG)
self._source = ""
self._has_strokes = False
self.update()
self.changed.emit()
def set_signature(self, value: Any) -> None:
text = str(value or "")
self.clear()
if not text.startswith("data:image") or "," not in text:
return
try:
data = base64.b64decode(text.split(",", 1)[1])
except (ValueError, TypeError):
return
image = QImage.fromData(data)
if image.isNull():
return
self._image.fill(self._PAD_BG)
painter = QPainter(self._image)
painter.drawImage(self._image.rect(), image)
painter.end()
self._source = text
self._has_strokes = True
self.update()
def is_empty(self) -> bool:
return not self._source and not self._has_strokes
def data_url(self) -> str:
if self._source:
return self._source
if self.is_empty():
return ""
byte_array = QByteArray()
buffer = QBuffer(byte_array)
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
self._image.save(buffer, "PNG")
return "data:image/png;base64," + bytes(byte_array.toBase64()).decode("ascii")
def paintEvent(self, _event: Any) -> None:
painter = QPainter(self)
painter.fillRect(self.rect(), self._PAD_BG)
painter.drawImage(self.rect(), self._image)
painter.setPen(QPen(self._PAD_BORDER, 1))
painter.drawRect(self.rect().adjusted(0, 0, -1, -1))
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() != Qt.MouseButton.LeftButton:
return
self._drawing = True
self._source = ""
self._last = self._image_point(event.position().toPoint())
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if not self._drawing:
return
point = self._image_point(event.position().toPoint())
painter = QPainter(self._image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(
QPen(
self._PAD_INK,
5,
Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap,
Qt.PenJoinStyle.RoundJoin,
)
)
painter.drawLine(self._last, point)
painter.end()
self._last = point
self._has_strokes = True
self.update()
self.changed.emit()
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._drawing = False
def _image_point(self, point: QPoint) -> QPoint:
return QPoint(
int(point.x() * self._image.width() / max(1, self.width())),
int(point.y() * self._image.height() / max(1, self.height())),
)
class PrescriptionTemplateDialog(QDialog):
"""Create, edit, or read one reusable prescription-library template."""
def __init__(
self,
repository: Any,
template: Any = None,
*,
mode: str = "add",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.template = template
self.mode = mode
self.template_id = first_value(template, "id", "template_id", default=None)
title = {"add": "新增处方模板", "edit": "编辑处方模板", "view": "查看处方模板"}[mode]
self.setWindowTitle(title)
self.resize(760, 690)
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 20)
root.setSpacing(12)
heading = QLabel(title)
heading.setProperty("role", "pageTitle")
root.addWidget(heading)
hint = QLabel("模板保存可复用药材组合;“禁用修改”只锁定导入后的处方,不锁定模板维护。")
hint.setProperty("role", "muted")
hint.setWordWrap(True)
root.addWidget(hint)
form = QFormLayout()
self.name_edit = QLineEdit(
str(first_value(template, "prescription_name", "name", default=""))
)
self.name_edit.setMaxLength(100)
self.name_edit.setPlaceholderText("请输入处方名称")
form.addRow("处方名称", self.name_edit)
self.formula_combo = QComboBox()
self.formula_combo.addItem("主方", "主方")
self.formula_combo.addItem("辅方", "辅方")
_set_combo_data(
self.formula_combo,
_formula(first_value(template, "formula_type", default="主方")),
)
form.addRow("处方类型", self.formula_combo)
self.public_check = QCheckBox("所有医生可查看和使用")
self.public_check.setChecked(_bool(first_value(template, "is_public", default=False)))
form.addRow("公开范围", self.public_check)
self.disable_edit_check = QCheckBox("导入后禁用处方药材修改")
self.disable_edit_check.setChecked(
_bool(first_value(template, "disable_edit", default=False))
)
form.addRow("禁用修改", self.disable_edit_check)
root.addLayout(form)
root.addWidget(QLabel("药材配方"))
self.herbs = HerbEditor(repository, show_formula=False)
existing = get_value(template, "herbs", None) or []
self.herbs.set_rows(existing, locked=False)
if not existing and mode != "view":
self.herbs.add_row(formula_type=self.formula_combo.currentData())
root.addWidget(self.herbs, 1)
self.validation = MessageBanner()
root.addWidget(self.validation)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.button(QDialogButtonBox.StandardButton.Close).setText(
"关闭" if mode == "view" else "取消"
)
buttons.rejected.connect(self.reject)
if mode != "view":
self.save_button = buttons.addButton("保存模板", QDialogButtonBox.ButtonRole.AcceptRole)
self.save_button.setProperty("variant", "primary")
self.save_button.clicked.connect(self.accept)
else:
self.save_button = None
self.name_edit.setReadOnly(True)
self.formula_combo.setEnabled(False)
self.public_check.setEnabled(False)
self.disable_edit_check.setEnabled(False)
for row in self.herbs.rows:
row.medicine.setEnabled(False)
row.dosage.setEnabled(False)
row.remove_button.hide()
self.herbs.main_button.hide()
root.addWidget(buttons)
def payload(self) -> dict[str, Any]:
herbs = []
for row in self.herbs.values():
item = dict(row)
item.pop("formula_type", None)
item.pop("locked", None)
herbs.append(item)
result: dict[str, Any] = {
"prescription_name": self.name_edit.text().strip(),
"formula_type": self.formula_combo.currentData(),
"herbs": herbs,
"is_public": int(self.public_check.isChecked()),
"disable_edit": int(self.disable_edit_check.isChecked()),
}
if self.template_id is not None:
result["id"] = self.template_id
return result
def accept(self) -> None:
payload = self.payload()
if not payload["prescription_name"]:
self.validation.show_message("请输入处方名称。", "warning")
self.name_edit.setFocus()
return
if not payload["herbs"]:
self.validation.show_message("请至少添加一味药材。", "warning")
return
duplicate_names = _duplicate_herb_names(payload["herbs"])
if duplicate_names:
self.validation.show_message("药材不可重复:" + "、".join(duplicate_names), "warning")
return
for index, herb in enumerate(payload["herbs"], 1):
if not str(herb.get("name") or "").strip():
self.validation.show_message(f"第 {index} 味药材名称不能为空。", "warning")
return
if not self.herbs.rows[index - 1].medicine.has_valid_selection:
self.validation.show_message(
f"第 {index} 味药材必须从药材主数据中选择。", "warning"
)
return
if _float(herb.get("dosage"), 0) <= 0:
self.validation.show_message(f"第 {index} 味药材剂量必须大于 0。", "warning")
return
self.validation.clear()
super().accept()
class TemplateImportDialog(QDialog):
"""Paginated prescription-library selector used by the prescription editor."""
def __init__(
self,
repository: Any,
prescribing_creator_id: int,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.prescribing_creator_id = prescribing_creator_id
self._page = 1
self._page_size = 15
self._generation = 0
self._selected: Any = None
self.setWindowTitle("从处方库导入")
self.resize(850, 580)
root = QVBoxLayout(self)
filters = QHBoxLayout()
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("处方名称")
self.name_edit.returnPressed.connect(self.search)
filters.addWidget(self.name_edit, 1)
self.formula_combo = QComboBox()
self.formula_combo.addItem("全部类型", "")
self.formula_combo.addItem("主方", "主方")
self.formula_combo.addItem("辅方", "辅方")
filters.addWidget(self.formula_combo)
query = QPushButton("查询")
query.clicked.connect(self.search)
filters.addWidget(query)
root.addLayout(filters)
mode_layout = QHBoxLayout()
mode_layout.addWidget(QLabel("导入方式"))
self.mode_combo = QComboBox()
self.mode_combo.addItem("覆盖同方型药材", "replace")
self.mode_combo.addItem("追加到末尾", "append")
mode_layout.addWidget(self.mode_combo)
mode_layout.addStretch(1)
root.addLayout(mode_layout)
self.banner = MessageBanner()
root.addWidget(self.banner)
self.table = QTableWidget(0, 6)
self.table.setHorizontalHeaderLabels(
["处方名称", "类型", "药材数", "药材明细", "公开范围", "创建人"]
)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.verticalHeader().hide()
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.table.itemDoubleClicked.connect(lambda _item: self.accept())
root.addWidget(self.table, 1)
pager = QHBoxLayout()
pager.addStretch(1)
self.total_label = QLabel("共 0 条")
pager.addWidget(self.total_label)
self.previous = QPushButton("上一页")
self.previous.clicked.connect(lambda: self._change_page(self._page - 1))
pager.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
pager.addWidget(self.page_label)
self.next = QPushButton("下一页")
self.next.clicked.connect(lambda: self._change_page(self._page + 1))
pager.addWidget(self.next)
root.addLayout(pager)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.rejected.connect(self.reject)
import_button = buttons.addButton("导入", QDialogButtonBox.ButtonRole.AcceptRole)
import_button.setProperty("variant", "primary")
import_button.clicked.connect(self.accept)
root.addWidget(buttons)
QTimer.singleShot(0, self.load)
@property
def import_mode(self) -> str:
return str(self.mode_combo.currentData())
def selected_template(self) -> Any:
return self._selected
def search(self) -> None:
self._page = 1
self.load()
def load(self) -> None:
self._generation += 1
generation = self._generation
page = self._page
page_size = self._page_size
prescription_name = self.name_edit.text().strip()
formula_type = self.formula_combo.currentData()
prescribing_creator_id = self.prescribing_creator_id
self.banner.show_message("正在加载处方库…", "info")
run_async(
lambda: _repository_action(
self.repository,
("list_prescription_templates", "prescription_library"),
page_no=page,
page_size=page_size,
prescription_name=prescription_name,
formula_type=formula_type,
prescribing_creator_id=prescribing_creator_id,
),
on_success=lambda result: self._apply(result, generation),
on_error=lambda error: self._error(error, generation),
)
def _apply(self, result: Any, generation: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.setRowCount(len(rows))
for row_index, row in enumerate(rows):
herbs = get_value(row, "herbs", None) or []
detail = "、".join(
f"{first_value(item, 'name', 'medicine_name', default='')} "
f"{first_value(item, 'dosage', 'amount', default='')}g"
for item in herbs
)
values = (
first_value(row, "prescription_name", "name"),
_formula(first_value(row, "formula_type")),
f"{len(herbs)}味",
detail,
"所有人可见" if _bool(first_value(row, "is_public")) else "仅自己可见",
first_value(row, "creator_name", "doctor_name"),
)
for column, value in enumerate(values):
item = QTableWidgetItem(display_text(value))
item.setData(Qt.ItemDataRole.UserRole, row)
self.table.setItem(row_index, column, item)
total = page_total(result, len(rows))
page_count = max(1, (total + self._page_size - 1) // self._page_size)
self.total_label.setText(f"共 {total} 条")
self.page_label.setText(f"{self._page} / {page_count}")
self.previous.setEnabled(self._page > 1)
self.next.setEnabled(self._page < page_count)
self.banner.clear()
def _error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _change_page(self, page: int) -> None:
if page >= 1:
self._page = page
self.load()
def accept(self) -> None:
row = self.table.currentRow()
item = self.table.item(row, 0) if row >= 0 else None
self._selected = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if self._selected is None:
self.banner.show_message("请选择一条处方模板。", "warning")
return
super().accept()
_PASTE_SKIP = re.compile(
r"^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)"
)
def parse_pasted_herbs(text: str) -> list[dict[str, Any]]:
"""Parse the admin-supported common recipe text forms."""
normalized = (
text.replace("\r\n", "\n")
.replace("\r", "\n")
.translate(str.maketrans("0123456789", "0123456789"))
)
normalized = re.sub(r"^\s*(?:Rp[:]*|处方[:]?|中药处方[:]?)\s*", "", normalized)
pieces: list[str] = []
for line in normalized.splitlines():
for piece in re.split(r"[、,,;]", line):
cleaned = piece.strip()
if cleaned:
pieces.extend(
part.strip()
for part in re.split(r"\s+(?=[\u4e00-\u9fff]{2,})", cleaned)
if part.strip()
)
result: list[dict[str, Any]] = []
for piece in pieces:
if _PASTE_SKIP.search(piece) or re.match(r"^\d", piece):
continue
equal = re.match(
r"^(.+?)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$",
piece,
)
if equal:
dosage = float(equal.group(2))
names = [name for name in re.split(r"[、,,\s]+", equal.group(1)) if name]
result.extend({"name": name.strip(), "dosage": dosage} for name in names)
continue
matched = re.match(r"^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$", piece)
if matched:
name = re.sub(r"[(][^)]*[)]$", "", matched.group(1)).strip()
dosage = float(matched.group(2))
if name and dosage > 0:
result.append({"name": name, "dosage": dosage})
continue
name = re.sub(r"[(][^)]*[)]$", "", piece).strip()
if 2 <= len(name) <= 16:
result.append({"name": name, "dosage": 6.0})
return result
class PasteHerbsDialog(QDialog):
"""Text recipe importer that resolves exact medicine-library names."""
def __init__(self, repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.repository = repository
self.resolved: list[dict[str, Any]] = []
self.setWindowTitle("导入药方(识别药材)")
self.resize(600, 470)
root = QVBoxLayout(self)
root.addWidget(QLabel("粘贴药名与剂量;仅药品库完全同名且唯一的药材会被录入主方。"))
self.text_edit = QTextEdit()
self.text_edit.setPlaceholderText("例如:黄芪15 党参12 茯苓10\n柴胡10g、白术12g")
root.addWidget(self.text_edit, 1)
self.mode_combo = QComboBox()
self.mode_combo.addItem("覆盖现有主方", "replace")
self.mode_combo.addItem("追加到末尾", "append")
root.addWidget(self.mode_combo)
self.banner = MessageBanner()
root.addWidget(self.banner)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
buttons.rejected.connect(self.reject)
self.import_button = buttons.addButton("识别并导入", QDialogButtonBox.ButtonRole.AcceptRole)
self.import_button.clicked.connect(self.accept)
root.addWidget(buttons)
@property
def import_mode(self) -> str:
return str(self.mode_combo.currentData())
def accept(self) -> None:
parsed = parse_pasted_herbs(self.text_edit.toPlainText())
if not parsed:
self.banner.show_message("未能解析出药材,请检查格式。", "warning")
return
self.import_button.setEnabled(False)
self.banner.show_message("正在核对药品库…", "info")
def resolve() -> tuple[list[dict[str, Any]], list[str]]:
accepted: list[dict[str, Any]] = []
skipped: list[str] = []
for herb in parsed:
result = _repository_action(
self.repository,
"list_medicines",
name=herb["name"],
page_no=1,
page_size=200,
status=1,
)
exact = [
row
for row in page_items(result)
if str(first_value(row, "name", default="")).strip() == herb["name"]
]
if len(exact) != 1:
skipped.append(herb["name"])
continue
accepted.append(
{
"medicine_id": _int(first_value(exact[0], "id", "medicine_id")),
"name": herb["name"],
"dosage": herb["dosage"],
"formula_type": "主方",
}
)
return accepted, skipped
run_async(
resolve,
on_success=self._resolved,
on_error=lambda error: self.banner.show_message(friendly_error(error), "danger"),
on_finished=lambda: self.import_button.setEnabled(True),
)
def _resolved(self, result: tuple[list[dict[str, Any]], list[str]]) -> None:
accepted, skipped = result
if not accepted:
self.banner.show_message(
"药品库没有唯一同名匹配:" + "、".join(dict.fromkeys(skipped)),
"warning",
)
return
self.resolved = accepted
if skipped:
QMessageBox.information(
self,
"部分导入",
"已跳过非唯一同名药材:" + "、".join(dict.fromkeys(skipped)),
)
super().accept()
class _PrescriptionSectionNavigator:
"""Compatibility shim for callers that used the former visible tab widget."""
def __init__(self, owner: PrescriptionEditorDialog) -> None:
self._owner = owner
self._current = 0
def count(self) -> int:
return 4
def currentIndex(self) -> int:
return self._current
def setCurrentIndex(self, index: int) -> None:
self._current = max(0, min(3, int(index)))
self._owner.scroll_to_section(self._current)
def tabText(self, index: int) -> str:
return ("患者与诊断", "药材配方", "剂型与用法", "医师签名")[index]
class PrescriptionEditorDialog(QDialog):
"""Full issued-prescription add/edit form matching the admin DTO."""
diagnosis_requested = Signal(int)
PRESCRIPTION_TYPES = ("浓缩水丸", "饮片", "颗粒", "丸剂", "散剂", "膏方", "汤剂")
DIETARY_OPTIONS = (
"辛辣食物",
"生冷食物",
"油腻食物",
"海鲜",
"牛羊肉",
"鸡蛋",
"豆制品",
"酒类",
"浓茶",
"咖啡",
"烟草",
"萝卜",
)
DRAWER_WIDTH = 1200
def __init__(
self,
repository: Any,
prescription: Any = None,
*,
mode: str = "add",
current_user: Any = None,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
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,
"creator_id",
default=first_value(current_user, "id", "user_id", default=0),
)
)
self.setWindowTitle("新增处方" if mode == "add" else "编辑处方")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setMinimumSize(420, 600)
self.resize(self.DRAWER_WIDTH, 900)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self.drawer_surface = QFrame()
self.drawer_surface.setObjectName("PrescriptionDrawerSurface")
self.drawer_surface.setStyleSheet(PRESCRIPTION_DRAWER_QSS)
surface_layout = QVBoxLayout(self.drawer_surface)
surface_layout.setContentsMargins(0, 0, 0, 0)
surface_layout.setSpacing(0)
root.addWidget(self.drawer_surface)
self.header = self._build_header()
surface_layout.addWidget(self.header)
self.body_scroll = QScrollArea()
self.body_scroll.setObjectName("PrescriptionDrawerBody")
self.body_scroll.setWidgetResizable(True)
self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.body_content = QWidget()
self.body_content.setObjectName("PrescriptionDrawerContent")
self.body_layout = QVBoxLayout(self.body_content)
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()
self.usage_section = self._build_usage_section()
self.doctor_section = self._build_doctor_section()
self.section_widgets = (
self.patient_section,
self.diagnosis_section,
self.herbs_section,
self.usage_section,
self.doctor_section,
)
for section in self.section_widgets:
self.body_layout.addWidget(section)
self.body_layout.addStretch(1)
self.body_scroll.setWidget(self.body_content)
surface_layout.addWidget(self.body_scroll, 1)
self.footer = QFrame()
self.footer.setObjectName("PrescriptionDrawerFooter")
footer_layout = QVBoxLayout(self.footer)
footer_layout.setContentsMargins(24, 10, 24, 12)
footer_layout.setSpacing(8)
self.validation = MessageBanner()
footer_layout.addWidget(self.validation)
self.buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save
)
self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
self.save_button = self.buttons.button(QDialogButtonBox.StandardButton.Save)
self.cancel_button.setText("取消")
self.save_button.setText("保存处方")
self.save_button.setProperty("variant", "primary")
self.buttons.rejected.connect(self.reject)
self.buttons.accepted.connect(self.accept)
footer_layout.addWidget(self.buttons)
surface_layout.addWidget(self.footer)
self.tabs = _PrescriptionSectionNavigator(self)
self._load_values()
self._load_editor_context()
def _build_header(self) -> QFrame:
header = QFrame()
header.setObjectName("PrescriptionDrawerHeader")
header.setFixedHeight(76)
layout = QHBoxLayout(header)
layout.setContentsMargins(24, 12, 18, 12)
layout.setSpacing(12)
copy = QVBoxLayout()
copy.setSpacing(2)
title = QLabel("中医处方单")
title.setObjectName("PrescriptionDrawerTitle")
copy.addWidget(title)
subtitle = QLabel("患者信息、诊断、药材与用法将在同一处方工作流中提交")
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)
close_button = QPushButton("×")
close_button.setObjectName("PrescriptionDrawerClose")
close_button.setToolTip("关闭")
close_button.clicked.connect(self.reject)
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignVCenter)
self.close_button = close_button
return header
def _section(self, title: str, hint: str = "") -> tuple[QFrame, QVBoxLayout]:
section = QFrame()
section.setObjectName("PrescriptionSection")
layout = QVBoxLayout(section)
layout.setContentsMargins(16, 15, 16, 17)
layout.setSpacing(12)
if title:
heading_row = QHBoxLayout()
heading_row.setContentsMargins(0, 0, 0, 0)
heading_row.setSpacing(12)
heading = QLabel(title)
heading.setObjectName("PrescriptionSectionTitle")
heading_row.addWidget(heading)
if hint:
heading_hint = QLabel(hint)
heading_hint.setObjectName("PrescriptionSectionHint")
heading_row.addWidget(heading_hint)
heading_row.addStretch(1)
layout.addLayout(heading_row)
return section, layout
def _field(self, label: str, widget: QWidget, *, required: bool = False) -> QWidget:
field = QWidget()
field.setObjectName("PrescriptionField")
layout = QVBoxLayout(field)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
caption = QLabel(label + (" *" if required else ""))
caption.setObjectName("PrescriptionFieldLabel")
layout.addWidget(caption)
widget.setSizePolicy(QSizePolicy.Policy.Expanding, widget.sizePolicy().verticalPolicy())
layout.addWidget(widget)
return field
def _place_field(
self,
grid: QGridLayout,
row: int,
column: int,
label: str,
widget: QWidget,
*,
column_span: int = 1,
required: bool = False,
) -> QWidget:
field = self._field(label, widget, required=required)
grid.addWidget(field, row, column, 1, column_span)
return field
@staticmethod
def _prepare_grid(grid: QGridLayout, columns: int = 3) -> None:
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(16)
grid.setVerticalSpacing(11)
for column in range(columns):
grid.setColumnStretch(column, 1)
def _build_patient_section(self) -> QFrame:
section, layout = self._section("患者信息", "处方身份信息")
grid = QGridLayout()
self._prepare_grid(grid)
self.patient_name = QLineEdit()
self.patient_name.setMaxLength(50)
self.gender = QComboBox()
self.gender.addItem("男", 1)
self.gender.addItem("女", 0)
self.age = QSpinBox()
self.age.setRange(0, 150)
self.phone = QLineEdit()
self.phone.setReadOnly(True)
self.phone.setPlaceholderText("未提供")
self.visit_no = QLineEdit()
self.date_edit = QDateEdit(QDate.currentDate())
self.date_edit.setDisplayFormat("yyyy-MM-dd")
self.date_edit.setCalendarPopup(True)
self._place_field(grid, 0, 0, "姓名", self.patient_name, required=True)
self._place_field(grid, 0, 1, "性别", self.gender, required=True)
self._place_field(grid, 0, 2, "年龄", self.age)
self._place_field(grid, 1, 0, "电话", self.phone)
self._place_field(grid, 1, 1, "门诊号", self.visit_no)
self._place_field(grid, 1, 2, "处方日期", self.date_edit, required=True)
layout.addLayout(grid)
return section
def _build_diagnosis_section(self) -> QFrame:
section, layout = self._section("诊断信息", "辨证信息随处方一并留档")
grid = QGridLayout()
self._prepare_grid(grid, 2)
self.tongue = QLineEdit()
self.tongue.setPlaceholderText("请输入面象")
self.tongue_image = QLineEdit()
self.tongue_image.setPlaceholderText("请输入舌象")
self.pulse = QLineEdit()
self.pulse.setPlaceholderText("请输入脉象")
self.pulse_condition = QLineEdit()
self.pulse_condition.setPlaceholderText("请输入脉象详情")
self.clinical_diagnosis = QTextEdit()
self.clinical_diagnosis.setPlaceholderText("请输入临床诊断")
self.clinical_diagnosis.setMaximumHeight(86)
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,
2,
0,
"临床诊断",
self.clinical_diagnosis,
column_span=2,
required=True,
)
layout.addLayout(grid)
return section
def _build_herbs_section(self) -> QFrame:
section, layout = self._section("")
toolbar = QFrame()
toolbar.setObjectName("PrescriptionRpToolbar")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(14, 12, 14, 12)
toolbar_layout.setSpacing(12)
marker = QFrame()
marker.setObjectName("PrescriptionRpMarker")
marker.setFixedWidth(4)
marker.setMinimumHeight(48)
toolbar_layout.addWidget(marker)
lead_widget = QWidget()
lead_widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
lead = QVBoxLayout(lead_widget)
lead.setContentsMargins(0, 0, 0, 0)
lead.setSpacing(4)
heading = QLabel("中药处方 (RP)")
heading.setObjectName("PrescriptionRpHeading")
lead.addWidget(heading)
self.herb_summary = QLabel()
self.herb_summary.setObjectName("PrescriptionRpMeta")
self.herb_summary.setWordWrap(True)
self.herb_summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
lead.addWidget(self.herb_summary)
self.rp_lock_label = QLabel("含“禁用修改”处方库模板,药材已锁定;可重新从处方库导入覆盖。")
self.rp_lock_label.setObjectName("PrescriptionRpLock")
self.rp_lock_label.setWordWrap(True)
self.rp_lock_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
lead.addWidget(self.rp_lock_label)
toolbar_layout.addWidget(lead_widget, 1)
actions_widget = QWidget()
actions = QHBoxLayout(actions_widget)
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(7)
self.add_main_button = QPushButton("添加主方")
self.add_main_button.setProperty("variant", "primary")
self.add_main_button.clicked.connect(lambda: self.herbs.add_row(formula_type="主方"))
actions.addWidget(self.add_main_button)
self.add_aux_button = QPushButton("添加辅方")
self.add_aux_button.clicked.connect(lambda: self.herbs.add_row(formula_type="辅方"))
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)
actions.addWidget(self.paste_button)
toolbar_layout.addWidget(actions_widget)
layout.addWidget(toolbar)
self.herbs = HerbEditor(
self.repository,
show_formula=True,
grid_mode=True,
show_actions=False,
)
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:
section, layout = self._section("剂型与用法", "按主方、辅方分别配置")
grid = QGridLayout()
self._prepare_grid(grid)
self.prescription_type = QComboBox()
for value in self.PRESCRIPTION_TYPES:
self.prescription_type.addItem(value, value)
self.prescription_type.currentIndexChanged.connect(self._type_changed)
self.dose_count = QSpinBox()
self.dose_count.setRange(1, 999)
self.dose_unit = QComboBox()
self.dose_unit.addItems(["剂", "丸", "袋", "盒", "瓶", "膏", "贴"])
self._place_field(grid, 0, 0, "处方类型", self.prescription_type, required=True)
self._place_field(grid, 0, 1, "剂数", self.dose_count)
self._place_field(grid, 0, 2, "剂量单位", self.dose_unit)
main_heading = QLabel("主方用法")
main_heading.setObjectName("PrescriptionUsageMain")
grid.addWidget(main_heading, 1, 0, 1, 3)
self.dosage_amount = QDoubleSpinBox()
self.dosage_amount.setRange(0, 100000)
self.dosage_amount.setDecimals(2)
self.dosage_unit = QComboBox()
self.dosage_unit.addItems(["g", "ml"])
self.dosage_bag_count = QSpinBox()
self.dosage_bag_count.setRange(1, 99)
self.need_decoction = QCheckBox("需要代煎")
self.bags_per_dose = QSpinBox()
self.bags_per_dose.setRange(1, 99)
self.times_per_day = QSpinBox()
self.times_per_day.setRange(1, 6)
self.usage_days = QSpinBox()
self.usage_days.setRange(1, 365)
self._place_field(grid, 2, 0, "单次用量", self.dosage_amount)
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)
self._place_field(grid, 3, 2, "服用天数", self.usage_days)
self._place_field(grid, 4, 0, "每天几次", self.times_per_day)
self.usage_instruction = QLineEdit()
self.usage_instruction.setMaxLength(200)
self._place_field(grid, 4, 1, "用法", self.usage_instruction, column_span=2)
self.usage_time = QComboBox()
self.usage_time.addItems(["饭前", "饭后", "饭中", "空腹", "睡前", "晨起", "随时"])
self.usage_way = QComboBox()
self.usage_way.addItems(
["温水送服", "开水冲服", "黄酒送服", "淡盐水送服", "米汤送服", "嚼服", "含化"]
)
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)
self.usage_notes = QTextEdit()
self.usage_notes.setMaximumHeight(72)
self._place_field(grid, 6, 0, "其他说明", self.usage_notes, column_span=3)
self.aux_usage_heading = QLabel("辅方用法")
self.aux_usage_heading.setObjectName("PrescriptionUsageAux")
grid.addWidget(self.aux_usage_heading, 7, 0, 1, 3)
self.aux_dosage_amount = QDoubleSpinBox()
self.aux_dosage_amount.setRange(0, 100000)
self.aux_dosage_amount.setDecimals(2)
self.aux_dosage_bag_count = QSpinBox()
self.aux_dosage_bag_count.setRange(1, 99)
self.aux_need_decoction = QCheckBox("辅方需要代煎")
self.aux_bags_per_dose = QSpinBox()
self.aux_bags_per_dose.setRange(1, 99)
self.aux_times_per_day = QSpinBox()
self.aux_times_per_day.setRange(1, 6)
self.aux_usage_days = QSpinBox()
self.aux_usage_days.setRange(1, 365)
self.aux_prescription_name = QLineEdit()
self._aux_dosage_field = self._place_field(
grid, 8, 0, "辅方单次用量", self.aux_dosage_amount
)
self._aux_bag_field = self._place_field(
grid, 8, 1, "辅方每次袋数", self.aux_dosage_bag_count
)
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
)
self._aux_bags_field = self._place_field(
grid, 9, 1, "辅方每贴出包数", self.aux_bags_per_dose
)
self._aux_times_field = self._place_field(
grid, 9, 2, "辅方每天次数", self.aux_times_per_day
)
self._aux_days_field = self._place_field(grid, 10, 0, "辅方服用天数", self.aux_usage_days)
self._aux_usage_fields = (
self._aux_dosage_field,
self._aux_bag_field,
self._aux_decoction_field,
self._aux_bags_field,
self._aux_times_field,
self._aux_days_field,
)
layout.addLayout(grid)
return section
def _build_doctor_section(self) -> QFrame:
section, layout = self._section("医生签名", "保存前需完成手写签名")
self.doctor_name = QLineEdit()
doctor_field = self._field("医生姓名", self.doctor_name, required=True)
doctor_field.setMaximumWidth(360)
layout.addWidget(doctor_field)
signature_head = QHBoxLayout()
signature_label = QLabel("医生手写签名 *")
signature_label.setObjectName("PrescriptionFieldLabel")
signature_head.addWidget(signature_label)
signature_head.addStretch(1)
clear = QPushButton("清空签名")
clear.setProperty("variant", "ghost")
signature_head.addWidget(clear)
layout.addLayout(signature_head)
self.signature = SignaturePad()
self.signature.setMaximumWidth(620)
self.signature.setMaximumHeight(180)
layout.addWidget(self.signature)
clear.clicked.connect(self.signature.clear)
return section
def scroll_to_section(self, index: int) -> None:
targets = (
self.patient_section,
self.herbs_section,
self.usage_section,
self.doctor_section,
)
target = targets[max(0, min(len(targets) - 1, index))]
self.body_scroll.ensureWidgetVisible(target, 0, 24)
def _fit_drawer_geometry(self) -> None:
parent = self.parentWidget()
if parent is None:
return
anchor = parent.window()
origin = anchor.mapToGlobal(QPoint(0, 0))
width = min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width()))
height = max(self.minimumHeight(), anchor.height())
self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height)
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._fit_drawer_geometry()
def _update_herb_summary(self) -> None:
if not hasattr(self, "herbs"):
return
main_count = sum(
_formula(row.formula_combo.currentData()) == "主方" for row in self.herbs.rows
)
aux_count = len(self.herbs.rows) - main_count
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)
self.add_main_button.setEnabled(not locked)
self.add_aux_button.setEnabled(not locked)
if hasattr(self, "_aux_usage_fields"):
self.aux_usage_heading.setVisible(aux_count > 0)
for field in self._aux_usage_fields:
field.setVisible(aux_count > 0)
self._apply_type_visibility()
def _load_values(self) -> None:
source = self._source
self.patient_name.setText(str(source.get("patient_name") or ""))
self.phone.setText(str(source.get("phone") or ""))
visit_no = str(source.get("visit_no") or "").strip()
if not visit_no:
visit_no = build_prescription_visit_no(
diagnosis_id=_int(source.get("diagnosis_id"), 0),
appointment_id=_int(source.get("appointment_id"), 0),
)
self.visit_no.setText(visit_no)
_set_combo_data(self.gender, source.get("gender", 1))
self.age.setValue(_int(source.get("age"), 0))
date_value = QDate.fromString(
str(source.get("prescription_date") or date.today().isoformat()),
"yyyy-MM-dd",
)
self.date_edit.setDate(date_value if date_value.isValid() else QDate.currentDate())
self.tongue.setText(str(source.get("tongue") or ""))
self.tongue_image.setText(str(source.get("tongue_image") or ""))
self.pulse.setText(str(source.get("pulse") or ""))
self.pulse_condition.setText(str(source.get("pulse_condition") or ""))
clinical = str(source.get("clinical_diagnosis") or "").strip()
if self.mode == "add" or clinical in _DIAGNOSIS_TYPE_LABELS or not clinical:
clinical = build_prescription_clinical_diagnosis(source) or clinical
if clinical in _DIAGNOSIS_TYPE_LABELS:
clinical = _DIAGNOSIS_TYPE_LABELS[clinical]
self.clinical_diagnosis.setPlainText(clinical)
_set_combo_data(
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(
source.get("dosage_unit")
or ("ml" if self.prescription_type.currentData() == "饮片" else "g")
)
)
self.dosage_bag_count.setValue(max(1, _int(source.get("dosage_bag_count"), 1)))
self.need_decoction.setChecked(_bool(source.get("need_decoction")))
self.bags_per_dose.setValue(max(1, _int(source.get("bags_per_dose"), 1)))
self.dose_count.setValue(max(1, _int(source.get("dose_count"), 7)))
self.dose_unit.setCurrentText(str(source.get("dose_unit") or "剂"))
self.times_per_day.setValue(max(1, _int(source.get("times_per_day"), 2)))
self.usage_days.setValue(max(1, _int(source.get("usage_days"), 7)))
self.usage_instruction.setText(str(source.get("usage_instruction") or ""))
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 ""
dietary_values = (
[str(item) for item in dietary]
if isinstance(dietary, (list, tuple))
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 {}
self.aux_dosage_amount.setValue(_float(aux.get("dosage_amount"), 5))
self.aux_dosage_bag_count.setValue(max(1, _int(aux.get("dosage_bag_count"), 1)))
self.aux_need_decoction.setChecked(_bool(aux.get("need_decoction")))
self.aux_bags_per_dose.setValue(max(1, _int(aux.get("bags_per_dose"), 1)))
self.aux_times_per_day.setValue(max(1, _int(aux.get("times_per_day"), 3)))
self.aux_usage_days.setValue(max(1, _int(aux.get("usage_days"), 7)))
self.aux_prescription_name.setText(str(aux.get("prescription_name") or ""))
self.doctor_name.setText(
str(
source.get("doctor_name")
or first_value(self.current_user, "name", "real_name", default="")
)
)
self.signature.set_signature(source.get("doctor_signature"))
raw_herbs = source.get("herbs") or []
locked = any(_bool(first_value(item, "locked", default=False)) for item in raw_herbs)
self.herbs.set_rows(raw_herbs, locked=locked)
if not raw_herbs:
self.herbs.add_row(formula_type="主方")
if self.mode == "edit":
self.patient_name.setReadOnly(True)
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")
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")
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:
if not hasattr(self, "_main_bag_field"):
return
value = self.prescription_type.currentData()
concentrated = value == "浓缩水丸"
decoction = value == "饮片"
self._main_bag_field.setVisible(concentrated)
self._main_decoction_field.setVisible(decoction)
self._main_bags_field.setVisible(decoction)
has_aux = any(
_formula(row.formula_combo.currentData()) == "辅方" for row in self.herbs.rows
)
self._aux_bag_field.setVisible(has_aux and concentrated)
self._aux_decoction_field.setVisible(has_aux and decoction)
self._aux_bags_field.setVisible(has_aux and decoction)
def _import_library(self) -> None:
if not self._prescribing_creator_id:
self.validation.show_message("无法确定开方医生,不能加载处方库。", "warning")
return
dialog = TemplateImportDialog(
self.repository,
self._prescribing_creator_id,
self,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
template = dialog.selected_template()
herbs = get_value(template, "herbs", None) or []
formula_type = _formula(first_value(template, "formula_type", default="主方"))
imported = []
locked = _bool(first_value(template, "disable_edit", default=False))
for herb in herbs:
row = _mapping(herb)
row["formula_type"] = formula_type
if locked:
row["locked"] = True
else:
row.pop("locked", None)
imported.append(row)
current = self.herbs.values()
if dialog.import_mode == "replace":
current = [row for row in current if _formula(row.get("formula_type")) != formula_type]
combined = [*current, *imported]
self.herbs.set_rows(combined, locked=locked)
if formula_type == "辅方":
self.aux_prescription_name.setText(
str(first_value(template, "prescription_name", "name", default=""))
)
def _paste_herbs(self) -> None:
dialog = PasteHerbsDialog(self.repository, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
current = self.herbs.values()
if dialog.import_mode == "replace":
current = [row for row in current if _formula(row.get("formula_type")) == "辅方"]
self.herbs.set_rows([*current, *dialog.resolved], locked=False)
def payload(self) -> dict[str, Any]:
hidden_keys = (
"id",
"diagnosis_id",
"creator_id",
"is_system_auto",
"is_shared",
"visible_role_ids",
"audit_status",
"audit_time",
"audit_by_name",
"audit_remark",
"business_prescription_audit_rejected",
"business_prescription_audit_remark",
)
result = {key: self._source.get(key) for key in hidden_keys if key in self._source}
if self.mode == "add":
result["audit_status"] = 0
result["creator_id"] = self._prescribing_creator_id
result.setdefault("is_shared", 0)
result.setdefault("visible_role_ids", [])
result.update(
{
"prescription_type": self.prescription_type.currentData(),
"dosage_amount": self.dosage_amount.value(),
"dosage_unit": self.dosage_unit.currentText().strip(),
"dosage_bag_count": self.dosage_bag_count.value(),
"need_decoction": int(self.need_decoction.isChecked()),
"bags_per_dose": self.bags_per_dose.value(),
"patient_name": self.patient_name.text().strip(),
"gender": self.gender.currentData(),
"age": self.age.value(),
"visit_no": self.visit_no.text().strip(),
"prescription_date": self.date_edit.date().toString("yyyy-MM-dd"),
"tongue": self.tongue.text().strip(),
"tongue_image": self.tongue_image.text().strip(),
"pulse": self.pulse.text().strip(),
"pulse_condition": self.pulse_condition.text().strip(),
"clinical_diagnosis": self.clinical_diagnosis.toPlainText().strip(),
"herbs": self.herbs.values(),
"dose_count": self.dose_count.value(),
"dose_unit": self.dose_unit.currentText().strip(),
"usage_days": self.usage_days.value(),
"times_per_day": self.times_per_day.value(),
"usage_instruction": self.usage_instruction.text().strip(),
"usage_time": self.usage_time.currentText().strip(),
"usage_way": self.usage_way.currentText().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(),
"aux_usage": {
"dosage_amount": self.aux_dosage_amount.value(),
"dosage_bag_count": self.aux_dosage_bag_count.value(),
"need_decoction": int(self.aux_need_decoction.isChecked()),
"bags_per_dose": self.aux_bags_per_dose.value(),
"times_per_day": self.aux_times_per_day.value(),
"usage_days": self.aux_usage_days.value(),
"prescription_name": self.aux_prescription_name.text().strip(),
},
}
)
return result
def accept(self) -> None:
payload = self.payload()
checks = (
(payload["patient_name"], "请输入患者姓名。", self.patient_name, 0),
(payload["prescription_date"], "请输入处方日期。", self.date_edit, 0),
(
payload["clinical_diagnosis"],
"请输入临床诊断。",
self.clinical_diagnosis,
0,
),
(payload["doctor_name"], "请输入医生姓名。", self.doctor_name, 3),
(
payload["doctor_signature"],
"请在签名板手写医生签名。",
self.signature,
3,
),
)
for value, message, widget, tab_index in checks:
if not value:
self.tabs.setCurrentIndex(tab_index)
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("浓缩水丸单次用量只能选择 1–10g。", "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
for index, herb in enumerate(herbs, 1):
if not str(herb.get("name") or "").strip():
self.tabs.setCurrentIndex(1)
self.validation.show_message(f"第 {index} 味药材名称不能为空。", "warning")
return
if not self.herbs.rows[index - 1].medicine.has_valid_selection:
self.tabs.setCurrentIndex(1)
self.validation.show_message(
f"第 {index} 味药材必须从药材主数据中选择。", "warning"
)
return
if _float(herb.get("dosage"), 0) <= 0:
self.tabs.setCurrentIndex(1)
self.validation.show_message(f"第 {index} 味药材剂量必须大于 0。", "warning")
return
self.validation.clear()
super().accept()
class PatchPatientDialog(QDialog):
"""Narrow patient identity correction that preserves audit state."""
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.prescription_id = _int(first_value(prescription, "id", "prescription_id"))
self.setWindowTitle("修正姓名、性别与手机号")
self.resize(460, 300)
root = QVBoxLayout(self)
form = QFormLayout()
form.addRow("处方编号", QLabel(str(self.prescription_id)))
self.patient_name = QLineEdit(str(first_value(prescription, "patient_name", default="")))
self.patient_name.setMaxLength(50)
form.addRow("患者姓名 *", self.patient_name)
self.gender = QComboBox()
self.gender.addItem("男", 1)
self.gender.addItem("女", 0)
_set_combo_data(self.gender, first_value(prescription, "gender", default=1))
form.addRow("性别 *", self.gender)
self.phone = QLineEdit(str(first_value(prescription, "phone", default="")))
self.phone.setMaxLength(20)
form.addRow("手机号 *", self.phone)
root.addLayout(form)
root.addWidget(QLabel("仅更新处方笺显示信息,不改变审核状态;有关联订单时写入订单日志。"))
self.banner = MessageBanner()
root.addWidget(self.banner)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save
)
buttons.button(QDialogButtonBox.StandardButton.Save).setText("保存")
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
def payload(self) -> dict[str, Any]:
return {
"id": self.prescription_id,
"patient_name": self.patient_name.text().strip(),
"gender": self.gender.currentData(),
"phone": self.phone.text().strip(),
}
def accept(self) -> None:
payload = self.payload()
if not payload["patient_name"]:
self.banner.show_message("请输入患者姓名。", "warning")
return
if not payload["phone"]:
self.banner.show_message("请输入手机号。", "warning")
return
super().accept()
class AuditPrescriptionDialog(QDialog):
"""Approve or reject a pending prescription with the canonical DTO."""
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.prescription_id = _int(first_value(prescription, "id", "prescription_id"))
self.action = ""
self.setWindowTitle("处方审核")
self.resize(500, 300)
root = QVBoxLayout(self)
root.addWidget(QLabel("通过:处方保持有效。驳回:将同时作废处方,且必须填写审核意见。"))
self.remark = QTextEdit()
self.remark.setPlaceholderText("通过可简要说明;驳回必填")
self.remark.setMaximumHeight(120)
root.addWidget(self.remark)
self.banner = MessageBanner()
root.addWidget(self.banner)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
buttons.rejected.connect(self.reject)
approve = buttons.addButton("审核通过", QDialogButtonBox.ButtonRole.AcceptRole)
approve.setProperty("variant", "primary")
approve.clicked.connect(lambda: self._choose("approve"))
reject = buttons.addButton("驳回处方", QDialogButtonBox.ButtonRole.DestructiveRole)
reject.setProperty("variant", "danger")
reject.clicked.connect(lambda: self._choose("reject"))
root.addWidget(buttons)
def _choose(self, action: str) -> None:
if action == "reject" and not self.remark.toPlainText().strip():
self.banner.show_message("驳回时请填写审核意见。", "warning")
return
self.action = action
super().accept()
def payload(self) -> dict[str, Any]:
return {
"id": self.prescription_id,
"action": self.action,
"remark": self.remark.toPlainText().strip(),
}
def _herb_rows(value: Any) -> list[dict[str, Any]]:
herbs = get_value(value, "herbs", None) or []
return [_mapping(item) for item in herbs] if isinstance(herbs, (list, tuple)) else []
def _status_text(value: Any) -> str:
if _int(first_value(value, "void_status", "is_void"), 0) == 1:
return "已作废"
if _bool(first_value(value, "business_prescription_audit_rejected", default=False)):
return "已驳回(业务订单审核)"
status = _int(first_value(value, "audit_status", "status", default=0), 0)
return {0: "待审核", 1: "已通过", 2: "已驳回"}.get(status, display_text(status))
def render_case_record_html(prescription: Any) -> str:
"""Render the immutable diagnosis snapshot as the admin A3 case sheet.
``QTextDocument`` only implements a deliberately small HTML/CSS subset. In
particular, CSS grid and ``display: table-row`` silently degrade into a
vertical text stream. The admin preview uses a three-column grid, so this
version expresses that layout with real HTML tables which Qt renders
consistently on screen, printers and PDF devices.
"""
source = _mapping(prescription)
snapshot = _mapping(source.get("case_record"))
patient = _mapping(snapshot.get("patient"))
diagnosis = _mapping(snapshot.get("diagnosis"))
case = {**snapshot, **patient, **diagnosis}
def esc(value: Any, default: str = "—") -> str:
if isinstance(value, (list, tuple, set)):
value = "、".join(display_text(item, "") for item in value if item not in (None, ""))
elif isinstance(value, Mapping):
value = json.dumps(value, ensure_ascii=False, default=str)
return html.escape(display_text(value, default))
def value(*keys: str, default: Any = None) -> Any:
for key in keys:
translated = case.get(f"{key}_text")
if translated not in (None, ""):
return translated
candidate = case.get(key)
if candidate not in (None, ""):
return candidate
return default
def present_date(raw: Any) -> Any:
return None if raw in (None, "", 0, "0", "0000-00-00") else raw
gender = case.get("gender", source.get("gender"))
gender_label = (
case.get("gender_text")
or case.get("gender_desc")
or ("男" if gender in (1, "1") else "女" if gender in (0, "0", 2, "2") else "—")
)
marital = case.get("marital_status_text") or {0: "未婚", 1: "已婚", 2: "离异"}.get(
_int(case.get("marital_status"), -1), "—"
)
age = case.get("age") or source.get("age")
age_text = f"{age}岁" if age not in (None, "") and not str(age).endswith("岁") else age
diabetes_history = value("diabetes_discovery_year")
if diabetes_history not in (None, "") and str(diabetes_history).strip().isdigit():
diabetes_history = f"{str(diabetes_history).strip()}年"
blood_pressure = "—"
if case.get("systolic_pressure") not in (None, "") and case.get("diastolic_pressure") not in (
None,
"",
):
blood_pressure = (
f"{display_text(case.get('systolic_pressure'))}/"
f"{display_text(case.get('diastolic_pressure'))} mmHg"
)
diagnosis_type = value("diagnosis_type_desc", "diagnosis_type")
diagnosis_type = _DIAGNOSIS_TYPE_LABELS.get(str(diagnosis_type or "").strip(), diagnosis_type)
def field_cell(label: str, field_value: Any, *, colspan: int = 1) -> str:
return (
f'<td class="cr-item" colspan="{colspan}">'
'<table class="cr-kv" width="100%" cellspacing="0" cellpadding="0"><tr>'
f'<td class="cr-label">{html.escape(label)}</td>'
f'<td class="cr-value">{esc(field_value)}</td>'
"</tr></table></td>"
)
def section(
title: str,
fields: Sequence[tuple[str, Any, bool]],
) -> str:
rows: list[str] = []
pending: list[str] = []
for label, field_value, full_width in fields:
if full_width:
if pending:
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
pending = []
rows.append(f'<tr>{field_cell(label, field_value, colspan=3)}</tr>')
continue
pending.append(field_cell(label, field_value))
if len(pending) == 3:
rows.append(f'<tr>{"".join(pending)}</tr>')
pending = []
if pending:
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
return (
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
'<tr><td><table class="cr-grid" width="100%" cellspacing="0" cellpadding="0">'
f'{"".join(rows)}</table></td></tr>'
'<tr><td class="cr-rule"></td></tr></table>'
)
sections = [
section(
"基本信息",
(
(
"诊单ID",
case.get("id") or case.get("diagnosis_id") or source.get("diagnosis_id"),
False,
),
("姓名", case.get("patient_name") or source.get("patient_name"), False),
("身份证号", case.get("id_card"), False),
("手机号", case.get("phone") or source.get("phone"), False),
("性别", gender_label, False),
("年龄", age_text, False),
("婚姻状态", marital, False),
(
"身高",
f"{case.get('height')} cm" if case.get("height") not in (None, "") else None,
False,
),
(
"体重",
f"{case.get('weight')} kg" if case.get("weight") not in (None, "") else None,
False,
),
("地区", case.get("region"), False),
),
),
section(
"生命体征",
(
("血压", blood_pressure, False),
(
"空腹血糖",
f"{case.get('fasting_blood_sugar')} mmol/L"
if case.get("fasting_blood_sugar") not in (None, "")
else None,
False,
),
),
),
section(
"主诉",
(
("诊断日期", present_date(value("diagnosis_date")), False),
("诊断类型", diagnosis_type, False),
("证型", value("syndrome_type_desc", "syndrome_type"), False),
("糖尿病期数", value("diabetes_type_desc", "diabetes_type"), False),
("发现糖尿病患病史", diabetes_history, False),
("当地医院诊断", value("local_hospital_diagnosis"), True),
("当地就诊医院", value("local_hospital_name"), True),
),
),
section(
"现病史",
(
("口腔感觉", value("appetite"), True),
("每日饮水量", value("water_intake"), True),
("体重变化", value("weight_change"), True),
("脂肪肝程度", value("fatty_liver_degree"), True),
("饮食情况", value("diet_condition"), True),
("肢体感觉", value("body_feeling"), True),
("睡眠情况", value("sleep_condition"), True),
("眼睛情况", value("eye_condition"), True),
("头部感觉", value("head_feeling"), True),
("出汗情况", value("sweat_condition"), True),
("皮肤情况", value("skin_condition"), True),
("小便情况", value("urine_condition"), True),
("大便情况", value("stool_condition"), True),
("腰肾情况", value("kidney_condition"), True),
("其他补充", case.get("symptoms") or case.get("clinical_diagnosis"), True),
),
),
section(
"既往史",
(
("", value("past_history"), True),
),
),
section(
"其他病史",
(
("外伤史", "有" if _bool(case.get("trauma_history")) else "无", False),
("手术史", "有" if _bool(case.get("surgery_history")) else "无", False),
("过敏史", "有" if _bool(case.get("allergy_history")) else "无", False),
("家族病史", "有" if _bool(case.get("family_history")) else "无", False),
(
"妊娠哺乳史",
"有" if _bool(case.get("pregnancy_history")) else "无",
False,
),
),
),
section(
"诊断信息",
(
("舌象", case.get("tongue_coating") or case.get("tongue"), True),
("脉象", case.get("pulse"), True),
("治则", case.get("treatment_principle"), True),
("医嘱", case.get("doctor_advice"), True),
),
),
]
return f"""
<!doctype html><html><head><meta charset="utf-8"><style>
body {{ margin:0; padding:12px; background:#eef1f5; color:#303133;
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
font-size:13px; }}
.paper {{ width:100%; margin:0 auto; background:#ffffff; border:1px solid #dcdfe6; }}
.paper-content {{ padding:26px 30px 34px; vertical-align:top; }}
h1 {{ text-align:center; font-size:20px; font-weight:600; margin:0;
padding:0 0 14px; border-bottom:2px solid #303133; }}
.cr-section {{ margin-top:14px; border-collapse:collapse; }}
.cr-section-title {{ padding:0 0 8px; color:#303133; font-size:15px; font-weight:600; }}
.cr-grid {{ table-layout:fixed; border-collapse:collapse; }}
.cr-item {{ width:33.33%; padding:4px 10px 5px 0; vertical-align:top; }}
.cr-item.empty {{ padding:0; }}
.cr-kv {{ border-collapse:collapse; table-layout:fixed; }}
.cr-label {{ width:88px; padding:0 7px 0 0; color:#909399; white-space:nowrap;
vertical-align:top; line-height:1.65; }}
.cr-value {{ padding:0; color:#303133; vertical-align:top; line-height:1.65; }}
.cr-rule {{ height:1px; border-bottom:1px solid #eeeeee; }}
</style></head><body>
<table class="paper" width="100%" cellspacing="0" cellpadding="0"><tr>
<td class="paper-content"><h1>甄养堂 详细病历</h1>{''.join(sections)}</td>
</tr></table></body></html>
"""
def render_prescription_html(prescription: Any, *, print_layout: bool = False) -> str:
"""Build the pharmacy-copy A4 slip used by preview, print and PDF export."""
source = _mapping(prescription)
# consumer/prescription/index.vue uses a strict 210 x 297 mm sheet. At
# Qt's 96 logical DPI this is 794 x 1123 px; keeping the HTML width fixed
# prevents QTextDocument from stretching the medicine columns with the
# containing dialog.
paper_width = "100%" if print_layout else "794px"
paper_dimensions = 'width="100%"' if print_layout else 'width="794" height="1123"'
body_padding = "0" if print_layout else "12px"
body_background = "#ffffff" if print_layout else "#f5f6f8"
paper_border = "0" if print_layout else "1px solid #d6d6d6"
base_font_size = "10px" if print_layout else "13px"
notice_font_size = "8.5px" if print_layout else "12px"
rp_font_size = "12px" if print_layout else "16px"
section_font_size = "9px" if print_layout else "12px"
bottom_height = "44px" if print_layout else "70px"
herbs = _herb_rows(prescription)
dose_count = max(1, _int(source.get("dose_count"), 1))
main = [row for row in herbs if _formula(row.get("formula_type")) == "主方"]
aux = [row for row in herbs if _formula(row.get("formula_type")) == "辅方"]
def esc(value: Any, default: str = "—") -> str:
return html.escape(display_text(value, default))
def number_text(value: Any, default: str = "0") -> str:
try:
number = float(value)
except (TypeError, ValueError):
return default
if number.is_integer():
return str(int(number))
return f"{number:.2f}".rstrip("0").rstrip(".")
def positive_number(value: Any, default: float) -> float:
number = _float(value, default)
return number if number > 0 else default
def date_text() -> str:
raw = source.get("create_time") or source.get("update_time")
if raw not in (None, ""):
raw_text = str(raw).strip()
if re.fullmatch(r"\d{10,13}(?:\.\d+)?", raw_text):
stamp = float(raw_text)
if stamp >= 10_000_000_000:
stamp /= 1000
try:
return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M")
except (OSError, OverflowError, ValueError):
pass
normalized = raw_text.replace("T", " ").replace("Z", "")
if len(normalized) >= 16:
return normalized[:16]
if normalized:
return normalized
return display_text(source.get("prescription_date"))
def usage_text(values: Mapping[str, Any], *, fallback: Mapping[str, Any]) -> str:
explicit = values.get("usage_text")
if explicit:
return display_text(explicit)
prescription_type = str(
values.get("prescription_type")
or fallback.get("prescription_type")
or "浓缩水丸"
)
times = int(positive_number(values.get("times_per_day"), 3))
amount = positive_number(values.get("dosage_amount"), 10)
unit = str(values.get("dosage_unit") or fallback.get("dosage_unit") or "")
if not unit:
unit = "ml" if prescription_type == "饮片" else "g"
way = str(
values.get("usage_way")
or values.get("usage_instruction")
or fallback.get("usage_way")
or fallback.get("usage_instruction")
or "温水送服"
)
usage_time = str(values.get("usage_time") or fallback.get("usage_time") or "")
segments = [f"每天{times}次"]
if prescription_type == "浓缩水丸":
bags = int(
positive_number(
values.get("dosage_bag_count") or values.get("bags_per_dose"),
1,
)
)
segments.extend((f"一次{bags}袋", f"每袋{number_text(amount)}{unit}"))
else:
segments.append(f"一次{number_text(amount)}{unit}")
segments.append(way)
if usage_time:
segments.append(usage_time)
return ", ".join(segments)
def herb_group(rows: list[dict[str, Any]], title: str, kind: str) -> str:
if not rows:
return ""
output = [
f'<tr><td class="rp-indent"></td><td class="section {kind}" colspan="5">'
f"{html.escape(title)}</td></tr>"
]
for index in range(0, len(rows), 2):
pair = rows[index : index + 2]
cells: list[str] = []
for pair_index, row in enumerate(pair):
if pair_index == 1:
cells.append('<td class="rp-gap"></td>')
dosage = number_text(row.get("dosage"))
total = number_text(_float(row.get("dosage")) * dose_count)
cells.extend(
(
f'<td class="herb-name">{esc(row.get("name"))} ({dosage}克)</td>',
f'<td class="herb-total">{total}克</td>',
)
)
if len(pair) == 1:
cells.extend(
(
'<td class="rp-gap"></td>',
'<td class="herb-name">&nbsp;</td>',
'<td class="herb-total"></td>',
)
)
output.append(f'<tr><td class="rp-indent"></td>{"".join(cells)}</tr>')
return "".join(output)
dietary = source.get("dietary_taboo")
if isinstance(dietary, (list, tuple, set)):
dietary = "、".join(str(item) for item in dietary if str(item or "").strip())
recipient_text = source.get("recipient_text")
if not recipient_text:
region = source.get("region")
if isinstance(region, (list, tuple)):
region = "".join(str(item) for item in region if item)
location = region or "".join(
str(source.get(key) or "")
for key in ("shipping_province", "shipping_city", "shipping_district")
)
recipient_parts = (
source.get("recipient_name") or source.get("patient_name"),
source.get("recipient_phone") or source.get("phone"),
location,
source.get("shipping_address") or source.get("address"),
)
recipient_text = "".join(
str(value).strip() for value in recipient_parts if str(value or "").strip()
)
serial = next(
(
source.get(key)
for key in (
"order_no",
"serial_no",
"serial_number",
"no",
"prescription_no",
"sn",
"visit_no",
)
if source.get(key)
),
f"G{source.get('id')}" if source.get("id") else "—",
)
gender = source.get("gender")
gender_text = "男" if gender in (1, "1", "男") else "女" if gender in (0, "0", "女") else "—"
age = display_text(source.get("age"))
age_text = age if age == "—" or age.endswith("岁") else f"{age}岁"
aux_usage = source.get("aux_usage")
aux_usage = dict(aux_usage) if isinstance(aux_usage, Mapping) else {}
main_usage_text = display_text(source.get("usage_text"), "") or usage_text(source, fallback=source)
aux_usage_text = usage_text(aux_usage, fallback=source) if aux else ""
advice = source.get("medical_advice") or source.get("doctor_advice")
remark_parts = [f"共{len(herbs)}味药"]
remark_parts.extend(
str(value).strip()
for value in (source.get("usage_notes"), source.get("remark"))
if str(value or "").strip()
)
pharmacy_remark = (
source.get("pharmacy_remark")
or source.get("pharmacy_note")
)
explicit_out = (
source.get("out_pellet_text") or source.get("out_pellet") or source.get("total_weight")
)
if explicit_out not in (None, ""):
out_pellet = display_text(explicit_out)
if re.fullmatch(r"\d+(?:\.\d+)?", out_pellet):
out_pellet += "克"
else:
out_pellet = ""
prescription_type = str(source.get("prescription_type") or "浓缩水丸")
days = positive_number(
source.get("medication_days") or source.get("usage_days") or source.get("dose_count"),
0,
)
times = positive_number(source.get("times_per_day"), 0)
amount = positive_number(source.get("dosage_amount"), 0)
bags = positive_number(source.get("dosage_bag_count"), 1)
if prescription_type == "浓缩水丸" and days and times and amount:
out_pellet = f"{number_text(days * times * amount * bags)}克"
elif herbs:
total_weight = sum(_float(row.get("dosage")) for row in herbs) * dose_count
out_pellet = f"{number_text(total_weight)}克"
prescription_type = display_text(source.get("prescription_type"), "浓缩水丸")
type_text = (
f"浓缩丸-{prescription_type}"
if re.search(r"丸|散|膏|片", prescription_type) and not prescription_type.startswith("浓缩丸-")
else prescription_type
)
per_dose = number_text(sum(_float(row.get("dosage")) for row in herbs))
signature = str(source.get("doctor_signature") or "").strip()
signature_html = (
f'<img class="signature" src="{html.escape(signature, quote=True)}" alt="医师签名" />'
if signature
else f'<span class="doctor-name">{esc(source.get("doctor_name"))}</span>'
)
herb_html = herb_group(main, "主方", "main") + herb_group(aux, "辅方", "aux")
if not herb_html:
herb_html = (
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
"暂无药材明细</td></tr>"
)
text_rows = [f"<p>主方服法:{esc(main_usage_text)}</p>"]
if aux_usage_text:
text_rows.append(f"<p>辅方服法:{esc(aux_usage_text)}</p>")
if advice:
text_rows.append(f"<p>医嘱:{esc(advice)}</p>")
if dietary:
text_rows.append(f"<p>忌口:{esc(dietary)}</p>")
text_rows.append(f"<p>备注:{esc(' '.join(remark_parts))}</p>")
if pharmacy_remark:
text_rows.append(f'<p class="warning">药房备注:{esc(pharmacy_remark)}</p>')
if out_pellet:
text_rows.append(f'<p class="warning">出丸:{esc(out_pellet)}</p>')
audit_lines: list[str] = []
if source.get("audit_by_name"):
audit_lines.append(
f"审核人:{esc(source.get('audit_by_name'))} {esc(source.get('audit_time'), '')}"
)
if source.get("audit_remark"):
audit_lines.append(f"审核意见:{esc(source.get('audit_remark'))}")
if source.get("business_prescription_audit_remark"):
audit_lines.append(f"业务订单审核意见:{esc(source.get('business_prescription_audit_remark'))}")
audit_html = ""
if audit_lines and not print_layout:
audit_html = f'<div class="audit">{"<br/>".join(audit_lines)}</div>'
return f"""
<!doctype html>
<html><head><meta charset="utf-8"><style>
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1f1f1f;
font-family:"Microsoft YaHei","PingFang SC",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-size:{base_font_size}; line-height:1.5; }}
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
border:{paper_border}; border-collapse:separate; border-spacing:0;
box-shadow:{"none" if print_layout else "0 0 0 1px #d6d6d6"}; }}
.paper-content {{ padding:8mm 10mm; vertical-align:top; }}
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
table-layout:fixed; background:#f3f4f6; margin:0 0 6px;
font-size:{notice_font_size}; color:#1f1f1f; }}
.notice td {{ border:0; padding:6px 10px; }}
.notice-text {{ width:49%; }}
.notice-meta {{ width:51%; text-align:right; white-space:nowrap; }}
.info {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; font-size:13px; }}
.info .full {{ border-top:0; }}
.key {{ white-space:nowrap; color:#1f1f1f; }}
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
.rp-padding {{ padding:8px 10px 16px; }}
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.rp td {{ border:0; padding:3px 0; vertical-align:middle; }}
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:4px; padding-bottom:4px; }}
.rp-indent {{ width:44px; }}
.rp-mark {{ width:44px; padding-right:8px !important;
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
.drug-head {{ color:#1f1f1f; }}
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap;
font-variant-numeric:tabular-nums; }}
.rp-gap {{ width:24px; padding:0 !important; }}
.section {{ padding-top:7px !important; padding-bottom:1px !important;
font-size:{section_font_size}; font-weight:600; }}
.section.main {{ color:#409eff; }}
.section.aux {{ color:#e6a23c; }}
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
.empty-herbs {{ color:#8a8f98; padding:18px 6px !important; text-align:center; }}
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:10px 12px; line-height:1.85; font-size:13px; }}
.rx-text p {{ margin:0; padding:0; }}
.warning {{ color:#d72424; font-weight:600; }}
.bottom {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:6px 10px;
height:{bottom_height}; vertical-align:middle; font-size:12px; color:#1f1f1f; }}
.bottom .doctor {{ width:28%; vertical-align:top; }}
.doctor-title {{ display:block; margin-bottom:4px; font-size:13px; }}
.doctor-name {{ display:block; margin-top:8px; font-size:13px; }}
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; object-fit:contain; }}
.meta-key {{ white-space:nowrap; margin-right:6px; }}
.audit {{ width:{paper_width}; margin:12px auto 0; padding:8px 12px; color:#6b7280;
border:1px dashed #d4d4d4; background:#fafafa; border-radius:4px;
font-size:12px; line-height:1.6; }}
</style></head><body>
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
<table class="notice" width="100%"><tr>
<td class="notice-text">服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点</td>
<td class="notice-meta">日期:{esc(date_text())}  编号:{esc(serial)}</td>
</tr></table>
<table class="info" width="100%">
<tr><td><span class="key">姓名</span> {esc(source.get("patient_name"))}</td>
<td><span class="key">性别</span> {esc(gender_text)}</td>
<td><span class="key">年龄</span> {esc(age_text)}</td>
<td><span class="key">电话</span> {esc(source.get("phone"))}</td></tr>
<tr><td class="full" colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
<tr><td class="full" colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
</table>
<table class="rp-frame" title="药房联" width="100%"><tr><td class="rp-padding">
<table class="rp" width="100%">
<col width="44"/><col/><col width="64"/><col width="24"/><col/><col width="64"/>
<tr class="rp-head"><td class="rp-mark">Rp.</td><td class="drug-head">用药 (单剂)</td>
<td class="total-head">总量</td><td class="rp-gap"></td>
<td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
{herb_html}
</table>
</td></tr></table>
<div class="rx-text">{"".join(text_rows)}</div>
<table class="bottom" width="100%"><tr>
<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>
<td><span class="meta-key">类型:</span> {esc(type_text)}</td>
<td><span class="meta-key">天数:</span> {dose_count}剂</td>
<td><span class="meta-key">单剂量:</span> {per_dose}克</td>
</tr></table>
</td></tr></table>
{audit_html}
</body></html>
"""
class _PrescriptionPaperPreview(QTextBrowser):
"""A4 prescription preview with the consumer page's floating watermark.
Qt rich text cannot paint CSS absolute-positioned or transformed elements,
so placing the watermark in the HTML makes it consume a table row and
deforms the paper. Paint it over the viewport at the corresponding
document position instead.
"""
_PAPER_WIDTH = 794
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
herbs = _herb_rows(prescription)
main_count = sum(
1 for row in herbs if _formula(row.get("formula_type")) == "主方"
)
aux_count = len(herbs) - main_count
medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2
section_count = int(main_count > 0) + int(aux_count > 0)
rp_height = 65 + medicine_rows * 30 + section_count * 22
self._watermark_document_y = 150 + rp_height // 2
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
super().paintEvent(event)
viewport = self.viewport()
center_y = self._watermark_document_y - self.verticalScrollBar().value()
if center_y < -90 or center_y > viewport.height() + 90:
return
paper_width = min(self._PAPER_WIDTH, viewport.width())
paper_left = max(0.0, (viewport.width() - paper_width) / 2)
painter = QPainter(viewport)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(paper_left + paper_width / 2, center_y)
painter.rotate(-22)
painter.setPen(QColor(31, 31, 31, 15))
font = QFont(
"Microsoft YaHei UI",
-1,
QFont.Weight.Bold,
)
font.setPixelSize(84)
painter.setFont(font)
painter.drawText(
QRectF(-230, -70, 460, 140),
Qt.AlignmentFlag.AlignCenter,
"药房联",
)
painter.end()
class PrescriptionDetailDialog(QDialog):
diagnosis_requested = Signal(int)
orders_requested = Signal(int)
def __init__(
self,
prescription: Any,
*,
initial_tab: str = "prescription",
can_open_diagnosis: bool = True,
can_open_orders: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.prescription = prescription
self.document = QTextDocument(self)
self.document.setDocumentMargin(0)
self.document.setHtml(render_prescription_html(prescription))
self.setWindowTitle("查看处方")
self.resize(920, 780)
root = QVBoxLayout(self)
actions = QHBoxLayout()
self.diagnosis_button = QPushButton("查看诊单详情")
diagnosis_id = _int(first_value(prescription, "diagnosis_id"), 0)
self.diagnosis_button.setVisible(can_open_diagnosis and diagnosis_id > 0)
self.diagnosis_button.clicked.connect(lambda: self.diagnosis_requested.emit(diagnosis_id))
actions.addWidget(self.diagnosis_button)
self.orders_button = QPushButton("查看关联订单")
prescription_id = _int(first_value(prescription, "id", "prescription_id"), 0)
self.orders_button.setVisible(can_open_orders and prescription_id > 0)
self.orders_button.clicked.connect(lambda: self.orders_requested.emit(prescription_id))
actions.addWidget(self.orders_button)
actions.addStretch(1)
print_button = QPushButton("打印")
print_button.clicked.connect(self.print_slip)
actions.addWidget(print_button)
pdf_button = QPushButton("导出 PDF")
pdf_button.setProperty("variant", "primary")
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)
self.preview.setStyleSheet(
"QTextBrowser#PrescriptionPaperPreview {"
"background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}"
)
self.preview.setDocument(self.document)
self.tabs = QTabWidget()
self.tabs.addTab(self.preview, "处方")
case_record = _mapping(_mapping(prescription).get("case_record"))
self.case_document: QTextDocument | None = None
self.case_preview: QTextBrowser | None = None
if case_record:
self.case_document = QTextDocument(self)
self.case_document.setDocumentMargin(0)
self.case_document.setHtml(render_case_record_html(prescription))
self.case_preview = QTextBrowser()
self.case_preview.setObjectName("CaseRecordPaperPreview")
self.case_preview.setOpenExternalLinks(False)
self.case_preview.setStyleSheet(
"QTextBrowser#CaseRecordPaperPreview {"
"background-color:#EEF1F5; border:1px solid #D8DEE8; padding:0;}"
)
self.case_preview.setDocument(self.case_document)
case_index = self.tabs.addTab(self.case_preview, "详细病历")
if initial_tab == "case":
self.tabs.setCurrentIndex(case_index)
root.addWidget(self.tabs, 1)
close = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
close.rejected.connect(self.reject)
root.addWidget(close)
def print_slip(self) -> None:
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
printer.setFullPage(True)
dialog = QPrintDialog(printer, self)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._print_document(printer).print_(printer)
def _print_document(self, printer: QPrinter) -> QTextDocument:
document = QTextDocument()
document.setDocumentMargin(0)
document.setPageSize(printer.pageRect(QPrinter.Unit.Point).size())
document.setHtml(render_prescription_html(self.prescription, print_layout=True))
return document
def choose_pdf_path(self) -> None:
patient = str(first_value(self.prescription, "patient_name", default="处方"))
prescription_id = first_value(self.prescription, "id", default="")
suggested = f"处方-{patient}-{prescription_id}.pdf"
path, _selected = QFileDialog.getSaveFileName(
self,
"导出处方 PDF",
suggested,
"PDF 文件 (*.pdf)",
)
if path:
self.export_pdf(path)
def export_pdf(self, path: str | Path) -> None:
output = str(path)
if not output.lower().endswith(".pdf"):
output += ".pdf"
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
printer.setOutputFileName(output)
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
printer.setFullPage(True)
self._print_document(printer).print_(printer)
class DiagnosisDetailDialog(QDialog):
"""Read-only diagnosis view preserving the important admin tab boundaries."""
def __init__(
self,
diagnosis: Any,
parent: QWidget | None = None,
*,
repository: Any = None,
permissions: Any = None,
) -> None:
super().__init__(parent)
source = _mapping(diagnosis)
self.repository = repository
self.permissions = permissions
self.setWindowTitle("诊单详情(只读)")
self.resize(880, 700)
root = QVBoxLayout(self)
tabs = QTabWidget()
groups = (
(
"病历",
(
"id",
"patient_id",
"patient_name",
"gender",
"age",
"phone",
"chief_complaint",
"present_illness",
"past_history",
"diagnosis",
"syndrome",
"treatment",
),
),
("医生备注", ("doctor_notes", "doctor_note", "notes")),
("日常记录", ("daily_records", "blood_records")),
("处方", ("prescriptions", "case_records")),
("沟通与指派", ("call_records", "chat_records", "assign_logs", "appointments")),
)
for title, keys in groups:
browser = QTextBrowser()
rows = []
for key in keys:
value = source.get(key)
if value in (None, "", [], {}):
continue
rendered = (
json.dumps(value, ensure_ascii=False, indent=2, default=str)
if isinstance(value, (Mapping, list, tuple))
else str(value)
)
rows.append(f"<h3>{html.escape(key)}</h3><pre>{html.escape(rendered)}</pre>")
browser.setHtml("".join(rows) or "<p>暂无数据</p>")
tabs.addTab(browser, title)
tabs.addTab(self._build_orders_tab(source), "业务订单")
root.addWidget(tabs, 1)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
def _build_orders_tab(self, source: Mapping[str, Any]) -> QWidget:
host = QWidget()
layout = QVBoxLayout(host)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(8)
rows: list[Any] = []
for key in ("prescription_orders", "orders"):
value = source.get(key)
if isinstance(value, list):
rows.extend(value)
latest = source.get("latest_prescription_order")
if isinstance(latest, Mapping) and latest:
latest_id = _int(first_value(latest, "id", "order_id"), 0)
if latest_id and not any(
_int(first_value(row, "id", "order_id"), 0) == latest_id for row in rows
):
rows.insert(0, latest)
if not rows:
empty = QLabel("暂无关联业务订单")
empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(empty, 1)
return host
table = QTableWidget(len(rows), 6)
table.setHorizontalHeaderLabels(
["订单号", "金额", "履约状态", "收货人", "手机", "创建时间"]
)
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
table.verticalHeader().hide()
table.horizontalHeader().setStretchLastSection(True)
for row_index, row in enumerate(rows):
values = (
first_value(row, "order_no", "sn", "id"),
first_value(row, "amount", "effective_amount"),
first_value(row, "fulfillment_status_text", "status_text", "status"),
first_value(row, "recipient_name", "patient_name"),
first_value(row, "recipient_phone", "phone"),
first_value(row, "create_time_text", "create_time"),
)
for column, value in enumerate(values):
item = QTableWidgetItem(display_text(value))
item.setData(Qt.ItemDataRole.UserRole, row)
table.setItem(row_index, column, item)
layout.addWidget(table, 1)
actions = QHBoxLayout()
view = QPushButton("查看订单详情")
view.setProperty("variant", "primary")
def open_selected() -> None:
row = table.currentRow()
item = table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
return
order_id = _int(first_value(order, "id", "order_id"), 0)
if order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)):
try:
order = self.repository.get_prescription_order(order_id)
except Exception: # noqa: BLE001 - fall back to embedded row
pass
from .diagnosis import present_order_detail
present_order_detail(
self.window() if self.window() is not None else self,
order,
order_id=order_id,
permissions=self.permissions,
exec_=True,
)
view.clicked.connect(open_selected)
table.itemDoubleClicked.connect(lambda _item: open_selected())
actions.addWidget(view)
actions.addStretch(1)
layout.addLayout(actions)
return host
class PrescriptionOrderDialog(QDialog):
"""Create a fulfilment order from one issued prescription."""
def __init__(
self,
repository: Any,
prescription: Any,
*,
can_select_ship_mode: bool = False,
can_view_internal_cost: bool = False,
can_edit_pharmacy_remark: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.prescription = prescription
self.can_select_ship_mode = can_select_ship_mode
self.can_view_internal_cost = can_view_internal_cost
self.can_edit_pharmacy_remark = can_edit_pharmacy_remark
self._paid_order_rows: list[Any] = []
self._deposit_min_amount = 0.0
self._paid_orders_generation = 0
self._paid_orders_diagnosis_id = 0
self._paid_orders_loading = False
self._paid_orders_ready = False
self.setWindowTitle("创建业务订单")
self.resize(860, 720)
root = QVBoxLayout(self)
tabs = QTabWidget()
tabs.addTab(self._build_recipient_tab(), "患者与收货")
tabs.addTab(self._build_service_tab(), "服务与支付单")
tabs.addTab(self._build_amount_tab(), "金额与确认")
self.tabs = tabs
root.addWidget(tabs, 1)
self.banner = MessageBanner()
root.addWidget(self.banner)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save
)
self.save_button = buttons.button(QDialogButtonBox.StandardButton.Save)
self.save_button.setText("创建订单")
self.save_button.setProperty("variant", "primary")
self.save_button.setEnabled(False)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
self._load_prescription_values()
QTimer.singleShot(0, self._load_paid_orders)
def _form_tab(self) -> tuple[QWidget, QFormLayout]:
tab = QWidget()
form = QFormLayout(tab)
form.setContentsMargins(18, 18, 18, 18)
form.setHorizontalSpacing(18)
form.setVerticalSpacing(11)
return tab, form
def _build_recipient_tab(self) -> QWidget:
tab, form = self._form_tab()
self.diagnosis_id = QSpinBox()
self.diagnosis_id.setRange(0, 2_000_000_000)
self.diagnosis_id.valueChanged.connect(self._diagnosis_changed)
form.addRow("诊单 ID *", self.diagnosis_id)
self.recipient_name = QLineEdit()
self.recipient_name.setMaxLength(50)
form.addRow("收货人 *", self.recipient_name)
self.recipient_phone = QLineEdit()
self.recipient_phone.setMaxLength(20)
form.addRow("收货手机 *", self.recipient_phone)
self.shipping_province = QLineEdit()
form.addRow("省 *", self.shipping_province)
self.shipping_city = QLineEdit()
form.addRow("市 *", self.shipping_city)
self.shipping_district = QLineEdit()
form.addRow("区/县 *", self.shipping_district)
self.shipping_address = QLineEdit()
form.addRow("详细地址 *", self.shipping_address)
return tab
def _build_service_tab(self) -> QWidget:
tab, form = self._form_tab()
self.ship_mode = QComboBox()
self.ship_mode.addItem("甘草药房", "gancao")
self.ship_mode.addItem("洛阳药房", "direct")
self.ship_mode.setEnabled(self.can_select_ship_mode)
form.addRow("发货类型", self.ship_mode)
self.is_follow_up = QCheckBox("复诊订单")
form.addRow("是否复诊", self.is_follow_up)
self.medication_days = QSpinBox()
self.medication_days.setRange(0, 365)
self.medication_days.setSpecialValueText("未填写")
form.addRow("服用天数", self.medication_days)
self.prev_staff = QLineEdit()
form.addRow("前序人员", self.prev_staff)
self.service_channel = QLineEdit()
form.addRow("服务渠道", self.service_channel)
self.service_package = QLineEdit()
self.service_package.setPlaceholderText("多个套餐以逗号分隔")
form.addRow("服务套餐", self.service_package)
self.express_company = QLineEdit("auto")
form.addRow("快递公司", self.express_company)
self.tracking_number = QLineEdit()
form.addRow("物流单号", self.tracking_number)
self.paid_orders = QListWidget()
self.paid_orders.setMinimumHeight(170)
form.addRow("关联支付单", self.paid_orders)
self.deposit_hint = QLabel("正在加载可关联支付单…")
self.deposit_hint.setProperty("role", "muted")
form.addRow("", self.deposit_hint)
return tab
def _build_amount_tab(self) -> QWidget:
tab, form = self._form_tab()
self.fee_type = QComboBox()
self.fee_type.addItem("药品费用", 3)
self.fee_type.addItem("挂号费", 1)
self.fee_type.addItem("问诊费", 2)
self.fee_type.addItem("首付", 4)
self.fee_type.addItem("尾款", 5)
self.fee_type.addItem("其他", 6)
form.addRow("费用类别 *", self.fee_type)
self.amount = QDoubleSpinBox()
self.amount.setRange(0, 10_000_000)
self.amount.setDecimals(2)
self.amount.setPrefix("¥ ")
form.addRow("订单金额 *", self.amount)
self.internal_cost = QDoubleSpinBox()
self.internal_cost.setRange(0, 10_000_000)
self.internal_cost.setDecimals(2)
self.internal_cost.setPrefix("¥ ")
self.internal_cost.setVisible(self.can_view_internal_cost)
self.internal_cost_label = QLabel("内部成本")
self.internal_cost_label.setVisible(self.can_view_internal_cost)
form.addRow(self.internal_cost_label, self.internal_cost)
self.remark_extra = QTextEdit()
self.remark_extra.setMaximumHeight(80)
self.remark_extra.setVisible(self.can_edit_pharmacy_remark)
self.remark_extra_label = QLabel("药房备注")
self.remark_extra_label.setVisible(self.can_edit_pharmacy_remark)
form.addRow(self.remark_extra_label, self.remark_extra)
self.remark_assistant = QTextEdit()
self.remark_assistant.setMaximumHeight(100)
form.addRow("医助备注", self.remark_assistant)
return tab
def _load_prescription_values(self) -> None:
diagnosis_id = _int(first_value(self.prescription, "diagnosis_id"), 0)
self.diagnosis_id.blockSignals(True)
self.diagnosis_id.setValue(diagnosis_id)
self.diagnosis_id.blockSignals(False)
self.diagnosis_id.setReadOnly(diagnosis_id > 0)
self.recipient_name.setText(str(first_value(self.prescription, "patient_name", default="")))
self.recipient_phone.setText(str(first_value(self.prescription, "phone", default="")))
self.shipping_province.setText(
str(first_value(self.prescription, "shipping_province", default=""))
)
self.shipping_city.setText(str(first_value(self.prescription, "shipping_city", default="")))
self.shipping_district.setText(
str(first_value(self.prescription, "shipping_district", default=""))
)
self.shipping_address.setText(
str(first_value(self.prescription, "shipping_address", default=""))
)
self.medication_days.setValue(max(0, _int(first_value(self.prescription, "usage_days"), 0)))
def _diagnosis_changed(self, _value: int) -> None:
self._load_paid_orders()
def _reset_paid_orders(self, message: str) -> None:
self._paid_order_rows = []
self._deposit_min_amount = 0.0
self.paid_orders.clear()
self.amount.setMinimum(0)
self.deposit_hint.setText(message)
self._paid_orders_ready = False
self.save_button.setEnabled(False)
def _load_paid_orders(self) -> None:
diagnosis_id = self.diagnosis_id.value()
self._paid_orders_generation += 1
generation = self._paid_orders_generation
self._paid_orders_diagnosis_id = diagnosis_id
self._paid_orders_loading = diagnosis_id > 0
self._reset_paid_orders("正在加载可关联支付单…")
if not diagnosis_id:
self.deposit_hint.setText("处方未关联诊单,无法加载支付单。")
self._paid_orders_loading = False
return
run_async(
lambda: _repository_action(
self.repository,
"list_paid_prescription_orders",
diagnosis_id=diagnosis_id,
),
on_success=lambda result: self._apply_paid_orders(result, diagnosis_id, generation),
on_error=lambda error: self._paid_orders_error(error, diagnosis_id, generation),
)
def _apply_paid_orders(self, result: Any, diagnosis_id: int, generation: int) -> None:
if (
generation != self._paid_orders_generation
or diagnosis_id != self._paid_orders_diagnosis_id
or diagnosis_id != self.diagnosis_id.value()
):
return
rows = page_items(result)
if not rows and isinstance(result, Mapping):
raw = result.get("lists") or get_value(result, "data.lists", None) or []
rows = list(raw) if isinstance(raw, (list, tuple)) else []
self._paid_order_rows = rows
self.paid_orders.clear()
for row in rows:
order_id = _int(first_value(row, "id", "order_id"), 0)
label = (
f"{first_value(row, 'order_no', default=order_id)} "
f{first_value(row, 'amount', default=0)} "
f"{first_value(row, 'remark', default='')}"
)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, order_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Unchecked)
self.paid_orders.addItem(item)
self._deposit_min_amount = _float(
first_value(
result,
"deposit_min_amount",
"extend.deposit_min_amount",
"data.deposit_min_amount",
default=0,
)
)
if self._deposit_min_amount > 0:
self.amount.setMinimum(self._deposit_min_amount)
self.amount.setValue(max(self.amount.value(), self._deposit_min_amount))
self.deposit_hint.setText(
f"已启用关联支付门槛:至少选择一笔支付单,订单金额不低于 "
f{self._deposit_min_amount:.2f}"
)
else:
self.deposit_hint.setText(f"可关联支付单 {len(rows)} 笔;当前未启用定金门槛。")
self._paid_orders_loading = False
self._paid_orders_ready = True
self.save_button.setEnabled(True)
def _paid_orders_error(
self,
error: Exception,
diagnosis_id: int,
generation: int,
) -> None:
if (
generation != self._paid_orders_generation
or diagnosis_id != self._paid_orders_diagnosis_id
or diagnosis_id != self.diagnosis_id.value()
):
return
self._paid_orders_loading = False
self._paid_orders_ready = False
self.deposit_hint.setText(f"支付单加载失败:{friendly_error(error)}")
self.save_button.setEnabled(False)
def _selected_paid_order_ids(self) -> list[int]:
result = []
for index in range(self.paid_orders.count()):
item = self.paid_orders.item(index)
if item.checkState() == Qt.CheckState.Checked:
result.append(_int(item.data(Qt.ItemDataRole.UserRole), 0))
return [value for value in result if value]
def payload(self) -> dict[str, Any]:
service_packages = [
value.strip()
for value in re.split(r"[,]", self.service_package.text())
if value.strip()
]
result: dict[str, Any] = {
"prescription_id": _int(first_value(self.prescription, "id", "prescription_id")),
"diagnosis_id": self.diagnosis_id.value(),
"recipient_name": self.recipient_name.text().strip(),
"recipient_phone": self.recipient_phone.text().strip(),
"shipping_address": self.shipping_address.text().strip(),
"shipping_province": self.shipping_province.text().strip(),
"shipping_city": self.shipping_city.text().strip(),
"shipping_district": self.shipping_district.text().strip(),
"is_follow_up": int(self.is_follow_up.isChecked()),
"prev_staff": self.prev_staff.text().strip(),
"service_channel": self.service_channel.text().strip(),
"service_package": ",".join(service_packages),
"express_company": self.express_company.text().strip() or "auto",
"tracking_number": self.tracking_number.text().strip(),
"ship_mode": self.ship_mode.currentData(),
"fee_type": self.fee_type.currentData(),
"amount": self.amount.value(),
"remark_extra": self.remark_extra.toPlainText().strip()
if self.can_edit_pharmacy_remark
else "",
"remark_assistant": self.remark_assistant.toPlainText().strip(),
}
if self.medication_days.value() > 0:
result["medication_days"] = self.medication_days.value()
if self.can_view_internal_cost and self.internal_cost.value() > 0:
result["internal_cost"] = self.internal_cost.value()
paid_ids = self._selected_paid_order_ids()
if paid_ids:
result["pay_order_ids"] = paid_ids
return result
def accept(self) -> None:
if self._paid_orders_loading or not self._paid_orders_ready:
self.tabs.setCurrentIndex(1)
self.banner.show_message("支付单与定金门槛尚未加载完成,暂不能创建订单。", "warning")
return
payload = self.payload()
required = (
(payload["diagnosis_id"], "请选择有效诊单。", 0),
(payload["recipient_name"], "请输入收货人。", 0),
(payload["recipient_phone"], "请输入收货手机号。", 0),
(payload["shipping_province"], "请输入省份。", 0),
(payload["shipping_city"], "请输入城市。", 0),
(payload["shipping_district"], "请输入区县。", 0),
(payload["shipping_address"], "请输入详细收货地址。", 0),
)
for value, message, tab in required:
if not value:
self.tabs.setCurrentIndex(tab)
self.banner.show_message(message, "warning")
return
if self._deposit_min_amount > 0 and not payload.get("pay_order_ids"):
self.tabs.setCurrentIndex(1)
self.banner.show_message("定金门槛开启时至少选择一笔支付单。", "warning")
return
if self.amount.value() < self._deposit_min_amount:
self.tabs.setCurrentIndex(2)
self.banner.show_message(
f"订单金额不得低于 ¥{self._deposit_min_amount:.2f}。",
"warning",
)
return
super().accept()
class PrescriptionOrderListDialog(QDialog):
"""Paginated order list with an optional prescription/patient filter."""
def __init__(
self,
repository: Any,
*,
prescription_id: int | None = None,
patient_id: int | None = None,
keyword: str | None = None,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.prescription_id = prescription_id
self.patient_id = patient_id
self.keyword = (keyword or "").strip()
self.permissions = permissions
self._page = 1
self._page_size = 15
self._generation = 0
self._detail_generation = 0
self._detail_order_id = 0
self.setWindowTitle("处方业务订单")
self.resize(960, 620)
root = QVBoxLayout(self)
self.banner = MessageBanner()
root.addWidget(self.banner)
self.table = QTableWidget(0, 8)
self.table.setHorizontalHeaderLabels(
["订单号", "处方 ID", "患者/收货人", "手机", "金额", "发货", "状态", "创建时间"]
)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.verticalHeader().hide()
self.table.horizontalHeader().setStretchLastSection(True)
self.table.itemDoubleClicked.connect(self._view_current)
root.addWidget(self.table, 1)
footer = QHBoxLayout()
view_button = QPushButton("查看订单详情")
view_button.setProperty("variant", "primary")
view_button.clicked.connect(self._view_current)
footer.addWidget(view_button)
footer.addStretch(1)
self.summary = QLabel()
footer.addWidget(self.summary)
previous = QPushButton("上一页")
previous.clicked.connect(lambda: self._change_page(self._page - 1))
footer.addWidget(previous)
self.previous = previous
self.page_label = QLabel()
footer.addWidget(self.page_label)
next_button = QPushButton("下一页")
next_button.clicked.connect(lambda: self._change_page(self._page + 1))
footer.addWidget(next_button)
self.next = next_button
root.addLayout(footer)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
QTimer.singleShot(0, self.load)
def load(self) -> None:
self._generation += 1
generation = self._generation
page = self._page
page_size = self._page_size
filters: dict[str, Any] = {}
if self.prescription_id:
filters["prescription_id"] = self.prescription_id
if self.patient_id:
filters["patient_id"] = self.patient_id
if self.keyword:
filters["keyword"] = self.keyword
self.banner.show_message("正在加载业务订单…", "info")
run_async(
lambda: _repository_action(
self.repository,
"list_prescription_orders",
page_no=page,
page_size=page_size,
**filters,
),
on_success=lambda result: self._apply(result, generation),
on_error=lambda error: self._error(error, generation),
)
def _apply(self, result: Any, generation: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.setRowCount(len(rows))
for row_index, row in enumerate(rows):
values = (
first_value(row, "order_no", "sn", "id"),
first_value(row, "prescription_id"),
first_value(row, "recipient_name", "patient_name"),
first_value(row, "recipient_phone", "phone"),
first_value(row, "amount"),
first_value(row, "ship_mode", "express_company"),
first_value(row, "status_text", "status"),
first_value(row, "create_time", "created_at"),
)
for column, value in enumerate(values):
item = QTableWidgetItem(display_text(value))
item.setData(Qt.ItemDataRole.UserRole, row)
self.table.setItem(row_index, column, item)
total = page_total(result, len(rows))
pages = max(1, (total + self._page_size - 1) // self._page_size)
self.summary.setText(f"共 {total} 条")
self.page_label.setText(f"{self._page} / {pages}")
self.previous.setEnabled(self._page > 1)
self.next.setEnabled(self._page < pages)
self.banner.clear()
def _error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _change_page(self, page: int) -> None:
if page >= 1:
self._page = page
self.load()
def _view_current(self, _item: Any = None) -> None:
row = self.table.currentRow()
item = self.table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
self.banner.show_message("请选择一条订单。", "warning")
return
order_id = _int(first_value(order, "id", "order_id"), 0)
if not order_id:
self._show_order(order)
return
self._detail_generation += 1
generation = self._detail_generation
self._detail_order_id = order_id
self.banner.show_message("正在加载订单详情…", "info")
run_async(
lambda: self.repository.get_prescription_order(order_id),
on_success=lambda result: self._detail_success(result, order_id, generation),
on_error=lambda error: self._detail_error(error, order_id, generation),
)
def _detail_success(self, order: Any, order_id: int, generation: int) -> None:
if generation != self._detail_generation or order_id != self._detail_order_id:
return
self._show_order(order)
def _detail_error(self, error: Exception, order_id: int, generation: int) -> None:
if generation == self._detail_generation and order_id == self._detail_order_id:
self.banner.show_message(friendly_error(error), "danger")
def _show_order(self, order: Any) -> None:
self.banner.clear()
permissions = self.permissions
if permissions is None and self.parent() is not None:
permissions = getattr(self.parent(), "permissions", None)
host = self.window() if self.window() is not None else self
from .diagnosis import present_order_detail
present_order_detail(
host,
order,
order_id=_int(first_value(order, "id", "order_id"), 0),
permissions=permissions,
exec_=True,
)
__all__ = [
"AuditPrescriptionDialog",
"DiagnosisDetailDialog",
"HerbEditor",
"HerbRowWidget",
"PasteHerbsDialog",
"PatchPatientDialog",
"PrescriptionDetailDialog",
"PrescriptionEditorDialog",
"PrescriptionOrderDialog",
"PrescriptionOrderListDialog",
"PrescriptionTemplateDialog",
"RemoteMedicineComboBox",
"SignaturePad",
"TemplateImportDialog",
"build_prescription_clinical_diagnosis",
"build_prescription_visit_no",
"parse_pasted_herbs",
"render_case_record_html",
"render_prescription_html",
]