2286 lines
101 KiB
Python
2286 lines
101 KiB
Python
"""Issued-prescription management matching the admin route contract."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping
|
|
from datetime import datetime, timedelta
|
|
from html import escape
|
|
from math import ceil
|
|
from typing import Any
|
|
|
|
from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
|
|
from PySide6.QtGui import (
|
|
QColor,
|
|
QFont,
|
|
QFontMetrics,
|
|
QIcon,
|
|
QPainter,
|
|
QPen,
|
|
QTextDocument,
|
|
QTextOption,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QComboBox,
|
|
QDateTimeEdit,
|
|
QDialog,
|
|
QDialogButtonBox,
|
|
QFrame,
|
|
QGridLayout,
|
|
QHBoxLayout,
|
|
QHeaderView,
|
|
QLabel,
|
|
QLineEdit,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QMenu,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSizePolicy,
|
|
QStackedWidget,
|
|
QStyle,
|
|
QStyledItemDelegate,
|
|
QStyleOptionButton,
|
|
QStyleOptionViewItem,
|
|
QToolTip,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from .. import icons, motion
|
|
from ..dialogs.issued_prescription_ai import (
|
|
PrescriptionAiStatisticsDialog,
|
|
agreement_safe_batch,
|
|
agreement_text,
|
|
batch_running,
|
|
can_open_issued_ai,
|
|
present_issued_prescription_ai,
|
|
state_text,
|
|
status_tooltip,
|
|
)
|
|
from ..dialogs.prescription import (
|
|
AuditPrescriptionDialog,
|
|
DiagnosisDetailDialog,
|
|
PatchPatientDialog,
|
|
PrescriptionDetailDialog,
|
|
PrescriptionEditorDialog,
|
|
PrescriptionOrderDialog,
|
|
PrescriptionOrderListDialog,
|
|
)
|
|
from ..filter_disclosure import FilterDisclosure
|
|
from ..infinite_list import InfiniteList
|
|
from ..prescriptions_style import prescriptions_stylesheet
|
|
from ..reception_style import body_family, heading_family
|
|
from ..widgets import (
|
|
EmptyState,
|
|
MessageBanner,
|
|
PageHeader,
|
|
SortableTable,
|
|
TableColumn,
|
|
display_text,
|
|
first_value,
|
|
format_record_time,
|
|
friendly_error,
|
|
get_value,
|
|
has_permission,
|
|
invoke,
|
|
page_items,
|
|
page_total,
|
|
run_async,
|
|
show_toast,
|
|
)
|
|
|
|
PRESCRIPTIONS_PAGE_QSS = """
|
|
#PrescriptionsPage { background: #F7F7F7; }
|
|
#PrescriptionsPage QLabel[role="pageTitle"] {
|
|
color: #1A1C1F; font-size: 20px; font-weight: 600;
|
|
}
|
|
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
|
|
color: #606163; font-size: 13px;
|
|
}
|
|
#PrescriptionsPage QFrame#PrescriptionFilterBar,
|
|
#PrescriptionsPage QFrame#PrescriptionTableCard {
|
|
background: #FFFFFF;
|
|
border: 1px solid #EDEDEE;
|
|
border-radius: 12px;
|
|
}
|
|
#PrescriptionsPage QFrame#PrescriptionToolbar {
|
|
background: #FFFFFF;
|
|
border: 0;
|
|
border-bottom: 1px solid #EDEDEE;
|
|
border-top-left-radius: 12px;
|
|
border-top-right-radius: 12px;
|
|
}
|
|
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
|
|
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
|
|
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
|
|
min-height: 32px; max-height: 32px; padding: 0 11px;
|
|
border-radius: 8px; font-size: 14px;
|
|
}
|
|
#PrescriptionsPage QPushButton {
|
|
min-height: 34px; max-height: 34px; padding: 0 14px;
|
|
border-radius: 8px; font-size: 13px;
|
|
}
|
|
#PrescriptionsPage QFrame#PrescriptionFilterBar QPushButton {
|
|
min-height: 32px; max-height: 32px;
|
|
}
|
|
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton {
|
|
min-height: 32px; max-height: 32px; padding: 0 13px;
|
|
}
|
|
#PrescriptionsPage QLabel#PrescriptionCountBadge {
|
|
min-height: 22px; max-height: 22px; padding: 0 9px;
|
|
color: #606163; background: #F7F7F7; border-radius: 11px;
|
|
font-size: 13px; font-weight: 500;
|
|
}
|
|
/* Row actions are ghost buttons. Boxed white buttons sat edge to edge with
|
|
3px gaps, so at 100% scaling they merged into one opaque slab that cut a
|
|
white rectangle through the row's zebra band and its selection wash - the
|
|
action column was the one part of a selected row that did not look selected.
|
|
Resting transparent also drops three borders per row out of the table, which
|
|
is most of what made the list look busy. */
|
|
#PrescriptionsPage QPushButton[rowAction="true"] {
|
|
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
|
padding: 0; border-radius: 7px;
|
|
background: transparent; border: 1px solid transparent;
|
|
}
|
|
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
|
min-width: 64px; max-width: 64px; padding: 0 9px;
|
|
color: #4F63D9; font-weight: 500;
|
|
}
|
|
#PrescriptionsPage QPushButton[rowAction="true"]:hover {
|
|
background: #EEF1FA; border-color: #8B9AD9;
|
|
}
|
|
#PrescriptionsPage QPushButton[rowAction="true"]:pressed {
|
|
background: #EEF1FA; border-color: #8B9AD9;
|
|
}
|
|
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover {
|
|
background: #FFE4E8; border-color: #FFC0C9;
|
|
}
|
|
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:pressed {
|
|
background: #FFD3DA; border-color: #FFA9B6;
|
|
}
|
|
#PrescriptionsPage QTableWidget {
|
|
border: 0; border-radius: 0; background: #FFFFFF;
|
|
alternate-background-color: #F7F7F7; font-size: 14px;
|
|
}
|
|
#PrescriptionsPage QTableWidget::item {
|
|
padding: 4px 8px; border-bottom: 1px solid #EDEDEE;
|
|
}
|
|
/* #FFFFFF is a 1% wash against the #FFFFFF row, so the selected row
|
|
was effectively invisible even though the toolbar's 查看 / 编辑 / 删除
|
|
actions all operate on it. A real tint plus a hover step makes the
|
|
target of those buttons obvious. */
|
|
#PrescriptionsPage QTableWidget::item:hover { background: #F7F7F7; }
|
|
#PrescriptionsPage QTableWidget::item:selected {
|
|
color: #1A1C1F; background: #EEF1FA;
|
|
}
|
|
#PrescriptionsPage QHeaderView::section {
|
|
min-height: 38px; max-height: 38px; padding: 0 8px;
|
|
background: #F7F7F7; color: #6A6B6D;
|
|
border: 0; border-bottom: 1px solid #EDEDEE;
|
|
font-size: 13px; font-weight: 500;
|
|
}
|
|
#PrescriptionsPage QWidget#Pager { min-height: 42px; }
|
|
#PrescriptionsPage QWidget#Pager QPushButton {
|
|
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
|
|
}
|
|
#PrescriptionsPage QWidget#Pager QLabel#PagerActive {
|
|
min-width: 48px; min-height: 30px; border-radius: 7px;
|
|
background: #4F63D9; color: #FFFFFF; font-weight: 500;
|
|
}
|
|
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
|
|
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
|
|
padding: 0; background: #FFFFFF; color: #606163; border-color: #EDEDEE;
|
|
}
|
|
#PrescriptionsPage QWidget#BusinessPager QPushButton {
|
|
min-height: 32px; max-height: 32px;
|
|
}
|
|
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
|
|
background: #4F63D9; color: #FFFFFF; border-color: #4F63D9;
|
|
}
|
|
#PrescriptionsPage QWidget#BusinessPager QLabel[pagerSize="true"] {
|
|
min-width: 64px; color: #606163; font-size: 13px;
|
|
}
|
|
|
|
/* The action column is the one cell that still needs real widgets. Keeping its
|
|
host transparent lets the view's own banding and selection wash show through,
|
|
instead of punching a white rectangle through the middle of the selected row. */
|
|
#PrescriptionsPage QWidget#PrescriptionTableCellHost { background: transparent; border: 0; }
|
|
"""
|
|
|
|
|
|
def _painted_icon(kind: str, color: str = "accent", size: int = 16) -> QIcon:
|
|
"""Return a page icon from the shared 24-unit icon grid.
|
|
|
|
``color`` takes a semantic role name; literal hex values still work so the
|
|
handful of remaining bespoke tints keep rendering.
|
|
"""
|
|
|
|
return icons.icon(kind, color, size)
|
|
|
|
|
|
# Decoration item roles. ``SortableTable`` stores the source record in
|
|
# ``UserRole`` on every column, so the delegate reads its own data from offsets
|
|
# above it.
|
|
_ROLE_TAG_KIND = Qt.ItemDataRole.UserRole + 10
|
|
_ROLE_AVATAR = Qt.ItemDataRole.UserRole + 11
|
|
_ROLE_LEAD_ICON = Qt.ItemDataRole.UserRole + 12
|
|
|
|
_TAG_PALETTE = {
|
|
"accent": ("#4F63D9", "#EEF1FA"),
|
|
"success": ("#287B65", "#E7F8F1"),
|
|
"warning": ("#A9691D", "#FFF5DF"),
|
|
"danger": ("#BE4B58", "#FFF0F2"),
|
|
"neutral": ("#606163", "#F7F7F7"),
|
|
}
|
|
|
|
|
|
class _RowDecorationDelegate(QStyledItemDelegate):
|
|
"""Paint status pills and the doctor avatar instead of hosting a widget.
|
|
|
|
Three of the nine columns used to carry a ``QWidget`` in every row purely to
|
|
render a coloured pill or a 20 px avatar. ``QTableWidget.setCellWidget`` is
|
|
the single most expensive call in the refresh path - about 0.3 ms each - so
|
|
a fifteen-row page spent roughly 14 ms per refresh building decoration a
|
|
delegate draws for nothing. Painting it here also means the row-selection
|
|
wash finally covers those three columns, which the widget hosts used to mask.
|
|
"""
|
|
|
|
def paint(self, painter: QPainter, option: Any, index: Any) -> None:
|
|
kind = index.data(_ROLE_TAG_KIND)
|
|
avatar = index.data(_ROLE_AVATAR)
|
|
lead_icon = index.data(_ROLE_LEAD_ICON)
|
|
if not kind and not avatar and not lead_icon:
|
|
super().paint(painter, option, index)
|
|
return
|
|
|
|
style_option = QStyleOptionViewItem(option)
|
|
self.initStyleOption(style_option, index)
|
|
text = style_option.text
|
|
style_option.text = ""
|
|
widget = style_option.widget
|
|
style = widget.style() if widget is not None else QApplication.style()
|
|
style.drawControl(
|
|
QStyle.ControlElement.CE_ItemViewItem, style_option, painter, widget
|
|
)
|
|
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
rect = style_option.rect
|
|
if kind:
|
|
foreground, background = _TAG_PALETTE.get(kind, _TAG_PALETTE["neutral"])
|
|
font = QFont(style_option.font)
|
|
font.setPixelSize(13)
|
|
font.setWeight(QFont.Weight.Normal)
|
|
painter.setFont(font)
|
|
metrics = QFontMetrics(font)
|
|
label = metrics.elidedText(text, Qt.TextElideMode.ElideRight, rect.width() - 20)
|
|
width = min(metrics.horizontalAdvance(label) + 16, max(rect.width() - 12, 24))
|
|
pill = QRectF(
|
|
rect.center().x() - width / 2.0, rect.center().y() - 10.0, width, 20.0
|
|
)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(QColor(background))
|
|
painter.drawRoundedRect(pill, 4.0, 4.0)
|
|
painter.setPen(QColor(foreground))
|
|
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
|
elif lead_icon:
|
|
glyph = icons.pixmap(str(lead_icon), "soft", 14)
|
|
painter.drawPixmap(
|
|
QRectF(rect.left() + 8, rect.center().y() - 7, 14, 14),
|
|
glyph,
|
|
QRectF(glyph.rect()),
|
|
)
|
|
painter.setPen(QColor("#1A1C1F"))
|
|
painter.setFont(style_option.font)
|
|
text_rect = rect.adjusted(28, 0, -8, 0)
|
|
painter.drawText(
|
|
text_rect,
|
|
int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter),
|
|
QFontMetrics(style_option.font).elidedText(
|
|
text, Qt.TextElideMode.ElideRight, text_rect.width()
|
|
),
|
|
)
|
|
else:
|
|
initial = str(avatar)[:1] or "医"
|
|
circle = QRectF(rect.left() + 8, rect.center().y() - 10, 20, 20)
|
|
painter.setPen(QPen(QColor("#EDEDEE"), 1.0))
|
|
painter.setBrush(QColor("#F7F7F7"))
|
|
painter.drawEllipse(circle.adjusted(0.5, 0.5, -0.5, -0.5))
|
|
initial_font = QFont(style_option.font)
|
|
initial_font.setPixelSize(10)
|
|
initial_font.setWeight(QFont.Weight.Medium)
|
|
painter.setFont(initial_font)
|
|
painter.setPen(QColor("#6A6B6D"))
|
|
painter.drawText(circle, Qt.AlignmentFlag.AlignCenter, initial)
|
|
painter.setFont(style_option.font)
|
|
painter.setPen(QColor("#1A1C1F"))
|
|
name_rect = rect.adjusted(35, 0, -8, 0)
|
|
painter.drawText(
|
|
name_rect,
|
|
int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter),
|
|
QFontMetrics(style_option.font).elidedText(
|
|
str(avatar), Qt.TextElideMode.ElideRight, name_rect.width()
|
|
),
|
|
)
|
|
painter.restore()
|
|
|
|
|
|
def _row_action_button(
|
|
kind: str,
|
|
tooltip: str,
|
|
callback: Callable[[], None],
|
|
parent: QWidget,
|
|
*,
|
|
danger: bool = False,
|
|
enabled: bool = True,
|
|
label: str = "",
|
|
mutation: bool = False,
|
|
) -> QPushButton:
|
|
button = QPushButton(parent)
|
|
button.setProperty("rowAction", True)
|
|
button.setProperty("danger", danger)
|
|
button.setProperty("labeled", bool(label))
|
|
button.setProperty("mutationAction", mutation)
|
|
button.setProperty("rowAllowed", enabled)
|
|
button.setText(label)
|
|
button.setAccessibleName(tooltip)
|
|
button.setToolTip(tooltip)
|
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
button.setFixedSize(64 if label else 28, 28)
|
|
button.setIcon(_painted_icon(kind, "danger" if danger else "accent", 15))
|
|
button.setIconSize(QSize(15, 15))
|
|
button.setEnabled(enabled)
|
|
button.clicked.connect(callback)
|
|
return button
|
|
|
|
|
|
def _int(value: Any, default: int = 0) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _truthy(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
return bool(value)
|
|
|
|
|
|
def _row_mapping(value: Any) -> dict[str, Any]:
|
|
"""Return only fields actually supplied by an API row when possible."""
|
|
|
|
if isinstance(value, Mapping):
|
|
return dict(value)
|
|
raw = getattr(value, "raw", None)
|
|
if isinstance(raw, Mapping) and raw:
|
|
return dict(raw)
|
|
fields = getattr(value, "__dataclass_fields__", {})
|
|
return {name: getattr(value, name, None) for name in fields if name != "raw"}
|
|
|
|
|
|
def merge_prescription_detail(row: Any, detail: Any) -> dict[str, Any]:
|
|
"""Match the admin editor's ``{...listRow, ...detail}`` state merge."""
|
|
|
|
merged = _row_mapping(row)
|
|
merged.update(_row_mapping(detail))
|
|
# Repository models retain the original API row in ``raw`` and expose
|
|
# canonical alias fields (for example ``patient_phone`` -> ``phone``) on
|
|
# the dataclass. Add those aliases without letting an omitted detail field
|
|
# overwrite a list-only audit/void value with its model default.
|
|
for source in (detail, row):
|
|
fields = getattr(source, "__dataclass_fields__", {})
|
|
for name in fields:
|
|
if name != "raw" and name not in merged:
|
|
merged[name] = getattr(source, name, None)
|
|
return merged
|
|
|
|
|
|
def prescription_status(row: Any) -> tuple[str, str]:
|
|
"""Return the admin audit-column status; void state has its own column."""
|
|
|
|
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
|
|
return "已驳回", "danger"
|
|
status = _int(first_value(row, "audit_status", "status", default=0), 0)
|
|
if status == 2:
|
|
return "已驳回", "danger"
|
|
if status == 1:
|
|
return "已通过", "success"
|
|
return "待审核", "warning"
|
|
|
|
|
|
def is_approved_active(row: Any) -> bool:
|
|
return (
|
|
_int(first_value(row, "audit_status", "status"), 0) == 1
|
|
and _int(first_value(row, "void_status", "is_void"), 0) != 1
|
|
)
|
|
|
|
|
|
def can_patch_patient(row: Any) -> bool:
|
|
return row is not None and _int(first_value(row, "void_status", "is_void"), 0) != 1
|
|
|
|
|
|
def can_create_order(row: Any) -> bool:
|
|
return can_patch_patient(row) and not _truthy(
|
|
first_value(row, "has_prescription_order", default=False)
|
|
)
|
|
|
|
|
|
def can_audit(row: Any) -> bool:
|
|
return (
|
|
row is not None
|
|
and _int(first_value(row, "audit_status", "status"), 0) == 0
|
|
and _int(first_value(row, "void_status", "is_void"), 0) != 1
|
|
)
|
|
|
|
|
|
def can_edit_or_delete(row: Any) -> bool:
|
|
return row is not None and not is_approved_active(row)
|
|
|
|
|
|
def _formula(value: Any) -> str:
|
|
text = str(value or "").strip().lower()
|
|
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
|
|
|
|
|
|
def _is_blank_prescription(row: Any) -> bool:
|
|
if row is None or _truthy(get_value(row, "is_system_auto")):
|
|
return True
|
|
raw = getattr(row, "raw", None)
|
|
source = raw if isinstance(raw, Mapping) and raw else row
|
|
missing = object()
|
|
herbs = get_value(source, "herbs", missing)
|
|
# Compact historical rows may omit herbs; omission is not an empty prescription.
|
|
if herbs is missing:
|
|
return False
|
|
return not isinstance(herbs, (list, tuple)) or not any(
|
|
str(get_value(herb, "name", "") or "").strip() for herb in herbs
|
|
)
|
|
|
|
|
|
def _order_warnings(row: Any) -> list[str]:
|
|
"""Match the PC list's linked-order checks, including blank herb rows."""
|
|
|
|
raw = getattr(row, "raw", None)
|
|
source = raw if isinstance(raw, Mapping) and raw else row
|
|
try:
|
|
has_order = float(get_value(source, "has_prescription_order", 0) or 0) == 1
|
|
except (TypeError, ValueError):
|
|
has_order = False
|
|
if not has_order:
|
|
return []
|
|
herbs = get_value(source, "herbs", None)
|
|
herbs = herbs if isinstance(herbs, (list, tuple)) else []
|
|
names = []
|
|
for herb in herbs:
|
|
raw_name = get_value(herb, "name", "")
|
|
names.append(str("" if raw_name is None else raw_name).strip())
|
|
names = [name for name in names if name]
|
|
if not names:
|
|
return ["请开方,当前处方药材为空白"]
|
|
seen: set[str] = set()
|
|
duplicate: list[str] = []
|
|
for name in names:
|
|
key = "".join(name.split()).lower()
|
|
if key and key in seen and name not in duplicate:
|
|
duplicate.append(name)
|
|
seen.add(key)
|
|
return [f"已有关联业务订单,当前处方存在重复药材:{'、'.join(duplicate)}"] if duplicate else []
|
|
|
|
|
|
def _sn_cell(_value: Any, row: Any) -> str:
|
|
return str(first_value(row, "sn", "prescription_no", "id", default="—"))
|
|
|
|
|
|
class _PrescriptionNumberDelegate(QStyledItemDelegate):
|
|
"""Paint visible PC-style reminders without changing the sortable SN value."""
|
|
|
|
def document(self, option: QStyleOptionViewItem, index: QModelIndex, width: int) -> QTextDocument:
|
|
row = index.data(Qt.ItemDataRole.UserRole)
|
|
number = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
|
|
record_id = display_text(first_value(row, "id", "prescription_id", default="—"))
|
|
document = QTextDocument()
|
|
document.setDocumentMargin(0)
|
|
document.setDefaultFont(option.font)
|
|
text_option = document.defaultTextOption()
|
|
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
|
document.setDefaultTextOption(text_option)
|
|
paragraphs = [
|
|
f'<p style="margin:0;color:#1A1C1F;font-size:14px;font-weight:500">{escape(number)}</p>',
|
|
f'<p style="margin:2px 0 0;color:#606163;font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
|
|
]
|
|
paragraphs.extend(
|
|
'<p style="margin:2px 0 0;color:#BE4B58;font-size:13px;font-weight:400">'
|
|
+ escape(warning)
|
|
+ "</p>"
|
|
for warning in _order_warnings(row)
|
|
)
|
|
document.setHtml("".join(paragraphs))
|
|
document.setTextWidth(max(1, width - 16))
|
|
return document
|
|
|
|
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
|
styled = QStyleOptionViewItem(option)
|
|
self.initStyleOption(styled, index)
|
|
styled.text = ""
|
|
# Passing the widget is what lets the page stylesheet's ``::item:selected``
|
|
# rule apply. Without it this column painted the palette's own highlight,
|
|
# two percent off the wash the rest of the row used - just enough to look
|
|
# like a stray vertical rule down the table.
|
|
table = self.parent()
|
|
table.style().drawControl(
|
|
QStyle.ControlElement.CE_ItemViewItem, styled, painter, table
|
|
)
|
|
document = self.document(option, index, option.rect.width())
|
|
painter.save()
|
|
painter.setClipRect(option.rect)
|
|
painter.translate(option.rect.left() + 8, option.rect.top() + 6)
|
|
document.drawContents(painter)
|
|
painter.restore()
|
|
|
|
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
|
|
width = self.parent().columnWidth(index.column())
|
|
document = self.document(option, index, width)
|
|
return QSize(width, max(36, ceil(document.size().height()) + 12))
|
|
|
|
|
|
def _patient_cell(_value: Any, row: Any) -> str:
|
|
gender = first_value(row, "gender", default=None)
|
|
gender_text = "男" if gender in (1, "1") else "女" if gender in (0, "0") else "未知"
|
|
age = first_value(row, "age", default="—")
|
|
return f"{first_value(row, 'patient_name', default='—')} · {gender_text} · {age}岁"
|
|
|
|
|
|
def _source_cell(_value: Any, row: Any) -> str:
|
|
return (
|
|
"空白处方"
|
|
if _truthy(first_value(row, "is_system_auto", "source", default=False))
|
|
else "手工"
|
|
)
|
|
|
|
|
|
def _audit_cell(_value: Any, row: Any) -> str:
|
|
label = prescription_status(row)[0]
|
|
reasons = []
|
|
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
|
|
reasons.append(
|
|
"业务订单审核:"
|
|
+ display_text(first_value(row, "business_prescription_audit_remark", default="—"))
|
|
)
|
|
if _int(first_value(row, "audit_status"), 0) == 2:
|
|
reasons.append(
|
|
"消费者处方审核:" + display_text(first_value(row, "audit_remark", default="—"))
|
|
)
|
|
return label + ("\n" + "\n".join(reasons) if reasons else "")
|
|
|
|
|
|
def _void_cell(_value: Any, row: Any) -> str:
|
|
return "作废" if _int(first_value(row, "void_status", "is_void"), 0) == 1 else "—"
|
|
|
|
|
|
def _doctor_cell(_value: Any, row: Any) -> str:
|
|
return str(first_value(row, "doctor_name", "creator_name", default="—"))
|
|
|
|
|
|
def _create_time_cell(value: Any, _row: Any) -> str:
|
|
raw = first_value(_row, "create_time_text", "create_time", default=value)
|
|
return format_record_time(raw)
|
|
|
|
|
|
class _DoctorOptionDelegate(QStyledItemDelegate):
|
|
"""Draw an explicit checked state independent of native item indicators."""
|
|
|
|
def sizeHint(self, option: Any, index: QModelIndex) -> QSize:
|
|
return QSize(220, 36)
|
|
|
|
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
|
checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = option.rect.adjusted(2, 1, -2, -1)
|
|
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
|
|
painter.drawRoundedRect(rect, 5, 5)
|
|
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
|
|
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
|
|
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
|
|
painter.drawRoundedRect(box, 3, 3)
|
|
if checked:
|
|
pen = QPen(QColor("#FFFFFF"), 2)
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
painter.setPen(pen)
|
|
x, y = int(box.x()), int(box.y())
|
|
painter.drawLine(x + 4, y + 8, x + 7, y + 11)
|
|
painter.drawLine(x + 7, y + 11, x + 12, y + 5)
|
|
font = QFont(body_family())
|
|
font.setPixelSize(13)
|
|
painter.setFont(font)
|
|
painter.setPen(QColor("#273244"))
|
|
text_rect = rect.adjusted(38, 0, -54, 0)
|
|
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
|
|
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
|
|
if checked:
|
|
painter.setPen(QColor("#1769E8"))
|
|
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
|
|
if option.state & QStyle.StateFlag.State_HasFocus:
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.setPen(QPen(QColor("#ADC8F2"), 1))
|
|
painter.drawRoundedRect(rect, 5, 5)
|
|
painter.restore()
|
|
|
|
def editorEvent(self, event: Any, model: Any, option: Any, index: QModelIndex) -> bool:
|
|
# The list handles whole-row toggles, independent of the native checkbox hit area.
|
|
return False
|
|
|
|
|
|
class _DoctorOptionsList(QListWidget):
|
|
@staticmethod
|
|
def _toggle(item: QListWidgetItem | None) -> None:
|
|
if item is not None and item.flags() & Qt.ItemFlag.ItemIsEnabled:
|
|
item.setCheckState(Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked
|
|
else Qt.CheckState.Checked)
|
|
|
|
def mousePressEvent(self, event: Any) -> None:
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
item = self.itemAt(event.position().toPoint())
|
|
self.setCurrentItem(item)
|
|
self._toggle(item)
|
|
event.accept()
|
|
return
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event: Any) -> None:
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
event.accept()
|
|
return
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def keyPressEvent(self, event: Any) -> None:
|
|
if event.key() == Qt.Key.Key_Space:
|
|
if not event.isAutoRepeat():
|
|
self._toggle(self.currentItem())
|
|
event.accept()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
class _DoctorSelectionDialog(QDialog):
|
|
def __init__(self, options: Mapping[int, str], selected: set[int], parent: QWidget) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("DoctorSelectionDialog")
|
|
self.setWindowTitle("选择医生")
|
|
self.resize(420, 500)
|
|
self.setStyleSheet("""
|
|
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
|
|
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
|
|
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
|
|
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
|
|
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
|
|
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
|
|
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
|
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
|
|
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
|
|
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
|
|
""")
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(16, 16, 16, 16)
|
|
layout.setSpacing(10)
|
|
self.search = QLineEdit(self)
|
|
self.search.setObjectName("DoctorOptionSearch")
|
|
self.search.setPlaceholderText("搜索医生姓名")
|
|
self.search.setClearButtonEnabled(True)
|
|
layout.addWidget(self.search)
|
|
summary = QHBoxLayout()
|
|
self.count_label = QLabel(self)
|
|
self.count_label.setObjectName("DoctorSelectionCount")
|
|
summary.addWidget(self.count_label, 1)
|
|
self.clear_button = QPushButton("清空选择", self)
|
|
self.clear_button.clicked.connect(self._clear)
|
|
summary.addWidget(self.clear_button)
|
|
layout.addLayout(summary)
|
|
self.listing = _DoctorOptionsList(self)
|
|
self.listing.setObjectName("DoctorOptionsList")
|
|
self.listing.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
|
self.listing.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self.listing.setMouseTracking(True)
|
|
self.listing.setItemDelegate(_DoctorOptionDelegate(self.listing))
|
|
for doctor_id, name in sorted(options.items(), key=lambda item: item[1]):
|
|
item = QListWidgetItem(name)
|
|
item.setToolTip(name)
|
|
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
|
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
item.setCheckState(Qt.CheckState.Checked if doctor_id in selected else Qt.CheckState.Unchecked)
|
|
self.listing.addItem(item)
|
|
layout.addWidget(self.listing, 1)
|
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok, self)
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setObjectName("ConfirmDoctorSelection")
|
|
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
|
self.buttons.accepted.connect(self.accept)
|
|
self.buttons.rejected.connect(self.reject)
|
|
layout.addWidget(self.buttons)
|
|
self.listing.itemChanged.connect(self._update_summary)
|
|
self.search.textChanged.connect(self._filter)
|
|
self._update_summary()
|
|
|
|
def selected_ids(self) -> set[int]:
|
|
return {_int(self.listing.item(i).data(Qt.ItemDataRole.UserRole))
|
|
for i in range(self.listing.count())
|
|
if self.listing.item(i).checkState() == Qt.CheckState.Checked}
|
|
|
|
def _update_summary(self, *_args: Any) -> None:
|
|
count = len(self.selected_ids())
|
|
self.count_label.setText(f"已选 {count} 位医生")
|
|
self.clear_button.setEnabled(count > 0)
|
|
|
|
def _filter(self, text: str) -> None:
|
|
query = text.strip().casefold()
|
|
for i in range(self.listing.count()):
|
|
item = self.listing.item(i)
|
|
item.setHidden(query not in item.text().casefold())
|
|
|
|
def _clear(self) -> None:
|
|
for i in range(self.listing.count()):
|
|
self.listing.item(i).setCheckState(Qt.CheckState.Unchecked)
|
|
|
|
|
|
class DoctorMultiSelect(QWidget):
|
|
"""Compact checkable doctor selector fed by list rows/extend data."""
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._options: dict[int, str] = {}
|
|
self._selected: set[int] = set()
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
self.button = QPushButton("全部医生")
|
|
self.button.clicked.connect(self._choose)
|
|
layout.addWidget(self.button)
|
|
|
|
def values(self) -> list[int]:
|
|
return sorted(self._selected)
|
|
|
|
def clear(self) -> None:
|
|
self._selected.clear()
|
|
self._update_text()
|
|
|
|
def update_options(self, rows: list[Any]) -> None:
|
|
for row in rows:
|
|
doctor_id = _int(first_value(row, "id", "creator_id", "doctor_id"), 0)
|
|
name = str(first_value(row, "name", "doctor_name", "creator_name", default="")).strip()
|
|
if doctor_id and name:
|
|
self._options[doctor_id] = name
|
|
self._update_text()
|
|
|
|
def _choose(self) -> None:
|
|
dialog = _DoctorSelectionDialog(self._options, self._selected, self)
|
|
try:
|
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
self._selected = dialog.selected_ids() - {0}
|
|
self._update_text()
|
|
finally:
|
|
dialog.deleteLater()
|
|
|
|
def _update_text(self) -> None:
|
|
if not self._selected:
|
|
self.button.setText("全部医生")
|
|
return
|
|
names = [self._options.get(value, str(value)) for value in sorted(self._selected)]
|
|
self.button.setText("、".join(names[:2]) + (f" 等{len(names)}人" if len(names) > 2 else ""))
|
|
|
|
|
|
def _blue_prescription_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
|
result = QIcon(icons.icon(kind, color, size))
|
|
result.addPixmap(icons.pixmap(kind, "#A4ADBA", size), QIcon.Mode.Disabled)
|
|
return result
|
|
|
|
|
|
class _PrescriptionCombo(QComboBox):
|
|
def paintEvent(self, event: Any) -> None:
|
|
super().paintEvent(event)
|
|
painter = QPainter(self)
|
|
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
|
|
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
|
painter.end()
|
|
|
|
|
|
class _PrescriptionDateTime(QDateTimeEdit):
|
|
def paintEvent(self, event: Any) -> None:
|
|
super().paintEvent(event)
|
|
painter = QPainter(self)
|
|
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 14)
|
|
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
|
painter.end()
|
|
|
|
|
|
class _PrescriptionDoctorButton(QPushButton):
|
|
def paintEvent(self, event: Any) -> None:
|
|
del event
|
|
option = QStyleOptionButton()
|
|
self.initStyleOption(option)
|
|
option.text = self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, max(1, self.width() - 48))
|
|
painter = QPainter(self)
|
|
self.style().drawControl(QStyle.ControlElement.CE_PushButton, option, painter, self)
|
|
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
|
|
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
|
painter.end()
|
|
|
|
def setText(self, text: str) -> None: # noqa: N802
|
|
super().setText(text)
|
|
self.setToolTip(text)
|
|
|
|
|
|
def _prescription_cell_background(painter: QPainter, option: Any, index: QModelIndex) -> None:
|
|
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
|
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
|
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
|
|
painter.setPen(QColor("#E6EDF6"))
|
|
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
|
if selected and index.column() == 0:
|
|
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
|
|
if option.state & QStyle.StateFlag.State_HasFocus:
|
|
painter.setPen(QColor("#75A5F0"))
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
|
|
|
|
|
class _PrescriptionInfoDelegate(QStyledItemDelegate):
|
|
"""Only arrange existing display text; preserve sort, check and source roles."""
|
|
|
|
def initStyleOption(self, option: QStyleOptionViewItem, index: QModelIndex) -> None: # noqa: N802
|
|
super().initStyleOption(option, index)
|
|
if index.column() == 0:
|
|
indicator = self.parent().style().subElementRect(
|
|
QStyle.SubElement.SE_ItemViewItemCheckIndicator, option, self.parent()
|
|
)
|
|
option.rect.translate(option.rect.center().x() - indicator.center().x(), 0)
|
|
|
|
def helpEvent(self, event: Any, view: Any, option: Any, index: Any) -> bool:
|
|
if event is not None and index.isValid() and index.column() not in (0, 2):
|
|
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
|
if text:
|
|
QToolTip.showText(event.globalPos(), str(text), view)
|
|
return True
|
|
return super().helpEvent(event, view, option, index)
|
|
|
|
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
|
painter.save()
|
|
painter.setClipRect(option.rect)
|
|
_prescription_cell_background(painter, option, index)
|
|
rect = option.rect.adjusted(12, 0, -12, -1)
|
|
font = QFont(body_family())
|
|
font.setPixelSize(14)
|
|
painter.setFont(font)
|
|
painter.setPen(QColor("#273244"))
|
|
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
|
|
column = index.column()
|
|
if column == 0:
|
|
styled = QStyleOptionViewItem(option)
|
|
self.initStyleOption(styled, index)
|
|
styled.rect = self.parent().style().subElementRect(QStyle.SubElement.SE_ItemViewItemCheckIndicator, styled, self.parent())
|
|
styled.state &= ~(QStyle.StateFlag.State_On | QStyle.StateFlag.State_Off | QStyle.StateFlag.State_NoChange)
|
|
styled.state |= QStyle.StateFlag.State_On if index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value else QStyle.StateFlag.State_Off
|
|
self.parent().style().drawPrimitive(QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck, styled, painter, self.parent())
|
|
elif column in (3, 6):
|
|
kind = index.data(_ROLE_TAG_KIND)
|
|
foreground, background = {
|
|
"accent": ("#1769E8", "#EDF4FF"), "success": ("#1769E8", "#EDF4FF"),
|
|
"warning": ("#AD640C", "#FFF5E5"), "danger": ("#BE4657", "#FFF0F2"),
|
|
}.get(kind, ("#5D6B80", "#F5F8FD"))
|
|
font.setPixelSize(13)
|
|
painter.setFont(font)
|
|
label = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max(1, rect.width() - 12))
|
|
width = min(rect.width(), painter.fontMetrics().horizontalAdvance(label) + 12)
|
|
pill = QRectF(rect.left(), rect.center().y() - 12, width, 24)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(QColor(background))
|
|
painter.drawRoundedRect(pill, 4, 4)
|
|
painter.setPen(QColor(foreground))
|
|
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
|
elif column != 2:
|
|
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else text.splitlines() if column == 12 else [text]
|
|
if len(lines) > 1:
|
|
primary, secondary = lines[0], " · ".join(lines[1:]) if column == 5 else lines[1]
|
|
if column == 5:
|
|
font = QFont(heading_family())
|
|
font.setPixelSize(14)
|
|
font.setWeight(QFont.Weight.Medium)
|
|
painter.setFont(font)
|
|
first_rect = rect.adjusted(0, 0, 0, 0)
|
|
first_rect.setTop(rect.center().y() - 24)
|
|
first_rect.setHeight(24)
|
|
painter.drawText(first_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(primary, Qt.TextElideMode.ElideRight, rect.width()))
|
|
font = QFont(body_family())
|
|
font.setPixelSize(13)
|
|
painter.setFont(font)
|
|
painter.setPen(QColor("#5D6B80"))
|
|
second_rect = first_rect.translated(0, 26)
|
|
painter.drawText(second_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(secondary, Qt.TextElideMode.ElideRight, rect.width()))
|
|
else:
|
|
painter.drawText(rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width()))
|
|
painter.restore()
|
|
|
|
|
|
class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
|
|
"""Keep warnings visible and measured while matching the compact blue list."""
|
|
|
|
def document(self, option: QStyleOptionViewItem, index: QModelIndex, width: int) -> QTextDocument:
|
|
row = index.data(Qt.ItemDataRole.UserRole)
|
|
number = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
|
|
record_id = display_text(first_value(row, "id", "prescription_id", default="—"))
|
|
document = QTextDocument()
|
|
document.setDocumentMargin(0)
|
|
font = QFont(body_family())
|
|
font.setPixelSize(14)
|
|
document.setDefaultFont(font)
|
|
text_option = document.defaultTextOption()
|
|
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
|
document.setDefaultTextOption(text_option)
|
|
paragraphs = [
|
|
f'<p style="margin:0;color:#273244;font-size:14px;font-weight:400">{escape(number)}</p>',
|
|
f'<p style="margin:6px 0 0;color:#5D6B80;font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
|
|
]
|
|
paragraphs.extend(
|
|
'<p style="margin:6px 0 0;color:#BE4657;font-size:13px;font-weight:400">' + escape(warning) + "</p>"
|
|
for warning in _order_warnings(row)
|
|
)
|
|
document.setHtml("".join(paragraphs))
|
|
document.setTextWidth(max(1, width - 24))
|
|
return document
|
|
|
|
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
|
painter.save()
|
|
painter.setClipRect(option.rect)
|
|
_prescription_cell_background(painter, option, index)
|
|
document = self.document(option, index, option.rect.width())
|
|
painter.translate(option.rect.left() + 12, option.rect.top() + max(12, (option.rect.height() - document.size().height()) / 2))
|
|
document.drawContents(painter)
|
|
painter.restore()
|
|
|
|
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
|
|
width = self.parent().columnWidth(index.column())
|
|
document = self.document(option, index, width)
|
|
return QSize(width, max(80, ceil(document.size().height()) + 28))
|
|
|
|
|
|
class _PrescriptionBlueTable(SortableTable):
|
|
def setCellWidget(self, row: int, column: int, widget: QWidget) -> None: # noqa: N802
|
|
super().setCellWidget(row, column, widget)
|
|
if column == 2:
|
|
widget.setObjectName("PrescriptionTableCellHost")
|
|
for button in widget.findChildren(QPushButton):
|
|
kind = {"查看处方": "eye", "编辑处方": "pencil", "删除处方": "trash"}.get(button.accessibleName())
|
|
if kind:
|
|
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
|
|
|
|
|
|
class PrescriptionsPage(QWidget):
|
|
"""Complete issued-prescription route: list, workflow actions, print, and orders."""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: Any,
|
|
permissions: Any = None,
|
|
current_user: Any = None,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("PrescriptionsPage")
|
|
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
self.setStyleSheet(prescriptions_stylesheet())
|
|
self.repository = repository
|
|
self.permissions = permissions
|
|
self.current_user = current_user
|
|
self._page = 1
|
|
self._page_size = 15
|
|
self._generation = 0
|
|
self._loading = False
|
|
self._refresh_pending = False
|
|
self._detail_generation = 0
|
|
self._detail_target = 0
|
|
self._diagnosis_detail_generation = 0
|
|
self._diagnosis_detail_target = 0
|
|
self._mutation_pending = False
|
|
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
|
self._ai_enabled = False
|
|
self._agreement_visible = False
|
|
self._capability_agreement_visible = False
|
|
self._ai_capability_token = 0
|
|
self._ai_capability_pending = False
|
|
self._ai_request_token = 0
|
|
self._ai_pending = False
|
|
self._ai_timer = QTimer(self)
|
|
self._ai_timer.setInterval(5000)
|
|
self._ai_timer.timeout.connect(self._load_ai_statuses)
|
|
self._ai_scroll_timer = QTimer(self)
|
|
self._ai_scroll_timer.setSingleShot(True)
|
|
self._ai_scroll_timer.setInterval(180)
|
|
self._ai_scroll_timer.timeout.connect(self._load_ai_statuses)
|
|
|
|
outer = QVBoxLayout(self)
|
|
outer.setContentsMargins(28, 16, 26, 8)
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setObjectName("PrescriptionWorkspaceScroll")
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self.content = QWidget()
|
|
self.content.setObjectName("PrescriptionWorkspaceContent")
|
|
root = QVBoxLayout(self.content)
|
|
root.setContentsMargins(0, 0, 0, 0)
|
|
root.setSpacing(10)
|
|
self.scroll.setWidget(self.content)
|
|
outer.addWidget(self.scroll)
|
|
header = PageHeader(
|
|
"已开处方",
|
|
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
|
|
)
|
|
self.header = header
|
|
header.setFixedHeight(96)
|
|
header.layout().setSpacing(12)
|
|
header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(7)
|
|
header.actions.setSpacing(12)
|
|
self.orders_button = QPushButton("业务订单", header)
|
|
self.orders_button.setMinimumWidth(86)
|
|
self.orders_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
|
|
self.orders_button.clicked.connect(lambda: self._open_orders())
|
|
header.add_action(self.orders_button)
|
|
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
|
self.ai_statistics_button.hide()
|
|
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
|
header.add_action(self.ai_statistics_button)
|
|
self.add_button = QPushButton("新增处方", header)
|
|
self.add_button.setObjectName("PrescriptionAddButton")
|
|
self.add_button.setMinimumWidth(122)
|
|
self.add_button.setProperty("variant", "primary")
|
|
self.add_button.setIcon(_blue_prescription_icon("plus", "#FFFFFF"))
|
|
self.add_button.setIconSize(QSize(15, 15))
|
|
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
|
|
self.add_button.clicked.connect(self._add_prescription)
|
|
header.add_action(self.add_button)
|
|
root.addWidget(header)
|
|
filters = self._build_filters()
|
|
self.filter_card = filters
|
|
root.addWidget(filters)
|
|
self.filter_disclosure = FilterDisclosure(self, [filters])
|
|
header.add_action(self.filter_disclosure.button)
|
|
header.set_compact()
|
|
self.banner = MessageBanner()
|
|
root.addWidget(self.banner)
|
|
table_card = self._build_table_card()
|
|
self.table_card = table_card
|
|
root.addWidget(table_card, 1)
|
|
for button, kind in ((self.view_button, "eye"), (self.edit_button, "pencil"), (self.delete_button, "trash")):
|
|
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
|
|
self._load_doctor_options()
|
|
|
|
def _build_filters(self) -> QWidget:
|
|
frame = QFrame()
|
|
frame.setObjectName("PrescriptionFilterBar")
|
|
frame.setFixedHeight(144)
|
|
grid = QGridLayout(frame)
|
|
self.filter_grid = grid
|
|
grid.setContentsMargins(16, 20, 16, 20)
|
|
grid.setHorizontalSpacing(12)
|
|
grid.setVerticalSpacing(16)
|
|
self.quick_date = _PrescriptionCombo()
|
|
self.quick_date.addItem("全部时间", "all")
|
|
self.quick_date.addItem("今日", "today")
|
|
self.quick_date.addItem("昨日", "yesterday")
|
|
self.quick_date.addItem("前天", "before_yesterday")
|
|
self.quick_date.addItem("自定义", "custom")
|
|
self.quick_date.currentIndexChanged.connect(self._quick_date_changed)
|
|
grid.addWidget(self.quick_date, 0, 0)
|
|
self.start_time = _PrescriptionDateTime(QDateTime.currentDateTime().addDays(-7))
|
|
self.start_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
|
self.start_time.setCalendarPopup(True)
|
|
self.start_time.setEnabled(False)
|
|
grid.addWidget(self.start_time, 0, 1)
|
|
self.date_separator = QLabel("至")
|
|
self.date_separator.setProperty("role", "muted")
|
|
self.date_separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
grid.addWidget(self.date_separator, 0, 2)
|
|
self.end_time = _PrescriptionDateTime(QDateTime.currentDateTime())
|
|
self.end_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
|
self.end_time.setCalendarPopup(True)
|
|
self.end_time.setEnabled(False)
|
|
grid.addWidget(self.end_time, 0, 3)
|
|
self.audit_filter = _PrescriptionCombo()
|
|
self.audit_filter.addItem("待审核", "pending")
|
|
self.audit_filter.addItem("全部审核状态", "all")
|
|
self.audit_filter.addItem("已通过", "passed")
|
|
self.audit_filter.addItem("未通过", "not_passed")
|
|
self.audit_filter.addItem("已驳回", "rejected")
|
|
self.audit_filter.setCurrentIndex(1)
|
|
grid.addWidget(self.audit_filter, 0, 4)
|
|
self.source_filter = _PrescriptionCombo()
|
|
self.source_filter.addItem("全部来源", "all")
|
|
self.source_filter.addItem("手工", "manual")
|
|
self.source_filter.addItem("空白处方", "system")
|
|
grid.addWidget(self.source_filter, 0, 5)
|
|
self.sn_filter = QLineEdit()
|
|
self.sn_filter.setPlaceholderText("处方编号")
|
|
self.sn_filter.setClearButtonEnabled(True)
|
|
self.sn_filter.returnPressed.connect(self._search)
|
|
grid.addWidget(self.sn_filter, 1, 0)
|
|
self.patient_filter = QLineEdit()
|
|
self.patient_filter.setPlaceholderText("患者姓名")
|
|
self.patient_filter.setClearButtonEnabled(True)
|
|
self.patient_filter.returnPressed.connect(self._search)
|
|
grid.addWidget(self.patient_filter, 1, 1)
|
|
self.doctor_filter = DoctorMultiSelect()
|
|
original_button = self.doctor_filter.button
|
|
self.doctor_filter.layout().removeWidget(original_button)
|
|
original_button.deleteLater()
|
|
self.doctor_filter.button = _PrescriptionDoctorButton("全部医生")
|
|
self.doctor_filter.button.setObjectName("PrescriptionDoctorButton")
|
|
self.doctor_filter.button.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
|
self.doctor_filter.button.clicked.connect(self.doctor_filter._choose)
|
|
self.doctor_filter.layout().addWidget(self.doctor_filter.button)
|
|
current_id = _int(first_value(self.current_user, "id", "user_id"), 0)
|
|
current_name = str(first_value(self.current_user, "name", "real_name", default="")).strip()
|
|
if current_id and current_name:
|
|
self.doctor_filter.update_options([{"id": current_id, "name": current_name}])
|
|
grid.addWidget(self.doctor_filter, 1, 2, 1, 3)
|
|
self.query_button = QPushButton("查询")
|
|
self.query_button.setObjectName("PrescriptionQueryButton")
|
|
self.query_button.setProperty("variant", "secondary")
|
|
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.query_button.clicked.connect(self._search)
|
|
self.filter_actions = QWidget()
|
|
actions = QHBoxLayout(self.filter_actions)
|
|
actions.setContentsMargins(0, 0, 0, 0)
|
|
actions.setSpacing(12)
|
|
actions.addStretch(1)
|
|
self.query_button.setFixedWidth(84)
|
|
actions.addWidget(self.query_button)
|
|
self.reset_button = QPushButton("重置")
|
|
self.reset_button.setProperty("variant", "ghost")
|
|
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.reset_button.clicked.connect(self._reset_filters)
|
|
self.reset_button.setFixedWidth(84)
|
|
actions.addWidget(self.reset_button)
|
|
grid.addWidget(self.filter_actions, 1, 5)
|
|
for column, (minimum, stretch) in enumerate(((140, 190), (212, 270), (20, 0), (212, 250), (150, 190), (180, 170))):
|
|
grid.setColumnMinimumWidth(column, minimum)
|
|
grid.setColumnStretch(column, stretch)
|
|
return frame
|
|
|
|
def _build_table_card(self) -> QWidget:
|
|
card = QFrame()
|
|
card.setObjectName("PrescriptionTableCard")
|
|
card.setMinimumHeight(280)
|
|
layout = QVBoxLayout(card)
|
|
layout.setContentsMargins(1, 0, 1, 1)
|
|
layout.setSpacing(0)
|
|
toolbar_host = QFrame(card)
|
|
toolbar_host.setObjectName("PrescriptionToolbar")
|
|
toolbar_host.setFixedHeight(64)
|
|
toolbar_scroll = QScrollArea()
|
|
toolbar_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
toolbar_scroll.setWidgetResizable(True)
|
|
toolbar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
toolbar_scroll.setFixedHeight(71)
|
|
toolbar_scroll.setWidget(toolbar_host)
|
|
toolbar = QHBoxLayout(toolbar_host)
|
|
toolbar.setContentsMargins(16, 7, 16, 7)
|
|
toolbar.setSpacing(8)
|
|
title = QLabel("处方列表")
|
|
title.setProperty("role", "sectionTitle")
|
|
toolbar.addWidget(title)
|
|
self.count_badge = QLabel("共 0 条", toolbar_host)
|
|
self.count_badge.setObjectName("PrescriptionCountBadge")
|
|
toolbar.addWidget(self.count_badge)
|
|
toolbar.addStretch(1)
|
|
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
|
|
toolbar.addWidget(self.view_button)
|
|
self.ai_report_button = self._action_button("AI 报告", "tcm.prescriptionAi/reports", self._open_ai_report)
|
|
self.ai_report_button.hide()
|
|
toolbar.addWidget(self.ai_report_button)
|
|
self.patch_button = self._action_button(
|
|
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
|
|
)
|
|
toolbar.addWidget(self.patch_button)
|
|
self.create_order_button = self._action_button(
|
|
"创建订单", "tcm.prescriptionOrder/create", self._create_order
|
|
)
|
|
toolbar.addWidget(self.create_order_button)
|
|
self.edit_button = self._action_button("编辑", "cf.prescription/edit", self._edit_selected)
|
|
toolbar.addWidget(self.edit_button)
|
|
self.audit_button = self._action_button(
|
|
"审核", "cf.prescription/audit", self._audit_selected
|
|
)
|
|
toolbar.addWidget(self.audit_button)
|
|
self.delete_button = self._action_button(
|
|
"删除", "cf.prescription/del", self._delete_selected, danger=True
|
|
)
|
|
toolbar.addWidget(self.delete_button)
|
|
refresh = QPushButton("刷新")
|
|
refresh.setProperty("variant", "ghost")
|
|
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
|
|
refresh.setIconSize(QSize(15, 15))
|
|
refresh.clicked.connect(self.refresh)
|
|
toolbar.addWidget(refresh)
|
|
layout.addWidget(toolbar_scroll)
|
|
self.ai_status_notice = QLabel("AI 分析:正在检查服务状态。", card)
|
|
self.ai_status_notice.setObjectName("PrescriptionAiStatusNotice")
|
|
self.ai_status_notice.setProperty("role", "muted")
|
|
self.ai_status_notice.setTextFormat(Qt.TextFormat.PlainText)
|
|
self.ai_status_notice.setWordWrap(True)
|
|
self.ai_status_notice.setMargin(12)
|
|
self.ai_status_notice.setAccessibleName("AI 分析状态")
|
|
layout.addWidget(self.ai_status_notice)
|
|
self.stack = QStackedWidget()
|
|
table_host = QWidget()
|
|
table_layout = QVBoxLayout(table_host)
|
|
table_layout.setContentsMargins(0, 0, 0, 0)
|
|
table_layout.setSpacing(0)
|
|
self.table = _PrescriptionBlueTable(
|
|
[
|
|
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
|
|
TableColumn("sn", "处方编号", 260, _sn_cell),
|
|
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
|
TableColumn("prescription_type", "处方类型", 96),
|
|
TableColumn("is_system_auto", "来源", 88, _source_cell),
|
|
TableColumn("patient_name", "患者信息", 190, _patient_cell),
|
|
TableColumn("audit_status", "审核状态", 110, _audit_cell),
|
|
TableColumn("void_status", "作废", 72, _void_cell),
|
|
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
|
|
TableColumn("assistant_name", "医助", 125),
|
|
TableColumn("create_time", "创建时间", 180, _create_time_cell),
|
|
TableColumn("__ai_status__", "AI 分析", 144, lambda _value, _row: "—"),
|
|
TableColumn("__ai_agreement__", "与 AI 一致度", 130, lambda _value, _row: "—"),
|
|
]
|
|
)
|
|
self.table.setObjectName("PrescriptionTable")
|
|
self.table.setMouseTracking(True)
|
|
self.table.setAlternatingRowColors(False)
|
|
self.table.verticalHeader().setMinimumSectionSize(80)
|
|
self.table.verticalHeader().setDefaultSectionSize(80)
|
|
self.table.horizontalHeader().setFixedHeight(42)
|
|
self.table.horizontalHeader().setMinimumSectionSize(36)
|
|
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
self.table.horizontalHeader().moveSection(2, 10)
|
|
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
|
|
self.table.setColumnWidth(column, width)
|
|
self.table.setColumnHidden(11, True)
|
|
self.table.setColumnHidden(12, True)
|
|
self.table.horizontalHeaderItem(12).setToolTip("分别显示千问、OpenAI 的药味与剂量一致度;点击查看逐味贡献。")
|
|
self.table.horizontalHeader().viewport().installEventFilter(self)
|
|
self.table.verticalScrollBar().valueChanged.connect(lambda _value: self._schedule_ai_statuses())
|
|
self.table.setWordWrap(False)
|
|
# Rows are not uniform: the number column's delegate grows a row that
|
|
# carries an order warning so the warning text stays readable without a
|
|
# hover, so the table has to keep measuring row heights after a sort or a
|
|
# column resize.
|
|
self.table.model().layoutChanged.connect(self.table.resizeRowsToContents)
|
|
self.table.setItemDelegate(_PrescriptionInfoDelegate(self.table))
|
|
self.table.setItemDelegateForColumn(1, _PrescriptionNumberBlueDelegate(self.table))
|
|
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
|
|
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
|
|
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.table.itemSelectionChanged.connect(self._selection_changed)
|
|
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
|
self.table.itemClicked.connect(lambda item: self._open_ai_report() if item.column() in {11, 12} else None)
|
|
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.table.customContextMenuRequested.connect(self._open_ai_context_menu)
|
|
table_layout.addWidget(self.table, 1)
|
|
self.pager = InfiniteList(self._page_size)
|
|
self.pager.bind(self.table)
|
|
self.stack.addWidget(table_host)
|
|
self.stack.addWidget(
|
|
EmptyState("没有找到处方", "请调整创建时间、审核状态或患者筛选后重试。")
|
|
)
|
|
self.stack.widget(1).layout().itemAt(0).setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
layout.addWidget(self.stack, 1)
|
|
layout.addWidget(self.pager)
|
|
return card
|
|
|
|
def resizeEvent(self, event: Any) -> None:
|
|
super().resizeEvent(event)
|
|
if not hasattr(self, "filter_grid"):
|
|
return
|
|
compact = self.width() < 1070
|
|
if getattr(self, "_compact_filters", None) == compact:
|
|
return
|
|
self._compact_filters = compact
|
|
controls = (self.quick_date, self.start_time, self.date_separator, self.end_time,
|
|
self.audit_filter, self.source_filter, self.sn_filter, self.patient_filter,
|
|
self.doctor_filter, self.filter_actions)
|
|
for control in controls:
|
|
self.filter_grid.removeWidget(control)
|
|
for column in range(6):
|
|
self.filter_grid.setColumnMinimumWidth(column, 0)
|
|
self.filter_grid.setColumnStretch(column, 0)
|
|
if compact:
|
|
positions = ((0, 0, 1), (0, 1, 1), (0, 2, 1), (0, 3, 1), (1, 0, 1),
|
|
(1, 1, 1), (1, 2, 2), (2, 0, 1), (2, 1, 2), (2, 3, 1))
|
|
widths = ((136, 180), (208, 250), (20, 0), (208, 250))
|
|
else:
|
|
positions = ((0, 0, 1), (0, 1, 1), (0, 2, 1), (0, 3, 1), (0, 4, 1),
|
|
(0, 5, 1), (1, 0, 1), (1, 1, 1), (1, 2, 3), (1, 5, 1))
|
|
widths = ((140, 190), (212, 270), (20, 0), (212, 250), (150, 190), (180, 170))
|
|
for control, (row, column, span) in zip(controls, positions, strict=True):
|
|
self.filter_grid.addWidget(control, row, column, 1, span)
|
|
for column, (minimum, stretch) in enumerate(widths):
|
|
self.filter_grid.setColumnMinimumWidth(column, minimum)
|
|
self.filter_grid.setColumnStretch(column, stretch)
|
|
self.filter_card.setFixedHeight(200 if compact else 144)
|
|
|
|
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
|
|
if column == 1:
|
|
self.table.resizeRowsToContents()
|
|
|
|
def eventFilter(self, watched: Any, event: Any) -> bool:
|
|
if hasattr(self, "table") and watched is self.table.horizontalHeader().viewport() and event.type() in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease, QEvent.Type.MouseButtonDblClick}:
|
|
header = self.table.horizontalHeader()
|
|
position = event.position().toPoint()
|
|
column = header.logicalIndexAt(position)
|
|
if column in {11, 12} and event.button() == Qt.MouseButton.LeftButton:
|
|
relative = position.x() - header.sectionViewportPosition(column)
|
|
# Keep native column resizing, but never rank mixed-model display
|
|
# text or treat unavailable values as numeric zero through sorting.
|
|
if 5 < relative < header.sectionSize(column) - 5:
|
|
return True
|
|
return super().eventFilter(watched, event)
|
|
|
|
def _action_button(
|
|
self,
|
|
text: str,
|
|
permission: str,
|
|
handler: Callable[[], None],
|
|
*,
|
|
danger: bool = False,
|
|
) -> QPushButton:
|
|
# Keep permission-driven initial visibility inside the page hierarchy;
|
|
# otherwise Windows briefly exposes the button as its own window.
|
|
button = QPushButton(text, self)
|
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
icon_kind = {
|
|
"查看": "eye",
|
|
"编辑": "pencil",
|
|
"删除": "trash",
|
|
}.get(text)
|
|
if icon_kind:
|
|
button.setIcon(
|
|
_painted_icon(icon_kind, "danger" if danger else "accent", 15)
|
|
)
|
|
button.setIconSize(QSize(15, 15))
|
|
if danger:
|
|
button.setProperty("variant", "danger")
|
|
button.setVisible(has_permission(self.permissions, permission))
|
|
button.setEnabled(False)
|
|
button.clicked.connect(handler)
|
|
return button
|
|
|
|
def _load_doctor_options(self) -> None:
|
|
"""Populate the creator multi-select from the repository's doctor catalog."""
|
|
|
|
method = getattr(self.repository, "list_diagnosis_doctors", None)
|
|
if not callable(method):
|
|
return
|
|
run_async(
|
|
method,
|
|
on_success=lambda result: self.doctor_filter.update_options(page_items(result)),
|
|
)
|
|
|
|
def _quick_date_changed(self) -> None:
|
|
custom = self.quick_date.currentData() == "custom"
|
|
self.start_time.setEnabled(custom)
|
|
self.end_time.setEnabled(custom)
|
|
self._search()
|
|
|
|
def _date_filters(self) -> tuple[str, str]:
|
|
value = self.quick_date.currentData()
|
|
if value == "all":
|
|
return "", ""
|
|
if value == "custom":
|
|
return (
|
|
self.start_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
|
|
self.end_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
|
|
)
|
|
offset = {"today": 0, "yesterday": 1, "before_yesterday": 2}.get(value, 0)
|
|
target = datetime.now().date() - timedelta(days=offset)
|
|
return (
|
|
f"{target.isoformat()} 00:00:00",
|
|
f"{target.isoformat()} 23:59:59",
|
|
)
|
|
|
|
def _search(self) -> None:
|
|
self._page = 1
|
|
self.refresh()
|
|
|
|
def _reset_filters(self) -> None:
|
|
self.quick_date.blockSignals(True)
|
|
self.quick_date.setCurrentIndex(0)
|
|
self.quick_date.blockSignals(False)
|
|
self.start_time.setEnabled(False)
|
|
self.end_time.setEnabled(False)
|
|
self.audit_filter.setCurrentIndex(1)
|
|
self.source_filter.setCurrentIndex(0)
|
|
self.sn_filter.clear()
|
|
self.patient_filter.clear()
|
|
self.doctor_filter.clear()
|
|
self._search()
|
|
|
|
def refresh(self) -> None:
|
|
self._ai_timer.stop()
|
|
self._ai_request_token += 1
|
|
self._ai_pending = False
|
|
self._ai_statuses.clear()
|
|
self._loading = True
|
|
self._refresh_pending = False
|
|
self._generation += 1
|
|
generation = self._generation
|
|
self._load_ai_capabilities()
|
|
page_size = self._page_size
|
|
start_time, end_time = self._date_filters()
|
|
filters: dict[str, Any] = {
|
|
"sn": self.sn_filter.text().strip(),
|
|
"patient_name": self.patient_filter.text().strip(),
|
|
"audit_filter": ""
|
|
if self.audit_filter.currentData() == "all"
|
|
else self.audit_filter.currentData(),
|
|
"source_filter": ""
|
|
if self.source_filter.currentData() == "all"
|
|
else self.source_filter.currentData(),
|
|
"start_time": start_time,
|
|
"end_time": end_time,
|
|
}
|
|
creator_ids = self.doctor_filter.values()
|
|
if creator_ids:
|
|
filters["creator_ids"] = creator_ids
|
|
# 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
|
|
# 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
|
|
# 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
|
|
self.pager.reload(
|
|
lambda page: invoke(self.repository, "prescriptions", page=page, page_size=page_size, **filters),
|
|
lambda result: self._apply_result(result, generation, self.pager.page),
|
|
lambda error: self._load_error(error, generation),
|
|
runner=run_async,
|
|
query_key=filters,
|
|
on_finished=lambda: self._load_finished(generation),
|
|
)
|
|
|
|
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
|
|
if generation != self._generation:
|
|
return
|
|
rows = page_items(result)
|
|
self.table.set_rows(rows)
|
|
self._decorate_rows()
|
|
self._render_ai_statuses()
|
|
total = page_total(result, len(rows))
|
|
self.pager.update_state(requested_page, total)
|
|
self.count_badge.setText(f"共 {total} 条")
|
|
motion.switch_stack(self.stack, 0 if rows else 1)
|
|
doctor_rows = []
|
|
for row in rows:
|
|
doctor_rows.append(
|
|
{
|
|
"id": first_value(row, "creator_id", "doctor_id"),
|
|
"name": first_value(row, "doctor_name", "creator_name"),
|
|
}
|
|
)
|
|
extend_doctors = get_value(result, "extend.doctors", None) or get_value(
|
|
result, "doctors", None
|
|
)
|
|
if isinstance(extend_doctors, (list, tuple)):
|
|
doctor_rows.extend(extend_doctors)
|
|
self.doctor_filter.update_options(doctor_rows)
|
|
self.banner.clear()
|
|
if rows and self.table.currentRow() < 0:
|
|
self.table.selectRow(0)
|
|
self._selection_changed()
|
|
self._load_ai_statuses()
|
|
|
|
def _decorate_rows(self) -> None:
|
|
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
|
|
|
|
sorting = self.table.isSortingEnabled()
|
|
self.table.setUpdatesEnabled(False)
|
|
self.table.setSortingEnabled(False)
|
|
try:
|
|
self._decorate_rows_locked()
|
|
finally:
|
|
self.table.setSortingEnabled(sorting)
|
|
self.table.setUpdatesEnabled(True)
|
|
|
|
def _decorate_rows_locked(self) -> None:
|
|
for row_index in range(self.table.rowCount()):
|
|
selector = self.table.item(row_index, 0)
|
|
if selector is None:
|
|
continue
|
|
# Read the row back from the visual table. SortableTable may have
|
|
# already reordered rows, so decorating the original result list
|
|
# can bind an action button to the wrong visible prescription.
|
|
row = selector.data(Qt.ItemDataRole.UserRole)
|
|
if row is None:
|
|
continue
|
|
selector.setFlags(
|
|
selector.flags()
|
|
| Qt.ItemFlag.ItemIsUserCheckable
|
|
| Qt.ItemFlag.ItemIsEnabled
|
|
| Qt.ItemFlag.ItemIsSelectable
|
|
)
|
|
selector.setCheckState(Qt.CheckState.Unchecked)
|
|
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
sn_item = self.table.item(row_index, 1)
|
|
if sn_item is not None:
|
|
sn_item.setForeground(QColor("#1A1C1F"))
|
|
font = sn_item.font()
|
|
font.setWeight(QFont.Weight.Normal)
|
|
sn_item.setFont(font)
|
|
warnings = _order_warnings(row)
|
|
description = "\n".join(
|
|
[sn_item.text(), f"ID: {first_value(row, 'id', 'prescription_id', default='—')}", *warnings]
|
|
)
|
|
sn_item.setToolTip(description)
|
|
sn_item.setData(Qt.ItemDataRole.AccessibleTextRole, description)
|
|
|
|
type_item = self.table.item(row_index, 3)
|
|
if type_item is not None:
|
|
type_item.setText(
|
|
display_text(first_value(row, "prescription_type", default="—"))
|
|
)
|
|
type_item.setData(_ROLE_TAG_KIND, "accent")
|
|
type_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
audit_text, audit_kind = prescription_status(row)
|
|
audit_item = self.table.item(row_index, 6)
|
|
if audit_item is not None:
|
|
if "\n" in audit_item.text():
|
|
audit_item.setToolTip(audit_item.text())
|
|
audit_item.setText(audit_text)
|
|
audit_item.setData(_ROLE_TAG_KIND, audit_kind)
|
|
audit_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
doctor_item = self.table.item(row_index, 8)
|
|
if doctor_item is not None:
|
|
doctor_item.setData(_ROLE_AVATAR, _doctor_cell(None, row))
|
|
|
|
# 保持宿主透明,让整行的斑马底色与选中底色透过操作列。
|
|
actions_host = QWidget(self.table.viewport())
|
|
actions = QHBoxLayout(actions_host)
|
|
actions.setContentsMargins(3, 0, 3, 0)
|
|
actions.setSpacing(3)
|
|
actions.addStretch(1)
|
|
if has_permission(self.permissions, "cf.prescription/read"):
|
|
actions.addWidget(
|
|
_row_action_button(
|
|
"eye",
|
|
"查看处方",
|
|
lambda _checked=False, target=row: self._run_row_action(
|
|
target, self._view_selected
|
|
),
|
|
actions_host,
|
|
)
|
|
)
|
|
if self._ai_enabled and can_open_issued_ai(self.permissions) and not _is_blank_prescription(row):
|
|
actions.addWidget(
|
|
_row_action_button(
|
|
"eye", "AI 报告",
|
|
lambda _checked=False, target=row: self._run_row_action(target, self._open_ai_report),
|
|
actions_host, label="AI",
|
|
)
|
|
)
|
|
if has_permission(self.permissions, "cf.prescription/edit"):
|
|
actions.addWidget(
|
|
_row_action_button(
|
|
"pencil",
|
|
"编辑处方",
|
|
lambda _checked=False, target=row: self._run_row_action(
|
|
target, self._edit_selected
|
|
),
|
|
actions_host,
|
|
enabled=can_edit_or_delete(row),
|
|
label="编辑",
|
|
mutation=True,
|
|
)
|
|
)
|
|
if has_permission(self.permissions, "cf.prescription/del"):
|
|
actions.addWidget(
|
|
_row_action_button(
|
|
"trash",
|
|
"删除处方",
|
|
lambda _checked=False, target=row: self._run_row_action(
|
|
target, self._delete_selected
|
|
),
|
|
actions_host,
|
|
danger=True,
|
|
enabled=can_edit_or_delete(row),
|
|
mutation=True,
|
|
)
|
|
)
|
|
actions.addStretch(1)
|
|
self.table.setCellWidget(row_index, 2, actions_host)
|
|
self._sync_row_mutation_actions()
|
|
self.table.resizeRowsToContents()
|
|
|
|
def _visible_ai_ids(self) -> list[int]:
|
|
if not self.isVisible() or not self.table.isVisible():
|
|
return []
|
|
ids = []
|
|
viewport = self.table.viewport().rect()
|
|
for index in range(self.table.rowCount()):
|
|
item = self.table.item(index, 0)
|
|
# Column 0 may be horizontally off-screen; use row geometry only.
|
|
top = self.table.rowViewportPosition(index)
|
|
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
|
|
continue
|
|
row = item.data(Qt.ItemDataRole.UserRole)
|
|
if _is_blank_prescription(row):
|
|
continue
|
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
|
if value > 0:
|
|
ids.append(value)
|
|
return list(dict.fromkeys(ids))[:100]
|
|
|
|
def _schedule_ai_statuses(self) -> None:
|
|
if self.isVisible():
|
|
self._ai_scroll_timer.start()
|
|
|
|
def _load_ai_statuses(self) -> None:
|
|
only_blank = self.table.rowCount() > 0 and all(
|
|
_is_blank_prescription(self.table.item(index, 0).data(Qt.ItemDataRole.UserRole))
|
|
for index in range(self.table.rowCount())
|
|
)
|
|
self.ai_status_notice.setVisible(not only_blank)
|
|
if only_blank:
|
|
self._ai_timer.stop()
|
|
self._set_ai_columns(False)
|
|
return
|
|
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
|
self._set_ai_columns(False)
|
|
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
|
return
|
|
if not callable(method):
|
|
self.ai_status_notice.setText("AI 分析:当前数据模式暂不支持此功能。")
|
|
return
|
|
if self._ai_pending:
|
|
return
|
|
ids = self._visible_ai_ids()
|
|
ids = [value for value in ids if value not in self._ai_statuses or batch_running(self._ai_statuses[value])]
|
|
if not ids:
|
|
self._ai_timer.stop()
|
|
if self.table.rowCount() == 0:
|
|
self.ai_status_notice.setText("AI 分析:当前列表没有处方,暂无可查看的分析结果。")
|
|
return
|
|
self._ai_pending = True
|
|
self._ai_request_token += 1
|
|
token, generation = self._ai_request_token, self._generation
|
|
run_async(
|
|
lambda: method(ids),
|
|
on_success=lambda result: self._ai_statuses_ready(result, ids, token, generation),
|
|
on_error=lambda error: self._ai_statuses_error(error, token, generation),
|
|
on_finished=lambda: self._ai_statuses_finished(token),
|
|
)
|
|
|
|
def _ai_statuses_finished(self, token: int) -> None:
|
|
if token == self._ai_request_token:
|
|
self._ai_pending = False
|
|
|
|
def _set_ai_columns(self, enabled: bool) -> None:
|
|
changed = enabled != self._ai_enabled
|
|
self._ai_enabled = enabled
|
|
self.table.setColumnHidden(11, not enabled)
|
|
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
|
|
if enabled:
|
|
header = self.table.horizontalHeader()
|
|
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
|
header.moveSection(header.visualIndex(12), header.visualIndex(11) + 1)
|
|
self.table.setColumnWidth(11, 144)
|
|
self.table.setColumnWidth(12, 130)
|
|
self.table.setColumnWidth(2, 178)
|
|
if changed:
|
|
self._decorate_rows()
|
|
|
|
def _set_agreement_visible(self, visible: bool) -> None:
|
|
self._agreement_visible = visible
|
|
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
|
|
self.ai_statistics_button.setVisible(
|
|
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
|
|
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
|
|
if not visible:
|
|
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
|
|
for index in range(self.table.rowCount()):
|
|
item = self.table.item(index, 12)
|
|
if item is not None:
|
|
item.setText("")
|
|
item.setToolTip("")
|
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
|
|
|
|
def _load_ai_capabilities(self) -> None:
|
|
self._ai_capability_token += 1
|
|
token, generation = self._ai_capability_token, self._generation
|
|
self._ai_request_token += 1
|
|
self._ai_pending = False
|
|
self._capability_agreement_visible = False
|
|
self._set_agreement_visible(False)
|
|
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
|
self._ai_capability_pending = callable(method)
|
|
if not callable(method):
|
|
return
|
|
run_async(method,
|
|
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
|
|
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
|
|
|
|
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
|
|
if token != self._ai_capability_token or generation != self._generation:
|
|
return
|
|
self._ai_capability_pending = False
|
|
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
|
|
self._set_agreement_visible(self._capability_agreement_visible)
|
|
if self._capability_agreement_visible:
|
|
# A previous deny discarded scores, including completed cached batches.
|
|
# Fetch them again after a fresh grant rather than displaying that cache.
|
|
self._ai_statuses.clear()
|
|
if self.isVisible() and not self._loading:
|
|
self._load_ai_statuses()
|
|
|
|
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
|
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
|
return
|
|
data = _row_mapping(result)
|
|
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
|
|
self._set_ai_columns(data.get("enabled") is True)
|
|
if not self._ai_enabled:
|
|
self._ai_timer.stop()
|
|
notice = "AI 分析未启用:暂不生成报告或一致度,请联系管理员启用。"
|
|
if can_open_issued_ai(self.permissions):
|
|
notice += "已有报告仍可从“AI 报告”查看。"
|
|
self.ai_status_notice.setText(notice)
|
|
self.ai_status_notice.setToolTip("")
|
|
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
|
return
|
|
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
|
|
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
|
|
self.ai_status_notice.setToolTip("")
|
|
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
|
|
+ ("可查看逐味对照。" if self._agreement_visible else ""))
|
|
for value in ids:
|
|
self._ai_statuses[value] = {}
|
|
for batch in data.get("items") or []:
|
|
value = _int(get_value(batch, "prescription_id"), 0)
|
|
if value in ids:
|
|
self._ai_statuses[value] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
|
|
self._render_ai_statuses()
|
|
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
|
self._ai_timer.start()
|
|
else:
|
|
self._ai_timer.stop()
|
|
|
|
def _render_ai_statuses(self) -> None:
|
|
sorting = self.table.isSortingEnabled()
|
|
self.table.setSortingEnabled(False)
|
|
changed = False
|
|
try:
|
|
for index in range(self.table.rowCount()):
|
|
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
|
batch = self._ai_statuses.get(value)
|
|
blank = _is_blank_prescription(row)
|
|
if batch is None and not blank:
|
|
continue
|
|
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
|
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
|
|
for column, text in ((11, state), (12, agreement)):
|
|
item = self.table.item(index, column)
|
|
if item.text() != text:
|
|
item.setText(text)
|
|
changed = True
|
|
item.setToolTip("" if blank or column == 12 and not self._agreement_visible else status_tooltip(batch))
|
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
|
if changed:
|
|
self.table.resizeRowsToContents()
|
|
finally:
|
|
self.table.setSortingEnabled(sorting)
|
|
|
|
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
|
if token == self._ai_request_token and generation == self._generation:
|
|
self._ai_timer.stop()
|
|
self._set_agreement_visible(False)
|
|
self._set_ai_columns(False)
|
|
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
|
self.ai_status_notice.setToolTip(friendly_error(error))
|
|
self.ai_report_button.setToolTip("AI 状态暂不可用:" + friendly_error(error))
|
|
|
|
def _open_ai_report(self) -> None:
|
|
row = self._selected()
|
|
if _is_blank_prescription(row):
|
|
return
|
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
|
if value > 0:
|
|
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
|
|
|
|
def _open_ai_context_menu(self, position: Any) -> None:
|
|
if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
|
|
return
|
|
item = self.table.itemAt(position)
|
|
if item is None:
|
|
return
|
|
self.table.selectRow(item.row())
|
|
row = self._selected()
|
|
if _is_blank_prescription(row):
|
|
return
|
|
menu = QMenu(self.table)
|
|
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
|
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
|
self._ai_context_menu = menu
|
|
menu.popup(self.table.viewport().mapToGlobal(position))
|
|
|
|
def _open_ai_statistics(self) -> None:
|
|
if not self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
|
return
|
|
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
dialog.show()
|
|
|
|
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
|
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
|
if target_id <= 0:
|
|
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
|
|
return
|
|
for row_index in range(self.table.rowCount()):
|
|
item = self.table.item(row_index, 0)
|
|
candidate = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
|
candidate_id = _int(
|
|
first_value(candidate, "id", "prescription_id", default=None), 0
|
|
)
|
|
if candidate is row or (
|
|
candidate_id > 0 and candidate_id == target_id
|
|
):
|
|
self.table.selectRow(row_index)
|
|
callback()
|
|
return
|
|
self.banner.show_message("该处方已不在当前列表,请刷新后重试。", "warning")
|
|
|
|
def _load_error(self, error: Exception, generation: int) -> None:
|
|
if generation == self._generation:
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _load_finished(self, generation: int) -> None:
|
|
if generation == self._generation:
|
|
self._loading = False
|
|
if self._refresh_pending:
|
|
self._refresh_pending = False
|
|
self.refresh()
|
|
|
|
def _selection_changed(self) -> None:
|
|
row = self.table.current_data()
|
|
active = not self._mutation_pending
|
|
self.view_button.setEnabled(active and row is not None)
|
|
ai_available = (
|
|
not _is_blank_prescription(row)
|
|
and can_open_issued_ai(self.permissions)
|
|
and callable(getattr(self.repository, "list_prescription_ai_reports", None))
|
|
)
|
|
self.ai_report_button.setVisible(ai_available)
|
|
self.ai_report_button.setEnabled(ai_available)
|
|
self.patch_button.setEnabled(active and can_patch_patient(row))
|
|
self.create_order_button.setEnabled(active and can_create_order(row))
|
|
self.edit_button.setEnabled(active and can_edit_or_delete(row))
|
|
self.audit_button.setEnabled(active and can_audit(row))
|
|
self.delete_button.setEnabled(active and can_edit_or_delete(row))
|
|
|
|
def _set_mutation_pending(self, pending: bool) -> None:
|
|
self._mutation_pending = pending
|
|
self.add_button.setEnabled(not pending)
|
|
self.orders_button.setEnabled(not pending)
|
|
self._sync_row_mutation_actions()
|
|
self._selection_changed()
|
|
|
|
def _sync_row_mutation_actions(self) -> None:
|
|
for button in self.table.findChildren(QPushButton):
|
|
if not bool(button.property("mutationAction")):
|
|
continue
|
|
button.setEnabled(
|
|
not self._mutation_pending and bool(button.property("rowAllowed"))
|
|
)
|
|
|
|
def _selected(self) -> Any:
|
|
return self.table.current_data()
|
|
|
|
def _get_prescription(self, prescription_id: int) -> Any:
|
|
method = self.repository.get_prescription
|
|
return method(prescription_id)
|
|
|
|
def _load_detail(
|
|
self,
|
|
row: Any,
|
|
callback: Callable[[Any], None],
|
|
*,
|
|
message: str = "正在加载处方详情…",
|
|
) -> None:
|
|
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
|
|
if not prescription_id:
|
|
self.banner.show_message("处方 ID 无效。", "warning")
|
|
return
|
|
self._detail_generation += 1
|
|
generation = self._detail_generation
|
|
self._detail_target = prescription_id
|
|
self.banner.show_message(message, "info")
|
|
run_async(
|
|
lambda: self._get_prescription(prescription_id),
|
|
on_success=lambda detail: self._detail_success(
|
|
detail, prescription_id, generation, callback
|
|
),
|
|
on_error=lambda error: self._detail_error(error, prescription_id, generation),
|
|
)
|
|
|
|
def _detail_success(
|
|
self,
|
|
detail: Any,
|
|
prescription_id: int,
|
|
generation: int,
|
|
callback: Callable[[Any], None],
|
|
) -> None:
|
|
if generation != self._detail_generation or prescription_id != self._detail_target:
|
|
return
|
|
detail_id = _int(first_value(detail, "id", "prescription_id"), 0)
|
|
if detail_id != prescription_id:
|
|
self.banner.show_message("处方详情与当前选择不一致,请刷新后重试。", "danger")
|
|
return
|
|
self.banner.clear()
|
|
callback(detail)
|
|
|
|
def _detail_error(self, error: Exception, prescription_id: int, generation: int) -> None:
|
|
if generation == self._detail_generation and prescription_id == self._detail_target:
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _view_selected(self) -> None:
|
|
row = self._selected()
|
|
if row is None or not has_permission(self.permissions, "cf.prescription/read"):
|
|
return
|
|
self._load_detail(row, self._show_detail)
|
|
|
|
def _show_detail(self, detail: Any) -> None:
|
|
if not has_permission(self.permissions, "cf.prescription/read"):
|
|
return
|
|
dialog = PrescriptionDetailDialog(
|
|
detail,
|
|
can_open_diagnosis=has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"),
|
|
can_open_orders=has_permission(self.permissions, "tcm.prescriptionOrder/lists"),
|
|
parent=self,
|
|
)
|
|
dialog.diagnosis_requested.connect(self._open_diagnosis)
|
|
dialog.orders_requested.connect(lambda prescription_id: self._open_orders(prescription_id))
|
|
dialog.exec()
|
|
|
|
def _open_diagnosis(self, diagnosis_id: int) -> None:
|
|
if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"):
|
|
self.banner.show_message("无权查看诊单详情。", "danger")
|
|
return
|
|
diagnosis_id = _int(diagnosis_id, 0)
|
|
if diagnosis_id <= 0:
|
|
self.banner.show_message("诊单 ID 无效。", "warning")
|
|
return
|
|
method = getattr(self.repository, "get_diagnosis_detail", None)
|
|
readonly_keyword = True
|
|
if not callable(method):
|
|
method = getattr(self.repository, "diagnosis_readonly_detail", None)
|
|
readonly_keyword = False
|
|
if not callable(method):
|
|
self.banner.show_message("当前 repository 不支持诊单详情。", "danger")
|
|
return
|
|
self._diagnosis_detail_generation += 1
|
|
generation = self._diagnosis_detail_generation
|
|
self._diagnosis_detail_target = diagnosis_id
|
|
self.banner.show_message("正在加载诊单详情…", "info")
|
|
run_async(
|
|
lambda: (
|
|
method(diagnosis_id, readonly=True) if readonly_keyword else method(diagnosis_id)
|
|
),
|
|
on_success=lambda detail: self._diagnosis_detail_success(
|
|
detail, diagnosis_id, generation
|
|
),
|
|
on_error=lambda error: self._diagnosis_detail_error(error, diagnosis_id, generation),
|
|
)
|
|
|
|
def _diagnosis_detail_success(self, detail: Any, diagnosis_id: int, generation: int) -> None:
|
|
if (
|
|
generation != self._diagnosis_detail_generation
|
|
or diagnosis_id != self._diagnosis_detail_target
|
|
):
|
|
return
|
|
self.banner.clear()
|
|
DiagnosisDetailDialog(
|
|
detail,
|
|
self,
|
|
repository=self.repository,
|
|
permissions=self.permissions,
|
|
).exec()
|
|
|
|
def _diagnosis_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
|
if (
|
|
generation == self._diagnosis_detail_generation
|
|
and diagnosis_id == self._diagnosis_detail_target
|
|
):
|
|
self.banner.show_message(friendly_error(error), "danger")
|
|
|
|
def _add_prescription(self) -> None:
|
|
if not has_permission(self.permissions, "cf.prescription/add"):
|
|
return
|
|
dialog = PrescriptionEditorDialog(
|
|
self.repository,
|
|
mode="add",
|
|
current_user=self.current_user,
|
|
parent=self,
|
|
)
|
|
dialog.diagnosis_requested.connect(self._open_diagnosis)
|
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
self._save_prescription(dialog.payload(), None)
|
|
|
|
def _edit_selected(self) -> None:
|
|
row = self._selected()
|
|
if (
|
|
row is None
|
|
or self._mutation_pending
|
|
or not has_permission(self.permissions, "cf.prescription/edit")
|
|
or not can_edit_or_delete(row)
|
|
):
|
|
return
|
|
self._load_detail(
|
|
row,
|
|
lambda detail: self._open_editor(merge_prescription_detail(row, detail)),
|
|
message="正在准备编辑处方…",
|
|
)
|
|
|
|
def _open_editor(self, detail: Any) -> None:
|
|
if not can_edit_or_delete(detail):
|
|
self.banner.show_message("该处方状态已变化,当前不能编辑。", "warning")
|
|
return
|
|
dialog = PrescriptionEditorDialog(
|
|
self.repository,
|
|
detail,
|
|
mode="edit",
|
|
current_user=self.current_user,
|
|
parent=self,
|
|
)
|
|
dialog.diagnosis_requested.connect(self._open_diagnosis)
|
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
self._save_prescription(
|
|
dialog.payload(),
|
|
_int(first_value(detail, "id", "prescription_id"), 0),
|
|
)
|
|
|
|
def _save_prescription(
|
|
self,
|
|
payload: dict[str, Any],
|
|
prescription_id: int | None,
|
|
) -> None:
|
|
permission = "cf.prescription/edit" if prescription_id else "cf.prescription/add"
|
|
if not has_permission(self.permissions, permission):
|
|
return
|
|
self._set_mutation_pending(True)
|
|
if prescription_id:
|
|
|
|
def operation() -> Any:
|
|
return self.repository.update_prescription(
|
|
prescription_id,
|
|
changes=payload,
|
|
)
|
|
|
|
message = "处方已保存并重新进入待审核。"
|
|
else:
|
|
|
|
def operation() -> Any:
|
|
return self.repository.create_prescription(payload)
|
|
|
|
message = "处方已新增并提交审核。"
|
|
run_async(
|
|
operation,
|
|
on_success=lambda _result: self._mutation_success(message),
|
|
on_error=self._mutation_error,
|
|
on_finished=lambda: self._set_mutation_pending(False),
|
|
)
|
|
|
|
def _delete_selected(self) -> None:
|
|
row = self._selected()
|
|
if (
|
|
row is None
|
|
or self._mutation_pending
|
|
or not has_permission(self.permissions, "cf.prescription/del")
|
|
or not can_edit_or_delete(row)
|
|
):
|
|
return
|
|
answer = QMessageBox.warning(
|
|
self,
|
|
"删除处方",
|
|
"确定删除该处方吗?此操作无法撤销。",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
|
QMessageBox.StandardButton.Cancel,
|
|
)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
|
|
if prescription_id <= 0:
|
|
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
|
|
return
|
|
self._set_mutation_pending(True)
|
|
run_async(
|
|
lambda: self.repository.delete_prescription(prescription_id),
|
|
on_success=lambda _result: self._mutation_success("处方已删除。"),
|
|
on_error=self._mutation_error,
|
|
on_finished=lambda: self._set_mutation_pending(False),
|
|
)
|
|
|
|
def _patch_selected(self) -> None:
|
|
row = self._selected()
|
|
if (
|
|
row is None
|
|
or not has_permission(self.permissions, "tcm.prescription/patchPatient")
|
|
or not can_patch_patient(row)
|
|
):
|
|
return
|
|
dialog = PatchPatientDialog(row, self)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
payload = dialog.payload()
|
|
self._set_mutation_pending(True)
|
|
run_async(
|
|
lambda: self.repository.patch_prescription_patient(
|
|
payload["id"],
|
|
patient_name=payload["patient_name"],
|
|
phone=payload["phone"],
|
|
gender=payload["gender"],
|
|
),
|
|
on_success=lambda _result: self._mutation_success("患者信息已修正。"),
|
|
on_error=self._mutation_error,
|
|
on_finished=lambda: self._set_mutation_pending(False),
|
|
)
|
|
|
|
def _audit_selected(self) -> None:
|
|
row = self._selected()
|
|
if (
|
|
row is None
|
|
or not has_permission(self.permissions, "cf.prescription/audit")
|
|
or not can_audit(row)
|
|
):
|
|
return
|
|
dialog = AuditPrescriptionDialog(row, self)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
payload = dialog.payload()
|
|
self._set_mutation_pending(True)
|
|
run_async(
|
|
lambda: self.repository.audit_prescription(
|
|
payload["id"],
|
|
action=payload["action"],
|
|
remark=payload["remark"],
|
|
),
|
|
on_success=lambda result: self._audit_success(result, payload["action"]),
|
|
on_error=self._mutation_error,
|
|
on_finished=lambda: self._set_mutation_pending(False),
|
|
)
|
|
|
|
def _audit_success(self, result: Any, action: str) -> None:
|
|
message = "处方已驳回并作废。" if action == "reject" else "处方审核已通过。"
|
|
self._mutation_success(message)
|
|
if (
|
|
isinstance(result, Mapping)
|
|
and result.get("wecom_notify_ok") is False
|
|
and result.get("wecom_notify_hint")
|
|
):
|
|
show_toast(self, str(result["wecom_notify_hint"]), "warning", 5000)
|
|
|
|
def _create_order(self) -> None:
|
|
row = self._selected()
|
|
if (
|
|
row is None
|
|
or not has_permission(self.permissions, "tcm.prescriptionOrder/create")
|
|
or not can_create_order(row)
|
|
):
|
|
return
|
|
self._load_detail(row, self._open_order_editor, message="正在准备业务订单…")
|
|
|
|
def _open_order_editor(self, detail: Any) -> None:
|
|
if not has_permission(self.permissions, "tcm.prescriptionOrder/create"):
|
|
return
|
|
dialog = PrescriptionOrderDialog(
|
|
self.repository,
|
|
detail,
|
|
can_select_ship_mode=has_permission(
|
|
self.permissions, "tcm.prescriptionOrder/setShipMode"
|
|
),
|
|
can_view_internal_cost=has_permission(self.permissions, "finance.account_log/lists"),
|
|
can_edit_pharmacy_remark=has_permission(
|
|
self.permissions, "tcm.prescriptionOrder/editRemarkExtra"
|
|
),
|
|
parent=self,
|
|
)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return
|
|
payload = dialog.payload()
|
|
self._set_mutation_pending(True)
|
|
run_async(
|
|
lambda: self.repository.create_prescription_order(payload),
|
|
on_success=lambda result: self._order_created(result),
|
|
on_error=self._mutation_error,
|
|
on_finished=lambda: self._set_mutation_pending(False),
|
|
)
|
|
|
|
def _order_created(self, result: Any) -> None:
|
|
order_no = first_value(result, "order_no", "data.order_no", default="")
|
|
message = f"业务订单已创建:{order_no}" if order_no else "业务订单已创建。"
|
|
self._mutation_success(message)
|
|
|
|
def _open_orders(self, prescription_id: int | None = None) -> None:
|
|
if not has_permission(self.permissions, "tcm.prescriptionOrder/lists"):
|
|
return
|
|
PrescriptionOrderListDialog(
|
|
self.repository,
|
|
prescription_id=prescription_id,
|
|
permissions=self.permissions,
|
|
parent=self,
|
|
).exec()
|
|
|
|
def _mutation_success(self, message: str) -> None:
|
|
self.banner.clear()
|
|
show_toast(self, message, "success", 3600)
|
|
self.refresh()
|
|
|
|
def _mutation_error(self, error: Exception) -> None:
|
|
message = friendly_error(error)
|
|
self.banner.show_message(message, "danger")
|
|
show_toast(self, message, "danger", 5000)
|
|
|
|
def showEvent(self, event: Any) -> None:
|
|
super().showEvent(event)
|
|
if self.table.rowCount() == 0 and not self._loading:
|
|
self.refresh()
|
|
elif not self._loading:
|
|
self._load_ai_capabilities()
|
|
|
|
def hideEvent(self, event: Any) -> None:
|
|
self._ai_timer.stop()
|
|
self._ai_scroll_timer.stop()
|
|
self._ai_request_token += 1
|
|
self._ai_capability_token += 1
|
|
self._ai_capability_pending = False
|
|
self._ai_pending = False
|
|
super().hideEvent(event)
|
|
|
|
|
|
__all__ = [
|
|
"InfiniteList",
|
|
"PrescriptionsPage",
|
|
"can_audit",
|
|
"can_create_order",
|
|
"can_edit_or_delete",
|
|
"can_patch_patient",
|
|
"is_approved_active",
|
|
"prescription_status",
|
|
]
|