更新
This commit is contained in:
@@ -7,5 +7,5 @@ __version__ = "1.2.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
DEBUG_MODE = True
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Confirmation and optional doctor note for completing an appointment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import MessageBanner
|
||||
|
||||
COMPLETION_NOTE_LIMIT = 500
|
||||
|
||||
|
||||
class AppointmentCompleteDialog(QDialog):
|
||||
"""Keep an unsaved note available if completion or note saving fails."""
|
||||
|
||||
submitted = Signal(str)
|
||||
|
||||
def __init__(self, *, can_note: bool, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._can_note = can_note
|
||||
self._busy = False
|
||||
self._completed = False
|
||||
self.setWindowTitle("完成问诊")
|
||||
self.resize(460, 350 if can_note else 230)
|
||||
self.setMinimumWidth(420)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 22, 24, 18)
|
||||
layout.setSpacing(10)
|
||||
title = QLabel("完成问诊", self)
|
||||
title.setProperty("dialogRole", "title")
|
||||
layout.addWidget(title)
|
||||
prompt = QLabel("是否添加医生备注?" if can_note else "确认完成该挂号吗?", self)
|
||||
layout.addWidget(prompt)
|
||||
self.note_edit = QPlainTextEdit(self)
|
||||
self.note_edit.setObjectName("AppointmentCompleteNote")
|
||||
self.note_edit.setAccessibleName("完成问诊医生备注")
|
||||
self.note_edit.setPlaceholderText("填写完成备注,将追加到医生备注时间轴")
|
||||
self.note_edit.setMinimumHeight(100)
|
||||
self.note_edit.setVisible(can_note)
|
||||
self.note_edit.textChanged.connect(self._limit_note)
|
||||
layout.addWidget(self.note_edit)
|
||||
self.note_counter = QLabel(f"0 / {COMPLETION_NOTE_LIMIT}", self)
|
||||
self.note_counter.setObjectName("AppointmentCompleteNoteCounter")
|
||||
self.note_counter.setProperty("dialogRole", "subtitle")
|
||||
self.note_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.note_counter.setVisible(can_note)
|
||||
layout.addWidget(self.note_counter)
|
||||
hint = QLabel("系统会再次核对服务端挂号状态;完成后不可撤销。", self)
|
||||
hint.setProperty("dialogRole", "subtitle")
|
||||
hint.setWordWrap(True)
|
||||
layout.addWidget(hint)
|
||||
self.banner = MessageBanner(parent=self)
|
||||
layout.addWidget(self.banner)
|
||||
self.buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self
|
||||
)
|
||||
self.confirm_button = self.buttons.button(QDialogButtonBox.StandardButton.Ok)
|
||||
self.confirm_button.setText("确认完成")
|
||||
self.confirm_button.setProperty("variant", "primary")
|
||||
self.confirm_button.setAutoDefault(False)
|
||||
self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
|
||||
self.cancel_button.setText("取消")
|
||||
self.cancel_button.setAutoDefault(False)
|
||||
self.buttons.accepted.connect(self._submit)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(self.buttons)
|
||||
mark_business_dialog(self, "AppointmentCompleteDialog")
|
||||
|
||||
def _limit_note(self) -> None:
|
||||
text = self.note_edit.toPlainText()
|
||||
if len(text) > COMPLETION_NOTE_LIMIT:
|
||||
text = text[:COMPLETION_NOTE_LIMIT]
|
||||
cursor_position = self.note_edit.textCursor().position()
|
||||
self.note_edit.blockSignals(True)
|
||||
self.note_edit.setPlainText(text)
|
||||
cursor = self.note_edit.textCursor()
|
||||
cursor.setPosition(min(cursor_position, self.note_edit.document().characterCount() - 1))
|
||||
self.note_edit.setTextCursor(cursor)
|
||||
self.note_edit.blockSignals(False)
|
||||
self.note_counter.setText(f"{len(text)} / {COMPLETION_NOTE_LIMIT}")
|
||||
|
||||
def _submit(self) -> None:
|
||||
if not self._busy and not self._completed:
|
||||
self.submitted.emit(self.note_edit.toPlainText().strip() if self._can_note else "")
|
||||
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.note_edit.setReadOnly(busy or self._completed)
|
||||
self.confirm_button.setEnabled(not busy and not self._completed)
|
||||
self.confirm_button.setText("正在完成…" if busy else "确认完成")
|
||||
self.cancel_button.setEnabled(not busy)
|
||||
if busy:
|
||||
self.banner.clear()
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
self.set_busy(False)
|
||||
self.banner.show_message(message, "danger")
|
||||
|
||||
def show_completed_warning(self, message: str) -> None:
|
||||
self._completed = True
|
||||
self.set_busy(False)
|
||||
self.confirm_button.hide()
|
||||
self.cancel_button.setText("关闭")
|
||||
self.banner.show_message(f"{message}。可复制上方备注,稍后在医生备注中补录。", "warning")
|
||||
|
||||
def reject(self) -> None:
|
||||
if not self._busy:
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
|
||||
if self._busy:
|
||||
event.ignore()
|
||||
else:
|
||||
super().closeEvent(event)
|
||||
@@ -4,10 +4,12 @@ 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, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
|
||||
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QTextDocument, QTextOption
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -23,6 +25,9 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QStyleOptionViewItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -381,26 +386,81 @@ def _formula(value: Any) -> str:
|
||||
|
||||
|
||||
def _order_warnings(row: Any) -> list[str]:
|
||||
if not _truthy(first_value(row, "has_prescription_order", default=False)):
|
||||
"""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(row, "herbs", None) or []
|
||||
if not isinstance(herbs, (list, tuple)) or not herbs:
|
||||
herbs = get_value(source, "herbs", None)
|
||||
herbs = herbs if isinstance(herbs, (list, tuple)) else []
|
||||
names = [str(get_value(herb, "name", "") or "").strip() for herb in herbs]
|
||||
names = [name for name in names if name]
|
||||
if not names:
|
||||
return ["请开方,当前处方药材为空白"]
|
||||
seen: set[str] = set()
|
||||
duplicate: list[str] = []
|
||||
for herb in herbs:
|
||||
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
|
||||
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 []
|
||||
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:#315CF4;font-size:12px;font-weight:600">{escape(number)}</p>',
|
||||
f'<p style="margin:2px 0 0;color:#7481A3;font-size:11px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||
]
|
||||
paragraphs.extend(
|
||||
'<p style="margin:2px 0 0;color:#DC2626;font-size:12px;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 = ""
|
||||
self.parent().style().drawControl(QStyle.ControlElement.CE_ItemViewItem, styled, painter)
|
||||
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 "未知"
|
||||
@@ -698,7 +758,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.table = SortableTable(
|
||||
[
|
||||
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
|
||||
TableColumn("sn", "处方编号", 174, _sn_cell),
|
||||
TableColumn("sn", "处方编号", 260, _sn_cell),
|
||||
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
||||
TableColumn("prescription_type", "处方类型", 96),
|
||||
TableColumn("is_system_auto", "来源", 88, _source_cell),
|
||||
@@ -713,6 +773,9 @@ class PrescriptionsPage(QWidget):
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
self.table.horizontalHeader().setFixedHeight(38)
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setItemDelegateForColumn(1, _PrescriptionNumberDelegate(self.table))
|
||||
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
|
||||
self.table.model().layoutChanged.connect(self.table.resizeRowsToContents)
|
||||
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
|
||||
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
@@ -729,6 +792,10 @@ class PrescriptionsPage(QWidget):
|
||||
layout.addWidget(self.stack, 1)
|
||||
return card
|
||||
|
||||
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
|
||||
if column == 1:
|
||||
self.table.resizeRowsToContents()
|
||||
|
||||
def _action_button(
|
||||
self,
|
||||
text: str,
|
||||
@@ -910,8 +977,11 @@ class PrescriptionsPage(QWidget):
|
||||
font.setWeight(QFont.Weight.DemiBold)
|
||||
sn_item.setFont(font)
|
||||
warnings = _order_warnings(row)
|
||||
if warnings:
|
||||
sn_item.setToolTip("\n".join(warnings))
|
||||
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)
|
||||
|
||||
prescription_type = display_text(
|
||||
first_value(row, "prescription_type", default="—")
|
||||
@@ -1002,6 +1072,7 @@ class PrescriptionsPage(QWidget):
|
||||
actions.addStretch(1)
|
||||
self.table.setCellWidget(row_index, 2, actions_host)
|
||||
self._sync_row_mutation_actions()
|
||||
self.table.resizeRowsToContents()
|
||||
|
||||
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
||||
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
||||
|
||||
@@ -34,10 +34,12 @@ from PySide6.QtGui import (
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QIcon,
|
||||
QKeySequence,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QShortcut,
|
||||
QTextCursor,
|
||||
QTextLayout,
|
||||
QTextOption,
|
||||
@@ -72,6 +74,7 @@ from ..diagnosis_drawer import DailyRecordPanel
|
||||
from ..diagnosis_editors import FlowLayout
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ..dialogs.ai_consult import present_ai_consult
|
||||
from ..dialogs.appointment_complete import COMPLETION_NOTE_LIMIT, AppointmentCompleteDialog
|
||||
from ..dialogs.prescription_ai import (
|
||||
can_open_diagnosis_ai_report,
|
||||
can_use_diagnosis_ai_assistant,
|
||||
@@ -112,6 +115,12 @@ _AI_AUTOMATIC_REQUEST_SLOTS = BoundedSemaphore(2)
|
||||
_AI_GENERATION_POOL = QThreadPool()
|
||||
_AI_GENERATION_POOL.setMaxThreadCount(4)
|
||||
_AI_GENERATION_POOL.setExpiryTimeout(30_000)
|
||||
# Keep explicit recovery reads independent of automatic analysis workers.
|
||||
_RECEPTION_REFRESH_POOL = QThreadPool()
|
||||
_RECEPTION_REFRESH_POOL.setMaxThreadCount(3)
|
||||
_RECEPTION_REFRESH_POOL.setExpiryTimeout(30_000)
|
||||
_REFRESH_TIMEOUT_MS = 30_000
|
||||
_REFRESH_COOLDOWN_MS = 1_500
|
||||
AI_MEDICAL_DISCLAIMER = (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
@@ -157,6 +166,32 @@ QCalendarWidget#ReceptionDateCalendar {
|
||||
border: 1px solid #DDE5FA;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0;
|
||||
color: #5469F0;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E4E9F6;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:hover {
|
||||
background-color: #EEF1FF;
|
||||
border-color: #C5CEFF;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:pressed {
|
||||
background-color: #E2E7FF;
|
||||
border-color: #9EACFF;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:focus {
|
||||
border-color: #5469F0;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:disabled {
|
||||
color: #8A93A8;
|
||||
background-color: #F7F8FB;
|
||||
border-color: #E4E9F6;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
@@ -306,6 +341,24 @@ QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:hover {
|
||||
color: #CF4656;
|
||||
background-color: #FFF0F2;
|
||||
border-color: #EFA3AD;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:pressed {
|
||||
color: #BF3949;
|
||||
background-color: #FFE4E8;
|
||||
border-color: #E58A98;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:focus {
|
||||
border-color: #CF4656;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:disabled {
|
||||
color: #A8AFBF;
|
||||
background-color: #F7F8FB;
|
||||
border-color: #E5E8EF;
|
||||
}
|
||||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: 1px solid #E6EAF5;
|
||||
@@ -2090,6 +2143,26 @@ def _is_local_material_reference(value: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
class _ReceptionCompleteButton(QPushButton):
|
||||
"""Completion action with a pointer only while it can be activated."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__("结束问诊", parent)
|
||||
self.setObjectName("ReceptionCompleteButton")
|
||||
self.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
|
||||
self.setIconSize(QSize(14, 14))
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||
super().changeEvent(event)
|
||||
if event.type() == QEvent.Type.EnabledChange:
|
||||
self.setCursor(
|
||||
Qt.CursorShape.PointingHandCursor
|
||||
if self.isEnabled()
|
||||
else Qt.CursorShape.ArrowCursor
|
||||
)
|
||||
|
||||
|
||||
class _NoteAttachmentPreview(QPushButton):
|
||||
"""Responsive inline thumbnail that remains clickable for a full preview."""
|
||||
|
||||
@@ -3587,6 +3660,8 @@ class ReceptionPage(QWidget):
|
||||
self._queue_query: dict[str, Any] | None = None
|
||||
self._queue_query_key: tuple[Any, ...] | None = None
|
||||
self._detail_loading = False
|
||||
self._completion_pending = False
|
||||
self._completion_dialog: AppointmentCompleteDialog | None = None
|
||||
self._detail_requests: set[tuple[int, int]] = set()
|
||||
self._detail_cancel_events: dict[tuple[int, int], Event] = {}
|
||||
self._detail_failed_requests: set[tuple[int, int]] = set()
|
||||
@@ -3679,6 +3754,12 @@ class ReceptionPage(QWidget):
|
||||
self.poll_timer = QTimer(self)
|
||||
self.poll_timer.setInterval(5_000)
|
||||
self.poll_timer.timeout.connect(self._poll_queue)
|
||||
self._refresh_cooldown = QTimer(self)
|
||||
self._refresh_cooldown.setSingleShot(True)
|
||||
self._refresh_cooldown.timeout.connect(self._finish_refresh_cooldown)
|
||||
self.refresh_shortcut = QShortcut(QKeySequence("F5"), self)
|
||||
self.refresh_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
self.refresh_shortcut.activated.connect(self._refresh_workspace)
|
||||
|
||||
def _ensure_diagnosis_dialog(self) -> DiagnosisDialog:
|
||||
dialog = getattr(self, "diagnosis_dialog", None)
|
||||
@@ -3705,6 +3786,14 @@ class ReceptionPage(QWidget):
|
||||
title.setObjectName("ReceptionQueueTitle")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch(1)
|
||||
self.refresh_button = QPushButton("刷新", header)
|
||||
self.refresh_button.setObjectName("ReceptionRefreshButton")
|
||||
self.refresh_button.setFixedWidth(58)
|
||||
self.refresh_button.setAccessibleName("刷新接诊台")
|
||||
self.refresh_button.setToolTip("刷新队列、当前患者和已保存的 AI 报告(F5)")
|
||||
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.refresh_button.clicked.connect(self._refresh_workspace)
|
||||
header_layout.addWidget(self.refresh_button)
|
||||
self.queue_date_button = _ReceptionDateButton(self._queue_date, header)
|
||||
self.queue_date_button.setObjectName("ReceptionDateButton")
|
||||
self.queue_date_button.setFixedWidth(118)
|
||||
@@ -3756,7 +3845,7 @@ class ReceptionPage(QWidget):
|
||||
self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll)
|
||||
self.queue_stack.addWidget(self.queue_list)
|
||||
self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载")
|
||||
self.queue_empty.action_requested.connect(lambda: self.refresh())
|
||||
self.queue_empty.action_requested.connect(self._refresh_workspace)
|
||||
self.queue_stack.addWidget(self.queue_empty)
|
||||
layout.addWidget(self.queue_stack, 1)
|
||||
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
|
||||
@@ -3827,10 +3916,7 @@ class ReceptionPage(QWidget):
|
||||
patient_head.addLayout(identity, 1)
|
||||
patient_head.addStretch(1)
|
||||
|
||||
self.complete_button = QPushButton("结束问诊", hero)
|
||||
self.complete_button.setObjectName("ReceptionCompleteButton")
|
||||
self.complete_button.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
|
||||
self.complete_button.setIconSize(QSize(14, 14))
|
||||
self.complete_button = _ReceptionCompleteButton(hero)
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
self.complete_button.setVisible(self._can_complete)
|
||||
patient_head.addWidget(self.complete_button)
|
||||
@@ -5267,6 +5353,7 @@ class ReceptionPage(QWidget):
|
||||
appointment_id: int,
|
||||
diagnosis_id: int | None,
|
||||
force: bool,
|
||||
read_only_refresh: bool = False,
|
||||
) -> None:
|
||||
if not force and _same_id(patient_id, self._ai_analysis_patient_id):
|
||||
qwen_state = self._ai_analysis_model_states["qwen"]
|
||||
@@ -5313,7 +5400,7 @@ class ReceptionPage(QWidget):
|
||||
self._patient_ai_list_requests.discard(cancelled_key)
|
||||
self._patient_ai_list_cancel_events.pop(cancelled_key, None)
|
||||
self._patient_ai_list_epochs.pop(cancelled_key, None)
|
||||
if self._patient_ai_request_pending(patient_id, "qwen"):
|
||||
if not read_only_refresh and self._patient_ai_request_pending(patient_id, "qwen"):
|
||||
self._sync_ai_analysis_view()
|
||||
return
|
||||
request_key = (request_generation, appointment_id, patient_id)
|
||||
@@ -5333,8 +5420,10 @@ class ReceptionPage(QWidget):
|
||||
patient_id,
|
||||
) or cancel_event.is_set():
|
||||
return _ASYNC_REQUEST_CANCELLED
|
||||
automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False)
|
||||
if not automatic_slot:
|
||||
automatic_slot = not read_only_refresh and _AI_AUTOMATIC_REQUEST_SLOTS.acquire(
|
||||
blocking=False
|
||||
)
|
||||
if not read_only_refresh and not automatic_slot:
|
||||
return _ASYNC_REQUEST_DEFERRED
|
||||
try:
|
||||
self._patient_ai_list_started.add(request_key)
|
||||
@@ -5347,15 +5436,18 @@ class ReceptionPage(QWidget):
|
||||
return _ASYNC_REQUEST_CANCELLED
|
||||
return method(patient_id)
|
||||
finally:
|
||||
_AI_AUTOMATIC_REQUEST_SLOTS.release()
|
||||
if automatic_slot:
|
||||
_AI_AUTOMATIC_REQUEST_SLOTS.release()
|
||||
|
||||
run_async(
|
||||
runner = self._run_refresh_read if read_only_refresh else run_async
|
||||
runner(
|
||||
request,
|
||||
on_success=lambda result: self._apply_patient_ai_report_list(
|
||||
result,
|
||||
request_generation,
|
||||
appointment_id,
|
||||
patient_id,
|
||||
read_only_refresh=read_only_refresh,
|
||||
),
|
||||
on_error=lambda error: self._patient_ai_report_list_error(
|
||||
error,
|
||||
@@ -5377,6 +5469,8 @@ class ReceptionPage(QWidget):
|
||||
request_generation: int,
|
||||
appointment_id: int,
|
||||
patient_id: int,
|
||||
*,
|
||||
read_only_refresh: bool = False,
|
||||
) -> None:
|
||||
request_key = (request_generation, appointment_id, patient_id)
|
||||
authoritative = request_key in self._patient_ai_list_requests
|
||||
@@ -5426,6 +5520,12 @@ class ReceptionPage(QWidget):
|
||||
)
|
||||
return
|
||||
|
||||
if read_only_refresh:
|
||||
self._set_ai_analysis_state(
|
||||
"missing", "暂无已保存的患者报告;刷新不会自动生成,可点击重新分析。"
|
||||
)
|
||||
return
|
||||
|
||||
if not self._can_ai_regenerate:
|
||||
self._set_ai_analysis_state(
|
||||
"missing",
|
||||
@@ -6217,14 +6317,111 @@ class ReceptionPage(QWidget):
|
||||
self._reset_queue_state()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, silent: bool = False) -> None:
|
||||
def _run_refresh_read(
|
||||
self,
|
||||
function: Any,
|
||||
*,
|
||||
on_success: Any,
|
||||
on_error: Any,
|
||||
on_finished: Any,
|
||||
priority: int = 0,
|
||||
) -> None:
|
||||
"""Bound GUI waiting; a timed-out worker may finish but cannot apply data.
|
||||
|
||||
Running HTTP calls are not forcibly terminated. The fixed-size pool
|
||||
bounds concurrency, and expired queued reads never call the backend.
|
||||
"""
|
||||
|
||||
settled = Event()
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True)
|
||||
timer.destroyed.connect(lambda _object=None: settled.set())
|
||||
|
||||
def settle(callback: Any, result: Any) -> None:
|
||||
if settled.is_set():
|
||||
return
|
||||
settled.set()
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
try:
|
||||
callback(result)
|
||||
finally:
|
||||
on_finished()
|
||||
|
||||
def request() -> Any:
|
||||
if settled.is_set():
|
||||
return _ASYNC_REQUEST_CANCELLED
|
||||
return function()
|
||||
|
||||
timer.timeout.connect(
|
||||
lambda: settle(on_error, TimeoutError("刷新超时,请检查网络后再次点击刷新。"))
|
||||
)
|
||||
timer.start(_REFRESH_TIMEOUT_MS)
|
||||
run_async(
|
||||
request,
|
||||
on_success=lambda result: settle(on_success, result),
|
||||
on_error=lambda error: settle(on_error, error),
|
||||
on_finished=lambda: settle(
|
||||
on_error, RuntimeError("刷新未返回有效结果,请重试。")
|
||||
),
|
||||
pool=_RECEPTION_REFRESH_POOL,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def _cancel_patient_ai_reads(self, patient_id: int | None) -> None:
|
||||
# Reads are normally shared across selection generations. Explicit
|
||||
# refresh revokes their authority, but never forgets an in-flight POST.
|
||||
for key in list(self._patient_ai_list_requests):
|
||||
if patient_id is not None and not _same_id(key[2], patient_id):
|
||||
continue
|
||||
cancel_event = self._patient_ai_list_cancel_events.pop(key, None)
|
||||
if cancel_event is not None:
|
||||
cancel_event.set()
|
||||
self._patient_ai_list_requests.discard(key)
|
||||
self._patient_ai_list_epochs.pop(key, None)
|
||||
self._patient_ai_list_started.discard(key)
|
||||
|
||||
def _refresh_workspace(self) -> None:
|
||||
"""Recover reads without replaying writes or clearing the doctor's draft."""
|
||||
|
||||
if self._refresh_cooldown.isActive():
|
||||
return
|
||||
if self._note_busy or self._completion_pending:
|
||||
show_toast(self, "正在提交,请等待提交结束后再刷新。", "warning")
|
||||
return
|
||||
if self._completion_dialog is not None and self._completion_dialog.isVisible():
|
||||
show_toast(self, "请先关闭完成问诊窗口,再刷新接诊台。", "warning")
|
||||
return
|
||||
if self._selected_appointment_id is not None and not self.notify_button.isEnabled():
|
||||
show_toast(self, "正在通知医助,请稍后刷新。", "warning")
|
||||
return
|
||||
self.refresh_button.setEnabled(False)
|
||||
self.refresh_button.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self._refresh_cooldown.start(_REFRESH_COOLDOWN_MS)
|
||||
context = self._selection_context()
|
||||
self._cancel_patient_ai_reads(context[3] if context is not None else None)
|
||||
# Invalidate a pending daily-range result before the fresh detail arrives.
|
||||
self._daily_generation += 1
|
||||
self._daily_loading = False
|
||||
self.daily_panel.set_loading(False)
|
||||
record = self._selected_record
|
||||
if record is not None:
|
||||
self._load_detail(record, clear=False, read_only_refresh=True)
|
||||
# A stuck queue must not prevent refreshing the selected patient's detail.
|
||||
self.refresh(workspace_refresh=True)
|
||||
|
||||
def _finish_refresh_cooldown(self) -> None:
|
||||
self.refresh_button.setEnabled(True)
|
||||
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def refresh(self, silent: bool = False, *, workspace_refresh: bool = False) -> None:
|
||||
"""Replace the queue with page one using a GUI-thread query snapshot."""
|
||||
|
||||
if silent and self._queue_loading:
|
||||
if silent and self._queue_loading and not workspace_refresh:
|
||||
return
|
||||
selected_date = self._queue_date or date.today().isoformat()
|
||||
page_size = self._queue_page_size
|
||||
if silent:
|
||||
if silent or workspace_refresh:
|
||||
loaded = max(len(self._queue_records), self._queue_page * self._queue_page_size)
|
||||
if loaded > page_size:
|
||||
page_size = loaded
|
||||
@@ -6239,12 +6436,15 @@ class ReceptionPage(QWidget):
|
||||
}
|
||||
query_key = self._query_key(query)
|
||||
if query_key != self._queue_query_key:
|
||||
self._clear_selection()
|
||||
if not workspace_refresh:
|
||||
self._clear_selection()
|
||||
self._reset_queue_state()
|
||||
self._queue_query = dict(query)
|
||||
self._queue_query["page_size"] = self._queue_page_size
|
||||
self._queue_query_key = query_key
|
||||
self._request_queue_page(query, append=False, silent=silent)
|
||||
self._request_queue_page(
|
||||
query, append=False, silent=silent, workspace_refresh=workspace_refresh
|
||||
)
|
||||
|
||||
def _poll_queue(self) -> None:
|
||||
"""Do not let the timer supersede an explicit or slower queue request."""
|
||||
@@ -6401,6 +6601,7 @@ class ReceptionPage(QWidget):
|
||||
*,
|
||||
append: bool,
|
||||
silent: bool,
|
||||
workspace_refresh: bool = False,
|
||||
) -> None:
|
||||
self._queue_generation += 1
|
||||
generation = self._queue_generation
|
||||
@@ -6412,12 +6613,18 @@ class ReceptionPage(QWidget):
|
||||
frozen_query = dict(query)
|
||||
page_no = int(frozen_query["page_no"])
|
||||
query_key = self._query_key(frozen_query)
|
||||
run_async(
|
||||
lambda frozen_query=frozen_query: invoke(
|
||||
def request() -> Any:
|
||||
if generation != self._queue_generation:
|
||||
return _ASYNC_REQUEST_CANCELLED
|
||||
return invoke(
|
||||
self.repository,
|
||||
"list_appointments",
|
||||
**frozen_query,
|
||||
),
|
||||
)
|
||||
|
||||
runner = self._run_refresh_read if workspace_refresh else run_async
|
||||
runner(
|
||||
request,
|
||||
on_success=lambda result: self._apply_queue(
|
||||
result,
|
||||
generation,
|
||||
@@ -6425,6 +6632,7 @@ class ReceptionPage(QWidget):
|
||||
append=append,
|
||||
query_key=query_key,
|
||||
silent=silent,
|
||||
workspace_refresh=workspace_refresh,
|
||||
),
|
||||
on_error=lambda error: self._queue_error(error, generation, silent=silent),
|
||||
on_finished=lambda: self._queue_finished(generation),
|
||||
@@ -6439,6 +6647,7 @@ class ReceptionPage(QWidget):
|
||||
append: bool = False,
|
||||
query_key: tuple[Any, ...] | None = None,
|
||||
silent: bool = False,
|
||||
workspace_refresh: bool = False,
|
||||
) -> None:
|
||||
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
|
||||
return
|
||||
@@ -6480,27 +6689,38 @@ class ReceptionPage(QWidget):
|
||||
),
|
||||
-1,
|
||||
)
|
||||
if row_to_select < 0 and records:
|
||||
preserve_selection = (workspace_refresh or silent) and selected_id is not None
|
||||
if row_to_select < 0 and records and not preserve_selection:
|
||||
row_to_select = 0
|
||||
self.queue_list.blockSignals(True)
|
||||
self._sync_queue_rows(records, append=append)
|
||||
if row_to_select >= 0 and self.queue_list.currentRow() != row_to_select:
|
||||
self.queue_list.setCurrentRow(row_to_select)
|
||||
elif row_to_select < 0:
|
||||
self.queue_list.setCurrentRow(-1)
|
||||
self.queue_list.blockSignals(False)
|
||||
self._sync_queue_row_selection()
|
||||
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
|
||||
self.queue_stack.setCurrentIndex(0 if records else 1)
|
||||
self.queue_banner.clear()
|
||||
if not records:
|
||||
self._clear_selection()
|
||||
if row_to_select < 0:
|
||||
# The patient may have left the queue. Keep the detail and unsaved
|
||||
# draft attached to that patient until the doctor selects another.
|
||||
if preserve_selection:
|
||||
self.queue_banner.show_message(
|
||||
"当前患者已不在筛选队列,已保留详情与未保存内容,可手动选择其他患者。",
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
self._clear_selection()
|
||||
return
|
||||
chosen = records[row_to_select]
|
||||
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
|
||||
self._selected_record = chosen
|
||||
if not append and not silent:
|
||||
if not append and not silent and not workspace_refresh:
|
||||
self._load_detail(chosen, silent=True, clear=False)
|
||||
else:
|
||||
self._select_record(chosen, silent=True)
|
||||
self._select_record(chosen, silent=True, read_only_refresh=workspace_refresh)
|
||||
|
||||
def _update_queue_filter_counts(self, result: Any, records: list[Any]) -> None:
|
||||
extend = get_value(result, "extend", None)
|
||||
@@ -6573,7 +6793,9 @@ class ReceptionPage(QWidget):
|
||||
if isinstance(row, QueueRow):
|
||||
row.set_selected(item is current)
|
||||
|
||||
def _select_record(self, record: Any, *, silent: bool = False) -> None:
|
||||
def _select_record(
|
||||
self, record: Any, *, silent: bool = False, read_only_refresh: bool = False
|
||||
) -> None:
|
||||
appointment_id = _record_id(record)
|
||||
if appointment_id is None:
|
||||
self._clear_selection()
|
||||
@@ -6589,9 +6811,16 @@ class ReceptionPage(QWidget):
|
||||
self._selected_record = record
|
||||
self._selected_appointment_id = appointment_id
|
||||
self._selected_detail = None
|
||||
self._load_detail(record, silent=silent, clear=True)
|
||||
self._load_detail(record, silent=silent, clear=True, read_only_refresh=read_only_refresh)
|
||||
|
||||
def _load_detail(self, record: Any, silent: bool = False, *, clear: bool = True) -> None:
|
||||
def _load_detail(
|
||||
self,
|
||||
record: Any,
|
||||
silent: bool = False,
|
||||
*,
|
||||
clear: bool = True,
|
||||
read_only_refresh: bool = False,
|
||||
) -> None:
|
||||
"""Start a new detail generation even while an older request is running."""
|
||||
|
||||
appointment_id = _record_id(record)
|
||||
@@ -6614,9 +6843,12 @@ class ReceptionPage(QWidget):
|
||||
self.detail_banner.show_message("正在加载患者详情…", "info")
|
||||
self._detail_loading = True
|
||||
self._detail_requests.add(request_key)
|
||||
run_async(
|
||||
runner = self._run_refresh_read if read_only_refresh else run_async
|
||||
runner(
|
||||
lambda: self._fetch_detail_bundle(record, appointment_id, cancel_event),
|
||||
on_success=lambda bundle: self._apply_detail(bundle, generation, appointment_id),
|
||||
on_success=lambda bundle: self._apply_detail(
|
||||
bundle, generation, appointment_id, read_only_refresh=read_only_refresh
|
||||
),
|
||||
on_error=lambda error: self._detail_error(error, generation, appointment_id),
|
||||
on_finished=lambda: self._detail_finished(generation, appointment_id),
|
||||
priority=generation,
|
||||
@@ -6877,7 +7109,12 @@ class ReceptionPage(QWidget):
|
||||
self.daily_panel.set_loading(False)
|
||||
|
||||
def _apply_detail(
|
||||
self, bundle: Any, generation: int, appointment_id: int | None = None
|
||||
self,
|
||||
bundle: Any,
|
||||
generation: int,
|
||||
appointment_id: int | None = None,
|
||||
*,
|
||||
read_only_refresh: bool = False,
|
||||
) -> None:
|
||||
expected_id = appointment_id or self._selected_appointment_id
|
||||
if (
|
||||
@@ -6966,7 +7203,9 @@ class ReceptionPage(QWidget):
|
||||
supports_patient_reports = self._can_patient_ai_read and callable(
|
||||
getattr(self.repository, "list_patient_ai_reports", None)
|
||||
) and callable(getattr(self.repository, "generate_patient_ai_report", None))
|
||||
if patient_id is None and supports_patient_reports:
|
||||
if read_only_refresh:
|
||||
self._refresh_saved_ai_reports(expected_id, diagnosis_id, patient_id)
|
||||
elif patient_id is None and supports_patient_reports:
|
||||
self._ai_analysis_generation += 1
|
||||
self._ai_analysis_loading = False
|
||||
self._ai_analysis_diagnosis_id = None
|
||||
@@ -6988,6 +7227,37 @@ class ReceptionPage(QWidget):
|
||||
patient_id=patient_id,
|
||||
)
|
||||
|
||||
def _refresh_saved_ai_reports(
|
||||
self, appointment_id: int, diagnosis_id: int | None, patient_id: int | None
|
||||
) -> None:
|
||||
self._cancel_patient_ai_reads(patient_id)
|
||||
if self._can_patient_ai_read and patient_id is not None and callable(
|
||||
getattr(self.repository, "list_patient_ai_reports", None)
|
||||
):
|
||||
# A generation is a write with an uncertain outcome while running.
|
||||
# Keep its single-flight identity and do not race it with an older GET.
|
||||
if any(
|
||||
self._patient_ai_request_pending(patient_id, model)
|
||||
for model in AI_ANALYSIS_MODELS
|
||||
):
|
||||
self._ai_analysis_operation_error = "报告生成任务仍在处理中,刷新不会重复生成。"
|
||||
self._sync_ai_analysis_view()
|
||||
return
|
||||
self._load_patient_ai_reports(
|
||||
patient_id,
|
||||
appointment_id=appointment_id,
|
||||
diagnosis_id=diagnosis_id,
|
||||
force=True,
|
||||
read_only_refresh=True,
|
||||
)
|
||||
elif not self._can_ai_analysis:
|
||||
self._set_ai_analysis_state("permission")
|
||||
elif not self._ai_analysis_payloads and not self._ai_analysis_requests:
|
||||
# The legacy analysis endpoint is a POST despite its get_* name.
|
||||
self._set_ai_analysis_state(
|
||||
"error", "当前数据源无法只读刷新 AI 报告;如需生成分析,请点击重试。"
|
||||
)
|
||||
|
||||
def _render_identity(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||||
patient_name = first_value(
|
||||
appointment,
|
||||
@@ -8168,7 +8438,10 @@ class ReceptionPage(QWidget):
|
||||
self.history_button.setEnabled(appointment_id is not None)
|
||||
self.more_button.setEnabled(appointment_id is not None)
|
||||
self.complete_button.setEnabled(
|
||||
self._can_complete and appointment_id is not None and status in RECEPTION_STATUSES
|
||||
self._can_complete
|
||||
and not self._completion_pending
|
||||
and appointment_id is not None
|
||||
and status in RECEPTION_STATUSES
|
||||
)
|
||||
note_enabled = self._can_note and diagnosis_id is not None and not self._note_busy
|
||||
self.note_edit.setEnabled(note_enabled)
|
||||
@@ -8180,6 +8453,10 @@ class ReceptionPage(QWidget):
|
||||
self, error: Exception, generation: int, appointment_id: int | None = None
|
||||
) -> None:
|
||||
expected_id = appointment_id or self._selected_appointment_id
|
||||
if expected_id is not None:
|
||||
cancel_event = self._detail_cancel_events.get((generation, expected_id))
|
||||
if cancel_event is not None:
|
||||
cancel_event.set()
|
||||
if (
|
||||
generation == self._detail_generation
|
||||
and expected_id is not None
|
||||
@@ -8734,6 +9011,12 @@ class ReceptionPage(QWidget):
|
||||
self._load_detail(self._selected_record, silent=True, clear=False)
|
||||
|
||||
def _complete_appointment(self) -> None:
|
||||
if self._completion_pending:
|
||||
return
|
||||
if self._completion_dialog is not None:
|
||||
self._completion_dialog.raise_()
|
||||
self._completion_dialog.activateWindow()
|
||||
return
|
||||
if not self._can_complete:
|
||||
show_toast(self, "当前账号没有完成接诊权限。", "danger")
|
||||
return
|
||||
@@ -8748,24 +9031,75 @@ class ReceptionPage(QWidget):
|
||||
if status not in RECEPTION_STATUSES:
|
||||
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"确认完成接诊",
|
||||
"系统会再次核对服务端挂号状态;完成后不可撤销。确认继续吗?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
dialog = AppointmentCompleteDialog(can_note=self._can_note, parent=self)
|
||||
self._completion_dialog = dialog
|
||||
dialog.submitted.connect(
|
||||
lambda note: self._submit_completion(dialog, generation, appointment_id, note)
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
dialog.finished.connect(lambda _result: self._close_completion_dialog(dialog))
|
||||
dialog.open()
|
||||
|
||||
def _close_completion_dialog(self, dialog: AppointmentCompleteDialog) -> None:
|
||||
if self._completion_dialog is dialog:
|
||||
self._completion_dialog = None
|
||||
dialog.deleteLater()
|
||||
|
||||
def _submit_completion(
|
||||
self,
|
||||
dialog: AppointmentCompleteDialog,
|
||||
generation: int,
|
||||
appointment_id: int,
|
||||
note: str,
|
||||
) -> None:
|
||||
if self._completion_pending or self._completion_dialog is not dialog:
|
||||
return
|
||||
if not self._context_current(generation, appointment_id):
|
||||
dialog.show_error("当前患者已切换或详情已更新,请关闭窗口后重新操作。")
|
||||
return
|
||||
self._completion_pending = True
|
||||
dialog.set_busy(True)
|
||||
self.complete_button.setEnabled(False)
|
||||
run_async(
|
||||
lambda: self._complete_after_revalidation(appointment_id),
|
||||
on_success=lambda _result: self._appointment_completed(generation, appointment_id),
|
||||
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
|
||||
on_finished=lambda: self._restore_action_state(generation, appointment_id),
|
||||
lambda: self._complete_after_revalidation(appointment_id, note),
|
||||
on_success=lambda warning: self._completion_succeeded(
|
||||
dialog, generation, appointment_id, warning
|
||||
),
|
||||
on_error=lambda error: self._completion_failed(dialog, error),
|
||||
on_finished=self._completion_finished,
|
||||
)
|
||||
|
||||
def _complete_after_revalidation(self, appointment_id: int) -> Any:
|
||||
def _completion_succeeded(
|
||||
self,
|
||||
dialog: AppointmentCompleteDialog,
|
||||
generation: int,
|
||||
appointment_id: int,
|
||||
warning: str,
|
||||
) -> None:
|
||||
if self._completion_dialog is dialog:
|
||||
if warning:
|
||||
dialog.show_completed_warning(warning)
|
||||
else:
|
||||
dialog.set_busy(False)
|
||||
dialog.accept()
|
||||
self._appointment_completed(generation, appointment_id, warning)
|
||||
|
||||
def _completion_failed(self, dialog: AppointmentCompleteDialog, error: Exception) -> None:
|
||||
if self._completion_dialog is dialog:
|
||||
dialog.show_error(friendly_error(error))
|
||||
|
||||
def _completion_finished(self) -> None:
|
||||
self._completion_pending = False
|
||||
if self._selected_appointment_id is not None:
|
||||
self._restore_action_state(self._detail_generation, self._selected_appointment_id)
|
||||
|
||||
def _complete_after_revalidation(self, appointment_id: int, note: str = "") -> str:
|
||||
if not self._can_complete:
|
||||
raise ValueError("当前账号没有完成接诊权限。")
|
||||
note = note.strip()
|
||||
if note and not self._can_note:
|
||||
raise ValueError("当前账号没有添加医生备注权限。")
|
||||
if len(note) > COMPLETION_NOTE_LIMIT:
|
||||
raise ValueError(f"备注不能超过 {COMPLETION_NOTE_LIMIT} 字。")
|
||||
detail = invoke(
|
||||
self.repository,
|
||||
"reception_detail",
|
||||
@@ -8784,15 +9118,35 @@ class ReceptionPage(QWidget):
|
||||
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
|
||||
if status not in RECEPTION_STATUSES:
|
||||
raise ValueError("挂号状态已变化,请刷新队列后重试")
|
||||
return invoke(
|
||||
invoke(
|
||||
self.repository,
|
||||
"complete_appointment",
|
||||
appointment_id=appointment_id,
|
||||
id=appointment_id,
|
||||
)
|
||||
|
||||
def _appointment_completed(self, generation: int, appointment_id: int) -> None:
|
||||
show_toast(self, "接诊已完成。", "success")
|
||||
if note:
|
||||
# Use the freshly validated diagnosis, never the patient's ID or
|
||||
# mutable selection: notes belong to the diagnosis timeline.
|
||||
diagnosis = get_value(detail, "diagnosis", None) or {}
|
||||
diagnosis_id = _as_int(first_value(diagnosis, "id", "diagnosis_id", default=None))
|
||||
if diagnosis_id is None or diagnosis_id <= 0:
|
||||
return "问诊已完成,但该预约没有关联诊单,备注未保存"
|
||||
try:
|
||||
invoke(
|
||||
self.repository,
|
||||
"add_doctor_note",
|
||||
diagnosis_id=diagnosis_id,
|
||||
content=note,
|
||||
)
|
||||
except Exception:
|
||||
return "问诊已完成,但备注未保存"
|
||||
return ""
|
||||
|
||||
def _appointment_completed(
|
||||
self, generation: int, appointment_id: int, warning: str = ""
|
||||
) -> None:
|
||||
show_toast(self, warning or "接诊已完成。", "warning" if warning else "success")
|
||||
if self._context_current(generation, appointment_id):
|
||||
self._clear_selection()
|
||||
self.refresh(silent=True)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs.appointment_complete import (
|
||||
COMPLETION_NOTE_LIMIT,
|
||||
AppointmentCompleteDialog,
|
||||
)
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class CompletionRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Any, ...]] = []
|
||||
self.fail_complete = False
|
||||
self.fail_note = False
|
||||
self.detail: dict[str, Any] = {
|
||||
"appointment": {"id": 51, "patient_id": 251, "status": 1},
|
||||
"diagnosis": {"id": 251, "patient_id": 151},
|
||||
"patient": {"id": 151},
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("revalidate", appointment_id))
|
||||
return self.detail
|
||||
|
||||
def complete_appointment(self, appointment_id: int) -> dict[str, bool]:
|
||||
self.calls.append(("complete", appointment_id))
|
||||
if self.fail_complete:
|
||||
raise RuntimeError("完成接口失败")
|
||||
return {"ok": True}
|
||||
|
||||
def add_doctor_note(self, diagnosis_id: int, content: str) -> dict[str, bool]:
|
||||
self.calls.append(("note", diagnosis_id, content))
|
||||
if self.fail_note:
|
||||
raise RuntimeError("备注接口失败")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
jobs: list[dict[str, Any]] = []
|
||||
toasts: list[tuple[str, str]] = []
|
||||
refreshes: list[bool] = []
|
||||
|
||||
def queue(function: Any, **options: Any) -> object:
|
||||
jobs.append({"function": function, **options})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(reception_module, "run_async", queue)
|
||||
monkeypatch.setattr(
|
||||
reception_module,
|
||||
"show_toast",
|
||||
lambda _parent, text, kind, *_args: toasts.append((text, kind)),
|
||||
)
|
||||
repository = CompletionRepository()
|
||||
page = ReceptionPage(
|
||||
repository,
|
||||
PermissionSet(["doctor.appointment/complete", "doctor.appointment/addDoctorNote"]),
|
||||
)
|
||||
page._selected_appointment_id = 51
|
||||
page._selected_record = dict(repository.detail["appointment"])
|
||||
page._selected_detail = repository.detail
|
||||
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||
monkeypatch.setattr(page, "refresh", lambda *, silent=False: refreshes.append(silent))
|
||||
yield page, repository, jobs, toasts, refreshes
|
||||
if page._completion_dialog is not None:
|
||||
page._completion_dialog.set_busy(False)
|
||||
page._completion_dialog.reject()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def finish_job(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
result = job["function"]()
|
||||
except Exception as error:
|
||||
job["on_error"](error)
|
||||
else:
|
||||
job["on_success"](result)
|
||||
finally:
|
||||
job["on_finished"]()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("can_note", [True, False])
|
||||
def test_completion_dialog_optional_note_limit_and_busy_state(
|
||||
application: QApplication, can_note: bool
|
||||
) -> None:
|
||||
dialog = AppointmentCompleteDialog(can_note=can_note)
|
||||
submitted: list[str] = []
|
||||
dialog.submitted.connect(submitted.append)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
assert dialog.windowTitle() == "完成问诊"
|
||||
assert dialog.note_edit.isVisible() is can_note
|
||||
assert dialog.note_counter.isVisible() is can_note
|
||||
assert dialog.note_counter.text() == "0 / 500"
|
||||
dialog.note_edit.setPlainText("字" * 501)
|
||||
assert dialog.note_edit.toPlainText() == "字" * COMPLETION_NOTE_LIMIT
|
||||
assert dialog.note_counter.text() == "500 / 500"
|
||||
dialog.note_edit.insertPlainText("额外内容")
|
||||
assert len(dialog.note_edit.toPlainText()) == COMPLETION_NOTE_LIMIT
|
||||
dialog.note_edit.setPlainText(" 测试备注\n第二行 ")
|
||||
dialog.confirm_button.click()
|
||||
assert submitted == ["测试备注\n第二行" if can_note else ""]
|
||||
dialog.set_busy(True)
|
||||
assert dialog.note_edit.isReadOnly()
|
||||
assert not dialog.cancel_button.isEnabled()
|
||||
dialog.confirm_button.click()
|
||||
dialog.reject()
|
||||
dialog.close()
|
||||
assert len(submitted) == 1
|
||||
assert dialog.isVisible()
|
||||
dialog.show_error("提交失败,请重试")
|
||||
assert dialog.note_edit.toPlainText() == " 测试备注\n第二行 "
|
||||
assert dialog.confirm_button.isEnabled()
|
||||
dialog.cancel_button.click()
|
||||
assert not dialog.isVisible()
|
||||
finally:
|
||||
dialog.set_busy(False)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_cancel_completion_does_not_make_requests(harness: Any) -> None:
|
||||
page, repository, jobs, _toasts, _refreshes = harness
|
||||
page.complete_button.click()
|
||||
dialog = page._completion_dialog
|
||||
assert isinstance(dialog, AppointmentCompleteDialog)
|
||||
page._complete_appointment()
|
||||
assert page._completion_dialog is dialog
|
||||
assert jobs == []
|
||||
dialog.note_edit.setPlainText("取消后不应保存")
|
||||
dialog.cancel_button.click()
|
||||
assert page._completion_dialog is None
|
||||
assert repository.calls == []
|
||||
assert jobs == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("note", ["", " \n ", " 测试备注\n补充内容 ", "字" * 500])
|
||||
def test_complete_then_append_note_and_refresh_without_duplicate_submission(
|
||||
harness: Any, note: str
|
||||
) -> None:
|
||||
page, repository, jobs, toasts, refreshes = harness
|
||||
page.complete_button.click()
|
||||
dialog = page._completion_dialog
|
||||
dialog.note_edit.setPlainText(note)
|
||||
dialog.confirm_button.click()
|
||||
assert page._completion_pending
|
||||
assert not page.complete_button.isEnabled()
|
||||
assert len(jobs) == 1
|
||||
# Polling and direct handler calls cannot enable/dispatch a second request.
|
||||
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||
assert not page.complete_button.isEnabled()
|
||||
page._complete_appointment()
|
||||
dialog.confirm_button.click()
|
||||
assert len(jobs) == 1
|
||||
finish_job(jobs.pop())
|
||||
expected = [("revalidate", 51), ("complete", 51)]
|
||||
if note.strip():
|
||||
expected.append(("note", 251, note.strip()))
|
||||
assert repository.calls == expected
|
||||
assert page._completion_dialog is None
|
||||
assert not page._completion_pending
|
||||
assert page._selected_appointment_id is None
|
||||
assert toasts[-1] == ("接诊已完成。", "success")
|
||||
assert refreshes == [True]
|
||||
|
||||
|
||||
def test_completion_without_note_permission_does_not_submit_hidden_note(harness: Any) -> None:
|
||||
page, repository, jobs, _toasts, _refreshes = harness
|
||||
page._can_note = False
|
||||
page._complete_appointment()
|
||||
dialog = page._completion_dialog
|
||||
assert dialog.note_edit.isHidden()
|
||||
dialog.note_edit.setPlainText("不可提交")
|
||||
dialog.confirm_button.click()
|
||||
finish_job(jobs.pop())
|
||||
assert repository.calls == [("revalidate", 51), ("complete", 51)]
|
||||
|
||||
|
||||
def test_completion_failure_preserves_note_and_allows_explicit_retry(harness: Any) -> None:
|
||||
page, repository, jobs, toasts, refreshes = harness
|
||||
repository.fail_complete = True
|
||||
page._complete_appointment()
|
||||
dialog = page._completion_dialog
|
||||
dialog.note_edit.setPlainText("需要保留的备注")
|
||||
dialog.confirm_button.click()
|
||||
finish_job(jobs.pop())
|
||||
assert repository.calls == [("revalidate", 51), ("complete", 51)]
|
||||
assert page._completion_dialog is dialog
|
||||
assert dialog.isVisible()
|
||||
assert dialog.note_edit.toPlainText() == "需要保留的备注"
|
||||
assert dialog.confirm_button.isEnabled()
|
||||
assert "完成接口失败" in dialog.banner.label.text()
|
||||
assert not page._completion_pending
|
||||
assert page.complete_button.isEnabled()
|
||||
assert page._selected_appointment_id == 51
|
||||
assert toasts == []
|
||||
assert refreshes == []
|
||||
repository.fail_complete = False
|
||||
dialog.confirm_button.click()
|
||||
finish_job(jobs.pop())
|
||||
assert repository.calls[-1] == ("note", 251, "需要保留的备注")
|
||||
assert page._completion_dialog is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_diagnosis", [True, False])
|
||||
def test_note_failure_is_partial_success_and_keeps_note_for_copying(
|
||||
harness: Any, missing_diagnosis: bool
|
||||
) -> None:
|
||||
page, repository, jobs, toasts, refreshes = harness
|
||||
if missing_diagnosis:
|
||||
repository.detail["diagnosis"] = {}
|
||||
else:
|
||||
repository.fail_note = True
|
||||
page._complete_appointment()
|
||||
dialog = page._completion_dialog
|
||||
dialog.note_edit.setPlainText("备注不能丢失")
|
||||
dialog.confirm_button.click()
|
||||
finish_job(jobs.pop())
|
||||
assert [call[0] for call in repository.calls] == (
|
||||
["revalidate", "complete"] if missing_diagnosis else ["revalidate", "complete", "note"]
|
||||
)
|
||||
assert page._selected_appointment_id is None
|
||||
assert refreshes == [True]
|
||||
assert toasts[-1][1] == "warning"
|
||||
assert "问诊已完成" in dialog.banner.label.text()
|
||||
assert "备注未保存" in dialog.banner.label.text()
|
||||
assert dialog.note_edit.toPlainText() == "备注不能丢失"
|
||||
assert dialog.note_edit.isReadOnly()
|
||||
assert dialog.confirm_button.isHidden()
|
||||
assert not dialog.confirm_button.isEnabled()
|
||||
assert dialog.cancel_button.text() == "关闭"
|
||||
dialog.confirm_button.click()
|
||||
assert jobs == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed_id", [True, False])
|
||||
def test_completion_revalidation_rejects_changed_record_before_any_write(
|
||||
harness: Any, changed_id: bool
|
||||
) -> None:
|
||||
page, repository, _jobs, _toasts, _refreshes = harness
|
||||
if changed_id:
|
||||
repository.detail["appointment"]["id"] = 52
|
||||
else:
|
||||
repository.detail["appointment"]["status"] = 3
|
||||
with pytest.raises(ValueError, match="不一致|状态已变化"):
|
||||
page._complete_after_revalidation(51, "测试备注")
|
||||
assert repository.calls == [("revalidate", 51)]
|
||||
|
||||
|
||||
def test_completion_checks_permissions_and_note_length_before_requests(harness: Any) -> None:
|
||||
page, repository, jobs, _toasts, _refreshes = harness
|
||||
with pytest.raises(ValueError, match="500"):
|
||||
page._complete_after_revalidation(51, "字" * 501)
|
||||
page._can_note = False
|
||||
with pytest.raises(ValueError, match="备注权限"):
|
||||
page._complete_after_revalidation(51, "测试备注")
|
||||
page._can_complete = False
|
||||
page._complete_appointment()
|
||||
assert page._completion_dialog is None
|
||||
with pytest.raises(ValueError, match="完成接诊权限"):
|
||||
page._complete_after_revalidation(51)
|
||||
assert repository.calls == []
|
||||
assert jobs == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("switch_before_confirm", [True, False])
|
||||
def test_completion_does_not_mutate_or_clear_a_new_selection(
|
||||
harness: Any, switch_before_confirm: bool
|
||||
) -> None:
|
||||
page, repository, jobs, _toasts, _refreshes = harness
|
||||
page._complete_appointment()
|
||||
dialog = page._completion_dialog
|
||||
dialog.note_edit.setPlainText("原患者的备注")
|
||||
if not switch_before_confirm:
|
||||
dialog.confirm_button.click()
|
||||
page._selected_appointment_id = 52
|
||||
page._selected_record = {"id": 52, "status": 1}
|
||||
page._selected_detail = {"appointment": page._selected_record, "diagnosis": {"id": 252}}
|
||||
page._detail_generation += 1
|
||||
page._update_action_state(page._selected_record, {"id": 252})
|
||||
if switch_before_confirm:
|
||||
dialog.confirm_button.click()
|
||||
assert jobs == []
|
||||
assert repository.calls == []
|
||||
assert "已切换" in dialog.banner.label.text()
|
||||
else:
|
||||
finish_job(jobs.pop())
|
||||
assert repository.calls[-1] == ("note", 251, "原患者的备注")
|
||||
assert page.complete_button.isEnabled()
|
||||
assert page._selected_appointment_id == 52
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QHBoxLayout, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.pages.reception import RECEPTION_QSS, _ReceptionCompleteButton
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controls():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
old_stylesheet, old_font, old_palette = app.styleSheet(), app.font(), app.palette()
|
||||
old_style = app.style().objectName()
|
||||
apply_theme(app)
|
||||
host = QWidget()
|
||||
host.setObjectName("ReceptionPage")
|
||||
host.setStyleSheet(RECEPTION_QSS)
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(12)
|
||||
button = _ReceptionCompleteButton(host)
|
||||
neighbor = QPushButton("通知医助", host)
|
||||
neighbor.setObjectName("ReceptionNotifyButton")
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(neighbor)
|
||||
host.show()
|
||||
host.activateWindow()
|
||||
button.clearFocus()
|
||||
neighbor.clearFocus()
|
||||
QTest.mouseMove(host, QPoint(1, 1))
|
||||
app.processEvents()
|
||||
yield app, host, button, neighbor
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
app.processEvents()
|
||||
app.setStyle(old_style)
|
||||
app.setFont(old_font)
|
||||
app.setPalette(old_palette)
|
||||
app.setStyleSheet(old_stylesheet)
|
||||
|
||||
|
||||
def surface_color(button: QPushButton, *, border: bool = False) -> str:
|
||||
image = button.grab().toImage()
|
||||
scale = image.devicePixelRatio()
|
||||
return image.pixelColor(
|
||||
round((0 if border else 7) * scale), round(button.height() / 2 * scale)
|
||||
).name()
|
||||
|
||||
|
||||
def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(controls):
|
||||
app, host, button, neighbor = controls
|
||||
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||
clicked: list[bool] = []
|
||||
button.clicked.connect(lambda: clicked.append(True))
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert button.underMouse()
|
||||
assert surface_color(button) == "#fff0f2"
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
|
||||
QTest.mousePress(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#ffe4e8"
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
# Dragging outside and releasing must not activate the completion action.
|
||||
QTest.mouseMove(button, QPoint(-5, -5))
|
||||
QTest.mouseRelease(button, Qt.MouseButton.LeftButton, pos=QPoint(-5, -5))
|
||||
QTest.mouseMove(host, QPoint(1, 1))
|
||||
button.clearFocus()
|
||||
app.processEvents()
|
||||
assert clicked == []
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
|
||||
|
||||
def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
|
||||
app, _host, button, _neighbor = controls
|
||||
clicked: list[bool] = []
|
||||
button.clicked.connect(lambda: clicked.append(True))
|
||||
button.setEnabled(False)
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#f7f8fb"
|
||||
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||||
assert clicked == []
|
||||
button.setEnabled(True)
|
||||
app.processEvents()
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
assert surface_color(button) != "#f7f8fb"
|
||||
|
||||
|
||||
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||
app, _host, button, neighbor = controls
|
||||
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||
neighbor.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
app.processEvents()
|
||||
border_before = surface_color(button, border=True)
|
||||
button.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
app.processEvents()
|
||||
assert button.hasFocus()
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
assert surface_color(button, border=True) != border_before
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#fff0f2"
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
@@ -2579,7 +2579,7 @@ def test_completion_revalidates_server_status_before_write(
|
||||
assert completed == []
|
||||
|
||||
repository.status = 4
|
||||
assert page._complete_after_revalidation(51) == {"ok": True}
|
||||
assert page._complete_after_revalidation(51) == ""
|
||||
assert completed == [51]
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from threading import Event
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class RefreshRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self.detail = {
|
||||
"appointment": {"id": 51, "patient_name": "刷新测试患者", "status": 1},
|
||||
"diagnosis": {"id": 251, "patient_id": 151, "symptoms": "原病历"},
|
||||
"patient": {"id": 151},
|
||||
}
|
||||
self.rows = [deepcopy(self.detail["appointment"])]
|
||||
self.reports: list[dict[str, Any]] = []
|
||||
|
||||
def list_appointments(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(("queue", query))
|
||||
return {"lists": deepcopy(self.rows), "count": len(self.rows)}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("detail", appointment_id))
|
||||
return deepcopy(self.detail)
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("reports", patient_id))
|
||||
return {"patient_id": patient_id, "reports": deepcopy(self.reports)}
|
||||
|
||||
def generate_patient_ai_report(self, patient_id: int, **_options: Any) -> None:
|
||||
self.calls.append(("POST", patient_id))
|
||||
raise AssertionError("刷新不得生成 AI 报告")
|
||||
|
||||
def get_diagnosis_ai_analysis(self, diagnosis_id: int, **_options: Any) -> None:
|
||||
self.calls.append(("legacy_POST", diagnosis_id))
|
||||
raise AssertionError("刷新不得调用名称为 get 的旧版生成接口")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
jobs: list[dict[str, Any]] = []
|
||||
toasts: list[str] = []
|
||||
|
||||
def queue(function: Any, **options: Any) -> object:
|
||||
jobs.append({"function": function, **options})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(reception_module, "run_async", queue)
|
||||
monkeypatch.setattr(reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 0)
|
||||
monkeypatch.setattr(
|
||||
reception_module, "show_toast", lambda _parent, text, *_args: toasts.append(text)
|
||||
)
|
||||
repository = RefreshRepository()
|
||||
page = ReceptionPage(
|
||||
repository,
|
||||
PermissionSet(
|
||||
[
|
||||
"doctor.appointment/addDoctorNote",
|
||||
"doctor.appointment/complete",
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
"tcm.diagnosis/aiAnalysis",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._selected_appointment_id = 51
|
||||
page._selected_record = deepcopy(repository.detail["appointment"])
|
||||
page._selected_detail = deepcopy(repository.detail)
|
||||
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||
yield page, repository, jobs, toasts
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def finish(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
result = job["function"]()
|
||||
except Exception as error:
|
||||
job["on_error"](error)
|
||||
else:
|
||||
job["on_success"](result)
|
||||
finally:
|
||||
job["on_finished"]()
|
||||
|
||||
|
||||
def expire_cooldown(page: ReceptionPage) -> None:
|
||||
page._refresh_cooldown.stop()
|
||||
page._refresh_cooldown.timeout.emit()
|
||||
|
||||
|
||||
def snapshot(version: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": version,
|
||||
"patient_id": 151,
|
||||
"model_key": "qwen",
|
||||
"version": version,
|
||||
"generated_at": f"2026-08-31 10:{version:02}:00",
|
||||
"report": {"diagnosis": f"测试报告第 {version} 版", "treatment_advice": "测试建议"},
|
||||
}
|
||||
|
||||
|
||||
def test_manual_refresh_supersedes_hung_queue_and_detail_independently(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page.refresh(workspace_refresh=True)
|
||||
old_queue = jobs.pop()
|
||||
page._load_detail(page._selected_record, clear=False)
|
||||
old_detail = jobs.pop()
|
||||
old_generation = page._detail_generation
|
||||
|
||||
page.refresh_button.click()
|
||||
|
||||
assert len(jobs) == 2
|
||||
assert page._detail_generation > old_generation
|
||||
assert page._queue_loading and page._detail_loading
|
||||
# A slow queue cannot hold the new detail back.
|
||||
finish(jobs[0])
|
||||
assert repository.calls == [("detail", 51)]
|
||||
assert not page._detail_loading
|
||||
assert page._queue_loading
|
||||
assert len(jobs) == 3 # exactly one fresh saved-report read
|
||||
finish(jobs[1])
|
||||
assert len(jobs) == 3 # queue must not duplicate detail work
|
||||
finish(jobs[2])
|
||||
old_queue["on_success"]({"lists": [{"id": 999, "patient_name": "迟到队列"}]})
|
||||
old_queue["on_finished"]()
|
||||
old_detail["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "旧详情"}}})
|
||||
old_detail["on_finished"]()
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page.patient_name_label.text() == "刷新测试患者"
|
||||
assert not page._queue_loading and not page._detail_loading
|
||||
assert [call[0] for call in repository.calls] == ["detail", "queue", "reports"]
|
||||
|
||||
|
||||
def test_refresh_preserves_query_loaded_pages_selection_and_drafts(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page._queue_date = "2026-08-03"
|
||||
page._queue_filter_status = None
|
||||
page.search_edit.blockSignals(True)
|
||||
page.search_edit.setText(" 测试 ")
|
||||
page.search_edit.blockSignals(False)
|
||||
page._queue_page = 3
|
||||
page._queue_records = [dict(id=index) for index in range(1, 46)]
|
||||
page.note_edit.setPlainText("尚未保存的备注")
|
||||
page._pending_tongue_images.append("draft-image.png")
|
||||
page._pending_report_files.append("draft-report.pdf")
|
||||
page.detail_tabs.setCurrentIndex(4)
|
||||
# The current patient is no longer in this queue: don't silently select another.
|
||||
repository.rows = [{"id": 52, "patient_name": "其他测试患者", "status": 1}]
|
||||
page._refresh_workspace()
|
||||
finish(jobs[1])
|
||||
query = repository.calls[-1][1]
|
||||
assert query == {
|
||||
"status": None,
|
||||
"start_date": "2026-08-03",
|
||||
"end_date": "2026-08-03",
|
||||
"patient_name": "测试",
|
||||
"page_no": 1,
|
||||
"page_size": 45,
|
||||
"include_status_counts": 1,
|
||||
}
|
||||
finish(jobs[0])
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page.queue_list.currentRow() == -1
|
||||
assert page.note_edit.toPlainText() == "尚未保存的备注"
|
||||
assert page._pending_tongue_images == ["draft-image.png"]
|
||||
assert page._pending_report_files == ["draft-report.pdf"]
|
||||
assert page.detail_tabs.currentIndex() == 4
|
||||
|
||||
|
||||
def test_refresh_can_retry_while_old_requests_never_finish(harness) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
page._refresh_workspace()
|
||||
page._refresh_workspace()
|
||||
assert len(jobs) == 2
|
||||
assert not page.refresh_button.isEnabled()
|
||||
expire_cooldown(page)
|
||||
page.refresh_button.click()
|
||||
assert len(jobs) == 4
|
||||
# Superseded queued work never goes to the server.
|
||||
assert jobs[0]["function"]() == {"cancelled": True}
|
||||
assert jobs[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||
jobs[0]["on_finished"]()
|
||||
jobs[1]["on_finished"]()
|
||||
assert page._queue_loading and page._detail_loading
|
||||
|
||||
|
||||
@pytest.mark.parametrize("empty", [True, False])
|
||||
def test_automatic_polls_after_refresh_keep_missing_patient_and_draft(harness, empty) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page.note_edit.setPlainText("不能因轮询丢失的备注")
|
||||
page._pending_tongue_images.append("draft-image.png")
|
||||
repository.rows = [] if empty else [{"id": 52, "patient_name": "其他患者", "status": 1}]
|
||||
page._refresh_workspace()
|
||||
finish(jobs[0])
|
||||
finish(jobs[1])
|
||||
finish(jobs[2])
|
||||
for _ in range(3):
|
||||
before = len(jobs)
|
||||
page._poll_queue()
|
||||
assert len(jobs) == before + 1
|
||||
finish(jobs[-1])
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page.note_edit.toPlainText() == "不能因轮询丢失的备注"
|
||||
assert page._pending_tongue_images == ["draft-image.png"]
|
||||
|
||||
|
||||
def test_destroyed_page_ignores_every_late_refresh_callback(harness) -> None:
|
||||
_page, repository, jobs, _toasts = harness
|
||||
closed_page = ReceptionPage(repository, PermissionSet([]))
|
||||
closed_page._refresh_workspace()
|
||||
job = jobs[-1]
|
||||
closed_page.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
job["on_success"]({"lists": []})
|
||||
job["on_error"](RuntimeError("迟到异常"))
|
||||
job["on_finished"]()
|
||||
assert job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||
assert not repository.calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("busy_flag", ["_note_busy", "_completion_pending"])
|
||||
def test_refresh_does_not_disturb_business_submission(harness, busy_flag: str) -> None:
|
||||
page, _repository, jobs, toasts = harness
|
||||
setattr(page, busy_flag, True)
|
||||
generation = page._detail_generation
|
||||
page._refresh_workspace()
|
||||
assert not jobs
|
||||
assert page._detail_generation == generation
|
||||
assert getattr(page, busy_flag)
|
||||
assert "正在提交" in toasts[-1]
|
||||
|
||||
|
||||
def test_refresh_invalidates_in_flight_ai_read_and_rejects_late_result(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page._load_patient_ai_reports(151, appointment_id=51, diagnosis_id=251, force=True)
|
||||
old_read = jobs.pop()
|
||||
old_key = next(iter(page._patient_ai_list_requests))
|
||||
page._patient_ai_list_started.add(old_key)
|
||||
cancel = page._patient_ai_list_cancel_events[old_key]
|
||||
repository.reports = [snapshot(2)]
|
||||
page._refresh_workspace()
|
||||
assert cancel.is_set()
|
||||
assert old_key not in page._patient_ai_list_requests
|
||||
finish(jobs[0])
|
||||
finish(jobs[2])
|
||||
assert page._ai_analysis_payloads["qwen"]["version"] == 2
|
||||
old_read["on_success"]({"patient_id": 151, "reports": [snapshot(1)]})
|
||||
old_read["on_finished"]()
|
||||
assert page._ai_analysis_payloads["qwen"]["version"] == 2
|
||||
assert not page._ai_analysis_loading
|
||||
|
||||
|
||||
def test_manual_ai_read_does_not_need_automatic_slots_or_generate_missing_reports(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
slots = reception_module._AI_AUTOMATIC_REQUEST_SLOTS
|
||||
assert slots.acquire(blocking=False)
|
||||
assert slots.acquire(blocking=False)
|
||||
try:
|
||||
page._refresh_workspace()
|
||||
finish(jobs[0])
|
||||
finish(jobs[2])
|
||||
assert ("reports", 151) in repository.calls
|
||||
assert page._ai_analysis_state == "missing"
|
||||
assert not page._ai_analysis_loading
|
||||
assert not slots.acquire(blocking=False) # GUI must not release others' slots
|
||||
finally:
|
||||
slots.release()
|
||||
slots.release()
|
||||
assert not any("POST" in call[0] for call in repository.calls)
|
||||
assert page.ai_analysis_regenerate_button.isEnabled()
|
||||
|
||||
|
||||
def test_refresh_keeps_started_generation_single_flight(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
key = (1, 51, 151, "qwen")
|
||||
page._patient_ai_generation_requests.add(key)
|
||||
page._patient_ai_generation_started.add(key)
|
||||
cancel = Event()
|
||||
page._patient_ai_generation_cancel_events[key] = cancel
|
||||
page._refresh_workspace()
|
||||
finish(jobs[0])
|
||||
assert len(jobs) == 2
|
||||
assert key in page._patient_ai_generation_requests
|
||||
assert key in page._patient_ai_generation_started
|
||||
assert not cancel.is_set()
|
||||
assert "不会重复生成" in page._ai_analysis_operation_error
|
||||
assert not any(call[0] in {"POST", "reports"} for call in repository.calls)
|
||||
|
||||
|
||||
def test_legacy_ai_refresh_never_invokes_post_endpoint(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page._can_patient_ai_read = False
|
||||
page._refresh_workspace()
|
||||
finish(jobs[0])
|
||||
assert len(jobs) == 2
|
||||
assert repository.calls == [("detail", 51)]
|
||||
assert "无法只读刷新" in page.ai_analysis_state_label.text()
|
||||
|
||||
|
||||
def test_first_selection_from_manual_refresh_is_read_only(harness) -> None:
|
||||
page, repository, jobs, _toasts = harness
|
||||
page._clear_selection()
|
||||
page._refresh_workspace()
|
||||
assert len(jobs) == 1
|
||||
finish(jobs[0])
|
||||
finish(jobs[1])
|
||||
finish(jobs[2])
|
||||
assert [call[0] for call in repository.calls] == ["queue", "detail", "reports"]
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page._ai_analysis_state == "missing"
|
||||
|
||||
|
||||
def test_late_refresh_cannot_switch_back_to_previous_patient(harness) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
page._refresh_workspace()
|
||||
second = {"id": 52, "patient_name": "新选择的测试患者", "status": 1}
|
||||
page._select_record(second)
|
||||
assert page._selected_appointment_id == 52
|
||||
jobs[0]["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "旧患者"}}})
|
||||
jobs[1]["on_success"]({"lists": [{"id": 51, "patient_name": "旧患者"}]})
|
||||
assert page._selected_appointment_id == 52
|
||||
assert page.patient_name_label.text() == "新选择的测试患者"
|
||||
assert page._detail_loading # stale finished must not clear patient B's loading
|
||||
|
||||
|
||||
def test_ai_read_timeout_is_retryable_and_cannot_install_late_snapshot(
|
||||
harness, monkeypatch
|
||||
) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
page._refresh_workspace()
|
||||
monkeypatch.setattr(reception_module, "_REFRESH_TIMEOUT_MS", 10)
|
||||
finish(jobs[0])
|
||||
finish(jobs[1])
|
||||
assert len(jobs) == 3
|
||||
QTest.qWait(60)
|
||||
assert not page._ai_analysis_loading
|
||||
assert page._ai_analysis_state == "error"
|
||||
assert page.ai_analysis_retry_button.isEnabled()
|
||||
jobs[2]["on_success"]({"patient_id": 151, "reports": [snapshot(1)]})
|
||||
assert not page._ai_analysis_payloads
|
||||
assert not page._patient_ai_list_requests
|
||||
|
||||
|
||||
def test_refresh_timeout_stops_loading_and_ignores_late_success(harness, monkeypatch) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
monkeypatch.setattr(reception_module, "_REFRESH_TIMEOUT_MS", 10)
|
||||
monkeypatch.setattr(reception_module, "_REFRESH_COOLDOWN_MS", 10)
|
||||
page._refresh_workspace()
|
||||
QTest.qWait(60)
|
||||
assert not page._queue_loading and not page._detail_loading
|
||||
assert page.refresh_button.isEnabled()
|
||||
assert "刷新超时" in page.queue_banner.label.text()
|
||||
assert "刷新超时" in page.detail_banner.label.text()
|
||||
jobs[0]["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "迟到详情"}}})
|
||||
jobs[1]["on_success"]({"lists": []})
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page.patient_name_label.text() != "迟到详情"
|
||||
assert "刷新超时" in page.detail_banner.label.text()
|
||||
assert jobs[0]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||
page._refresh_workspace()
|
||||
assert len(jobs) == 4
|
||||
|
||||
|
||||
def test_refresh_read_error_and_missing_callback_are_terminal(harness) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
page._refresh_workspace()
|
||||
jobs[0]["on_error"](RuntimeError("测试断网"))
|
||||
jobs[0]["on_finished"]()
|
||||
jobs[1]["on_finished"]()
|
||||
assert not page._queue_loading and not page._detail_loading
|
||||
assert "测试断网" in page.detail_banner.label.text()
|
||||
assert "未返回有效结果" in page.queue_banner.label.text()
|
||||
|
||||
|
||||
def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application) -> None:
|
||||
page, _repository, jobs, _toasts = harness
|
||||
page._queue_records = [page._selected_record] # prevent showEvent's initial fetch
|
||||
page.resize(1440, 1000)
|
||||
page.show()
|
||||
page.activateWindow()
|
||||
page.poll_timer.stop()
|
||||
application.processEvents()
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.refresh_button.text() == "刷新"
|
||||
assert page.refresh_button.width() >= 50
|
||||
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
|
||||
page.note_edit.setFocus()
|
||||
application.processEvents()
|
||||
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||
assert len(jobs) == 2
|
||||
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||
assert len(jobs) == 2
|
||||
assert page._refresh_cooldown.isActive()
|
||||
assert page._refresh_cooldown in page.findChildren(QTimer)
|
||||
Reference in New Issue
Block a user