diff --git a/app/src/doctor_workstation/services/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py index 3c40de4ad..10e8cfea6 100755 --- a/app/src/doctor_workstation/services/mock_repository.py +++ b/app/src/doctor_workstation/services/mock_repository.py @@ -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.""" diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py index 9a420b56d..a5a5e39f9 100755 --- a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py +++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py @@ -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: diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 850920943..87c46b86e 100755 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -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( diff --git a/app/tests/test_consultation_doctor_filter.py b/app/tests/test_consultation_doctor_filter.py new file mode 100644 index 000000000..b04502bc7 --- /dev/null +++ b/app/tests/test_consultation_doctor_filter.py @@ -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 diff --git a/app/tests/test_consultation_patient_name_selection.py b/app/tests/test_consultation_patient_name_selection.py new file mode 100644 index 000000000..b8a42c436 --- /dev/null +++ b/app/tests/test_consultation_patient_name_selection.py @@ -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() diff --git a/app/tests/test_consultations_parity_ui.py b/app/tests/test_consultations_parity_ui.py index f90c310fa..94dee4df7 100755 --- a/app/tests/test_consultations_parity_ui.py +++ b/app/tests/test_consultations_parity_ui.py @@ -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() diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php index 55e20e5e5..ebf6bada7 100755 --- a/server/app/adminapi/lists/tcm/DiagnosisLists.php +++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php @@ -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]; } /** diff --git a/server/tests/DiagnosisDoctorFilterSqlFixture.php b/server/tests/DiagnosisDoctorFilterSqlFixture.php new file mode 100644 index 000000000..9ea3cb27d --- /dev/null +++ b/server/tests/DiagnosisDoctorFilterSqlFixture.php @@ -0,0 +1,174 @@ +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; diff --git a/server/tests/DiagnosisDoctorFilterTest.py b/server/tests/DiagnosisDoctorFilterTest.py new file mode 100644 index 000000000..7ed3f39b9 --- /dev/null +++ b/server/tests/DiagnosisDoctorFilterTest.py @@ -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()