4157 lines
168 KiB
Python
4157 lines
168 KiB
Python
"""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"> </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",
|
||
]
|