This commit is contained in:
gr
2026-11-05 17:28:39 +08:00
9 changed files with 894 additions and 18 deletions
@@ -15,6 +15,7 @@ from pathlib import Path
from threading import RLock
from typing import Any, Literal, TypeVar
from doctor_workstation.core.appointment_modes import appointment_type_description
from doctor_workstation.core.errors import ApiBusinessError, RepositoryNotFoundError
from doctor_workstation.core.models import (
Appointment,
@@ -2169,7 +2170,13 @@ class DemoDoctorRepository:
with self._lock:
rows = list(self._consultations)
keyword = str(filters.get("patient_name") or filters.get("keyword") or "").strip()
pending_wide = _bool(filters.get("pending_assign")) and bool(
str(filters.get("pending_assign_keyword") or filters.get("keyword") or "").strip()
)
keyword = str(
(filters.get("pending_assign_keyword") if pending_wide else None)
or filters.get("patient_name") or filters.get("keyword") or ""
).strip()
if keyword:
needle = keyword.lower()
rows = [
@@ -2179,17 +2186,64 @@ class DemoDoctorRepository:
or needle in row.patient_phone.lower()
or needle in row.phone_masked.lower()
]
if filters.get("status") not in (None, ""):
if not pending_wide and filters.get("status") not in (None, ""):
status = str(filters["status"])
rows = [row for row in rows if str(row.status) == status]
if filters.get("appointment_status") not in (None, ""):
doctor_name = str(filters.get("doctor_name") or "").strip().casefold()
doctor_appointments: dict[int, list[dict[str, Any]]] = {}
if doctor_name:
appointment_date = "" if pending_wide else str(filters.get("appointment_date") or "")
status_filter = filters.get("appointment_status")
appointment_status = "" if pending_wide or status_filter is None else str(status_filter)
completed = not pending_wide and _bool(filters.get("completed_appointment"))
def matches_doctor(row: Consultation) -> bool:
appointments = row.appointments or (
[{
"id": row.appointment_id,
"doctor_id": row.doctor_id,
"doctor_name": row.doctor_name,
"status": row.appointment_status,
"appointment_date": row.appointment_date,
"appointment_time": row.appointment_time,
}]
if row.has_appointment else []
)
for appointment in appointments:
status = str(appointment.get("status", ""))
name = str(appointment.get("doctor_name") or "")
# Older demo fixtures omit repeated doctor metadata on nested appointments.
if not name and appointment.get("doctor_id", row.doctor_id) == row.doctor_id:
name = row.doctor_name
booked_date = str(
appointment.get("appointment_date")
or appointment.get("date")
or appointment.get("time_text")
or ""
)[:10]
if (
status in {"1", "3", "4"}
and doctor_name in name.casefold()
and (not appointment_date or booked_date == appointment_date)
and (not appointment_status or status == appointment_status)
and (not completed or status == "3")
):
matched = deepcopy(appointment)
matched.update(doctor_name=name, appointment_date=booked_date)
if not matched.get("doctor_id") and name == row.doctor_name:
matched["doctor_id"] = row.doctor_id
doctor_appointments.setdefault(row.id, []).append(matched)
return row.id in doctor_appointments
rows = [row for row in rows if matches_doctor(row)]
if not pending_wide and not doctor_name and filters.get("appointment_status") not in (None, ""):
appointment_status = str(filters["appointment_status"])
rows = [row for row in rows if str(row.appointment_status) == appointment_status]
if filters.get("has_appointment") not in (None, ""):
if not pending_wide and filters.get("has_appointment") not in (None, ""):
has_appointment = _bool(filters["has_appointment"])
rows = [row for row in rows if row.has_appointment is has_appointment]
confirmed_filter = filters.get("diagnosis_confirmed", filters.get("confirmed"))
if confirmed_filter not in (None, ""):
if not pending_wide and confirmed_filter not in (None, ""):
confirmed = _bool(confirmed_filter)
rows = [row for row in rows if row.confirmed is confirmed]
for filter_name, attribute_name in (
@@ -2209,7 +2263,7 @@ class DemoDoctorRepository:
assistant_id = int(filters["assistant_id"])
rows = [row for row in rows if row.assistant_id == assistant_id]
appointment_date = str(filters.get("appointment_date") or "")
if appointment_date:
if not pending_wide and appointment_date and not doctor_name:
rows = [row for row in rows if row.appointment_date == appointment_date]
start_date = str(
filters.get("latest_appointment_start_date") or filters.get("start_date") or ""
@@ -2217,12 +2271,12 @@ class DemoDoctorRepository:
end_date = str(
filters.get("latest_appointment_end_date") or filters.get("end_date") or ""
)
if start_date:
if not pending_wide and start_date:
rows = [row for row in rows if row.appointment_date >= start_date]
if end_date:
if not pending_wide and end_date:
rows = [row for row in rows if row.appointment_date <= end_date]
channel = str(filters.get("latest_appointment_channel_source") or "").strip()
if channel:
if not pending_wide and channel:
rows = [
row
for row in rows
@@ -2230,13 +2284,13 @@ class DemoDoctorRepository:
]
assign_start = str(filters.get("latest_assign_start_date") or "")
assign_end = str(filters.get("latest_assign_end_date") or "")
if assign_start:
if not pending_wide and assign_start:
rows = [
row
for row in rows
if str(row.raw.get("latest_assign_date") or "") >= assign_start
]
if assign_end:
if not pending_wide and assign_end:
rows = [
row
for row in rows
@@ -2244,14 +2298,14 @@ class DemoDoctorRepository:
]
if _bool(filters.get("pending_booking")):
rows = [row for row in rows if not row.has_appointment]
if _bool(filters.get("completed_appointment")):
if not pending_wide and _bool(filters.get("completed_appointment")):
rows = [
row
for row in rows
if str(row.appointment_status) == "3"
or any(str(item.get("status")) == "3" for item in row.appointments)
]
if _bool(filters.get("pending_assign")):
if not pending_wide and _bool(filters.get("pending_assign")):
rows = [row for row in rows if not row.assistant_id]
sort_direction = str(filters.get("sort_unserved_days") or "").lower()
if sort_direction in {"asc", "desc"}:
@@ -2262,8 +2316,66 @@ class DemoDoctorRepository:
),
reverse=sort_direction == "desc",
)
if doctor_name:
rows = [self._consultation_for_appointments(row, doctor_appointments[row.id]) for row in rows]
return _page(rows, page_no, page_size)
def _consultation_for_appointments(
self, row: Consultation, appointments: list[dict[str, Any]]
) -> Consultation:
"""Project matching appointments without changing the stored demo diagnosis."""
result = deepcopy(row)
result.appointments = sorted(appointments, key=lambda item: (
str(item.get("appointment_date") or ""),
str(item.get("appointment_time") or item.get("time_text") or ""),
int(item.get("id") or 0),
))
primary = result.appointments[0]
result.appointment_id = int(primary.get("id") or 0) or None
result.doctor_id = int(primary.get("doctor_id") or 0) or None
result.doctor_name = str(primary.get("doctor_name") or "")
result.appointment_date = str(primary.get("appointment_date") or "")
result.appointment_time = str(primary.get("appointment_time") or primary.get("time_text") or "")
if result.appointment_time.startswith(result.appointment_date + " "):
result.appointment_time = result.appointment_time[11:]
if not result.appointment_time and result.appointment_id == row.appointment_id:
result.appointment_time = row.appointment_time
result.has_appointment = True
result.appointment_status = primary.get("status")
result.appointment_status_text = {"1": "待接诊", "3": "已完成", "4": "已过号"}.get(
str(result.appointment_status), ""
)
prescriptions = sorted(
(rx for rx in self._prescriptions if result.appointment_id and rx.appointment_id == result.appointment_id),
key=lambda rx: (bool(rx.void_status), -rx.id),
)
current_rx = prescriptions[0] if prescriptions else None
result.raw.update({
"appointments": deepcopy(result.appointments),
"appointment": deepcopy(primary),
"appointment_id": result.appointment_id,
"doctor_id": result.doctor_id,
"doctor_name": result.doctor_name,
"appointment_doctor_id": result.doctor_id,
"appointment_doctor_name": result.doctor_name,
"appointment_date": result.appointment_date,
"appointment_time": result.appointment_time,
"appointment_time_text": str(primary.get("time_text") or " ".join(
part for part in (result.appointment_date, result.appointment_time) if part
)),
"appointment_status": result.appointment_status,
"appointment_status_text": result.appointment_status_text,
"appointment_type": primary.get("appointment_type") or "video",
"appointment_type_desc": appointment_type_description(primary.get("appointment_type")),
"has_appointment": 1,
"current_has_prescription": int(current_rx is not None),
"current_prescription_id": current_rx.id if current_rx else 0,
"prescription_audit_status": current_rx.audit_status if current_rx else -1,
"prescription_void_status": current_rx.void_status if current_rx else 0,
})
return result
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Return a complete demo diagnosis aggregate for edit or readonly views."""
@@ -17,6 +17,7 @@ from PySide6.QtCore import (
QItemSelectionModel,
QLine,
QModelIndex,
QPersistentModelIndex,
QPoint,
QRect,
QSize,
@@ -27,9 +28,12 @@ from PySide6.QtCore import (
from PySide6.QtGui import (
QAction,
QColor,
QContextMenuEvent,
QFont,
QFontMetrics,
QIcon,
QKeyEvent,
QKeySequence,
QLinearGradient,
QMouseEvent,
QPainter,
@@ -815,6 +819,14 @@ class DiagnosisTableModel(QAbstractTableModel):
@staticmethod
def _tooltip(row: Any, column: int) -> str:
if column == 2:
name = display_text(first_value(row, "patient_name", "name"), "")
if not name:
return ""
shortcut = QKeySequence(QKeySequence.StandardKey.Copy).toString(
QKeySequence.SequenceFormat.NativeText
)
return f"{name}\n拖动选择姓名文字后按 {shortcut},或右键复制完整姓名"
if column == 4:
channel = first_value(
row,
@@ -928,6 +940,8 @@ class DiagnosisHeader(QHeaderView):
class DiagnosisItemDelegate(QStyledItemDelegate):
"""Paint compact semantic cells without allocating QWidget trees per cell."""
selectable_names = False
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
model = index.model()
if not isinstance(model, DiagnosisTableModel):
@@ -948,7 +962,8 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
if index.column() == 0:
self._paint_checkbox(painter, option, index)
elif index.column() in {1, 2}:
self._paint_identity(painter, option.rect, record, index.column())
if index.column() != 2 or not self.selectable_names:
self._paint_identity(painter, option.rect, record, index.column())
elif index.column() == 4:
self._paint_appointment(painter, option.rect, record)
elif index.column() == 5:
@@ -1337,7 +1352,7 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
days = _as_int(value)
color = "accent" if days <= 2 else _BLUE_WARNING.name() if days <= 6 else _BLUE_DANGER.name()
self._text(painter, content, str(value) if known else "—", pixels=13, color=color)
elif column not in {10, 11}:
elif column not in {10, 11} and not (column == 2 and self.selectable_names):
text = display_text(index.data(), "—")
is_new = (
column == 1 and get_value(record, "assign_read_at", "sentinel") is None
@@ -1407,6 +1422,69 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
f"最近渠道:{channel}", pixels=13, color="muted")
class _PatientNameLabel(QLabel):
"""Real selectable text above the painted row; selection never edits the patient."""
def __init__(self, index: QModelIndex, view: DiagnosisTableView, *, tech_blue: bool) -> None:
super().__init__(display_text(index.data()), view)
self._index = QPersistentModelIndex(index)
self._view = view
self.setObjectName("DiagnosisPatientNameText")
self.setTextFormat(Qt.TextFormat.PlainText)
self.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
| Qt.TextInteractionFlag.TextSelectableByKeyboard
)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setCursor(Qt.CursorShape.IBeamCursor)
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.setWordWrap(True)
self.setContentsMargins(8, 8 if tech_blue else 4, 8 if tech_blue else 6, 8 if tech_blue else 4)
self.setMinimumSize(0, 0)
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
font = _blue_font(14, medium=True) if tech_blue else QFont(view.font())
if not tech_blue:
font.setPixelSize(14)
font.setBold(True)
self.setFont(font)
color = TECH_BLUE["heading"] if tech_blue else TEXT.name()
self.setStyleSheet(
f"QLabel#DiagnosisPatientNameText {{ background: transparent; border: none; "
f"padding: 0; color: {color}; }}"
)
self.setToolTip(str(index.data(Qt.ItemDataRole.ToolTipRole) or ""))
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if self._index.isValid():
self._view.setCurrentIndex(QModelIndex(self._index))
super().mousePressEvent(event)
def contextMenuEvent(self, event: QContextMenuEvent) -> None: # noqa: N802
menu = QMenu(self)
copy_selection = menu.addAction("复制选中文字")
copy_selection.setEnabled(self.hasSelectedText())
copy_name = menu.addAction("复制患者姓名")
chosen = menu.exec(event.globalPos())
if chosen == copy_selection:
QApplication.clipboard().setText(self.selectedText())
elif chosen == copy_name and self._index.isValid():
name = display_text(
first_value(self._index.data(DiagnosisTableModel.ROW_ROLE), "patient_name", "name"), ""
)
if name:
QApplication.clipboard().setText(name)
menu.deleteLater()
event.accept()
def enterEvent(self, event: QEvent) -> None: # noqa: N802
self._view.hovered_row.emit(self._index.row())
super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802
self._view.hovered_row.emit(-1)
super().leaveEvent(event)
class _CompatItem:
"""Tiny QTableWidgetItem-shaped adapter retained for existing callers/tests."""
@@ -1497,6 +1575,39 @@ class DiagnosisTableView(QTableView):
def resizeRowsToContents(self) -> None: # noqa: N802 - compatibility
self.rows_replaced.emit()
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 - Qt virtual
if event.matches(QKeySequence.StandardKey.Copy):
index = self.currentIndex()
if index.isValid() and self.selectionModel().isRowSelected(index.row()):
name = display_text(first_value(self.current_data(), "patient_name", "name"), "")
if name:
QApplication.clipboard().setText(name)
event.accept()
return
super().keyPressEvent(event)
def contextMenuEvent(self, event: QContextMenuEvent) -> None: # noqa: N802 - Qt virtual
index = (
self.currentIndex()
if event.reason() == QContextMenuEvent.Reason.Keyboard
else self.indexAt(event.pos())
)
if not index.isValid() or index.column() != 2:
super().contextMenuEvent(event)
return
name = display_text(
first_value(self._diagnosis_model().record(index.row()), "patient_name", "name"), ""
)
if not name:
return
self.setCurrentIndex(index)
menu = QMenu(self)
copy_action = menu.addAction("复制患者姓名")
if menu.exec(event.globalPos()) == copy_action:
QApplication.clipboard().setText(name)
menu.deleteLater()
event.accept()
def mouseMoveEvent(self, event: QMouseEvent) -> None:
row = self.indexAt(event.position().toPoint()).row()
@@ -1651,6 +1762,7 @@ class DiagnosisTableHost(QFrame):
self.setFont(_blue_font(14))
self.model = DiagnosisTableModel(parent=self, tech_blue=tech_blue)
self.delegate = _TechBlueDiagnosisDelegate(self) if tech_blue else DiagnosisItemDelegate(self)
self.delegate.selectable_names = True
self.selection = QItemSelectionModel(self.model, self)
self.main = DiagnosisTableView(self, tech_blue=tech_blue)
self.fixed = DiagnosisTableView(self, tech_blue=tech_blue)
@@ -1794,6 +1906,10 @@ class DiagnosisTableHost(QFrame):
def _install_fixed_widgets(self) -> None:
for row, record in enumerate(self.model.rows):
name_index = self.model.index(row, 2)
self.main.setIndexWidget(
name_index, _PatientNameLabel(name_index, self.main, tech_blue=self.tech_blue)
)
if self.action_policy.get("appointment_cancel", False):
overlay = _AppointmentActionOverlay(record, row, self.main, tech_blue=self.tech_blue)
if overlay.has_actions:
@@ -2005,10 +2005,13 @@ class ConsultationsPage(QWidget):
self.patient_name_edit.text().strip() or self.keyword_edit.text().strip()
)
if pending_keyword or keyword:
return {
pending_filters = {
"pending_assign": "1",
"pending_assign_keyword": pending_keyword or keyword,
}
if filters["doctor_name"]:
pending_filters["doctor_name"] = filters["doctor_name"]
return pending_filters
filters["pending_assign_order_month"] = self._pending_assign_month_value()
filters["pending_assign_keyword"] = ""
return filters
@@ -2122,12 +2125,16 @@ class ConsultationsPage(QWidget):
requests.append(("pending_booking", MappingProxyType(pending)))
if _canonical_allowed(self.permissions, "tcm.diagnosis/assign"):
pending_keyword = self.pending_assign_keyword.text().strip()
shared_keyword = self.keyword_edit.text().strip()
shared_keyword = (
self.patient_name_edit.text().strip() or self.keyword_edit.text().strip()
)
if self._pending_assign and (pending_keyword or shared_keyword):
assign = {
"pending_assign": "1",
"pending_assign_keyword": pending_keyword or shared_keyword,
}
if shared["doctor_name"]:
assign["doctor_name"] = shared["doctor_name"]
else:
assign = dict(shared)
assign.update(
@@ -0,0 +1,138 @@
"""Doctor queries keep appointment relationships and pagination intact."""
from copy import deepcopy
from datetime import date
from typing import Any
import pytest
from doctor_workstation.core.models import Consultation, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
@pytest.fixture
def repository() -> DemoDoctorRepository:
repository = DemoDoctorRepository(today=date(2026, 11, 5))
def row(diagnosis_id: int, appointments: list[dict[str, Any]]) -> Consultation:
appointments = [dict(appointment, id=diagnosis_id * 10 + index) for index, appointment in enumerate(appointments)]
primary = appointments[0] if appointments else {}
return Consultation.from_dict({
"id": diagnosis_id,
"patient_name": f"患者{diagnosis_id}",
"doctor_name": primary.get("doctor_name", "陈医生"),
"doctor_id": primary.get("doctor_id"),
"appointment_id": primary.get("id"),
"appointment_date": "2026-11-05",
"appointment_type": primary.get("appointment_type"),
"appointment_status": primary.get("status"),
"has_appointment": bool(appointments),
"appointments": appointments,
})
def appointment(doctor: str, day: str = "2026-11-05", status: int = 1) -> dict[str, Any]:
return {
"doctor_name": doctor, "doctor_id": 201 if doctor == "陈医生" else 202,
"appointment_date": day, "appointment_time": "09:00", "status": status,
"appointment_type": "video" if doctor == "陈医生" else "text",
}
repository._consultations = [
row(1, [appointment("李医生"), appointment("陈医生"), appointment("陈医生", status=4), appointment("陈医生")]),
row(2, [appointment("李医生"), appointment("陈医生", "2026-11-04", 3)]),
row(3, [appointment("李医生"), appointment("陈医生", status=3)]),
row(4, [appointment("陈医生", status=2)]),
row(5, []),
]
repository._prescriptions = [
Prescription.from_dict({"id": 101, "appointment_id": 10, "audit_status": 0}),
Prescription.from_dict({"id": 102, "appointment_id": 11, "audit_status": 1}),
]
return repository
@pytest.mark.parametrize(
("filters", "expected"),
[
({"doctor_name": " 陈医生 "}, [1, 2, 3]),
({"doctor_name": "陈"}, [1, 2, 3]),
({"doctor_name": "不存在"}, []),
({"doctor_name": " "}, [1, 2, 3, 4, 5]),
({"doctor_name": "陈医生", "appointment_date": "2026-11-05"}, [1, 3]),
({"doctor_name": "陈医生", "appointment_date": "2026-11-05", "appointment_status": 1}, [1]),
({"doctor_name": "陈医生", "appointment_date": "2026-11-05", "completed_appointment": 1}, [3]),
({"doctor_name": "陈医生", "keyword": "患者3"}, [3]),
({"doctor_name": "陈医生", "pending_assign": 1, "pending_assign_keyword": "患者",
"appointment_date": "1900-01-01", "appointment_status": 2, "status": 99}, [1, 2, 3]),
],
)
def test_demo_doctor_filter_matches_effective_same_appointment(
repository: DemoDoctorRepository, filters: dict[str, Any], expected: list[int]
) -> None:
result = repository.list_consultations(**filters)
assert [row.id for row in result.items] == expected
assert result.count == len(expected)
if str(filters.get("doctor_name") or "").strip():
for row in result.items:
assert row.doctor_name == "陈医生"
assert row.doctor_id == 201
assert row.appointment_id == row.appointments[0]["id"]
assert row.appointment_status == row.appointments[0]["status"]
assert row.appointment_date == row.appointments[0]["appointment_date"]
assert all(appointment["doctor_name"] == "陈医生" for appointment in row.appointments)
assert row.raw["appointment_doctor_name"] == row.doctor_name
assert row.raw["appointment_type"] == "video"
def test_demo_projection_preserves_source_and_uses_current_appointment_prescription(
repository: DemoDoctorRepository,
) -> None:
before = deepcopy(repository._consultations)
result = repository.list_consultations(doctor_name="陈医生", appointment_status=1)
row = result.items[0]
assert [appointment["id"] for appointment in row.appointments] == [11, 13]
assert row.raw["current_prescription_id"] == 102
assert row.raw["prescription_audit_status"] == 1
row.appointments[0]["doctor_name"] = "changed"
assert repository._consultations == before
assert [item.raw for item in repository._consultations] == [item.raw for item in before]
other = repository.list_consultations(doctor_name="李医生").items[0]
assert other.appointment_id == 10
assert other.raw["current_prescription_id"] == 101
assert repository.list_consultations().count == 5
def test_demo_doctor_filter_runs_before_pagination(repository: DemoDoctorRepository) -> None:
result = repository.list_consultations(doctor_name="陈医生", page_no=2, page_size=1)
assert [row.id for row in result.items] == [2]
assert result.count == 3
def test_default_demo_data_supports_doctor_filter() -> None:
repository = DemoDoctorRepository(today=date(2026, 11, 5))
result = repository.list_consultations(doctor_name="陈医生")
assert [row.id for row in result.items] == [501, 502, 503]
assert result.count == 3
def test_remote_preserves_doctor_query_and_server_pagination() -> None:
class Client:
calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
self.calls.append((endpoint, params))
return {"lists": [{"id": 502, "patient_name": "患者"}], "count": 7}
client = Client()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repository.list_consultations(
doctor_name="陈医生", start_date="2026-11-05", end_date="2026-11-05",
status=3, department_id=10, page_no=2, page_size=1,
)
assert client.calls == [("tcm.diagnosis/lists", {
"doctor_name": "陈医生", "appointment_date": "2026-11-05",
"completed_appointment": 1, "assistant_dept_id": 10, "page_no": 2, "page_size": 1,
})]
assert [row.id for row in result.items] == [502]
assert result.count == 7
@@ -0,0 +1,62 @@
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, QLabel
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("tech_blue", [True, False])
def test_patient_name_can_be_drag_selected_and_copied_without_opening_or_editing(
application: QApplication, tech_blue: bool,
) -> None:
host = DiagnosisTableHost(tech_blue=tech_blue)
host.resize(1400, 400)
rows = [{"id": 1, "patient_name": "测试甲"}, {"id": 2, "patient_name": "测试乙"}]
host.set_rows(rows)
opened = []
host.main.itemDoubleClicked.connect(opened.append)
host.show()
for _ in range(10):
application.processEvents()
label = host.main.indexWidget(host.model.index(1, 2))
assert isinstance(label, QLabel)
start = QPoint(label.contentsMargins().left(), label.height() // 2)
end = QPoint(start.x() + label.fontMetrics().horizontalAdvance(label.text()) + 2, start.y())
QTest.mousePress(label, Qt.MouseButton.LeftButton, pos=start)
QTest.mouseMove(label, end, 30)
QTest.mouseRelease(label, Qt.MouseButton.LeftButton, pos=end)
assert label.selectedText() == "测试乙"
assert host.main.currentRow() == 1
QTest.keyClick(label, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier)
assert application.clipboard().text() == "测试乙"
QTest.keyClicks(label, "renamed")
assert label.text() == "测试乙"
assert host.model.rows == rows
assert host.selected_records() == []
# Double-clicking text must stay in text selection, not open the diagnosis.
QTest.mouseDClick(label, Qt.MouseButton.LeftButton, pos=QPoint(start.x() + 7, start.y()))
assert label.hasSelectedText()
assert opened == []
# An unchanged poll preserves the live text selection and label instance.
selected = label.selectedText()
host.set_rows([dict(row) for row in rows])
assert host.main.indexWidget(host.model.index(1, 2)) is label
assert label.selectedText() == selected
host.close()
host.deleteLater()
application.processEvents()
+40
View File
@@ -288,6 +288,46 @@ def test_default_query_matches_admin_today_and_page_size_contract(
"sort_unserved_days",
}.issubset(query)
assert "consultation_type" not in query
page.close()
application.processEvents()
@pytest.mark.parametrize(
"pending_keyword_field",
[None, "patient_name_edit", "keyword_edit", "pending_assign_keyword"],
)
def test_doctor_search_scopes_list_and_counts_and_reset_clears_it(
application: QApplication,
immediate_async: None,
pending_keyword_field: str | None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_consultations(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page.doctor_edit.setText(" 测试医生 ")
if pending_keyword_field:
getattr(page, pending_keyword_field).setText("测试患者")
page._choose_pending_assign()
else:
page.search_button.click()
assert any(query["page_size"] == 15 for query in calls)
assert any(query["page_size"] == 1 for query in calls)
assert all(query["doctor_name"] == "测试医生" for query in calls)
if pending_keyword_field:
pending_queries = [query for query in calls if query.get("pending_assign") == "1"]
assert {query["page_size"] for query in pending_queries} == {1, 15}
assert all(query["pending_assign_keyword"] == "测试患者" for query in pending_queries)
calls.clear()
page.reset_button.click()
assert calls
assert all(not query.get("doctor_name") for query in calls)
page.close()
application.processEvents()
@@ -98,6 +98,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 关键字:待分配 Tab 用多字段宽搜;其它 Tab 仍为姓名/手机。
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
$this->applyDoctorNameFilter($query, $pendingWideSearch);
// 待分配医助列表:assistant_id 为 NULL 或 0(宽搜不加此条件)
if ($narrowPendingAssignPool) {
@@ -191,7 +192,11 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$appointmentMap = [];
$subQuery = Appointment::where('patient_id', 'in', $diagnosisIds)
->where('status', 'in', [1, 3, 4]); // 1=已预约 3=已完成 4=已过号
if (!empty($this->params['appointment_date'])) {
$doctorConditions = $this->doctorAppointmentConditions($pendingWideSearch, (new Appointment())->getTable());
if ($doctorConditions !== null) {
// 根级操作挂号与嵌套预约必须使用筛选命中的挂号,不能取该患者其他医生的第一条预约。
$subQuery->whereRaw($doctorConditions[0], $doctorConditions[1]);
} elseif (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$subQuery->where('appointment_date', (string) $this->params['appointment_date']);
}
@@ -568,6 +573,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$this->applyDiagnosisListKeywordFilter($query, $pendingAssign);
$this->applyDoctorNameFilter($query, $pendingWideSearch);
if ($narrowPendingAssignPool) {
$query->whereRaw('(assistant_id IS NULL OR assistant_id = 0)');
@@ -643,6 +649,60 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$this->applyPendingAssignBusinessOrderMonthFilter($query);
return $query->count();
}
/**
* 医生姓名按有效挂号关联;日期、状态必须命中同一条挂号,避免历史医生串入当前队列。
* EXISTS 在分页前执行且与 count 共用,不会因同医生的多次挂号重复计数。
*/
private function applyDoctorNameFilter($query, bool $pendingWideSearch): void
{
$doctorConditions = $this->doctorAppointmentConditions($pendingWideSearch, 'doctor_apt');
if ($doctorConditions === null) {
return;
}
$aptTbl = (new Appointment())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$query->whereExists(new \think\db\Raw(
"SELECT 1 FROM {$aptTbl} doctor_apt WHERE doctor_apt.patient_id = {$diagTbl}.id AND " . $doctorConditions[0],
$doctorConditions[1]
));
}
/**
* 列表/count 的诊单筛选与列表预约组装共用条件,保证医生、日期、状态来自同一挂号。
* @return array{0:string,1:array}|null
*/
private function doctorAppointmentConditions(bool $pendingWideSearch, string $appointmentAlias): ?array
{
$doctorName = trim((string) ($this->params['doctor_name'] ?? ''));
if ($doctorName === '') {
return null;
}
$adminTbl = (new Admin())->getTable();
$conditions = [
"{$appointmentAlias}.status IN (" . implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES) . ')',
"EXISTS (SELECT 1 FROM {$adminTbl} filter_doctor WHERE filter_doctor.id = {$appointmentAlias}.doctor_id"
. ' AND filter_doctor.delete_time IS NULL AND filter_doctor.name LIKE :diagnosis_doctor_name)',
];
$bindings = ['diagnosis_doctor_name' => '%' . $doctorName . '%'];
// 待分配关键词宽搜保持原有日期/状态豁免,但仍尊重明确输入的医生姓名。
if (!$pendingWideSearch) {
if (!empty($this->params['appointment_date'])) {
$conditions[] = "{$appointmentAlias}.appointment_date = :diagnosis_doctor_date";
$bindings['diagnosis_doctor_date'] = (string) $this->params['appointment_date'];
}
if (isset($this->params['appointment_status']) && $this->params['appointment_status'] !== '') {
$conditions[] = "{$appointmentAlias}.status = :diagnosis_doctor_status";
$bindings['diagnosis_doctor_status'] = (int) $this->params['appointment_status'];
}
if (isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1') {
$conditions[] = "{$appointmentAlias}.status = 3";
}
}
return [implode(' AND ', $conditions), $bindings];
}
/**
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
// Execute real list/count SQL and hydration against Python's synthetic SQLite database.
// JSON lines on stdout/stdin transport SQL/results; no application database is connected.
require dirname(__DIR__) . '/vendor/autoload.php';
final class DiagnosisDoctorNoConnection extends think\db\connector\Mysql
{
public function connect(array $config = [], $linkNum = 0, $autoConnection = false): PDO
{
throw new RuntimeException('Doctor filter tests must never open a database connection');
}
public function getFields(string $tableName): array { return []; }
}
final class DiagnosisDoctorQuery extends think\db\Query
{
public static array $captured = [];
public function with($with) { return $this; }
public function append(array $append = []) { return $this; }
private function executeFixture(): array
{
$this->parseOptions();
$query = [
'sql' => $this->getConnection()->getBuilder()->select($this),
'bindings' => array_map(static fn ($binding) => $binding[0], $this->getBind()),
];
self::$captured[] = $query;
echo json_encode(['query' => $query], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL;
$response = json_decode((string) fgets(STDIN), true, 512, JSON_THROW_ON_ERROR);
if (isset($response['error'])) {
throw new RuntimeException($response['error']);
}
return $response['rows'];
}
public function select(array $data = []): think\Collection
{
return new think\Collection($this->executeFixture());
}
public function count(string $field = '*'): int
{
$this->field('COUNT(*) AS think_count');
return (int) $this->executeFixture()[0]['think_count'];
}
public function column(string|array $field, string $key = ''): array
{
$this->field($key !== '' ? [$key, $field] : $field);
return array_column($this->executeFixture(), $field, $key !== '' ? $key : null);
}
public function getTableFields(string $tableName = ''): array
{
return [];
}
}
class DiagnosisDoctorModel
{
protected const TABLE = 'tcm_diagnosis';
public function getTable(): string { return static::TABLE; }
private static function query(): DiagnosisDoctorQuery
{
$query = (new DiagnosisDoctorQuery(new DiagnosisDoctorNoConnection(['type' => 'mysql'])))
->table(static::TABLE);
return static::TABLE === 'tcm_diagnosis' ? $query->whereNull('delete_time') : $query;
}
public static function where(...$args): DiagnosisDoctorQuery { return static::query()->where(...$args); }
public static function whereIn(...$args): DiagnosisDoctorQuery { return static::query()->whereIn(...$args); }
}
final class DiagnosisDoctorAppointment extends DiagnosisDoctorModel
{
protected const TABLE = 'doctor_appointment';
}
final class DiagnosisDoctorAdmin extends DiagnosisDoctorModel { protected const TABLE = 'admin'; }
final class DiagnosisDoctorPrescription extends DiagnosisDoctorModel { protected const TABLE = 'tcm_prescription'; }
// Unrelated list enrichment is empty in this fixture; current appointment prescriptions run real SQL.
final class DiagnosisDoctorEmptyModel
{
public static function where(...$args): self { return new self(); }
public static function whereIn(...$args): self { return new self(); }
public function __call($name, $args) { return $this; }
public function column(...$args): array { return []; }
public function select(): think\Collection { return new think\Collection([]); }
}
final class DiagnosisDoctorRevisitDue
{
public static function buildListPayload(...$args): array { return []; }
public static function emptyPayload(): array { return []; }
}
final class DiagnosisDoctorDb
{
public static function name(string $name): DiagnosisDoctorQuery
{
return (new DiagnosisDoctorQuery(new DiagnosisDoctorNoConnection(['type' => 'mysql'])))->table($name);
}
}
final class DiagnosisDoctorRoles
{
public static array $roles = [];
public static function where(...$args): self { return new self(); }
public function column(...$args): array { return self::$roles; }
}
class_alias(DiagnosisDoctorModel::class, 'app\\common\\model\\tcm\\Diagnosis');
class_alias(DiagnosisDoctorAppointment::class, 'app\\common\\model\\doctor\\Appointment');
class_alias(DiagnosisDoctorAdmin::class, 'app\\common\\model\\auth\\Admin');
class_alias(DiagnosisDoctorRoles::class, 'app\\common\\model\\auth\\AdminRole');
class_alias(DiagnosisDoctorPrescription::class, 'app\\common\\model\\tcm\\Prescription');
foreach (['tcm\\CallRecord', 'tcm\\BloodRecord', 'tcm\\DietRecord', 'tcm\\ExerciseRecord', 'dict\\DictData'] as $model) {
class_alias(DiagnosisDoctorEmptyModel::class, 'app\\common\\model\\' . $model);
}
class_alias(DiagnosisDoctorRevisitDue::class, 'app\\common\\service\\tcm\\RevisitDueService');
class_alias(DiagnosisDoctorDb::class, 'think\\facade\\Db');
class DiagnosisDoctorLists extends app\adminapi\lists\tcm\DiagnosisLists
{
public array $visibleIds = [301];
protected function dataScopeShouldApply(): bool { return true; }
protected function getDataScopeVisibleAdminIds(): ?array { return $this->visibleIds; }
}
$cases = [
'all' => [],
'blank' => ['doctor_name' => ' '],
'doctor' => ['doctor_name' => ' 陈医生 '],
'partial' => ['doctor_name' => '陈'],
'unknown' => ['doctor_name' => '不存在'],
'other' => ['doctor_name' => '李医生'],
'today' => ['doctor_name' => '陈医生', 'appointment_date' => '2026-11-05'],
'today_pending' => ['doctor_name' => '陈医生', 'appointment_date' => '2026-11-05', 'appointment_status' => 1],
'today_completed' => ['doctor_name' => '陈医生', 'appointment_date' => '2026-11-05', 'completed_appointment' => 1],
'latest_range' => ['doctor_name' => '陈医生', 'latest_appointment_start_date' => '2026-11-05', 'latest_appointment_end_date' => '2026-11-05'],
'page' => ['doctor_name' => '陈医生', 'fixture_offset' => 1, 'fixture_length' => 2],
'keyword' => ['doctor_name' => '陈医生', 'keyword' => '目标'],
'assistant' => ['doctor_name' => '陈医生', 'fixture_role' => 2],
'no_scope' => ['doctor_name' => '陈医生', 'fixture_scope' => []],
'injection' => ['doctor_name' => "陈医生' OR 1=1 --"],
'pending_wide' => [
'doctor_name' => '陈医生', 'pending_assign' => 1, 'pending_assign_keyword' => '目标',
'appointment_date' => '1900-01-01', 'appointment_status' => 2,
],
];
$reflection = new ReflectionClass(DiagnosisDoctorLists::class);
$result = [];
foreach ($cases as $name => $params) {
$lists = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('params')->setValue($lists, $params);
$reflection->getProperty('adminId')->setValue($lists, 301);
$reflection->getProperty('searchWhere')->setValue($lists, []);
$lists->visibleIds = $params['fixture_scope'] ?? [301];
$lists->limitOffset = $params['fixture_offset'] ?? 0;
$lists->limitLength = $params['fixture_length'] ?? 100;
DiagnosisDoctorRoles::$roles = isset($params['fixture_role']) ? [$params['fixture_role']] : [];
DiagnosisDoctorQuery::$captured = [];
$result[$name] = [
'count' => $lists->count(),
'lists' => $lists->lists(),
'queries' => DiagnosisDoctorQuery::$captured,
];
}
echo json_encode(['result' => $result], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL;
+167
View File
@@ -0,0 +1,167 @@
"""Execute real ThinkPHP list/count SQL and nonempty list hydration against synthetic SQLite rows.
Run: python server/tests/DiagnosisDoctorFilterTest.py
Requires PHP plus the existing server/vendor dependencies, never production data.
"""
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import unittest
class DiagnosisDoctorFilterTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.db = sqlite3.connect(":memory:")
cls.db.row_factory = sqlite3.Row
cls.db.create_function("CONCAT", -1, lambda *values: "".join(map(str, values)))
cls.db.executescript("""
CREATE TABLE tcm_diagnosis (
id INTEGER, patient_id INTEGER, patient_name TEXT, id_card TEXT, phone TEXT,
gender INTEGER, age INTEGER, diagnosis_date TEXT, diagnosis_type TEXT,
syndrome_type TEXT, assistant_id INTEGER, assign_read_at INTEGER, status INTEGER,
create_time INTEGER, update_time INTEGER, delete_time INTEGER, remark TEXT,
symptoms TEXT, current_medications TEXT
);
CREATE TABLE doctor_appointment (
id INTEGER, patient_id INTEGER, doctor_id INTEGER, appointment_date TEXT,
appointment_time TEXT, status INTEGER, appointment_type TEXT, create_time INTEGER
);
CREATE TABLE admin (id INTEGER, name TEXT, delete_time INTEGER);
CREATE TABLE tcm_prescription (
id INTEGER, diagnosis_id INTEGER, appointment_id INTEGER, doctor_name TEXT,
creator_id INTEGER, prescription_date TEXT, create_time INTEGER,
void_status INTEGER, audit_status INTEGER, delete_time INTEGER
);
INSERT INTO admin VALUES (201, '陈医生', NULL), (202, '李医生', NULL), (203, '陈医生已删除', 1);
INSERT INTO tcm_prescription VALUES
(101, 1, 1, '陈医生', 201, '2026-11-05', 1, 0, 1, NULL),
(102, 1, 13, '李医生', 202, '2026-11-03', 1, 0, 0, NULL);
""")
for diagnosis_id in range(1, 10):
cls.db.execute(
"INSERT INTO tcm_diagnosis (id, patient_id, patient_name, assistant_id, delete_time) VALUES (?, ?, ?, ?, ?)",
(diagnosis_id, diagnosis_id + 100, f"目标患者{diagnosis_id}", 302 if diagnosis_id == 7 else 301,
1 if diagnosis_id == 8 else None),
)
appointments = [
# Two same-doctor appointments must still yield one diagnosis/count.
(1, 1, 201, "2026-11-05", 1), (2, 1, 201, "2026-11-05", 1),
# Historical doctor A must not appear in doctor A's today queue.
(3, 2, 201, "2026-11-04", 3), (4, 2, 202, "2026-11-05", 1),
# Doctor A completed while doctor B is pending on the same day.
(5, 3, 201, "2026-11-05", 3), (6, 3, 202, "2026-11-05", 1),
(7, 4, 201, "2026-11-05", 2), # Cancelled does not establish ownership.
(8, 5, 202, "2026-11-05", 1),
(9, 6, 201, "2026-11-06", 4),
(10, 7, 201, "2026-11-05", 1), # Outside assistant data scope.
(11, 8, 201, "2026-11-05", 1), # Deleted diagnosis.
(12, 9, 203, "2026-11-05", 1), # Deleted doctor.
(13, 1, 202, "2026-11-03", 1), # Earlier other-doctor row must never drive actions.
(14, 1, 201, "2026-11-05", 4), # Same doctor/day, wrong status for pending queue.
]
cls.db.executemany(
"INSERT INTO doctor_appointment VALUES (?, ?, ?, ?, '09:00:00', ?, 'video', 1)", appointments
)
fixture = Path(__file__).with_name("DiagnosisDoctorFilterSqlFixture.php")
with subprocess.Popen(
[os.environ.get("PHP_BINARY", "php"), str(fixture)], encoding="utf-8",
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
) as process:
for line in process.stdout:
message = json.loads(line)
if "result" in message:
cls.results = message["result"]
break
query = message["query"]
try:
rows = cls.db.execute(query["sql"], query["bindings"]).fetchall()
response = {"rows": [dict(row) for row in rows]}
except sqlite3.Error as error:
response = {"error": f"{error}: {query['sql']}"}
process.stdin.write(json.dumps(response, ensure_ascii=False) + "\n")
process.stdin.flush()
_, errors = process.communicate(timeout=20)
if process.returncode:
raise AssertionError(errors)
@classmethod
def tearDownClass(cls):
cls.db.close()
def result(self, case):
result = self.results[case]
return [row["id"] for row in result["lists"]], result["count"]
def assert_case(self, case, expected):
ids, count = self.result(case)
self.assertCountEqual(ids, expected, case)
self.assertEqual(count, len(expected), case)
def test_name_filter_effective_appointments_and_data_scope(self):
for case in ("doctor", "partial", "keyword", "assistant"):
self.assert_case(case, [1, 2, 3, 6])
self.assert_case("other", [1, 2, 3, 5])
self.assert_case("unknown", [])
self.assert_case("no_scope", [])
def test_blank_filter_keeps_existing_list(self):
self.assertEqual(self.result("blank"), self.result("all"))
self.assert_case("all", [1, 2, 3, 4, 5, 6, 9])
def test_date_and_status_must_match_the_same_doctors_appointment(self):
self.assert_case("today", [1, 3])
self.assert_case("today_pending", [1])
self.assert_case("today_completed", [3])
def test_latest_date_range_retains_its_existing_latest_appointment_meaning(self):
self.assert_case("latest_range", [1, 2, 3])
def test_pagination_does_not_change_filtered_count(self):
all_ids, count = self.result("doctor")
self.assertEqual(self.result("page"), (all_ids[1:3], count))
def test_pending_keyword_wide_search_keeps_explicit_doctor(self):
self.assert_case("pending_wide", [1, 2, 3, 6, 7])
def test_hydrated_appointments_root_and_prescription_use_matching_appointment(self):
for case in ("doctor", "today", "today_pending", "today_completed", "pending_wide"):
for row in self.results[case]["lists"]:
with self.subTest(case=case, diagnosis=row["id"]):
self.assertTrue(row["appointments"])
for appointment in row["appointments"]:
self.assertEqual(appointment["doctor_name"], "陈医生")
self.assertEqual(appointment["doctor_id"], 201)
if case.startswith("today"):
self.assertTrue(appointment["time_text"].startswith("2026-11-05"))
if case in ("today_pending", "today_completed"):
self.assertEqual(appointment["status"], 1 if case == "today_pending" else 3)
primary = row["appointments"][0]
for root, nested in (
("appointment_id", "id"), ("appointment_status", "status"),
("appointment_doctor_id", "doctor_id"), ("appointment_doctor_name", "doctor_name"),
("appointment_time_text", "time_text"), ("appointment_type", "appointment_type"),
):
self.assertEqual(row[root], primary[nested])
row = next(row for row in self.results["today_pending"]["lists"] if row["id"] == 1)
self.assertEqual([appointment["id"] for appointment in row["appointments"]], [1, 2])
self.assertEqual(row["current_prescription_id"], 101)
self.assertEqual(row["prescription_audit_status"], 1)
def test_doctor_filter_does_not_change_latest_appointment_summary(self):
row = next(row for row in self.results["doctor"]["lists"] if row["id"] == 2)
self.assertEqual(row["appointment_id"], 3)
self.assertEqual(row["latest_appointment_id"], 4)
def test_doctor_name_is_bound_data(self):
self.assert_case("injection", [])
for query in self.results["injection"]["queries"]:
self.assertNotIn("陈医生' OR 1=1 --", query["sql"])
self.assertIn("%陈医生' OR 1=1 --%", query["bindings"].values())
if __name__ == "__main__":
unittest.main()