This commit is contained in:
Your Name
2026-09-01 10:42:16 +08:00
parent 456dd667df
commit 5bd5eae62d
358 changed files with 2276 additions and 495 deletions
+1 -1
View File
@@ -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)
+400 -46
View File
@@ -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)
+306
View File
@@ -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"
+1 -1
View File
@@ -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()
+413
View File
@@ -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)