"""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'
{escape(number)}
', f'ID: {escape(record_id)}
', ] paragraphs.extend( '' + escape(warning) + "
" 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'{escape(number)}
', f'ID: {escape(record_id)}
', ] paragraphs.extend( '' + escape(warning) + "
" 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", ]