gengx
This commit is contained in:
@@ -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()
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user