更新
This commit is contained in:
@@ -31,6 +31,7 @@ from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
@@ -977,6 +978,7 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.readonly_scroll.viewport().installEventFilter(self)
|
||||
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
|
||||
content = QWidget()
|
||||
self.readonly_content_layout = QVBoxLayout(content)
|
||||
@@ -1001,6 +1003,25 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_content_layout.addWidget(self.readonly_error)
|
||||
self.readonly_patient_card = self._build_readonly_patient_card()
|
||||
self.readonly_content_layout.addWidget(self.readonly_patient_card)
|
||||
self.readonly_prescriptions_table = self._new_table(
|
||||
"prescription", "DiagnosisReadonlyPrescriptionsTable"
|
||||
)
|
||||
# Keep the open action visible beside the editor; full prescription
|
||||
# contents remain available through the existing detail dialog.
|
||||
self.readonly_prescriptions_table.setColumnHidden(1, True)
|
||||
self.readonly_prescriptions_table.setColumnHidden(3, True)
|
||||
self.readonly_prescriptions_table.horizontalHeaderItem(0).setText("开方日期")
|
||||
self.readonly_prescriptions_table.horizontalHeaderItem(2).setText("临床诊断")
|
||||
for column, width in ((0, 100), (4, 70), (5, 84), (6, 64)):
|
||||
self.readonly_prescriptions_table.setColumnWidth(column, width)
|
||||
self.readonly_prescriptions_table.horizontalHeader().setSectionResizeMode(
|
||||
2, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.readonly_prescriptions_table.setMinimumHeight(220)
|
||||
self._add_readonly_section("prescription", "历史处方", self.readonly_prescriptions_table)
|
||||
self.readonly_content_layout.setAlignment(
|
||||
self._readonly_sections["prescription"], Qt.AlignmentFlag.AlignLeft
|
||||
)
|
||||
self.case_grid = CaseGrid()
|
||||
self.readonly_content_layout.addWidget(self.case_grid)
|
||||
|
||||
@@ -1993,6 +2014,17 @@ class DiagnosisDialog(QDialog):
|
||||
self._sync_host_geometry()
|
||||
|
||||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||
if (
|
||||
hasattr(self, "readonly_scroll")
|
||||
and watched is self.readonly_scroll.viewport()
|
||||
and event.type() == QEvent.Type.Resize
|
||||
and "prescription" in self._readonly_sections
|
||||
):
|
||||
# Other patient cards can have a wider minimum size; the history
|
||||
# actions must still fit inside the visible side pane.
|
||||
self._readonly_sections["prescription"].setFixedWidth(
|
||||
max(0, self.readonly_scroll.viewport().width() - 32)
|
||||
)
|
||||
if watched is self._owner and event.type() in {
|
||||
QEvent.Type.Resize,
|
||||
QEvent.Type.Move,
|
||||
@@ -2747,7 +2779,7 @@ class DiagnosisDialog(QDialog):
|
||||
|
||||
def _load_visible_readonly_sections(self) -> None:
|
||||
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||||
for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"):
|
||||
for key in ("prescription", "notes", "daily", "orders", "video", "chat", "assign", "appointment"):
|
||||
if self._tab_allowed(permission_map.get(key, ())):
|
||||
self._ensure_tab_loaded(key)
|
||||
|
||||
@@ -3853,12 +3885,15 @@ class DiagnosisDialog(QDialog):
|
||||
first_value(row, "doctor_name"),
|
||||
"已作废"
|
||||
if voided
|
||||
else first_value(row, "status_text", "audit_status_text", default="正常"),
|
||||
else first_value(
|
||||
row, "status_text", "audit_status_text",
|
||||
default=_audit_text(first_value(row, "audit_status", default=None)),
|
||||
),
|
||||
"",
|
||||
)
|
||||
)
|
||||
for table in self._table_registry["prescription"]:
|
||||
table.set_empty_text("暂无病历记录,开方后会自动显示。")
|
||||
table.set_empty_text("暂无历史处方,开方后会自动显示。")
|
||||
table.set_matrix(matrix)
|
||||
self._semanticize(table, 5)
|
||||
for row_index, row in enumerate(rows):
|
||||
@@ -3875,6 +3910,7 @@ class DiagnosisDialog(QDialog):
|
||||
voided = _truthy(first_value(row, "void_status", "is_void", default=False))
|
||||
can_edit = (
|
||||
self._editable
|
||||
and table is not self.readonly_prescriptions_table
|
||||
and self._can_edit_prescription
|
||||
and (not approved or voided)
|
||||
)
|
||||
@@ -3958,6 +3994,10 @@ class DiagnosisDialog(QDialog):
|
||||
if actual_id != prescription_id:
|
||||
self._show_message("服务端处方与当前行不一致,已停止打开。", "danger")
|
||||
return
|
||||
detail_diagnosis_id = _int(first_value(prescription, "diagnosis_id", default=0), 0)
|
||||
if self._diagnosis_id > 0 and detail_diagnosis_id != self._diagnosis_id:
|
||||
self._show_message("该处方不属于当前诊单,已停止打开。", "danger")
|
||||
return
|
||||
self._clear_message()
|
||||
if edit:
|
||||
self._open_prescription_edit_dialog(prescription, prescription_id)
|
||||
|
||||
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...core import UserProfile
|
||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||
from ...core.errors import ApiBusinessError
|
||||
from .. import icons
|
||||
from ..consultations_style import consultations_stylesheet
|
||||
from ..diagnosis_index_widgets import (
|
||||
@@ -2235,9 +2236,15 @@ class ConsultationsPage(QWidget):
|
||||
self._departments_loading = False
|
||||
self._departments_loaded = False
|
||||
self.department_combo.clear()
|
||||
self.department_combo.addItem("部门加载失败", "")
|
||||
permission_denied = isinstance(error, ApiBusinessError) and any(
|
||||
text in str(error) for text in ("权限不足", "无权限", "无权")
|
||||
)
|
||||
self.department_combo.addItem("暂无部门筛选权限" if permission_denied else "部门加载失败", "")
|
||||
self.department_combo.setEnabled(False)
|
||||
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
|
||||
message = (
|
||||
"当前账号无权读取部门,请联系管理员检查部门筛选权限,处理后点击重试。"
|
||||
if permission_denied else f"部门加载失败:{friendly_error(error)}。点击重试。"
|
||||
)
|
||||
self.department_combo.setToolTip(message)
|
||||
self.department_retry_button.setToolTip(message)
|
||||
self.department_retry_button.show()
|
||||
|
||||
@@ -8,12 +8,24 @@ from html import escape
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
|
||||
from PySide6.QtCore import (
|
||||
QDateTime,
|
||||
QEvent,
|
||||
QModelIndex,
|
||||
QPersistentModelIndex,
|
||||
QPointF,
|
||||
QRectF,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QIcon,
|
||||
QKeySequence,
|
||||
QMouseEvent,
|
||||
QPainter,
|
||||
QPen,
|
||||
QTextDocument,
|
||||
@@ -44,6 +56,7 @@ from PySide6.QtWidgets import (
|
||||
QStyledItemDelegate,
|
||||
QStyleOptionButton,
|
||||
QStyleOptionViewItem,
|
||||
QTextEdit,
|
||||
QToolTip,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -969,7 +982,184 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
|
||||
return QSize(width, max(80, ceil(document.size().height()) + 28))
|
||||
|
||||
|
||||
class _PrescriptionCopyText(QTextEdit):
|
||||
"""One transient, read-only text surface for the active painted cell."""
|
||||
|
||||
def __init__(self, table: _PrescriptionBlueTable) -> None:
|
||||
super().__init__(table.viewport())
|
||||
self.table = table
|
||||
self.setObjectName("PrescriptionCopyText")
|
||||
self.setReadOnly(True)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setStyleSheet(
|
||||
f"QTextEdit#PrescriptionCopyText {{background:{PALETTE['accent_soft']};"
|
||||
f"color:{PALETTE['text']};border:0;padding:0;"
|
||||
f"selection-background-color:{PALETTE['accent']};selection-color:white;}}"
|
||||
)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(
|
||||
lambda point: table.customContextMenuRequested.emit(self.viewport().mapTo(table.viewport(), point))
|
||||
)
|
||||
self.hide()
|
||||
|
||||
def keyPressEvent(self, event: Any) -> None: # noqa: N802
|
||||
if event.matches(QKeySequence.StandardKey.Copy):
|
||||
if self.textCursor().hasSelection():
|
||||
self.copy()
|
||||
else:
|
||||
self.table.copy_cell()
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Escape:
|
||||
self.table._hide_copy_text()
|
||||
self.table.setFocus()
|
||||
event.accept()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def wheelEvent(self, event: Any) -> None: # noqa: N802
|
||||
self.table._hide_copy_text()
|
||||
QApplication.sendEvent(self.table.viewport(), event)
|
||||
|
||||
|
||||
class _PrescriptionBlueTable(SortableTable):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._copy_index = QPersistentModelIndex()
|
||||
self._copy_drag = False
|
||||
self.copy_text = _PrescriptionCopyText(self)
|
||||
for signal in (
|
||||
self.model().modelAboutToBeReset, self.model().layoutAboutToBeChanged,
|
||||
self.model().rowsAboutToBeRemoved, self.model().rowsAboutToBeInserted,
|
||||
self.horizontalScrollBar().valueChanged, self.verticalScrollBar().valueChanged,
|
||||
self.horizontalHeader().sectionResized, self.horizontalHeader().sectionMoved,
|
||||
):
|
||||
signal.connect(self._hide_copy_text)
|
||||
self.model().dataChanged.connect(self._copy_data_changed)
|
||||
self.selectionModel().currentChanged.connect(self._copy_current_changed)
|
||||
|
||||
@staticmethod
|
||||
def _copyable(index: QModelIndex) -> bool:
|
||||
return index.isValid() and index.column() in (1, 3, 4, 5, 6, 7, 8, 9, 10)
|
||||
|
||||
def cell_copy_text(self, index: QModelIndex) -> str:
|
||||
if not self._copyable(index):
|
||||
return ""
|
||||
if index.column() == 1:
|
||||
return str(index.data(Qt.ItemDataRole.AccessibleTextRole) or index.data() or "")
|
||||
return str(index.data() or "")
|
||||
|
||||
def row_copy_text(self, row: int) -> str:
|
||||
header = self.horizontalHeader()
|
||||
return "\t".join(
|
||||
self.cell_copy_text(self.model().index(row, column)).replace("\n", " ")
|
||||
for column in (header.logicalIndex(visual) for visual in range(self.columnCount()))
|
||||
if column not in (0, 2) and not self.isColumnHidden(column)
|
||||
)
|
||||
|
||||
def copy_cell(self) -> None:
|
||||
text = self.cell_copy_text(self.currentIndex())
|
||||
if text:
|
||||
QApplication.clipboard().setText(text)
|
||||
|
||||
def _hide_copy_text(self, *_args: Any) -> None:
|
||||
self.copy_text.hide()
|
||||
self._copy_index = QPersistentModelIndex()
|
||||
self._copy_drag = False
|
||||
|
||||
def _copy_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None:
|
||||
if current != self._copy_index:
|
||||
self._hide_copy_text()
|
||||
|
||||
def _copy_data_changed(self, top: QModelIndex, bottom: QModelIndex, *_args: Any) -> None:
|
||||
if (self._copy_index.isValid()
|
||||
and top.row() <= self._copy_index.row() <= bottom.row()
|
||||
and top.column() <= self._copy_index.column() <= bottom.column()):
|
||||
self._hide_copy_text()
|
||||
|
||||
def _show_copy_text(self, index: QModelIndex) -> None:
|
||||
self._copy_index = QPersistentModelIndex(index)
|
||||
self.copy_text.setGeometry(self.visualRect(index).adjusted(1, 1, -1, -1))
|
||||
text = self.cell_copy_text(index)
|
||||
if index.column() == 5:
|
||||
parts = text.rsplit(" · ", 2)
|
||||
text = parts[0] + ("\n" + " · ".join(parts[1:]) if len(parts) > 1 else "")
|
||||
elif index.column() == 10:
|
||||
text = "\n".join(text.rsplit(" ", 1))
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(14)
|
||||
self.copy_text.setFont(font)
|
||||
self.copy_text.setPlainText(text)
|
||||
document = self.copy_text.document()
|
||||
document.setDocumentMargin(0)
|
||||
document.setTextWidth(max(1, self.copy_text.width() - 24))
|
||||
top = max(11, round((self.copy_text.height() - document.size().height()) / 2))
|
||||
self.copy_text.setViewportMargins(11, top, 11, 0)
|
||||
self.copy_text.show()
|
||||
self.copy_text.raise_()
|
||||
self.copy_text.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
def _forward_copy_mouse(self, event: QMouseEvent) -> None:
|
||||
target = self.copy_text.viewport()
|
||||
local = QPointF(target.mapFromGlobal(event.globalPosition().toPoint()))
|
||||
forwarded = QMouseEvent(
|
||||
event.type(), local, event.globalPosition(), event.button(), event.buttons(), event.modifiers()
|
||||
)
|
||||
QApplication.sendEvent(target, forwarded)
|
||||
event.accept()
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
self._hide_copy_text()
|
||||
super().mousePressEvent(event)
|
||||
if event.button() == Qt.MouseButton.LeftButton and self._copyable(index):
|
||||
self._show_copy_text(index)
|
||||
self._copy_drag = True
|
||||
self._forward_copy_mouse(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
if self._copy_drag:
|
||||
self._forward_copy_mouse(event)
|
||||
return
|
||||
self.viewport().setCursor(
|
||||
Qt.CursorShape.IBeamCursor if self._copyable(self.indexAt(event.position().toPoint()))
|
||||
else Qt.CursorShape.ArrowCursor
|
||||
)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if event.button() == Qt.MouseButton.LeftButton and self._copyable(index):
|
||||
self.setCurrentIndex(index)
|
||||
if self._copy_index != index:
|
||||
self._show_copy_text(index)
|
||||
self._forward_copy_mouse(event)
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
if self._copy_drag:
|
||||
self._forward_copy_mouse(event)
|
||||
self._copy_drag = False
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: Any) -> None: # noqa: N802
|
||||
if event.matches(QKeySequence.StandardKey.Copy):
|
||||
if self._copyable(self.currentIndex()):
|
||||
self.copy_cell()
|
||||
elif self.currentRow() >= 0:
|
||||
QApplication.clipboard().setText(self.row_copy_text(self.currentRow()))
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def resizeEvent(self, event: Any) -> None: # noqa: N802
|
||||
if hasattr(self, "copy_text"):
|
||||
self._hide_copy_text()
|
||||
super().resizeEvent(event)
|
||||
|
||||
def setCellWidget(self, row: int, column: int, widget: QWidget) -> None: # noqa: N802
|
||||
super().setCellWidget(row, column, widget)
|
||||
if column == 2:
|
||||
@@ -1827,18 +2017,26 @@ class PrescriptionsPage(QWidget):
|
||||
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
|
||||
|
||||
def _open_ai_context_menu(self, position: Any) -> None:
|
||||
if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
|
||||
return
|
||||
item = self.table.itemAt(position)
|
||||
if item is None:
|
||||
return
|
||||
self.table.selectRow(item.row())
|
||||
self.table.setCurrentItem(item)
|
||||
row = self._selected()
|
||||
if _is_blank_prescription(row):
|
||||
return
|
||||
menu = QMenu(self.table)
|
||||
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
||||
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||
selected = self.table.copy_text.textCursor().selectedText() if self.table.copy_text.isVisible() else ""
|
||||
if selected:
|
||||
menu.addAction("复制选中文字", lambda: QApplication.clipboard().setText(selected.replace("\u2029", "\n")))
|
||||
text = self.table.cell_copy_text(self.table.indexFromItem(item))
|
||||
if text:
|
||||
menu.addAction("复制单元格", lambda: QApplication.clipboard().setText(text))
|
||||
row_text = self.table.row_copy_text(item.row())
|
||||
menu.addAction("复制整行", lambda: QApplication.clipboard().setText(row_text))
|
||||
if (can_open_issued_ai(self.permissions)
|
||||
and callable(getattr(self.repository, "list_prescription_ai_reports", None))
|
||||
and not _is_blank_prescription(row)):
|
||||
menu.addSeparator()
|
||||
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
||||
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||
self._ai_context_menu = menu
|
||||
menu.popup(self.table.viewport().mapToGlobal(position))
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTimeoutError
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import consultations as module
|
||||
|
||||
@@ -160,8 +160,8 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch
|
||||
widget._load_departments()
|
||||
assert not widget._departments_loaded
|
||||
assert not widget._departments_loading
|
||||
assert widget.department_combo.currentText() == "部门加载失败"
|
||||
assert "无权限" in widget.department_combo.toolTip()
|
||||
assert widget.department_combo.currentText() == "暂无部门筛选权限"
|
||||
assert "联系管理员" in widget.department_combo.toolTip()
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
@@ -170,6 +170,19 @@ def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch
|
||||
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||
|
||||
|
||||
def test_network_failure_remains_retryable_and_is_not_reported_as_permission_denied(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
client.departments = ApiTimeoutError("request timed out")
|
||||
widget._load_departments()
|
||||
assert widget.department_combo.currentText() == "部门加载失败"
|
||||
assert "超时" in widget.department_combo.toolTip()
|
||||
assert "联系管理员" not in widget.department_combo.toolTip()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
assert widget._departments_loaded
|
||||
|
||||
|
||||
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
@@ -7,8 +7,8 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import widgets
|
||||
@@ -412,6 +412,114 @@ def _finish_queued(callback: dict[str, Any], result: Any) -> None:
|
||||
callback["on_finished"]()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner_width", [1024, 1440])
|
||||
def test_embedded_prescription_history_opens_full_detail_without_changing_draft(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch, owner_width: int,
|
||||
) -> None:
|
||||
listed: list[int] = []
|
||||
fetched: list[int] = []
|
||||
shown: list[int] = []
|
||||
|
||||
class Repository(_DiagnosisRepository):
|
||||
def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]:
|
||||
return []
|
||||
|
||||
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
listed.append(diagnosis_id)
|
||||
return [
|
||||
{"id": 92, "diagnosis_id": diagnosis_id, "prescription_date": "2026-11-03",
|
||||
"doctor_name": "陈医生", "clinical_diagnosis": "气阴两虚", "audit_status": 1},
|
||||
{"id": 81, "diagnosis_id": diagnosis_id, "prescription_date": "2026-10-20",
|
||||
"doctor_name": "李医生", "clinical_diagnosis": "脾虚", "void_status": 1},
|
||||
]
|
||||
|
||||
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
|
||||
fetched.append(prescription_id)
|
||||
return {"id": prescription_id, "diagnosis_id": 745, "patient_name": "测试患者",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}], "usage_instruction": "水煎服"}
|
||||
|
||||
def inspect_detail(dialog: dialog_module.PrescriptionDetailDialog) -> int:
|
||||
shown.append(dialog.prescription["id"])
|
||||
assert "黄芪" in dialog.document.toPlainText()
|
||||
assert "水煎服" in dialog.document.toPlainText()
|
||||
assert dialog.diagnosis_button.isHidden()
|
||||
assert dialog.orders_button.isHidden()
|
||||
return QDialog.DialogCode.Rejected
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
monkeypatch.setattr(dialog_module.PrescriptionDetailDialog, "exec", inspect_detail)
|
||||
owner = QDialog()
|
||||
owner.resize(owner_width, 800)
|
||||
owner.show()
|
||||
editor = PrescriptionEditorDialog(
|
||||
Repository(), {"diagnosis_id": 745, "patient_name": "测试患者"}, mode="edit",
|
||||
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"]),
|
||||
parent=owner,
|
||||
)
|
||||
editor.show()
|
||||
application.processEvents()
|
||||
editor.clinical_diagnosis.setPlainText("尚未保存的新诊断")
|
||||
draft = editor.payload()
|
||||
editor.diagnosis_button.click()
|
||||
for _ in range(6):
|
||||
application.processEvents()
|
||||
detail = editor._diagnosis_view
|
||||
assert detail is not None
|
||||
table = detail.readonly_prescriptions_table
|
||||
assert listed == [745]
|
||||
assert table.rowCount() == 2
|
||||
assert table.item(0, 5).text() == "审核通过"
|
||||
assert table.item(1, 5).text() == "已作废"
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
assert table.mapToGlobal(QPoint(table.width(), 0)).x() <= detail.readonly_scroll.viewport().mapToGlobal(
|
||||
QPoint(detail.readonly_scroll.viewport().width(), 0)
|
||||
).x()
|
||||
assert detail._readonly_sections["prescription"].y() < detail.case_grid.y()
|
||||
actions = table.cellWidget(1, 6).findChildren(QPushButton)
|
||||
assert [button.text() for button in actions] == ["查看"]
|
||||
actions[0].click()
|
||||
assert fetched == [81] and shown == [81]
|
||||
assert editor.payload() == draft
|
||||
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||
detail.readonly_close_button.click()
|
||||
editor.diagnosis_button.click()
|
||||
assert listed == [745, 745]
|
||||
assert editor.payload() == draft
|
||||
editor.reject()
|
||||
owner.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_readonly_history_respects_permission_and_shows_empty_and_failure(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
class Repository(_DiagnosisRepository):
|
||||
def get_doctor_notes(self, _diagnosis_id: int) -> list[Any]:
|
||||
return []
|
||||
|
||||
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[Any]:
|
||||
calls.append(diagnosis_id)
|
||||
if len(calls) > 1:
|
||||
raise RuntimeError("测试加载失败")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]))
|
||||
dialog.open_for(745, editable=False)
|
||||
assert dialog._readonly_sections["prescription"].isHidden()
|
||||
assert calls == []
|
||||
dialog.refresh_permissions(PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/chufang"]))
|
||||
dialog.open_for(745, editable=False)
|
||||
assert "暂无历史处方" in dialog.readonly_prescriptions_table.empty_label.text()
|
||||
dialog.open_for(745, editable=False)
|
||||
assert "加载失败" in dialog.readonly_prescriptions_table.empty_label.text()
|
||||
assert dialog.readonly_prescriptions_table.rowCount() == 0
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_prescription_lists_snapshot_queries_and_supersede_stale_refresh(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -246,6 +246,94 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column", [1, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
def test_all_prescription_data_cells_select_and_copy_without_editing(
|
||||
shell_factory, application, column,
|
||||
) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
item = table.item(0, column)
|
||||
original = item.text()
|
||||
row_id = _record_id(table, 0)
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center())
|
||||
assert table.copy_text.isVisible()
|
||||
assert _record_id(table, table.currentRow()) == row_id
|
||||
assert table.cellWidget(0, column) is None
|
||||
QTest.keyClick(table.copy_text, Qt.Key.Key_A, Qt.KeyboardModifier.ControlModifier)
|
||||
QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
|
||||
copied = application.clipboard().text()
|
||||
if column == 1:
|
||||
assert original in copied and f"ID: {row_id}" in copied
|
||||
elif column == 5:
|
||||
assert copied == original.replace(" · ", "\n", 1).replace(" · ", " · ")
|
||||
elif column == 10:
|
||||
assert copied == "\n".join(original.rsplit(" ", 1))
|
||||
else:
|
||||
assert copied == original
|
||||
QTest.keyClicks(table.copy_text, "must-not-edit")
|
||||
assert item.text() == original
|
||||
assert table.item(0, 0).checkState() == Qt.CheckState.Unchecked
|
||||
|
||||
|
||||
def test_text_drag_copy_and_double_click_do_not_open_prescription(shell_factory, application, monkeypatch):
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
opened = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: opened.append(True))
|
||||
item = table.item(1, 8)
|
||||
rect = table.visualItemRect(item)
|
||||
start = QPoint(rect.left() + 12, rect.center().y())
|
||||
end = QPoint(rect.right() - 12, start.y())
|
||||
QTest.mousePress(table.viewport(), Qt.MouseButton.LeftButton, pos=start)
|
||||
QTest.mouseMove(table.viewport(), end, 30)
|
||||
QTest.mouseRelease(table.viewport(), Qt.MouseButton.LeftButton, pos=end)
|
||||
assert table.copy_text.textCursor().selectedText() == item.text()
|
||||
QTest.keyClick(table.copy_text, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
|
||||
assert application.clipboard().text() == item.text()
|
||||
table.copy_text.customContextMenuRequested.emit(QPoint(4, 4))
|
||||
menu = page._ai_context_menu
|
||||
selected_action = next(action for action in menu.actions() if action.text() == "复制选中文字")
|
||||
application.clipboard().clear()
|
||||
selected_action.trigger()
|
||||
assert application.clipboard().text() == item.text()
|
||||
menu.close()
|
||||
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=start + QPoint(8, 0))
|
||||
assert table.copy_text.textCursor().hasSelection()
|
||||
assert opened == []
|
||||
|
||||
|
||||
def test_copy_context_menu_and_refresh_follow_the_visible_row(shell_factory, application):
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
# Copy remains available without AI/report permissions, including blank prescriptions.
|
||||
page.permissions = PermissionSet([])
|
||||
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
item = table.item(0, 1)
|
||||
page._open_ai_context_menu(table.visualItemRect(item).center())
|
||||
menu = page._ai_context_menu
|
||||
actions = {action.text(): action for action in menu.actions()}
|
||||
assert set(actions) == {"复制单元格", "复制整行"}
|
||||
actions["复制单元格"].trigger()
|
||||
assert item.text() in application.clipboard().text()
|
||||
assert f"ID: {_record_id(table, 0)}" in application.clipboard().text()
|
||||
actions["复制整行"].trigger()
|
||||
copied = application.clipboard().text()
|
||||
for column in range(3, 11):
|
||||
assert table.item(0, column).text() in copied
|
||||
assert "\t" in copied and "复制" not in copied
|
||||
menu.close()
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(item).center())
|
||||
assert table.copy_text.isVisible()
|
||||
table.sortItems(1, Qt.SortOrder.AscendingOrder)
|
||||
assert table.copy_text.isHidden()
|
||||
table.setCurrentItem(table.item(0, 5))
|
||||
QTest.keyClick(table, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
|
||||
assert application.clipboard().text() == table.item(0, 5).text()
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=table.visualItemRect(table.item(0, 5)).center())
|
||||
page.refresh()
|
||||
assert table.copy_text.isHidden()
|
||||
|
||||
|
||||
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory(1024, 768)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
|
||||
@@ -80,6 +80,17 @@ class AuthMiddleware
|
||||
// 当前访问路径
|
||||
$accessUri = strtolower($request->controller() . '/' . $request->action());
|
||||
|
||||
// 患者列表的部门筛选复用列表权限;只允许 GET 查询显式启用数据范围的部门树。
|
||||
// 必须与 DeptController::all 读取同一个 GET 参数,避免请求体参数意外放行全量部门。
|
||||
if ($accessUri === 'dept.dept/all'
|
||||
&& $request->isGet()
|
||||
&& in_array($request->get('apply_data_scope'), [1, '1'], true)) {
|
||||
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
||||
if (in_array('tcm.diagnosis/lists', $uris, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot analysis shares the assistant grant and must never use the
|
||||
// unregistered-route bypass, even before menus have been synchronized.
|
||||
if ($accessUri === 'tcm.diagnosis/aitonguescreenshot') {
|
||||
|
||||
Reference in New Issue
Block a user