diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index 57f1098f6..3fa9dc584 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -299,3 +299,41 @@ export function assistantPerformanceRevisitRate(params: { }): Promise { return request.get({ url: '/stats.assistantPerformance/revisitRate', params }) } + + +/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */ +export type RevisitRateCohortType = 'new' | 'inherit' + +export interface RevisitRateQuery { + start_date: string + end_date: string + dept_ids?: string + assistant_id?: number +} + +export interface RevisitRateDetailQuery extends RevisitRateQuery { + scope_assistant_id?: number + dept_id?: number +} + +const revisitCohortPrefix = (cohortType: RevisitRateCohortType) => + cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate' + +export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params }) +} + +export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` }) +} + +export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params }) +} + +export function revisitCohortVisitOrderLines( + params: RevisitRateDetailQuery & { slot: number }, + cohortType: RevisitRateCohortType +) { + return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params }) +} diff --git a/admin/src/views/stats/revisit_assign_rate/components/CohortReport.vue b/admin/src/views/stats/revisit_assign_rate/components/CohortReport.vue new file mode 100644 index 000000000..061ae2f0a --- /dev/null +++ b/admin/src/views/stats/revisit_assign_rate/components/CohortReport.vue @@ -0,0 +1,901 @@ + + + + + diff --git a/admin/src/views/stats/revisit_assign_rate/index.vue b/admin/src/views/stats/revisit_assign_rate/index.vue new file mode 100644 index 000000000..805136cbc --- /dev/null +++ b/admin/src/views/stats/revisit_assign_rate/index.vue @@ -0,0 +1,7 @@ + + + diff --git a/admin/src/views/stats/revisit_inherit_rate/index.vue b/admin/src/views/stats/revisit_inherit_rate/index.vue new file mode 100644 index 000000000..b521ef6a8 --- /dev/null +++ b/admin/src/views/stats/revisit_inherit_rate/index.vue @@ -0,0 +1,7 @@ + + + diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py index d51331f06..a2ecb96be 100644 --- a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py +++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py @@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page. from __future__ import annotations -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from math import ceil from typing import Any @@ -75,12 +75,9 @@ TEXT = QColor("#1A1C1F") SECONDARY = QColor("#606163") PLACEHOLDER = QColor("#8E8F90") _INVALID_INDEX = QModelIndex() -# The last two columns are the frozen right-hand pane. They were 120 + 410, -# which pinned 532 px of the table. Measured against what they actually hold: -# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline -# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So -# 265 px of the action column was permanently blank on every row while the data -# columns to its left were horizontally scrolling. +# The last two columns form the compact frozen pane. Video status and ending +# time occupy separate lines; actions keep two inline links plus 更多, with +# the remaining actions in its menu so clinical columns retain more space. _TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160) _BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158) _BLUE_WARNING = QColor("#A9691D") @@ -1609,6 +1606,7 @@ class DiagnosisTableHost(QFrame): self, *, action_policy: Mapping[str, bool] | None = None, + video_entry_allowed: Callable[[Any], bool] | None = None, parent: QWidget | None = None, tech_blue: bool = False, ) -> None: @@ -1618,6 +1616,7 @@ class DiagnosisTableHost(QFrame): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setMinimumHeight(0) self.action_policy = dict(action_policy or {}) + self.video_entry_allowed = video_entry_allowed self.force_open_prescription = False if tech_blue: self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10] @@ -1633,6 +1632,9 @@ class DiagnosisTableHost(QFrame): self._fixed_height_timer.timeout.connect(self._sync_fixed_height) self.main.setObjectName("DiagnosisMainTable") self.fixed.setObjectName("DiagnosisFixedTable") + # Index widgets supply their own margins. Inherited item padding would + # shrink a 92px video cell to 76px and clip its wrapped status text. + self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }") for view in (self.main, self.fixed): view.setModel(self.model) view.setSelectionModel(self.selection) @@ -1786,11 +1788,11 @@ class DiagnosisTableHost(QFrame): host.hovered_row.connect(self._set_hover_row) layout = QHBoxLayout(host) layout.setContentsMargins(3, 2, 3, 2) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) video_capable = self.action_policy.get("video_call", False) + entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record) call_state = video_call_state(record) is_text = appointment_type_value(record) == "text" - if video_capable and _appointment_active(record) and (is_text or call_state == "live"): + if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"): button = QToolButton(host) button.setText("图文沟通" if is_text else "进入视频问诊") button.setProperty("rowLink", "primary") @@ -1803,10 +1805,17 @@ class DiagnosisTableHost(QFrame): button.clicked.connect( lambda _checked=False, item=record: self.video_requested.emit(item) ) - layout.addWidget(button) + layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter) else: status = "—" - if video_capable: + if not entry_allowed: + # Match the admin's public status without turning an active + # doctor's room into a receiver entry for an unrelated user. + if _appointment_active(record) and can_appointment_video(appointment_type_value(record)): + status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get( + call_state, first_value(record, "video_call_hint.label", default="—") + ) + elif video_capable: if call_state == "pending_room": status = "等待接通" elif _appointment_active(record): @@ -1816,10 +1825,17 @@ class DiagnosisTableHost(QFrame): _appointment_status(record), "未挂号" if not _has_appointment(record) else "—", ) - label = QLabel(display_text(status), host) + status_text = display_text(status) + label = QLabel(status_text.replace(" · ", "\n", 1), host) label.setProperty("fixedMuted", True) + label.setTextFormat(Qt.TextFormat.PlainText) + label.setToolTip(status_text) label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setWordWrap(True) + # Fill the cell instead of centering the label at its sizeHint: + # its wrapped height can grow when the available width changes. + label.setMinimumWidth(0) + label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) layout.addWidget(label) return host diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py index 0fdb6f2bf..b056bd449 100644 --- a/app/src/doctor_workstation/ui/dialogs/ai_consult.py +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -11,6 +11,7 @@ from copy import deepcopy from datetime import datetime from html import escape from threading import Event +from time import monotonic from typing import Any from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot @@ -2338,8 +2339,14 @@ class _RichMessage(QTextBrowser): self.document().contentsChanged.connect(self._fit) def set_payload(self, raw: Any) -> None: - render_chat_payload(self, raw, role=self._role) - QTimer.singleShot(0, self._fit) + # Markdown parsing and block formatting both emit contentsChanged. Fit + # once after the complete update instead of laying out each mutation. + self._fitting = True + try: + render_chat_payload(self, raw, role=self._role) + finally: + self._fitting = False + self._fit() def resizeEvent(self, event) -> None: # type: ignore[override] super().resizeEvent(event) @@ -3177,6 +3184,8 @@ class _AiStreamWorker(QRunnable): if self.is_cancelled(): return self.signals.event.emit(event) + if isinstance(event, Mapping) and event.get("event") == "done": + return except Exception as error: # UI boundary: render transport/domain errors in place. if not self.is_cancelled(): self.signals.error.emit(error) @@ -3517,6 +3526,7 @@ class AiConsultDialog(QDialog): self._detail: Mapping[str, Any] = {} self._workspace_sections: dict[str, dict[str, Any]] = {} self._generation = 0 + self._session_closed = False self._image_generation = 0 self._image_preview: QWidget | None = None # 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。 @@ -3528,8 +3538,12 @@ class AiConsultDialog(QDialog): self._stream_worker: _AiStreamWorker | None = None self._stream_bubble: _ChatBubble | None = None self._stream_text = "" + self._stream_prompt = "" self._pending_chunks: list[str] = [] self._stream_meta: dict[str, Any] = {} + self._stream_started_at: float | None = None + self._stream_first_delta_at: float | None = None + self._stream_ended_at: float | None = None self._patient_ai_context: str = "" self._patient_ai_context_labels: list[str] = [] self._patient_ai_context_bubble: _ChatBubble | None = None @@ -3546,6 +3560,9 @@ class AiConsultDialog(QDialog): self._stream_timeout_timer = QTimer(self) self._stream_timeout_timer.setSingleShot(True) self._stream_timeout_timer.timeout.connect(self._stream_timed_out) + self._stream_elapsed_timer = QTimer(self) + self._stream_elapsed_timer.setInterval(1000) + self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed) self.setObjectName("AiConsultDialog") self.setWindowTitle("问诊详情") # QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。 @@ -3562,6 +3579,7 @@ class AiConsultDialog(QDialog): self.setStyleSheet(AI_CONSULT_QSS) mark_business_dialog(self, "AiConsultDialog") self._build() + self.finished.connect(self._end_session) def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override] # QLineEdit emits returnPressed before the key reaches QDialog. The @@ -3585,6 +3603,7 @@ class AiConsultDialog(QDialog): source_title: str = "问诊列表", ) -> None: self._cancel_stream() + self._session_closed = False self.diagnosis_id = diagnosis_id self.patient_id = _as_int(patient_id) self._seed = dict(seed) if isinstance(seed, Mapping) else {} @@ -3792,7 +3811,7 @@ class AiConsultDialog(QDialog): button.setCursor(Qt.CursorShape.PointingHandCursor) button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) button.clicked.connect( - lambda _checked=False, text=prompt: self._ask(text) + lambda _checked=False, text=prompt: self._ask(text, replace_active=True) ) chips.addWidget(button, 1) more = QPushButton("更多") @@ -3803,7 +3822,7 @@ class AiConsultDialog(QDialog): for label, prompt in QUICK_PROMPTS[3:]: action = more_menu.addAction(label) action.triggered.connect( - lambda _checked=False, text=prompt: self._ask(text) + lambda _checked=False, text=prompt: self._ask(text, replace_active=True) ) more.setMenu(more_menu) chips.addWidget(more, 1) @@ -3996,7 +4015,7 @@ class AiConsultDialog(QDialog): for index, (kind, label, prompt) in enumerate(TOOL_ITEMS): card = _ClickCard() card.setObjectName("AiConsultToolCard") - card.clicked.connect(lambda text=prompt: self._ask(text)) + card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True)) inner = QVBoxLayout(card) inner.setContentsMargins(8, 10, 8, 10) inner.setSpacing(6) @@ -5976,11 +5995,14 @@ class AiConsultDialog(QDialog): def _use_suggestion(self, question: str) -> None: self.input.setText(question) - self._ask(f"请针对该问题给出问诊话术与判断要点:{question}") + self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True) - def _ask(self, prompt: str) -> None: + def _ask(self, prompt: str, *, replace_active: bool = False) -> None: text = prompt.strip() - if self._asking or not text: + if not text: + return + if self._asking and (not replace_active or text == self._stream_prompt): + show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info") return if self.diagnosis_id <= 0: show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning") @@ -5997,8 +6019,12 @@ class AiConsultDialog(QDialog): "warning", ) return - self._cancel_stream() + self._cancel_stream(replaced=self._asking) + self._stream_started_at = monotonic() + self._stream_prompt = text self._follow_chat = True + self.tabs.setCurrentIndex(0) + self._set_chat_filter("all") self.input.clear() self._append_bubble( "doctor", text, time_text=datetime.now().strftime("%H:%M") @@ -6019,6 +6045,8 @@ class AiConsultDialog(QDialog): self._pending_chunks.clear() self._stream_meta.clear() self._asking = True + self._update_stream_elapsed() + self._stream_elapsed_timer.start() self.send_button.setEnabled(False) generation = self._generation stream_generation = self._stream_generation @@ -6047,26 +6075,65 @@ class AiConsultDialog(QDialog): def _stream_is_current(self, generation: int, stream_generation: int) -> bool: return generation == self._generation and stream_generation == self._stream_generation + def _update_stream_elapsed(self) -> None: + if self._stream_bubble is None: + return + model = display_text( + first_value(self._stream_meta, "model_label", "model_key"), "" + ) + if self._stream_started_at is None: + self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M"))) + return + now = self._stream_ended_at if self._stream_ended_at is not None else monotonic() + elapsed = max(0.0, now - self._stream_started_at) + if self._stream_ended_at is None: + status = ( + f"生成中 · 已用时 {int(elapsed)} 秒" + if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒" + ) + self._stream_bubble.set_time_text(_join_meta(model, status)) + return + first_delta = ( + f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒" + if self._stream_first_delta_at is not None else "" + ) + self._stream_bubble.set_time_text( + _join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M")) + ) + + def _stop_stream_clock(self) -> None: + if self._stream_ended_at is None: + self._stream_ended_at = monotonic() + self._stream_elapsed_timer.stop() + self._update_stream_elapsed() + def _stream_event( self, generation: int, stream_generation: int, event: Any, ) -> None: - if not self._stream_is_current(generation, stream_generation): + if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None: return payload = dict(event) if isinstance(event, Mapping) else {} kind = str(payload.get("event") or "").lower() if kind == "start": self._stream_meta.update(payload) + self._update_stream_elapsed() if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks: self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT) return if kind == "delta": chunk = payload.get("text") if isinstance(chunk, str) and chunk: + if self._stream_first_delta_at is None: + self._stream_first_delta_at = monotonic() + self._update_stream_elapsed() self._pending_chunks.append(chunk) - if not self._flush_timer.isActive(): + if not self._stream_text: + # The first received text replaces the waiting state now. + self._flush_stream_chunks() + elif not self._flush_timer.isActive(): self._flush_timer.start() return if kind != "done": @@ -6074,6 +6141,7 @@ class AiConsultDialog(QDialog): self._stream_meta.update(payload) self._stream_completed = True self._stream_timeout_timer.stop() + self._stop_stream_clock() if not self._stream_text and not self._pending_chunks: answer = first_value(payload, "answer", "content", default="") if answer not in (None, ""): @@ -6083,17 +6151,15 @@ class AiConsultDialog(QDialog): if not self._stream_text: self._stream_text = "未返回分析内容。" self._stream_bubble.set_payload(self._stream_text) - model = display_text( - first_value(self._stream_meta, "model_label", "model_key"), - "", - ) - self._stream_bubble.set_time_text( - _join_meta(model, datetime.now().strftime("%H:%M")) - ) self._stream_bubble.set_provenance(self._stream_meta) self._stream_bubble.finalize_clinical_analysis() + # A terminal event completes the UI request even if transport cleanup + # has not returned yet. The next click must not depend on finished. + self._asking = False + self.send_button.setEnabled(True) def _flush_stream_chunks(self) -> None: + self._flush_timer.stop() if not self._pending_chunks or self._stream_bubble is None: return self._stream_text += "".join(self._pending_chunks) @@ -6108,9 +6174,10 @@ class AiConsultDialog(QDialog): stream_generation: int, error: Exception, ) -> None: - if not self._stream_is_current(generation, stream_generation): + if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None: return self._stream_timeout_timer.stop() + self._stop_stream_clock() self._flush_stream_chunks() message = friendly_error(error) if self._stream_text: @@ -6119,7 +6186,8 @@ class AiConsultDialog(QDialog): self._stream_text = message if self._stream_bubble is not None: self._stream_bubble.set_payload(self._stream_text) - self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M")) + self._asking = False + self.send_button.setEnabled(True) def _stream_finished( self, @@ -6130,6 +6198,8 @@ class AiConsultDialog(QDialog): if not self._stream_is_current(generation, stream_generation): return self._stream_timeout_timer.stop() + self._flush_stream_chunks() + self._stop_stream_clock() if self._stream_worker is worker: self._stream_worker = None # 连接结束却既没有 done 也没有报错时(服务端静默断开), @@ -6141,7 +6211,6 @@ class AiConsultDialog(QDialog): ): self._stream_text = AI_STREAM_SILENT_TEXT self._stream_bubble.set_payload(self._stream_text) - self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M")) self._asking = False self.send_button.setEnabled(True) @@ -6151,34 +6220,53 @@ class AiConsultDialog(QDialog): self._flush_stream_chunks() bubble = self._stream_bubble text = self._stream_text + self._stop_stream_clock() self._cancel_stream() if bubble is not None: bubble.set_payload( f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT ) - bubble.set_time_text(datetime.now().strftime("%H:%M")) - def _cancel_stream(self) -> None: + def _cancel_stream(self, *, replaced: bool = False) -> None: + if replaced and self._asking: + self._flush_stream_chunks() + self._stop_stream_clock() + if self._stream_bubble is not None: + notice = "已停止本次生成,已切换到新的请求。" + self._stream_bubble.set_payload( + f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice + ) self._stream_generation += 1 self._flush_timer.stop() self._stream_timeout_timer.stop() + self._stream_elapsed_timer.stop() if self._stream_worker is not None: self._stream_worker.cancel() self._stream_worker = None self._stream_bubble = None self._pending_chunks.clear() self._stream_text = "" + self._stream_prompt = "" self._stream_completed = False self._stream_meta.clear() + self._stream_started_at = None + self._stream_first_delta_at = None + self._stream_ended_at = None self._asking = False self.send_button.setEnabled(True) - def closeEvent(self, event: Any) -> None: # type: ignore[override] + def _end_session(self) -> None: + if self._session_closed: + return + self._session_closed = True self._generation += 1 self._image_generation += 1 _abort_pending_thumbnails(self) self._close_image_preview() self._cancel_stream() + + def closeEvent(self, event: Any) -> None: # type: ignore[override] + self._end_session() super().closeEvent(event) def _open_full_record(self) -> None: diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 4f4d2e992..850920943 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -38,6 +38,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from ...core import UserProfile from ...core.appointment_modes import appointment_type_value, can_appointment_video from .. import icons from ..consultations_style import consultations_stylesheet @@ -1493,6 +1494,7 @@ class ConsultationsPage(QWidget): table_wrap_layout.setSpacing(0) self.table_host = DiagnosisTableHost( tech_blue=True, + video_entry_allowed=self._can_enter_list_call, action_policy={ "view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"), "edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"), @@ -3053,8 +3055,11 @@ class ConsultationsPage(QWidget): _as_int(payload.get(key), 0) > 0 for key in ("appointment_id", "patient_id", "diagnosis_id") ) + entry_allowed = has_record and self._can_enter_list_call(record) + self.video_button.setVisible(entry_allowed) + self.call_toolbar_button.setVisible(entry_allowed) self.video_button.setEnabled( - has_record + entry_allowed and is_video_available(record) and (appointment_type_value(record) == "text" or video_call_is_live(record)) and valid_ids @@ -3496,6 +3501,28 @@ class ConsultationsPage(QWidget): self._checked_changed(len(self.table_host.selected_records())) self._selection_changed() + def _can_enter_list_call(self, record: Any) -> bool: + if not self._native_video_capable or record is None: + return False + # Text appointments retain their IM entry; this restriction concerns + # receiving an existing video call from the diagnosis list. + if appointment_type_value(record) == "text": + return True + if not can_appointment_video(appointment_type_value(record)): + return False + roles = UserProfile.from_dict({ + "role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[]) + }).role_ids + if 1 in roles: + return False + user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0)) + assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0)) + return bool( + user_id > 0 + and assistant_id == user_id + and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall") + ) + def _request_video(self) -> None: record = self.table.current_data() if not self._native_video_capable: @@ -3503,6 +3530,9 @@ class ConsultationsPage(QWidget): return if record is None or not is_video_available(record): self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning") + return + if not self._can_enter_list_call(record): + self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning") return if appointment_type_value(record) != "text" and not video_call_is_live(record): self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning") diff --git a/app/src/doctor_workstation/ui/pages/reception.py b/app/src/doctor_workstation/ui/pages/reception.py index 5ee00a128..95370b2e5 100644 --- a/app/src/doctor_workstation/ui/pages/reception.py +++ b/app/src/doctor_workstation/ui/pages/reception.py @@ -321,6 +321,11 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta { font-size: 13px; font-weight: 400; } +QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant { + color: #273244; + font-size: 13px; + font-weight: 400; +} QWidget#ReceptionPage QLabel#StatusBadge { font-size: 13px; font-weight: 400; @@ -4394,8 +4399,17 @@ class ReceptionPage(QWidget): actions.setContentsMargins(0, 0, 0, 0) actions.setSpacing(14) actions.addStretch(1) - patient_head.addLayout(actions) - patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom) + action_group = QVBoxLayout() + action_group.setContentsMargins(0, 0, 0, 0) + action_group.setSpacing(6) + self.current_assistant_label = QLabel("当前医助:—", hero) + self.current_assistant_label.setObjectName("ReceptionCurrentAssistant") + self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText) + self.current_assistant_label.setWordWrap(True) + action_group.addWidget(self.current_assistant_label) + action_group.addLayout(actions) + patient_head.addLayout(action_group) + patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom) self.complete_button = _ReceptionCompleteButton(hero) self.complete_button.clicked.connect(self._complete_appointment) @@ -7885,9 +7899,14 @@ class ReceptionPage(QWidget): ) ) ) - self.appointment_labels["assistant"].setText( - display_text(first_value(appointment, "assistant_name")) - ) + # reception normalises these fields from the diagnosis's current + # assistant, matching the recipient of the notification action. + assistant_name = display_text(first_value(appointment, "assistant_name"), "") + assistant_id = _as_int(first_value(appointment, "assistant_id"), None) + missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—" + self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}") + self.current_assistant_label.setToolTip(self.current_assistant_label.text()) + self.appointment_labels["assistant"].setText(assistant_name or "—") self.appointment_labels["type"].setText( appointment_type_description(appointment_type_value(appointment)) ) @@ -8821,6 +8840,8 @@ class ReceptionPage(QWidget): ) self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—") self.patient_meta_label.setToolTip("") + self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—") + self.current_assistant_label.setToolTip("") self.detail_status.set_status("—", "neutral") for label in getattr(self, "appointment_labels", {}).values(): label.setText("—") @@ -8886,6 +8907,8 @@ class ReceptionPage(QWidget): return self.patient_meta_label.setText(message) self.patient_meta_label.setToolTip("") + self.current_assistant_label.setText("当前医助:—") + self.current_assistant_label.setToolTip("") self._set_case_placeholder(message) self.prescription_hint.setText("处方:患者详情加载失败") self.prescription_hint.setProperty("casePrescriptionState", "unknown") diff --git a/app/tests/test_ai_consult_ui.py b/app/tests/test_ai_consult_ui.py index ee1fa8bcc..4a326866d 100644 --- a/app/tests/test_ai_consult_ui.py +++ b/app/tests/test_ai_consult_ui.py @@ -2,13 +2,16 @@ from __future__ import annotations import json import os +from threading import Event +from time import monotonic from types import SimpleNamespace from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import httpx import pytest -from PySide6.QtCore import QPoint, Qt +from PySide6.QtCore import QPoint, Qt, QThread from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QApplication, @@ -21,7 +24,7 @@ from PySide6.QtWidgets import ( ) from doctor_workstation.core import PermissionSet -from doctor_workstation.services import DemoDoctorRepository +from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module from doctor_workstation.ui.dialogs.ai_consult import ( AiConsultDialog, @@ -1577,6 +1580,356 @@ def test_answered_stream_replaces_the_pending_placeholder( dialog.close() +def test_stream_clock_covers_preparation_first_text_and_completion( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dialog = _silent_dialog(monkeypatch) + now = [100.0] + monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0]) + dialog._ask("请生成问诊小结") + bubble = dialog._stream_bubble + assert bubble is not None + generation, stream_generation = dialog._generation, dialog._stream_generation + assert "已等待 0 秒" in bubble.stamp.text() + assert dialog._stream_elapsed_timer.isActive() + + now[0] = 103.0 + dialog._stream_elapsed_timer.timeout.emit() + assert "已等待 3 秒" in bubble.stamp.text() + dialog._stream_event(generation, stream_generation, {"event": "start"}) + assert "已等待 3 秒" in bubble.stamp.text() + + now[0] = 104.2 + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"}) + # No timer flush or done event is needed to display the first text. + assert bubble._raw_payload == "问诊小结" + assert "生成中" in bubble.stamp.text() + assert "已用时 4 秒" in bubble.stamp.text() + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"}) + assert dialog._flush_timer.isActive() + + now[0] = 108.7 + dialog._stream_event( + generation, stream_generation, {"event": "done", "model_label": "千问"} + ) + assert bubble._raw_payload == "问诊小结:请补充舌脉。" + assert "千问" in bubble.stamp.text() + assert "首字 4.2 秒" in bubble.stamp.text() + assert "耗时 8.7 秒" in bubble.stamp.text() + assert not dialog._stream_elapsed_timer.isActive() + assert not dialog._flush_timer.isActive() + stamp = bubble.stamp.text() + now[0] = 130.0 + dialog._stream_finished(generation, stream_generation, dialog._stream_worker) + dialog._update_stream_elapsed() + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"}) + assert bubble.stamp.text() == stamp + assert bubble._raw_payload == "问诊小结:请补充舌脉。" + assert dialog.send_button.isEnabled() + dialog.close() + + +@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"]) +def test_stream_clock_stops_on_every_exit( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + ending: str, +) -> None: + dialog = _silent_dialog(monkeypatch) + now = [100.0] + monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0]) + dialog._ask("请总结病情") + bubble = dialog._stream_bubble + assert bubble is not None + now[0] = 112.4 + generation, stream_generation = dialog._generation, dialog._stream_generation + worker = dialog._stream_worker + if ending == "error": + dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙")) + dialog._stream_finished(generation, stream_generation, worker) + elif ending == "timeout": + dialog._stream_timed_out() + elif ending == "silent": + dialog._stream_finished(generation, stream_generation, worker) + elif ending == "switch": + dialog.open_for(diagnosis_id=502, patient_id=302) + else: + dialog.close() + assert not dialog._stream_elapsed_timer.isActive() + assert not dialog._stream_timeout_timer.isActive() + assert not dialog._asking + assert dialog.send_button.isEnabled() + if ending in {"error", "timeout", "silent"}: + assert "耗时 12.4 秒" in bubble.stamp.text() + stamp = bubble.stamp.text() + now[0] = 140.0 + dialog._update_stream_elapsed() + assert bubble.stamp.text() == stamp + if ending == "switch": + dialog._ask("新患者的问题") + assert dialog._stream_bubble is not None + assert "已等待 0 秒" in dialog._stream_bubble.stamp.text() + assert dialog._stream_first_delta_at is None + dialog.close() + + +def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise HTTP parsing, repository, real worker and Qt delivery together.""" + release = Event() + waiting_for_end = Event() + + class DelayedStream(httpx.SyncByteStream): + def __iter__(self): + first = ( + 'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n' + 'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n' + ).encode() + # A network chunk may end partway through a Chinese character. + split = first.index("先".encode()) + 1 + yield first[:split] + yield first[split:] + waiting_for_end.set() + if not release.wait(5): + raise AssertionError("UI did not receive the first streamed text") + yield ( + 'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n' + 'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n' + ).encode() + + client = ApiClient( + "https://example.test", + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=DelayedStream() + ) + ), + ) + dialog = AiConsultDialog(RemoteDoctorRepository(client)) + dialog.diagnosis_id = 501 + dialog._context_notice_shown = True + delivered_on = [] + original_event = dialog._stream_event + + def record_event(*args: Any) -> None: + delivered_on.append(QThread.currentThread()) + original_event(*args) + + monkeypatch.setattr(dialog, "_stream_event", record_event) + dialog.show() + try: + dialog._ask("请生成问诊小结") + deadline = monotonic() + 3 + while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline: + QTest.qWait(10) + bubble = dialog._stream_bubble + assert bubble is not None and bubble.body is not None + assert waiting_for_end.is_set() + assert bubble.body.toPlainText() == "先收到的内容" + assert not dialog._stream_completed + assert dialog._asking + assert "生成中" in bubble.stamp.text() + release.set() + deadline = monotonic() + 3 + while dialog._asking and monotonic() < deadline: + QTest.qWait(10) + assert dialog._stream_completed + assert not dialog._asking + assert bubble.body.toPlainText() == "先收到的内容,然后完成。" + assert delivered_on and all(thread == application.thread() for thread in delivered_on) + assert not dialog._stream_elapsed_timer.isActive() + finally: + release.set() + dialog.close() + client.close() + + +@pytest.mark.parametrize("terminal", ["done", "error"]) +def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + terminal: str, +) -> None: + dialog = _silent_dialog(monkeypatch) + dialog.show() + application.processEvents() + cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard") + assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6 + previous = [] + for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True): + # Exercise the text surface the doctor clicks, including event propagation. + target = next(child for child in card.findChildren(QLabel) if child.text() == label) + QTest.mouseClick(target, Qt.MouseButton.LeftButton) + worker = dialog._stream_worker + assert worker is not None and worker.prompt == prompt + assert dialog._asking + generation, stream_generation = dialog._generation, dialog._stream_generation + for old_generation, old_stream_generation, old_worker in previous: + dialog._stream_finished(old_generation, old_stream_generation, old_worker) + assert dialog._stream_worker is worker + assert dialog._asking + previous.append((generation, stream_generation, worker)) + if terminal == "done": + dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"}) + else: + dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙")) + # The transport may take longer to close; it must not keep the UI locked. + assert not dialog._asking + assert dialog.send_button.isEnabled() + dialog.close() + + +def test_clicking_another_quick_tool_switches_the_active_request( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dialog = _silent_dialog(monkeypatch) + notices = [] + monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1])) + dialog.show() + application.processEvents() + cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard") + QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton) + old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble + old_generation, old_stream_generation = dialog._generation, dialog._stream_generation + assert old_worker is not None and old_bubble is not None + dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"}) + + QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton) + worker, bubble = dialog._stream_worker, dialog._stream_bubble + assert worker is not None and worker is not old_worker + assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2] + assert old_worker.is_cancelled() + assert "已收到的分析" in old_bubble._raw_payload + assert "已停止" in old_bubble._raw_payload + assert bubble is not None and bubble is not old_bubble + assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT + assert "已等待 0 秒" in bubble.stamp.text() + assert dialog._asking + + dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"}) + dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误")) + dialog._stream_finished(old_generation, old_stream_generation, old_worker) + assert dialog._stream_worker is worker + assert dialog._asking + assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT + + # Repeated clicks on the same running tool do not submit duplicate requests. + QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton) + assert dialog._stream_worker is worker + assert not worker.is_cancelled() + assert notices and "正在生成" in notices[-1] + dialog.close() + + +def test_stream_worker_does_not_wait_for_another_event_after_done() -> None: + continued_after_done = [] + + class DoneThenIdleRepository: + def stream_diagnosis_ai(self, *args: Any, **kwargs: Any): + yield {"event": "done", "answer": "已完成"} + continued_after_done.append(True) + raise AssertionError("the terminal event must end iteration") + + worker = ai_consult_module._AiStreamWorker( + DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom" + ) + events, errors, finished = [], [], [] + worker.signals.event.connect(events.append) + worker.signals.error.connect(errors.append) + worker.signals.finished.connect(lambda: finished.append(True)) + worker.run() + assert [event["event"] for event in events] == ["done"] + assert continued_after_done == [] + assert errors == [] + assert finished == [True] + + +@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"]) +def test_quick_actions_switch_requests_and_reveal_the_chat( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + entry: str, +) -> None: + dialog = _silent_dialog(monkeypatch) + dialog.show() + application.processEvents() + dialog._ask("之前的请求") + old_worker = dialog._stream_worker + assert old_worker is not None + dialog.tabs.setCurrentIndex(1) + dialog._set_chat_filter("doctor") + if entry == "tool": + target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0] + prompt = ai_consult_module.TOOL_ITEMS[0][2] + QTest.mouseClick(target, Qt.MouseButton.LeftButton) + elif entry == "chip": + target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情") + prompt = ai_consult_module.QUICK_PROMPTS[0][1] + target.click() + elif entry == "menu": + more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多") + prompt = ai_consult_module.QUICK_PROMPTS[3][1] + more.menu().actions()[0].trigger() + else: + target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0] + prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}" + QTest.mouseClick(target, Qt.MouseButton.LeftButton) + assert old_worker.is_cancelled() + assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt + assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0 + assert dialog._chat_filter == "all" + assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible() + dialog.close() + + +@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"]) +def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + exit_action: str, +) -> None: + dialog = _silent_dialog(monkeypatch) + dialog.show() + application.processEvents() + dialog._ask("请分析病情") + worker = dialog._stream_worker + assert worker is not None + generation, stream_generation = dialog._generation, dialog._stream_generation + if exit_action == "back": + next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click() + elif exit_action == "side": + dialog.findChild(QPushButton, "AiConsultSideClose").click() + elif exit_action == "escape": + QTest.keyClick(dialog, Qt.Key.Key_Escape) + else: + getattr(dialog, exit_action)() + assert not dialog.isVisible() + assert worker.is_cancelled() + assert not dialog._asking + assert not dialog._stream_timeout_timer.isActive() + assert not dialog._stream_elapsed_timer.isActive() + closed_generation = dialog._generation + assert closed_generation > generation + dialog.close() + assert dialog._generation == closed_generation + + dialog.open_for(diagnosis_id=502, patient_id=302) + dialog.show() + dialog._ask("新患者的问题") + new_worker, bubble = dialog._stream_worker, dialog._stream_bubble + assert new_worker is not None and not new_worker.is_cancelled() + dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"}) + dialog._stream_finished(generation, stream_generation, worker) + assert dialog._stream_worker is new_worker + assert dialog._asking + assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT + dialog.close() + + def test_ai_consult_window_can_be_maximized( application: QApplication, monkeypatch: pytest.MonkeyPatch, diff --git a/app/tests/test_consultations_parity_ui.py b/app/tests/test_consultations_parity_ui.py index ab8da1fb0..f90c310fa 100644 --- a/app/tests/test_consultations_parity_ui.py +++ b/app/tests/test_consultations_parity_ui.py @@ -7,7 +7,7 @@ from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import QDate, Signal +from PySide6.QtCore import QDate, QEvent, Signal from PySide6.QtWidgets import ( QApplication, QDialog, @@ -19,7 +19,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from doctor_workstation.core import PermissionSet +from doctor_workstation.core import PermissionSet, UserProfile from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module from doctor_workstation.ui.pages import consultations as consultations_module from doctor_workstation.ui.pages.consultations import ( @@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy( application.processEvents() -def test_native_call_does_not_reuse_video_qr_permission( +def test_list_video_entry_uses_watch_permission_not_video_qr_permission( application: QApplication, ) -> None: class VideoRepository: @@ -686,13 +686,17 @@ def test_native_call_does_not_reuse_video_qr_permission( pass live_row = _row( + assistant_id=66, video_call_hint={ "state": "live", "label": "视频通话进行中", "start_time": 1787102100, } ) - page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([])) + page = ConsultationsPage( + VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]), + current_user={"id": 66, "role_id": 2}, + ) emitted: list[dict[str, Any]] = [] page.video_requested.connect(emitted.append) page.table.set_rows([live_row]) @@ -738,10 +742,13 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live( def end_call(self, diagnosis_id: int) -> None: pass - page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([])) + page = ConsultationsPage( + VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]), + current_user={"id": 66, "role_id": 2}, + ) emitted: list[dict[str, Any]] = [] page.video_requested.connect(emitted.append) - page.table.set_rows([_row(video_call_hint=hint)]) + page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)]) page.table.selectRow(0) application.processEvents() @@ -752,6 +759,92 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live( assert emitted == [] page.close() application.processEvents() + + +@pytest.mark.parametrize("user", [ + {"id": 66, "role_id": 1}, + {"id": 66, "role_id": "1"}, + {"id": 66, "role_ids": [1, 2]}, + {"id": 66, "role_id": "1,2"}, + {"id": 66, "roles": [{"id": 1}]}, + {"id": 66, "role_id": 1, "root": True}, + UserProfile.from_dict({"id": 66, "role_id": [1, 2]}), +]) +@pytest.mark.parametrize("state,status", [ + ("live", "通话中"), ("pending_room", "接通中"), ("none", "—"), +]) +def test_doctor_video_column_is_readonly_even_with_full_permission( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + user: Any, + state: str, + status: str, +) -> None: + from doctor_workstation.services import DemoDoctorRepository + + monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None) + # Even a mixed-role doctor assigned to this row must not get the receiver entry. + row = _row(assistant_id=66, video_call_hint={"state": state}) + page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user) + emitted = [] + page.video_requested.connect(emitted.append) + try: + page.table_host.set_rows([row]) + page.table.selectRow(0) + page._selection_changed() + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10)) + assert cell.findChildren(QToolButton) == [] + assert status in [label.text() for label in cell.findChildren(QLabel)] + assert page.call_toolbar_button.isHidden() + assert not page.video_button.isEnabled() + page._row_video(row) + page.video_button.setEnabled(True) + page.video_button.click() + assert emitted == [] + finally: + page.close() + page.deleteLater() + application.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + +@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [ + ({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True), + ({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False), + ({"id": 66, "role_id": 2}, [], 66, False), + ({"id": 66, "role_id": 2}, ["*"], 77, False), + ({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False), + ({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True), + ({"id": 0, "role_id": 2}, ["*"], 0, False), + (None, ["*"], 66, False), +]) +def test_list_video_entry_requires_the_assigned_user_and_watch_permission( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + user: Any, + permissions: list[str], + assistant_id: int, + allowed: bool, +) -> None: + from doctor_workstation.services import DemoDoctorRepository + + monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None) + row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"}) + page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user) + emitted = [] + page.video_requested.connect(emitted.append) + try: + page.table_host.set_rows([row]) + page.table.selectRow(0) + page._selection_changed() + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10)) + assert bool(cell.findChildren(QToolButton)) is allowed + assert page.video_button.isEnabled() is allowed + page._request_video() + assert bool(emitted) is allowed + finally: + page.close() + page.deleteLater() + application.sendPostedEvents(None, QEvent.Type.DeferredDelete) @pytest.mark.parametrize( diff --git a/app/tests/test_diagnosis_drawer_visual.py b/app/tests/test_diagnosis_drawer_visual.py index cc31fd978..1e28aca6c 100644 --- a/app/tests/test_diagnosis_drawer_visual.py +++ b/app/tests/test_diagnosis_drawer_visual.py @@ -1121,6 +1121,76 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain( dialog.close() +@pytest.mark.parametrize("locked", [False, True]) +def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity( + application: QApplication, + locked: bool, +) -> None: + class MaskedRepository(VisualRepository): + def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]: + raise AssertionError("a masked phone must not be checked as plaintext") + + def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]: + raise AssertionError("a masked identity must not be checked as plaintext") + + repository = MaskedRepository(locked=locked) + for source in (repository.detail["diagnosis"], repository.detail["patient"]): + source["phone"] = "156****7862" + source["id_card"] = "1101**********1234" + dialog = _open_dialog( + application, (1024, 640), mode="edit", repository=repository, + permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]), + ) + saved = [] + dialog.saved.connect(lambda: saved.append(True)) + try: + assert dialog.edit_fields["phone"].isReadOnly() + assert dialog.edit_fields["phone"].toPlainText() == "156****7862" + assert dialog.edit_fields["id_card"].isReadOnly() + dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善") + dialog._save() + assert saved == [True] + assert len(repository.updates) == 1 + payload = repository.updates[0] + assert payload["chief_complaint"] == "口渴较前改善" + assert "phone" not in payload and "id_card" not in payload + assert "156****7862" not in str(payload) + assert "1101**********1234" not in str(payload) + finally: + dialog.close() + + +def test_plain_phone_permission_keeps_validation_and_duplicate_checks( + application: QApplication, +) -> None: + class PlainRepository(VisualRepository): + def __init__(self) -> None: + super().__init__() + self.phone_checks = [] + + def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]: + self.phone_checks.append(dict(payload)) + return {"exists": False} + + repository = PlainRepository() + dialog = _open_dialog( + application, (1024, 640), mode="edit", repository=repository, + permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]), + ) + try: + assert not dialog.edit_fields["phone"].isReadOnly() + dialog.edit_fields["phone"].setPlainText("156****7862") + dialog._save() + assert not repository.phone_checks + assert not repository.updates + dialog.edit_fields["phone"].setPlainText("15600007862") + dialog._save() + assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}] + assert repository.updates[-1]["phone"] == "15600007862" + finally: + dialog.close() + + def test_order_detail_action_requires_exact_permission(application: QApplication) -> None: repository = VisualRepository() dialog = _open_dialog( diff --git a/app/tests/test_diagnosis_video_status_layout.py b/app/tests/test_diagnosis_video_status_layout.py new file mode 100644 index 000000000..4d985d806 --- /dev/null +++ b/app/tests/test_diagnosis_video_status_layout.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget + +from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost +from doctor_workstation.ui.reception_style import body_family + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.mark.parametrize("tech_blue", [False, True]) +@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)]) +@pytest.mark.parametrize("with_channel", [False, True]) +def test_public_video_status_fits_frozen_cell_with_inherited_item_padding( + application: QApplication, tech_blue: bool, size: tuple[int, int], + with_channel: bool, +) -> None: + root = QWidget() + font = QFont(body_family()) + font.setPixelSize(13) + root.setFont(font) + # Match GLOBAL_QSS without changing the application's theme between tests. + root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }") + layout = QVBoxLayout(root) + host = DiagnosisTableHost( + parent=root, tech_blue=tech_blue, + action_policy={"video_call": True, "view": True, "edit": True}, + video_entry_allowed=lambda _record: False, + ) + layout.addWidget(host) + statuses = [ + ("ended", "视频已结束 · 10-17 14:19"), + ("ended", "视频已结束 · 10-17 14:27"), + ("ended", "视频已结束"), + ("live", "通话中"), + ("pending_room", "接通中"), + ("missed", "上次通话未接听"), + ("cancelled", "上次通话已取消"), + ("none", "—"), + ] + rows = [ + { + "id": index + 1, "patient_id": index + 100, "patient_name": "测试患者", + "gender": 1, "age": 50, "has_appointment": 1, + "appointment_id": index + 200, "appointment_status": 1, + "appointment_type": "video", + "latest_appointment_channel_text": "自媒体4H" if with_channel else "", + "appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生", + "time_text": "2026-10-17 14:15", "appointment_type": "video"}], + "video_call_hint": {"state": state, "label": text}, + } + for index, (state, text) in enumerate(statuses * 4) + ] + try: + host.set_rows(rows) + root.resize(*size) + root.show() + for _ in range(4): + application.processEvents() + for index, (_state, text) in enumerate(statuses): + cell = host.fixed.indexWidget(host.model.index(index, 10)) + assert not cell.findChildren(QToolButton) + label = cell.findChild(QLabel) + assert label is not None + assert label.height() >= label.heightForWidth(label.width()) + assert cell.rect().contains(label.geometry()) + assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1 + assert label.toolTip() == text + if " · " in text: + assert label.text() == text.replace(" · ", "\n") + assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width() + assert cell.width() >= host.fixed.columnWidth(10) - 2 + host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum()) + for fraction in (2, 3): + host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction) + application.processEvents() + assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value() + row = host.main.indexAt(host.main.viewport().rect().center()).row() + assert row >= 0 + assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect( + host.model.index(row, 10) + ).top() + finally: + root.close() + root.deleteLater() + application.sendPostedEvents(None, QEvent.Type.DeferredDelete) diff --git a/app/tests/test_text_consult_ui.py b/app/tests/test_text_consult_ui.py index b54459322..2e15bf252 100644 --- a/app/tests/test_text_consult_ui.py +++ b/app/tests/test_text_consult_ui.py @@ -7,7 +7,7 @@ import pytest from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton -from doctor_workstation.core import PermissionSet +from doctor_workstation.core import PermissionSet, UserProfile from doctor_workstation.core.appointment_modes import ( appointment_type_description, appointment_type_value, @@ -76,13 +76,18 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application page.deleteLater() -def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch): +@pytest.mark.parametrize("current_user", [ + None, + {"id": 66, "role_id": 1}, + UserProfile.from_dict({"id": 66, "role_id": [1, 2]}), +]) +def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user): monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None) row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1, "appointment_status": 1, "latest_appointment_id": 71, "appointments": [{"id": 71, "status": 1, "appointment_type": "text"}], "video_call_hint": {"state": "none"}} - page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"])) + page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user) try: page.table_host.set_rows([row]) page.table.selectRow(0) diff --git a/artifacts/desktop-ai-quick-tools-review.md b/artifacts/desktop-ai-quick-tools-review.md new file mode 100644 index 000000000..a7b1f6e8f --- /dev/null +++ b/artifacts/desktop-ai-quick-tools-review.md @@ -0,0 +1,75 @@ +# Desktop AI quick-tools lifecycle review + +审查时间:2026-10-17。范围:`app/src/doctor_workstation/ui/dialogs/ai_consult.py` 的工具绑定、流状态与关闭路径;辅助读取 repository / API SSE 实现和现有 UI 测试。审查时业务文件及测试已有未提交改动,未编辑这些文件,也未调用生产或任何外部服务。 + +## 结论 + +### 1. 已确认:done 后仍由 worker.finished 持有忙碌门控 + +- 六个工具在 `ai_consult.py:4009-4012` 用 `_ClickCard.clicked = Signal()` 绑定各自的默认参数 `prompt`。信号不携带 bool 参数,因此未发现常见的 lambda 参数覆盖或循环变量晚绑定问题。 +- `_ask()` 在 `5996` 遇到 `_asking` 就无提示返回;卡片本身仍保留可点击外观,忙碌期间的工具选择被直接丢弃。 +- `_stream_event(done)` 在 `6130-6143` 标记完成、停止总超时与耗时计时、渲染最终内容,但不清除 `_asking`,也不启用发送按钮。 +- `_asking` 只在 `_stream_finished()` 的 `6196-6197` 恢复。`_AiStreamWorker.run()` 在 `3185-3188` 对 done 仍然继续迭代,直到底层迭代器退出才会进入 `finally` 发出 finished。 +- 因而“已显示最终答案”和“允许再次点击工具”不是同一个时点。done 后迭代器或连接清理阻塞时,工具无反应可以持续;同时总超时已经停止,手工调用 `_stream_timed_out()` 也会被 `_stream_completed` 门控直接返回。 +- 当前 `RemoteDoctorRepository.stream_diagnosis_ai()` 在 yield done 后有 `return`(`repository.py:1438-1442`),通常会很快触发 finished,所以不能仅凭该缺陷断言真实部署每次都会永久卡住。确定的客户端缺陷是 UI 终态仍依赖传输退出,并且在此期间没有超时恢复。 + +### 2. 已确认:返回、侧栏 ×、Escape 的 reject 路径绕过流清理 + +- 返回与侧栏 × 绑定 `self.reject`(`3710`、`3966`),但唯一关闭清理在 `closeEvent()`(`6231-6237`);没有覆盖 `done()` / `reject()` 或连接 `finished` 作统一清理。 +- Qt 的 `QDialog.reject()` 隐藏对话框并结束 exec,不触发此处的 closeEvent 清理。离线调用已证实:reject 后 `_asking=True`、worker 未取消、两个流计时器仍运行、generation 未变化。 +- `open_for()` 会先 `_cancel_stream()` 并递增会话 generation(`3601-3608`),所以复用同一对话框且按标准 open_for 重开时能够隔离旧回调。`present_ai_consult()` 每次新建一个对话框,旧对话框带父对象时隐藏后仍可能被保留,造成后台 worker / timer 继续存在。 +- 这是生命周期缺陷;它本身不能证明同一已显示窗口的其他工具永久失效,但应与本次门控修复一起覆盖。 + +### 3. 其他退出路径 + +- `_stream_failed()` 与 done 相同,停止计时并显示错误,却只靠稍后的 finished 解锁。实际 worker 的 except 紧接 finally,常规错误路径间隙很短;仍建议终态入口统一恢复 UI 状态。 +- `_stream_finished()` 对 generation / stream_generation 作检查;`_cancel_stream()` 与 `open_for()` 的 generation 隔离正确,迟到旧事件不会覆盖新流。 +- 取消仅设置线程 Event,无法中断正在阻塞的 next()/网络读取;因此 UI 必须先解除对旧 worker 的依赖,再由 worker 完成清理。 +- 未发现六个 prompt 绑定错误。连续点击无反馈的直接入口是 `_ask()` 的无提示忙碌返回,而非按钮失去信号连接。 + +## 离线复现 + +使用 `app/.venv/Scripts/python.exe`、`QT_QPA_PLATFORM=offscreen`,以 stdin 临时脚本运行,未新增或修改业务 / 测试文件。 + +1. 将线程池 start 替换成仅记录 worker 的函数,点击第一张工具卡,直接投递 done,不投递 finished,再点第二张卡。 + +```json +{"case":"done_before_finished","asking":true,"send_enabled":false,"timeout_active":false,"completed":true,"second_click_started_worker":false} +{"case":"forced_timeout_after_done","asking":true} +``` + +补发 finished 后再次点第二张卡,第二个 worker 正常创建。 + +2. 使用真实 QThreadPool / QRunnable 与完全本地的假 repository。假生成器 yield done 后在 Event 上等待,模拟终态数据已到但迭代器未退出。UI 事件循环实际收到 done 后立即发起第二问: + +```json +{"case":"real_qrunnable_done_then_open_iterator","done_delivered":true,"iterator_resumed_after_done":true,"asking":true,"timeout_active":false,"prompts":["first"]} +{"case":"iterator_released","asking":false,"send_enabled":true} +``` + +3. fake pool 下 show 对话框并开始一问,调用实际 `dialog.reject()`: + +```json +{"case":"reject","visible":false,"asking":true,"worker_cancelled":false,"generation_unchanged":true,"stream_generation_unchanged":true,"elapsed_timer_active":true,"timeout_active":true} +{"case":"open_for_after_reject","worker_cancelled":true,"asking":false,"generation_changed":true} +``` + +所有复现脚本 exit code 0,未访问 API。 + +## 建议的最小修复 + +1. 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除 `_asking` 并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的 `_cancel_stream()` 直接处理 done。 +2. worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。 +3. 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。 +4. 在 `done(result)` 或其他统一的对话框结束入口中执行 generation 失效与 `_cancel_stream()`,覆盖 reject / accept / Escape / 窗口关闭;确保和 closeEvent 重入时幂等。 + +## 建议回归情境 + +- 按截图六个工具依次真实点击(包含卡片文字和图标区域),每个 done 到达即点下一个,不提前补发 finished;每次 prompt 与 worker 都应正确创建。 +- done 后迭代器保持打开 / cleanup 延迟:UI 必须允许下一问,旧 worker 的迟到 delta/error/finished 不得改动新气泡或错误解锁新请求。 +- 正在首帧等待或生成中点击另一工具,验证所选忙碌策略且用户可理解当前状态。 +- error 到达但 finished 延迟、无 done 的静默结束、总超时、切换患者后迟到信号,均应恢复交互且不串患者。 +- 对返回按钮、侧栏 ×、Escape、原生窗口关闭分别验证 worker 已取消、计时器停止、旧 generation 无效;重开后新请求正常。 +- 保留首字即时显示、40ms 后续合并、完成后结构化渲染、首字/总耗时固定、一次性全量上下文提示的现有行为。 + +现有 `test_answered_stream_replaces_the_pending_placeholder` 与耗时完成测试在 done 后立即手工调用 finished,掩盖终态间隙;现有关闭耗时测试用 `dialog.close()`,未覆盖 reject 路径。 diff --git a/artifacts/desktop-ai-stream-backend-review.md b/artifacts/desktop-ai-stream-backend-review.md new file mode 100644 index 000000000..083095848 --- /dev/null +++ b/artifacts/desktop-ai-stream-backend-review.md @@ -0,0 +1,55 @@ +# 桌面端 AI 助手:服务端流式链路审查 + +日期:2026-10-17 + +## 结论 + +- 正常链路已经逐帧转发:`DifyChatService::sendStreamRequest()` 在 `CURLOPT_WRITEFUNCTION` 内解析 SSE 并立即调用 `onDelta`;`DiagnosisController::runAssistantSse()` 对每个 delta `echo` 后执行 `ob_flush()` / `flush()`,没有等模型全文完成才发送。 +- 控制器清理 PHP 输出缓冲、关闭输出压缩、设置 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache, no-transform`,并先发送 2048 字节注释和 start 事件。 +- 首字之前仍有真实耗时:`prepareAssistant()` 在 SSE headers 前完成权限、DataScope、病例聚合和模型预检。病例正文超过 120,000 字节时,`fitContextForPrompt()` 同步调用 `PatientAiReportLogic::compactSourceForPrompt()`;后者依次通过 blocking 请求读取全部分片并可能归并摘要。保留这一流程以保持全量临床来源覆盖,不能用截断病例来换速度。 +- 兼容重试也可能增加首字等待:通用 `/v1` 地址存在协议回退;`streamPreparedAssistant()` 在尚未交付任何 delta 且流失败为 `UPSTREAM_REJECTED` / `INCOMPLETE_RESPONSE` / `EMPTY_RESPONSE` 时,会额外做一次 blocking 兼容尝试。已经交付文本后不会重复生成。 +- 因此前端需要在用户提交时立即反馈并开始计秒,不能等 SSE start 才开始计时。`AssistantSseTiming` 已提供不含患者正文的 prepare、first_delta、upstream_done 等日志,可定位实际延迟阶段。 + +## 修复 + +发现 SSE 分帧正则 `(?:\r\n|\r|\n){2}` 允许回溯,把一个 CRLF 拆成两个换行;仅收到一行 data 时就会提前解析。合法的多行 CRLF data 事件会被拆坏并丢失文本,可能进一步触发无 delta 的兼容重试。 + +将换行匹配改为原子组 `(?>\r\n|\r|\n){2}`,使 CRLF 始终作为一个换行。事件到达空行即转发,不等待 message_end 或网络 EOF。接口、权限、事件格式、错误码和凭据保护保持兼容。 + +修改文件: + +- `server/app/common/service/DifyChatService.php` +- `server/tests/DifyChatStreamContractTest.php` +- `server/tests/DifyChatStreamDeliveryTest.php`(新增) + +新增行为覆盖: + +- data 行尚未结束事件时不解析;完整首帧在终止事件及 EOF 前立即交付。 +- 多行 CRLF data 在 1 字节、2 字节及整段传输切块下保持为同一事件。 +- 离线模拟实际 cURL header/write 回调:每帧在下一次网络读取之前、`curl_exec()` 返回之前到达消费者;Dify / OpenAI 均覆盖。 +- HTTP 错误正文不进入 delta;消费者拒绝后停止继续读取和交付。 + +新增回归在修复前实际失败:`FAIL: a data line alone is not a complete SSE event`;应用修复后通过。 + +## 验证 + +以下命令全部退出码 0: + +```powershell +php server/tests/DifyChatStreamContractTest.php +php server/tests/DifyChatStreamDeliveryTest.php +php server/tests/DifyChatAncientBooksReuseContractTest.php +php server/tests/DiagnosisAiAssistantStreamContractTest.php +php server/tests/DiagnosisAiAssistantTimingTest.php +php server/tests/DiagnosisAiAssistantContractTest.php +php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php +php server/tests/DiagnosisAiAnalysisSecurityTest.php +php server/tests/DiagnosisAiAnalysisContractTest.php +php server/tests/PrescriptionAiUpstreamContractTest.php +php -l server/app/common/service/DifyChatService.php +php -l server/tests/DifyChatStreamContractTest.php +php -l server/tests/DifyChatStreamDeliveryTest.php +git diff --check -- server/app/common/service/DifyChatService.php server/tests/DifyChatStreamContractTest.php server/tests/DifyChatStreamDeliveryTest.php +``` + +验证均离线进行,没有调用生产模型、读取凭据或测试生产代理配置;因此不提供生产首字时延数值。 diff --git a/artifacts/desktop-ai-stream-done.png b/artifacts/desktop-ai-stream-done.png new file mode 100644 index 000000000..2e652e90f Binary files /dev/null and b/artifacts/desktop-ai-stream-done.png differ diff --git a/artifacts/desktop-ai-stream-generating.png b/artifacts/desktop-ai-stream-generating.png new file mode 100644 index 000000000..d6af37ce4 Binary files /dev/null and b/artifacts/desktop-ai-stream-generating.png differ diff --git a/artifacts/desktop-ai-stream-validation.md b/artifacts/desktop-ai-stream-validation.md new file mode 100644 index 000000000..dee72bd5e --- /dev/null +++ b/artifacts/desktop-ai-stream-validation.md @@ -0,0 +1,53 @@ +# 桌面 AI 对话即时反馈与秒数验证 + +日期:2026-10-17 + +## 行为 + +- 提交问题即显示“已等待 0 秒”,资料准备与模型等待期间每秒更新。 +- 第一段有效文本到达时立即替换等待占位;后续文本按 40 ms 合并刷新,保留顺序。 +- 生成期间显示“生成中 · 已用时 N 秒”;完成后显示模型、首字耗时、总耗时和时间。 +- 完成、错误、超时、静默断连、切换患者、关闭窗口均停止秒数计时;新问题重新计时。迟到事件不会覆盖已结束的结果。 +- 富文本解析和格式调整完成后统一测量高度,减少一次更新内的重复布局。 +- 服务端修复 CRLF 分帧错误,详见 [后端审查](desktop-ai-stream-backend-review.md)。 + +## 验证 + +在 `app/` 下执行: + +```powershell +.venv/Scripts/python.exe -m pytest -o addopts= tests/test_ai_consult_ui.py tests/test_ai_consult_workspace_ui.py tests/test_api_client.py tests/test_repository_parity.py +.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/dialogs/ai_consult.py tests/test_ai_consult_ui.py +``` + +结果:156 passed in 4.75s;Ruff 通过。 + +新增端到端回归使用真实 httpx 解析器、仓库、线程池 worker 和 Qt 事件交付,暂停上游结束事件,确认首段中文文本已出现在界面;覆盖跨网络块的 UTF-8 字符、后续文本顺序、结束状态以及回调运行在 UI 主线程。未调用生产模型。 + +服务端 10 项相关契约/行为测试与 3 项 PHP 语法检查通过,命令及结果见后端审查。 + +1308 × 806 离屏检查三个状态,完成状态元信息没有截断: + +- [等待](desktop-ai-stream-waiting.png) +- [生成中](desktop-ai-stream-generating.png) +- [完成](desktop-ai-stream-done.png) + +本地一次富文本更新对比:同一离屏控件连续 20 次更新,原逻辑 157.9 ms,新逻辑 129.6 ms。该数值仅为本机布局检查,不代表生产模型首字时延。 + +## 扩展检查边界 + +额外运行 `test_dialog_detail_layout.py`、`test_list_detail_geometry.py`、`test_diagnosis_detail_contract.py`、`test_ui_detail_refinements.py`:34 通过,3 失败,分别为挂号列表横向溢出、患者状态按钮宽度、问诊筛选控件高度。这些模块未在本次任务中修改。将 AI 模块恢复为 Git HEAD 源码后再次运行同一组,仍是同样的 3 项失败(34 passed in 12.22s),确认不是本次修改引入。 + +AI 用例与其他布局用例连跑时,在后者 fixture 的 `apply_theme()` / `app.setStyle("Fusion")` 处停滞;独立运行布局组能完成。将 AI 模块恢复为 Git HEAD 源码后,该组合也会停滞,已停止诊断进程;没有修改全局主题或相应布局。 + +## 生效范围 + +已修改仓库源码,未生成安装包或部署生产服务。源码运行的桌面端重启后加载;已安装的打包版本需重新构建发布。服务端 CRLF 修复需部署。病例聚合及模型首字前的真实耗时仍然存在,当前以实时秒数明确展示。 + +## 追加:快捷工具后续点击无反应 + +同日修复后续反馈:完成/报错到达后立即解除忙碌状态,不依赖后台线程退出;worker 收到 done 后不再拉取下一个事件。生成中切换其他快捷工具、快捷指令或对话建议会停止当前请求,保留已有文本和耗时并明确标注停止;重复点击相同的进行中工具只提示,不重复提交。新请求自动展示问诊对话并恢复全部对话筛选,避免结果被其他资料页或筛选隐藏。 + +统一窗口结束清理,覆盖返回、侧栏 ×、Escape、accept/reject 和原生关闭;旧请求的 delta/error/finished 回调无法影响新请求。审查依据见 [快捷工具生命周期审查](desktop-ai-quick-tools-review.md)。 + +新增 4 项核心回归在修复前均实际失败,修复后通过;另覆盖 4 个快捷入口和 6 种窗口退出方式。相同桌面端测试命令最终结果为 **170 passed in 5.84s**,Ruff 与 `git diff --check` 通过。未修改服务端、打包或重启正在使用的桌面端进程。 diff --git a/artifacts/desktop-ai-stream-waiting.png b/artifacts/desktop-ai-stream-waiting.png new file mode 100644 index 000000000..289990aea Binary files /dev/null and b/artifacts/desktop-ai-stream-waiting.png differ diff --git a/artifacts/diagnosis-edit-masked-phone-backend.md b/artifacts/diagnosis-edit-masked-phone-backend.md new file mode 100644 index 000000000..77e75ad6f --- /dev/null +++ b/artifacts/diagnosis-edit-masked-phone-backend.md @@ -0,0 +1,45 @@ +# 诊单编辑:脱敏手机号缺省提交的后端修复 + +日期:2026-10-17。范围:后端编辑校验及离线 PHP 回归。 + +## 根因 + +桌面医生账户无 `tcm.diagnosis/phonePlain` 权限时只看到脱敏手机号,保存时会排除 `phone`/`id_card`。`DiagnosisController::edit()` 先执行 `DiagnosisValidate::sceneEdit()`,后调用 `DiagnosisLogic::edit()`。编辑场景原样继承全局 `phone = require|mobile`,因此合法的缺省 `phone` 请求在到达更新逻辑前就被“请输入手机号”拦截。 + +同一编辑场景还继承 `patient_name`、`gender`、`age` 的全局 `require`,即使这些基本信息被业务锁定、调用者仅修改临床信息,省略后也会被拦截。新增回归在修复前首先复现“请输入患者姓名”;另有单独省略 `phone`、其他基础信息照常提交的用例对应本次医生保存失败。 + +`DiagnosisLogic::edit()` 已直接用收到的 `$params` 更新,不会为缺省手机号补空值。基本信息变更比较也已使用 `array_key_exists` 跳过未提交字段,因此不需要修改 controller 或 logic。 + +## 修改 + +- `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:仅在 `sceneEdit()` 中,将 `phone`、`patient_name`、`gender`、`age` 的无条件 `require` 替换为 `requireWhenPresent`。 +- 新规则区分缺省键与显式提交:缺省表示保留;显式空串/null 仍拒绝,原有 `mobile`、姓名长度、性别范围、年龄类型/范围规则继续执行。`require` 前缀确保 ThinkPHP 对空串/null 也调用此规则。 +- `server/tests/DiagnosisValidateEditSceneTest.php`:运行真实 validator 与真实 `DiagnosisLogic::edit()`,仅模型、数据库边界及权限读取使用内存替身,覆盖保存与业务锁行为。 + +## 边界与回归覆盖 + +- 编辑可省略所有受保护的基本信息,也可仅省略手机号而保留姓名/性别/年龄;省略的 `phone`、`id_card`、姓名/性别/年龄不会加入 update,原值保持。 +- 显式手机号 `''`、null、`156****7862`、短号码、带空格号码、false、数组均拒绝;合法新号码仍通过格式校验。 +- 显式姓名/性别/年龄为空仍拒绝;姓名超长、非法性别、非法或越界年龄仍拒绝;`gender = 0`、`age = 0` 仍有效。 +- 编辑 ID 必填且诊单必须存在。`diagnosis_type`、`local_hospital_name` 等无关要求不变。 +- 新增场景仍要求手机号、姓名、性别、年龄;新增空/脱敏手机号仍拒绝。 +- 无额外权限的医生在最近订单未完成时,可以保存不含基本信息的临床修改;任何实际基本信息变更仍返回“最近业务订单未完成,无法修改患者基本信息”,且不会写入。原有 root 编辑能力保持。 +- 未修改明文手机号权限、返回数据、创建权限、其他业务授权、统计或 AI 服务实现。 + +## 实际验证 + +执行目录:`D:/web/zyt`;PHP 8.2.9 CLI。测试未初始化应用、未读取生产配置,数据库/模型/权限边界为测试内存替身,没有数据库或外部服务调用。 + +| 命令 | 实际结果 | +| --- | --- | +| `php server/tests/DiagnosisValidateEditSceneTest.php`(修复前) | exit 1,`edit accepts omitted locked/basic/masked fields; got: "请输入患者姓名"` | +| `php -l server/app/adminapi/validate/tcm/DiagnosisValidate.php` | exit 0,No syntax errors detected | +| `php -l server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,No syntax errors detected | +| `php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,Diagnosis edit-scene validation and masked/basic-field preservation: OK | +| `php server/tests/DiagnosisValidateAddSceneTest.php` | exit 0,Diagnosis add-scene validation: OK | +| `php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php` | exit 0,Diagnosis workspace row authorization: OK | +| `php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php` | exit 0,Diagnosis AI prescription generation permission: OK | +| `php server/tests/DiagnosisAiAnalysisSecurityTest.php` | exit 0,Diagnosis AI analysis security: OK | +| `git diff --check -- server/app/adminapi/validate/tcm/DiagnosisValidate.php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,无输出 | + +本报告证明离线编辑校验、更新参数保留与原有业务锁行为;未进行生产登录或真实数据库写入。 diff --git a/artifacts/diagnosis-edit-masked-phone-validation.md b/artifacts/diagnosis-edit-masked-phone-validation.md new file mode 100644 index 000000000..df0614b80 --- /dev/null +++ b/artifacts/diagnosis-edit-masked-phone-validation.md @@ -0,0 +1,35 @@ +# 医生编辑诊单提示“请输入手机号”验证 + +日期:2026-10-17。 + +## 结论与修改 + +无 `tcm.diagnosis/phonePlain` 权限的医生只能看到脱敏手机号,桌面编辑器按既有隐私规则在保存时省略 `phone`、`id_card`。后端编辑场景继承了手机号的无条件必填规则,导致保存被拦截。 + +已修改 `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:编辑场景允许省略受保护或被锁定的基本字段,省略即保留原值;主动提交的字段仍须非空且格式正确。新增诊单必填规则、明文权限、订单基本信息锁保持。桌面业务代码无需修改,仅增加回归。 + +后端详细覆盖与结果见 [后端报告](diagnosis-edit-masked-phone-backend.md)。 + +## 实际跨层核对 + +使用桌面测试数据构造无明文权限的医生编辑器,从真实 `DiagnosisDialog` 保存,经真实 `RemoteDoctorRepository`、`ApiClient`,用 `httpx.MockTransport` 捕获发送到 `tcm.diagnosis/edit` 的 JSON。临床修改已保留,手机号和身份证字段均缺省。数据为测试样例,无真实网络请求。 + +将这份原样请求交给真实 PHP 编辑校验器,仅替换诊单存在性数据库查询: + +- HEAD 原校验器稳定复现“请输入手机号”。 +- 修复后校验通过。 +- 测试脚本:`.codex-tasks/diagnosis-edit-cross-layer-check.php`;捕获请求为同目录 `diagnosis-edit-masked-payload.json`。 + +## 回归结果 + +| 验证 | 结果 | +| --- | --- | +| 桌面新增回归:无明文权限、有/无基本信息锁保存;有明文权限时格式及重复检查 | 3 passed | +| `pytest -o addopts= tests/test_diagnosis_drawer_visual.py tests/test_diagnosis_detail_contract.py tests/test_diagnosis_loading.py --tb=short` | 68 passed,exit 0 | +| `ruff check tests/test_diagnosis_drawer_visual.py` | 通过 | +| PHP 编辑、新增、行级授权、AI 处方权限、AI 分析安全离线回归 | 5 个脚本通过 | +| 真实桌面请求对照原/新 PHP 校验器 | 原校验复现错误,新校验通过 | + +桌面完整回归期间 Qt 媒体加载记录过 `0xc0000139` 原生日志;进程继续完成全部测试并以 0 退出。本次未修改相关媒体代码。 + +未登录生产、未写真实数据库、未执行部署。本修复需服务端更新后生效,医生无需补录明文手机号。 diff --git a/artifacts/doctor-video-column.png b/artifacts/doctor-video-column.png new file mode 100644 index 000000000..c70eca44a Binary files /dev/null and b/artifacts/doctor-video-column.png differ diff --git a/artifacts/doctor-video-role-contract.md b/artifacts/doctor-video-role-contract.md new file mode 100644 index 000000000..95d0c169a --- /dev/null +++ b/artifacts/doctor-video-role-contract.md @@ -0,0 +1,102 @@ +# 桌面问诊列表:医生角色视频入口契约核对 + +核对日期:2026-10-17。范围仅为本地源码只读审查;未访问生产系统,未修改业务代码或测试代码。 + +## 结论 + +1. **医生角色是 `role_id=1`,医助是 `role_id=2`。** `3` 不是医生角色;“我的患者”管理角色集合为 `[3,7,8]`。 +2. 管理后台 PC/H5 的旁观入口均要求:**有已预约的视频挂号、当前用户等于诊单 `assistant_id`、拥有 `tcm.diagnosis/watchCall` 权限**。无入口资格时只能看通话状态。 +3. 服务端 `watchCall` 业务层再次强制 `assistant_id == 当前登录 admin_id`,**没有 root 绕过**。root 仅在通用鉴权中绕过菜单权限。 +4. 需要区分源码现状与本次新要求:**现有后台没有显式的“角色数组包含医生 1 即拒绝”分支**。混合医生/医助角色如果恰好被指派为助理且有权限,按现代码仍可能进入旁观;不能把这一规则误报为现有后端保证。 +5. 桌面列表当前不检查角色或助理归属,仅检查 repository 视频能力、预约和通话状态,因此医生也会得到“进入视频问诊”。本次用户要求应在桌面显示和点击处都补上医生角色限制。 +6. 桌面该入口实际发出 `mode="im"`,获取的是 `getCallSignature` 通话/聊天凭证,**不是**后台的 `watchCall` 纯拉流接口;不能仅根据文案把两者视为同一实现。 + +## 本次实施与验证 + +- 桌面列表对含医生角色 `1` 的账号(含混合角色及通配菜单权限)只显示 `通话中 / 接通中 / —` 等公开状态,不显示进入视频问诊;同类工具栏入口同步隐藏。 +- 表格显示、选中行按钮状态和实际点击处理使用同一资格判断;直接调用旧入口或强行启用按钮也不能发出视频请求。 +- 非医生账号仍须同时满足本人诊单医助归属和 `tcm.diagnosis/watchCall` 权限;超级管理员也不绕过医助归属。有效预约、live 状态与标识完整性仍由原有流程检查。 +- 医生的图文预约继续显示“图文沟通”,不受视频入口限制;接诊台的医生通话流程未改动。未将既有 IM 接收流程改造成纯拉流旁观,也未修改后端接口。 +- 新增角色/指派/权限矩阵回归在修改前 27 项失败,修改后全部通过;覆盖角色整数、字符串、数组、逗号字符串、对象列表及 UserProfile,以及混合角色、root、无身份、错误指派、仅二维码权限。 +- 最终组合检查:165 通过,1 项既有分页组合用例失败(`test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 的请求页码为 `[1,2]`,预期 `[1,2,3]`)。恢复 Git HEAD 的两个业务模块并排除新增用例后,同一组合仍复现该失败(136 通过、1 失败)。该分页用例及整个视觉模块单独运行均通过,未修改分页实现。 +- Ruff、修改范围的 `git diff --check` 通过;离屏检查已生成 [医生视角截图](doctor-video-column.png),使用本地示例数据,未连接生产服务。源码未打包发布,运行中的客户端需更新重启。 + +## 角色与身份数据 + +| 项目 | 源码契约 | 位置 | +| --- | --- | --- | +| 医生 | ID `1`;数据库角色关联按成员包含判断,而非要求只有单一角色 | `server/app/api/logic/DoctorLogic.php:96`、`server/app/adminapi/logic/firstvisit/MyPatientLogic.php:22` | +| 医助 | ID `2` | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:23` | +| 管理角色 | `[3,7,8]`,用于“我的患者”的团队范围判断 | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:24` | +| 服务端旧角色字段 | `Admin` 模型追加 `role_id`,值是当前用户所有 `admin_role.role_id` 数组 | `server/app/common/model/auth/Admin.php:29`、`:44` | +| 身份接口 | `auth.admin/mySelf` 的 `user` 包含 `id`、`root`、`role_ids`(整数数组),兼容模型追加的 `role_id` 数组;外层为 `user/menu/permissions` | `server/app/adminapi/logic/auth/AdminLogic.php:285`、`:319`、`:327` | +| 桌面模型 | `UserProfile.id`、`role_ids: tuple[int,...]`、`root: bool`、`permissions`、`raw` | `app/src/doctor_workstation/core/models.py:125` | +| 桌面转换 | 优先 `role_ids`,再 `role_id`,再 `roles`;支持数字、逗号字符串、集合/列表和 `{id: ...}` 元素,归一成整数元组;root 兼容 `is_root` | `app/src/doctor_workstation/core/models.py:94`、`:141` | +| 当前用户流向 | repository 的 `get_session()` 读取 mySelf 并建立 `UserProfile`;shell 取得 `current_user` 后传入页面,`ConsultationsPage` 已保留 `self.current_user` | `app/src/doctor_workstation/services/repository.py:902`、`app/src/doctor_workstation/ui/shell.py:899`、`:2014`、`app/src/doctor_workstation/ui/pages/consultations.py:944` | + +混合角色应检查**集合包含 `1`**,不能只比较 `role_id == 1`,也不能只检查角色数组首项。现有 shell 的角色文案也以 `1 in roles` 优先显示“医生”:`app/src/doctor_workstation/ui/shell.py:1521`。 + +权限 `"*"` 与 root 不能互相等同。后台 root 会获得 `['*']`,拥有全部菜单权限的非 root 也可能得到 `['*']`;桌面 `PermissionSet.is_superuser` 仅表示存在全局通配符,不代表 `UserProfile.root` 为真。 + +- 后台授予逻辑:`server/app/adminapi/logic/auth/AuthLogic.php:56`。 +- 桌面通配权限逻辑:`app/src/doctor_workstation/core/permissions.py:60`。 + +## 管理后台显示与点击逻辑 + +PC:`admin/src/views/tcm/diagnosis/index.vue`。 + +- `:362`:列标题为“视频旁观”。 +- `:1545`:`isAssignedAssistant` 使用 `row.assistant_id ?? row.assistant` 与 `userStore.userInfo.id` 数字比较。 +- `:1563`:`canWatchCallEntry = isAppointmentActiveForVideo && isAssignedAssistant && hasPermission(['tcm.diagnosis/watchCall'])`。 +- `:1971`:从 `appointments` 中找 `status==1` 的有效挂号,视频条件另要求 `canAppointmentVideoCall(type)`。旧空类型按视频处理,`text/phone/unknown` 不允许旁观视频,见 `admin/src/utils/appointment-type.ts:12`。 +- `:1579`:有入口资格且状态为 `live` 或 `pending_room` 时显示“进入旁观”;pending 状态点击后仅提示等待。 +- `:1591`:点击重新校验入口资格,并要求状态必须为 `live`,才打开旁观弹窗。 +- `:1609`:无入口资格时显示公开状态:`live=通话中`、`pending_room=接通中`、`none=—`,其他使用服务端 label。 + +H5:`admin/src/views/tcm/diagnosis/index_h5.vue`。 + +- `:210`、`:1035`、`:1040`、`:1044`、`:1075` 分别对应显示、指派关系、资格、状态和点击校验;规则与 PC 一致。 +- `:1068`:仅有入口资格,或状态处于 live/pending 时才显示旁观条;无资格时为只读状态文案。与 PC 的小差别是 none 状态文本为空,且整条通常不显示。 + +`hasPermission` 对 `"*"` 放行,但**不会跳过助理归属**:`admin/src/utils/perm.ts:3`。 + +## 服务端旁观保护与超管 + +`server/app/adminapi/controller/tcm/DiagnosisController.php:609` 的 `watchCall()` 从 GET 读取 diagnosis_id,但把调用者身份绑定为控制器的当前登录 `$this->adminId`,不采用客户端传来的 admin_id。 + +`server/app/adminapi/logic/tcm/DiagnosisLogic.php:2678` 的 `getAssistantWatchRoomParams()`: + +1. diagnosis_id/admin_id 必须为正数。 +2. 诊单必须存在且未删除。 +3. `diag.assistant_id` 必须等于 admin_id,否则“仅本诊单指派的医助可旁观该通话”。 +4. 获取该诊单 `status=1` 的最新 CallRecord,room_id 必须存在,否则要求等医生接通。 +5. 返回当前登录账号对应的 `doctor_{adminId}` UserSig 和房间参数。 + +**此函数没有读取角色或 root,也没有主动重复前端的有效视频挂号校验。** 角色/menu 权限由通用鉴权负责;归属和进行中房间由业务逻辑负责。`AuthMiddleware.php:70` 的 root 放行发生在通用菜单鉴权层,不能使 watchCall 业务层的 assistant_id 检查失效。非 root 的菜单权限检查还受菜单路由登记情况影响,不宜把权限名称硬编码成额外、已存在的业务断言。 + +对照通话/聊天接口:桌面 `get_call_ticket()` 调用 `tcm.diagnosis/getCallSignature`(`app/src/doctor_workstation/services/repository.py:2696`)。该接口经 `DiagnosisLogic.php:898` 的 `canManageDiagnosis` 校验患者/诊单访问关系,并解析挂号通话策略;它不是旁观资格接口。`canManageDiagnosis` 使用 `MyPatientLogic`:root 可看全部、管理角色用团队数据范围、一线医生/医助按本人接诊/归属关系;这不意味着医生能从通用问诊列表旁观其他医生。 + +## 桌面入口缺口 + +| 环节 | 当前实现 | 位置 | +| --- | --- | --- | +| 列能力 | `action_policy['video_call'] = _native_video_capable`,只证明 repository 有 get_call_ticket/start_call/bind_call_room/end_call 方法 | `app/src/doctor_workstation/ui/pages/consultations.py:177`、`:1504` | +| 表格按钮 | 有能力 + 已预约 +(图文类型或 live),显示“图文沟通”或“进入视频问诊”;只用 ID 完整性控制 enabled | `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1784` | +| 顶部通话 | 可见性只检查原生能力,enabled 来自兼容 video_button | `app/src/doctor_workstation/ui/pages/consultations.py:1385`、`:3056` | +| 兼容按钮 | “进入视频问诊”仍存在于隐藏兼容操作容器,可通过程序调用 | `app/src/doctor_workstation/ui/pages/consultations.py:1467` | +| 行点击 | 选择该行后进入统一 `_request_video()` | `app/src/doctor_workstation/ui/pages/consultations.py:2366` | +| 最终点击处理 | 检查能力、已预约、live(text 例外)、ID 完整,然后发出 video_requested;没有医生角色/助理归属/旁观权限校验 | `app/src/doctor_workstation/ui/pages/consultations.py:3499` | +| 发出内容 | `source='consultations'`、`mode='im'`,注释明确此页为助理/管理员接收现有医生邀请 | `app/src/doctor_workstation/ui/pages/consultations.py:288` | + +导航目前“我的患者”为 `PatientsPage`,“问诊列表”为 `ConsultationsPage`,两者不同;本次所述“视频问诊”列实际在后者 `DiagnosisTableHost`,见 `app/src/doctor_workstation/ui/shell.py:159`。本次无需扩大为其他医生主动发起通话入口的限制。 + +## 本次新要求对应的修复边界 + +- 医生角色集合包含 `1` 时,问诊列表的视频列不显示可点击“进入视频问诊”,并在最终点击处理再次拦截,避免工具栏、隐藏兼容按钮、信号或直接调用绕过。 +- 混合 `[1,2]`、`[1,3]` 等账号也属于含医生角色。用户要求是医生角色禁用该视频入口,不能依赖它是否还有其他授权角色或 `"*"` 权限来恢复入口。 +- 已有后台没有“root 优先于医生拒绝”的列表旁观特例;从本次“医生角色不允许”的文字看,含医生角色的账号即使还拥有 `"*"` 也不应因通配权限恢复入口。若实现保留 root 特例,应明确这是另行产品规则,而非从现有 watchCall 业务代码推导出的例外。 +- 医生主动接诊/接诊台的视频功能不在本次旁观入口限制范围内。 +- 表格“图文沟通”共用同一 action_policy 和 `_request_video()`,用户反馈针对视频入口;若不另有要求,应保留 text 预约正常图文入口,避免把 `video_call=False` 当作全部沟通能力开关。 +- 对不受限制的非医生账号,本次改动可保留既有流程;若还要完整对齐后台的指派助理 + watchCall 权限,则属于另外一项权限收敛,需要单独决定与验证,不能把现有 IM 接收入口直接替换成旁观拉流实现。 + +本文件为静态核对结果,无运行或生产授权验证结论。 diff --git a/artifacts/reception-current-assistant-contract.md b/artifacts/reception-current-assistant-contract.md new file mode 100644 index 000000000..14071fc4a --- /dev/null +++ b/artifacts/reception-current-assistant-contract.md @@ -0,0 +1,61 @@ +# 接诊台当前医助数据契约 + +审查范围:只读静态代码;未连接生产数据,未修改业务代码。仓库 `.trellis/` 不存在。 + +## 结论 + +接诊台头部应显示聚合详情的 `detail.appointment.assistant_name`。它的业务来源是当前诊单 `tcm_diagnosis.assistant_id → admin.name`,与“通知医助”的实际接收人一致。`detail.appointment.assistant_id` 也已由服务端覆盖为当前诊单医助 ID。 + +不要回退到历史挂号 `doctor_appointment.assistant_id` / `appointment_assistant_id`、指派日志前任医助、处方医助、`doctor_name` 或当前登录人姓名。成功加载详情后,空的当前医助字段应保持空态,不应再从旧队列记录补出姓名。 + +## 请求与结构 + +- 桌面端真实方法名是 `RemoteDoctorRepository.get_reception(appointment_id)`,不是 `get_reception_detail`;GET `doctor.appointment/reception`,参数 `{id: appointment_id}`,返回映射原样保留。见 [repository.py:1102](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1102)。`remote_repository.py` 只是重导出模块。 +- 服务端返回 `{appointment, diagnosis, doctor_notes, tracking_notes}`,见 [AppointmentLogic.php:722](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:722)。桌面端兼容外层 `{data: ...}`,见 [reception.py:7392](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7392)。 +- 关联关系特别注意:`appointment.patient_id == tcm_diagnosis.id`,不是基础患者表 ID;诊单另有自己的 `patient_id`。见 [AppointmentLogic.php:678](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:678)。 + +```json +{ + "appointment": { + "id": 123, + "patient_id": 456, + "assistant_id": 2001, + "assistant_name": "某医助", + "doctor_name": "某医生" + }, + "diagnosis": {"id": 456, "assistant_id": 2001}, + "doctor_notes": [], + "tracking_notes": [] +} +``` + +以上仅是字段结构示例,不是线上数据。 + +## 服务端字段语义及通知一致性 + +| 位置 | 确切行为 | +| --- | --- | +| [AppointmentLogic.php:489](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:489) `detail()` | 选择 `a.*` 并关联患者姓名、电话、`ad.name as doctor_name`;本身不补医助姓名,原始 `a.assistant_id` 属于挂号记录。 | +| [AppointmentLogic.php:687](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:687) `reception()` | 按 `diagnosis.assistant_id` 查 Admin,再写入 `appointment.assistant_name`;`appointment.assistant_id = diagnosis.assistant_id ?? 0`。这一覆盖使聚合 appointment 的医助变为当前诊单口径。 | +| [AppointmentLogic.php:887](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:887) `notifyAssistant()` | 按挂号 `patient_id` 找诊单,再读诊单当前 `assistant_id`,查 Admin 的 `work_wechat_userid` 发送通知。桌面端仅 POST `{id: appointment_id}`,见 [repository.py:1164](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1164)。 | +| [AppointmentLists.php:241](D:/web/zyt/server/app/adminapi/lists/doctor/AppointmentLists.php:241) 队列列表 | `u.assistant_id as assistant_id`、`asst.name as assistant_name` 来自当前诊单;另外保留 `a.assistant_id as appointment_assistant_id`,两者语义不同。 | + +未分配时,聚合详情姓名为 `""`,ID 为诊单原有空值(缺少时 `0`);`notifyAssistant()` 对 `assistant_id <= 0` 返回“该患者尚未指派医助”。若 ID 存在但 Admin 查不到/姓名为空,显示姓名同样为空;不能仅根据姓名为空断言未分配。通知另会返回“医助账号不存在”或未绑定企业微信的具体错误。UI 可用“未分配”表示明确的无 ID 状态,有 ID 无姓名可显示“—”或“医助 #ID”。 + +## 管理端与桌面端现状 + +- 管理端患者信息卡没有多级姓名回退:`apt = detail.appointment`,显示 `apt.assistant_name || '—'`(当前文案为“客服”);见 [index.vue:271](D:/web/zyt/admin/src/views/patient/reception/index.vue:271)、[PatientInfoCard.vue:19](D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientInfoCard.vue:19)。队列也显示 `row.assistant_name || '—'`,见 [index.vue:72](D:/web/zyt/admin/src/views/patient/reception/index.vue:72)。 +- `Appointment` 模型已有 `assistant_id: int | None`、`assistant_name: str`,`from_dict` 直接读取同名键,默认 `None` / `""`;见 [models.py:195](D:/web/zyt/app/src/doctor_workstation/core/models.py:195)、[models.py:233](D:/web/zyt/app/src/doctor_workstation/core/models.py:233)。`Consultation` 也有同名字段,未把医生姓名作为医助别名。接诊聚合没有专用 dataclass,直接用 dict。 +- 桌面端详情已把聚合 `appointment` 传入身份/预约渲染;见 [reception.py:7661](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7661)。兼容字段 `appointment_labels['assistant']` 已读取 `appointment.assistant_name`,见 [reception.py:7888](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7888);但其所在 `appointment_card` 被移到隐藏兼容容器,见 [reception.py:4740](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:4740)。因此修复可以在头部直接复用现有详情字段,无须新增 API 或修改 core model。 + +建议展示优先级:已成功取得接诊聚合详情时,以其 `appointment.assistant_name` 为准;加载前可使用当前选中队列的同口径姓名作临时展示。切换患者、取消选择、详情失败时按当前患者生命周期清空/重置,避免沿用上一位患者的名字。 + +## 实施与验证 + +已在“通知医助”按钮上方增加独立的“当前医助:姓名”文本,读取已核实的聚合详情字段。无医助 ID 时显示未分配;有 ID 但无姓名时显示姓名暂缺;加载与失败状态会清空上一位患者姓名。未修改 API、通知收件人或服务端代码。 + +离屏验证 816、1080、1340 三种页面宽度,文字与通知按钮不重叠且不产生横向滚动;检查了姓名、未分配、姓名暂缺、未知、患者切换和详情失败。截图:[头部预览](reception-current-assistant-header.png),其中姓名来自本地示例数据。 + +现有检查:蓝色布局 15 项通过;单独运行接诊与刷新模块 77 passed in 11.92s;结束问诊按钮 3 项通过;Ruff 和 `git diff --check` 通过。 + +组合运行全局主题布局模块与接诊模块时,原有 AI 放大按钮尺寸断言出现 28 × 36 对 28 × 28 的差异。恢复 Git HEAD 接诊模块后,在同一主题条件下仍复现该断言失败;接诊模块单独运行全部通过。该问题未在本次姓名显示修改中扩展处理。 diff --git a/artifacts/reception-current-assistant-header.png b/artifacts/reception-current-assistant-header.png new file mode 100644 index 000000000..864133b7a Binary files /dev/null and b/artifacts/reception-current-assistant-header.png differ diff --git a/artifacts/revisit-cohort-assignment-research.md b/artifacts/revisit-cohort-assignment-research.md new file mode 100644 index 000000000..1c819098b --- /dev/null +++ b/artifacts/revisit-cohort-assignment-research.md @@ -0,0 +1,62 @@ +# 复诊接诊率:指派 / 继承写入与兼容性研究 + +2026-10-16。只读检查产品源码;未读取 `.env` 或凭据;未连接数据库。项目存在 `AGENTS.md`,但 `.trellis/workflow.md` 不存在,无法读取 Trellis 流程与分层规范。 + +## 关键结论 + +- 现有报表是“当月指派分母 + 当月成交分子”,不是“指派日期区间内患者的后续接诊队列”。仅把 `month` 换成起止日期会继续产生区间外患者进入分子的错误。 +- 新指派、继承已有可用事件字段,无需为重新起算修改患者全局诊次。应在统计层根据入组指派事件重新计算后续订单;`RevisitVisitSlotService` 同时服务诊单节点、到期快照,改变它会影响其他业务。 +- 自动指派和个人业绩都直接依赖当前统计实现。页面改队列口径时要明确兼容入口;尤其不要悄悄改变自动指派档位。 + +## 写入侧与数据字段 + +| 入口 | 现有行为 | 注意点 | +| --- | --- | --- | +| `DiagnosisLogic::assign`(约 673 行) | 诊单行 `FOR UPDATE`;更新 `assistant_id`、`assign_read_at`;同事务插入指派日志。`is_inherit=1` 仅在目标医助大于 0 时成立。 | `from==to` 允许写入;当前 slot 统计忽略该行,旧 exclude 仍计入新指派。新队列避免把自指派当成新承接事件。 | +| 同上,继承 | 同事务用 `slotAtTime($id,$now)` 写 `inherit_visit_slot` 和 `inherit_visit_slot_calc_time`。 | 快照只用于展示 / 对账。现有统计通过有效订单实时重算 k,所以事后退款、取消、offset 修改会联动。不能把快照当唯一订单数依据;历史回退没有快照。 | +| `AutoAssignPendingDiagnosis::applyAssignment`(约 567 行) | `is_inherit=0`、操作人 0、名称“系统自动分配”;同事务改医助并写日志;`from` 可推断为一中心原医助。 | 自动指派属于新指派,不能以 `from>0` 判定继承。 | +| `AutoAssignLogLogic::rollbackOne`(约 72 行) | 锁自动指派日志和诊单;仅当前医助仍为自动分配目标时允许回退;回到前任 / 0。写指派日志 `is_inherit=0`,并写自动分配日志 rollback 字段。 | 保留写入规则;新口径需把历史回退识别为继承,目标 0 的释放行不入组。 | +| `ExpressTrackingService`(约 702 行) | 发货释放写 `to_assistant_id=0`,未显式写 `is_inherit`(列默认 0)。 | 释放不是新指派,但会终止原医助持有时间段。 | + +主要表 / 模型: + +- `DiagnosisAssignLog` 只设置表名 `tcm_diagnosis_assign_log`,关闭自动时间戳,无字段白名单。指派核心数据:`id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit, inherit_visit_slot, inherit_visit_slot_calc_time`;还含最新订单创建人 / 时间快照与操作人信息。 +- `tcm_diagnosis`:`assistant_id` 是当前归属,不应代替历史持有时间线;`revisit_slot_start_offset` 为全局诊次偏移(钳制 0~20)。 +- `tcm_prescription_order`:有效业务单剔除软删、履约状态 4/9/10;统一调用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`;序列稳定排序 `(diagnosis_id, create_time, id)`。 +- 迁移:`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql`;`server/sql/1.9.20261012/06_add_diagnosis_assign_log_inherit_visit_slot.sql`。继承标记引入前的数据无法可靠恢复继承类型,不应按 `from>0` 猜测。 +- 现有订单复合索引迁移:`server/sql/1.9.20261012/01_add_prescription_order_diag_ct_index.sql`,`(diagnosis_id, delete_time, fulfillment_status, create_time)`。源码内未找到指派日志建表 DDL,不能据此断言线上索引情况。 + +## rollback 与重新起算 + +- `RevisitRateLogic::fetchRollbackKeySet` 使用 `diagnosis_id | rollback_time | rollback_admin_id` 关联到指派日志 `diagnosis_id | create_time | operator_admin_id`;同一事务同一 `$now` 保证当前写入可匹配。需要继承独立页时复用这一兼容规则,不能只筛 `is_inherit=1`。 +- 现有 `RevisitRateCalculator::compute` 在 slot 模式将命中回退的日志当继承;exclude 模式完全保留其 `is_inherit=0` 的旧语义。回退无继承快照,订单实时计算仍可用。 +- 新继承“重新起算”应按继承事件时间切开序列,继承前历史单不进入继承后的接诊次数。保留同一秒约定:日志时间 `t <= order.create_time` 时归新医助,继承前完成单计数用严格 `< t`。 +- 转派 / 释放 / 再次继承会形成多个持有段;要先明确入组去重,再把订单归属到有效段,避免同一订单被多个队列事件重复领取。当前实现按同一医助 × 诊单去重,继承取区间内最早,且同区间新指派优先覆盖继承;这一合并规则不适合直接复用到“新指派和继承分别统计”。 +- 当前拒收 / 退款处理是“该诊单存在任意时间状态 9/10 有效订单,则整诊单从分母剔除”,分子只是剔除对应无效单。若沿用需在两个独立页面和明细保持一致;不要无意中改变这一业务规则。 + +## 当前统计调用方 + +1. `AutoAssignPendingDiagnosis::execute` 调 `RevisitRateLogic::overview(['month'=>上个自然月,'inherit_mode'=>'slot'])`,再从部门 children 中取医助率。配置实际值为 `project.revisit_rate.auto_assign_tier_field='visit2_rate_new_assign'`,页面默认实际是 `default_inherit_mode='exclude'`(部分注释仍写默认 slot)。 + - 分档字段白名单仅 `visit2_rate_new_assign` / `visit2_rate`,对应 `rate_version=v1/v2`。无分母必须继续返回 `null`,否则可能改变谁参与自动指派。 + - 自动日志同时记录两套率。历史 v1 行无 `visit2_rate_new_assign` 时,前端回落 `visit2_rate`。不要重解释历史日志字段;如变更分档口径要单独版本化。 +2. `AssistantPerformanceController::revisitRate` 只收 `month`,Logic 从配置取 mode,再调 `RevisitRateLogic::assistantOverview`,只返回本人行。 + - 本人统计有 600 秒缓存,key 为 `month + assistantId + mode`。改统计口径需更新缓存 key 版本;加日期范围 / 队列类型需纳入 key。 + - 前端个人业绩卡片仍按月,响应类型依赖 `month, inherit_mode, slots, in_scope, row`。保持 month 兼容可避免页面破坏,但卡片文案和规则应同步说明指派队列口径。 +3. `RevisitRateLogic::overview` 目前不处理 `assistant_id`;只有两个下钻方法调用 `applyRowScope`。新增医助筛选需同时约束 overview 的医助行、部门汇总、总计以及下钻集合。部门归属取当前 `admin_dept` 中最小有效 dept_id,默认限二中心子树。 +4. `YejiStatsLogic` 的“被指派数”仍独立按 `is_inherit=0`、区间指派、医助×诊单去重;避免此次顺带修改整个业绩看板。 + +新独立页还需菜单 / 权限迁移与 MCP review 文案。`AuthMiddleware` 对未登记接口默认放行,因此新增接口须登记权限。现有 `deptOptions` 还被复诊到期页复用,不应改成仅某一队列的选项。 + +## 测试运行方式与已验证基线 + +无根级 PHPUnit 配置,`server/composer.json` 无 test script / PHPUnit;相关测试是独立 PHP CLI 脚本,仅加载 `server/vendor/autoload.php`,不启动应用、不连库。当前 PATH PHP:`D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe`。 + +已从项目根目录运行并通过: + +```powershell +php server/tests/RevisitRateContractTest.php # 45 checks +php server/tests/RevisitRateInheritSlotTest.php # 57 checks +php server/tests/RevisitVisitSlotServiceTest.php # 20 checks +``` + +`RevisitRateContractTest` 是源码字符串 / Reflection 契约,明确锁住“分子按当月下单”“继承从 k+1 列入分母”等旧实现,新队列实现需要合理更新这些断言,不能仅为通过保留过时语义。建议纯函数覆盖:区间指派后跨月成交、区间外患者同月成交不计入、自指派、转派终止归属、多次继承与重置、回退继承、同秒排序、医助筛选汇总和两类明细对账;保留全局诊次服务现有测试。 diff --git a/artifacts/revisit-cohort-backend-review.md b/artifacts/revisit-cohort-backend-review.md new file mode 100644 index 000000000..51c641631 --- /dev/null +++ b/artifacts/revisit-cohort-backend-review.md @@ -0,0 +1,54 @@ +# 复诊指派队列后端独立审阅 + +2026-10-16。范围:`RevisitCohortCalculator` / `RevisitCohortLogic`、两个 Controller、菜单 SQL、两项新增测试。未修改产品代码、未读凭据、未连接业务数据库。 + +主代理处理结果:下述释放返还问题已修复,计算器以实际时间线判断持有人是否变化;新增两项纯计算及两项 MySQL 集成回归通过。性能观察也已处理:先按周期(及选中医助)查询候选诊单,再分块读取对应完整时间线。 + +## 需要修复:1 项 + +### [P2] 释放后重新指派给原医助被误当作自指派,患者和后续订单全部漏计 + +- 位置:`server/app/adminapi/logic/stats/RevisitCohortCalculator.php:39`,`$from === $aid` 直接跳过整条事件,既不入组也不更新持有时间线。 +- 写入侧证据:`DiagnosisLogic::assign` 第 714~715 行在诊单当前 `assistant_id=0` 时调用 `inferPreviousAssistantIdFromLatestAssignLog`;该方法在最近日志为 `A→0` 时回落其 `from_assistant_id=A`。因此用户实际执行的“未分配→A”会写成 `from=A,to=A`,这与真实自指派有相同字段,但不是无操作。 +- 最小复现(同一诊单,日志 / 订单按要求排序): + +| 时间戳 | 事件 | +| --- | --- | +| 10 | 指派 `0→7`,新指派 | +| 11 | 第 1 笔有效订单 | +| 20 | 释放 `7→0` | +| 30 | 从未分配重新指派给 7;现有写入逻辑记录 `7→7`,`is_inherit=0` | +| 31 | 第 2 笔有效订单 | + +调用 `compute($logs,$orders,[],[],[],25,35,100,'new')` 后,实测 `assigned_count=0, visit2_count=0`,应各为 1。若重新承接时勾选继承,日志为 `7→7,is_inherit=1`,同样漏掉本次继承患者和其首单二诊。 + +这还会影响区间更早的原医助队列:重新承接事件被跳过后,时间线一直停在释放状态,后续成交不再归属任何有效持有段。 + +建议根据同一诊单此前真实时间线的最后目标判断是否“持有人没有变化”,不能仅比较日志的 `from/to`;至少识别前一有效事件为 `to=0` 的重新承接。保留真实 `A→A` 的不入组、不重置行为。增加“释放后返还同一医助”的新指派及继承两个纯函数 / 数据库用例,区别于已有“转到其他人后再次继承”测试。 + +## 其余检查 + +- 已确认日期筛选约束入组事件,成交累计到 `time()`;区间外患者不会进入分子,后续转派 / 释放终止该持有段。 +- 继承后订单以段内序号 `+1` 从二诊起算,忽略全局诊次 / offset;新指派保留全局诊次。真实自指派当前测试覆盖且不重置。 +- 历史 rollback 关联键参与继承识别,`is_inherit=0` 回退不会混入新指派。被拒收 / 退款的诊单同时从队列分母、分子剔除,无孤立分子。 +- 医助筛选先作用于整个 context,再构造部门 / 总计及明细;部门仍限制二中心。部门 / 总计按患者、诊次去重,明细与指标一致。 +- 继承 Controller 用 `static::COHORT_TYPE` 固定类型,不能通过请求参数切换到另一类;继承 Controller 可正常 autoload。 +- 菜单 SQL 按对应权限逐项授予原角色,未扩大只有患者明细角色的订单明细权限;页面与三个接口都登记。部署仍须执行 SQL 并清理 `AdminAuthCache`,否则系统现有“未登记接口默认放行”规则会作用于新接口。 +- 旧 `RevisitRateLogic` 仅将共用读取方法改为 protected,自动指派 / 个人业绩仍调用旧口径,未发现行为改动。 + +## 性能观察(未作为阻塞问题) + +`RevisitCohortLogic::cohortContext` 第 188~198 行先把所有未删诊单截至当前的全部历史指派日志读入 PHP,再计算候选诊单并过滤;即使只查一天、单个医助甚至空队列,首个查询扫描 / 返回的数据量仍与全量历史指派数相关。随后订单扫描已收窄到候选诊单。建议后续先在 SQL 查区间内候选诊单,再按候选诊单拉全量持有时间线,保持释放 / 转派语义;本次没有业务库规模和执行计划,无法证明已有请求达到超时 / 内存上限,因此不将其列为已复现缺陷。 + +## 验证 + +独立运行通过: + +```text +RevisitCohortCalculatorTest OK (32 checks) +RevisitRateContractTest OK (45 checks) +RevisitRateInheritSlotTest OK (57 checks) +RevisitVisitSlotServiceTest OK (20 checks) +``` + +四个新增 / 修改核心 PHP 文件 `php -l` 通过。额外通过 PHP 标准输入执行上述最小复现,确认实际输出为全零。主代理报告的隔离 MySQL 5.7 数据库测试 20 checks 已阅读源码,本次未重跑其临时数据库。数据库测试目前没有“释放后返还原医助”场景。 diff --git a/artifacts/revisit-cohort-browser-check.json b/artifacts/revisit-cohort-browser-check.json new file mode 100644 index 000000000..9add55a50 --- /dev/null +++ b/artifacts/revisit-cohort-browser-check.json @@ -0,0 +1,21 @@ +{ + "passed": 15, + "checks": [ + "新指派页可见且独立命名", + "默认新指派汇总加载", + "日期范围允许未来结束日", + "医助筛选传入汇总请求", + "患者明细沿用日期和医助筛选", + "切换部门清空范围外医助", + "医助选项按部门限制", + "重置清空部门医助", + "独立继承页加载", + "继承页说明首单二诊", + "继承页人数列不混合新指派", + "继承订单使用独立接口", + "继承后诊次明细可见", + "底栏展示复诊合计率", + "浏览器无运行时异常" + ], + "errors": [] +} \ No newline at end of file diff --git a/artifacts/revisit-cohort-frontend.md b/artifacts/revisit-cohort-frontend.md new file mode 100644 index 000000000..db0feebd9 --- /dev/null +++ b/artifacts/revisit-cohort-frontend.md @@ -0,0 +1,31 @@ +> 2026-10-17 更正:以下为10月16日实施记录。原页面及原 API 已恢复,指派周期新报表迁至 revisit_assign_rate / stats.revisitAssignRate,继承报表仍独立。最新验证见 revisit-legacy-source-proof.json、revisit-legacy-restored-compile.json、revisit-legacy-restored-browser-check.json 及部署计划。 + +# 复诊统计前端交付与验证 + +日期:2026-10-16 + +## 实现 + +- `admin/src/views/stats/revisit_rate/index.vue`:原入口保留,专门展示新指派患者。 +- `admin/src/views/stats/revisit_inherit_rate/index.vue`:独立继承复诊入口。 +- `admin/src/views/stats/revisit_rate/components/CohortReport.vue`:共享 Element Plus 报表,按 `cohortType` 分离接口、权限、患者人数列及说明。不再保留旧/新继承混算切换。 +- `admin/src/api/stats.ts`:以第二参数选择 `/stats.revisitRate` 或 `/stats.revisitInheritRate`;保留个人业绩依赖的 `RevisitRateInheritMode`。 + +日期筛选默认当月 1 日至当天,支持任意范围及未来结束日期(如 10 月 30 日);日期范围选患者,订单累计至查询当天。继承页明确写明:继承后第一笔有效订单算二诊,第二笔算三诊,不使用继承前全局诊次或偏移。 + +保留部门树、医助/部门层级明细、服务端去重合计、患者只读详情、订单只读抽屉;新增可搜索和清空的医助筛选,按选中部门及其下级过滤,切换部门会清空范围外医助。展示服务端 `observed_until`。 + +下钻复用已展示报表的查询快照,顶层 `assistant_id` 与医助行 `scope_assistant_id` 独立传递。汇总和明细分别用请求序号丢弃旧响应;筛选重新查询会关闭并清空明细。 + +## 验证 + +1. 使用项目现有 `@vue/compiler-sfc` 编译两页入口和共享组件:脚本、模板、SCSS 均通过。 +2. 使用真实 Vue `ref/computed`、编译后的 SFC setup 及受控异步 API 验证 26 项行为,全部通过: + - 默认日期、未来结束日期保留、部门子树医助过滤、超出范围自动清空。 + - 查询快照、保留顶层医助、独立行级医助/部门范围、返回 count、动态诊次排序去重。 + - 汇总/明细乱序响应、筛选后迟到明细的丢弃、重新查询清空明细。 + - 继承文案、单类人数列、独立继承权限、两套接口各四个 action 路径。 +3. `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --pretty false`:全项目有 61 条既有类型诊断;本次 API、两页入口及共享组件的诊断为 **0**。存量错误包括聊天组件、编辑器声明、录像 API 及既有处方表单类型等。 +4. 前端负责文件统一 LF;`git diff --check` 通过。 + +未执行含 release 的 `npm run build`,未发布。浏览器与真实后端联调由主代理继续完成。 diff --git a/artifacts/revisit-cohort-inherit-detail.png b/artifacts/revisit-cohort-inherit-detail.png new file mode 100644 index 000000000..b6844425d Binary files /dev/null and b/artifacts/revisit-cohort-inherit-detail.png differ diff --git a/artifacts/revisit-cohort-inherit.png b/artifacts/revisit-cohort-inherit.png new file mode 100644 index 000000000..08af32616 Binary files /dev/null and b/artifacts/revisit-cohort-inherit.png differ diff --git a/artifacts/revisit-cohort-new.png b/artifacts/revisit-cohort-new.png new file mode 100644 index 000000000..cccda1d55 Binary files /dev/null and b/artifacts/revisit-cohort-new.png differ diff --git a/artifacts/revisit-legacy-restore-review.md b/artifacts/revisit-legacy-restore-review.md new file mode 100644 index 000000000..aab2e5389 --- /dev/null +++ b/artifacts/revisit-legacy-restore-review.md @@ -0,0 +1,72 @@ +# 原复诊接诊率入口恢复只读审查 + +日期:2026-10-17。基线:`HEAD=c67b3eff69d9a3ac2046861751f159692a0abcd7`。仅审查本地源码、Git 和既有验证记录;未读 `.env` / 凭据,未连接业务库,未发布,未修改产品文件。`.trellis/workflow.md` 在当前工作区不存在。本报告是唯一写入文件。 + +## 结论 + +最小可靠恢复是将原 Controller、原 `/stats/revisit_rate/index.vue` 及原 API 函数恢复为 HEAD 的完整契约,保留原 `RevisitRateLogic`、`RevisitRateCalculator`、`RevisitVisitSlotService`、配置、自动分配及个人业绩调用链。新队列功能另外使用 `stats.revisitAssignRate/*` 和 `/stats/revisit_assign_rate`;继承继续使用 `stats.revisitInheritRate/*` 和 `/stats/revisit_inherit_rate`。 + +仅保留旧计算器还不能保持原功能:上一轮将 `stats.revisitRate/*` Controller 改为队列逻辑,现有页面和外部调用者即使 URL 不变也会获得不同的患者集合、时间范围、分子与分母。因此恢复必须覆盖入口及 API 契约。 + +审查开始时,原 `RevisitRateLogic` 的差异仅为 7 个 `private` 方法改为 `protected`,计算器与诊次服务无 Git 差异。审查过程中主代理已将旧 Controller、旧页面、旧 MCP review 项恢复,旧 Logic 也恢复为 HEAD,并将队列数据源抽为独立 trait。该并行状态只是观察结果,最终应以主代理收尾 diff 为准。 + +## HEAD 原入口与依赖 + +| 层 | 原契约 | +| --- | --- | +| 菜单页面 | 名称「复诊接诊率」,`paths=/stats/revisit_rate`,`component=/stats/revisit_rate/index` | +| 菜单/API 权限 | `stats.revisitRate/overview`、`stats.revisitRate/deptOptions`、`stats.revisitRate/assignLines`、`stats.revisitRate/visitOrderLines` | +| Controller | 四个 action 直接调用 `RevisitRateLogic` 同名方法;overview / 两个明细保留 120 秒执行时间 | +| 查询参数 | overview:`month, dept_ids, inherit_mode`;患者明细另带 `assistant_id, dept_id, kind=new|inherit|all`;订单明细另带 `slot=0|2..50` | +| 前端 API | `revisitRateOverview`、`revisitRateDeptOptions`、`revisitRateAssignLines`、`revisitRateVisitOrderLines`;保留 `RevisitRateInheritMode` 和 `RevisitRateAssignKind` 导出 | +| 默认口径 | `server/config/project.php` 的 `default_inherit_mode='exclude'`;页面首次请求不传 inherit_mode,接收服务端返回口径;用户可手动选择 slot | +| 原页面筛选 | 月份、部门、exclude / slot 口径;重置回默认口径;包含新指派 / 继承 / 合计及订单下钻 | +| 下钻权限 | 患者数:`stats.revisitRate/assignLines`;订单数:`stats.revisitRate/visitOrderLines`;患者详情:`tcm.diagnosis/detail`;订单详情:`tcm.prescriptionOrder/detail` | +| 原个人业绩 | `AssistantPerformanceLogic::revisitRate` → `RevisitRateLogic::assistantOverview`;本人结果按月 / 医助 / 口径缓存 600 秒 | +| 原自动分配 | `AutoAssignPendingDiagnosis` 使用旧 `RevisitRateLogic`;取 slot 模式 overview,但分档字段配置仍为 `visit2_rate_new_assign` | +| 原 MCP 项 | `server/app/mcp/catalog/review/stats.php` 中原四个 `stats.revisitRate/*` 说明和参数白名单必须继续描述按月统计 | + +原菜单来源为 `server/sql/1.9.20260610/add_revisit_rate_menu.sql`。恢复旧入口无需改原菜单记录、原权限或执行旧菜单脚本。Vue 路由由 `admin/src/router/index.ts` 的 `import.meta.glob('/src/views/**/*.vue')` 按数据库 component 解析,新增独立入口只需相应组件文件和菜单 component 正确对应。 + +## 需保持的算法含义 + +- 分母取所选月的指派事件;原 exclude 模式仅按 `is_inherit=0` 构造医助×诊单集合,保留自指派及历史回退的原处理方式。原 slot 模式另外计算继承起算诊次与每列分母。 +- 分子取所选月的有效订单,诊次为跨月累计的全局有效实单序号加原诊单偏移;订单按当时持有人归属。往月指派、当月成交仍可进入分子,率可超过 100%。 +- 有拒收 / 退款业务订单的诊单整体剔除;有效订单过滤仍委托原业绩口径;二中心组织范围、当前人事部门归类和去重规则保持 HEAD。 +- 队列统计把“入组指派周期”和“累计观察时刻”分开,继承重新起算诊次。这些是另一张统计表的含义,不能复用原四个 API 名称替换原算法。 + +## 新入口权限与发布边界 + +`AuthMiddleware::handle` 当前对未登记到菜单权限集合的普通 API 默认放行;只增加 PHP Controller 而漏掉菜单权限登记,会使新增数据接口对登录用户过宽。新增 `stats.revisitAssignRate` 的 overview 和 3 个子接口都应登记;继承入口同理。角色授权应逐 action 从原对应权限复制,不能因拥有 overview 就自动授予患者或订单明细。执行菜单迁移后需按现有流程刷新 `AdminAuthCache`。 + +原菜单无需改名或搬迁。新指派周期菜单应新增独立 path / component,避免覆盖旧 `stats.revisitRate/overview` 的记录。若已有继承菜单脚本会引用旧页作为父目录与授权来源,可保留这种引用;新增指派周期脚本也可按同样逐权限方式授权。 + +## 本地证据能确认的发布状态 + +- `git diff -- server/public/admin`、该目录的 `git status --short` 均为空:当前仓库的公开管理端产物没有相对 HEAD 被替换。 +- 公开产物 `server/public/admin/assets/stats-Bz24MGMf.js` 仍包含原四个 `stats.revisitRate/*` 路径;在公开 JS 中未找到 `stats.revisitInheritRate`、`scope_assistant_id` 或新队列标题。不能据此推断远端服务器文件状态。 +- 既有验证计划记录生产构建输出为 `.codex-tasks/revisit-cohort-build`,该隔离目录确实存在;记录明确写了未运行 release、业务库迁移未执行。它是先前代理的本地记录,不是生产部署审计。 +- `admin/package.json` 的 `npm run build` 实际执行 `vite build && node scripts/release.mjs`;release 会移除并重拷贝 `../server/public/admin`。只做验证构建时应继续使用单独输出目录,并避免运行 release。 +- 源码改动在当前工作区尚未提交。Git 未提交、公开目录未变和临时构建存在,可以支持“本地未完成正式 release”的判断,不能单独证明生产环境从未部署。此次未查询生产、不读凭据、不连业务库,故不能声称已经确认线上恢复或线上未受影响。 + +## 已执行验证 + +以下测试只加载 autoload / 纯函数或反射源码,不初始化应用数据库,全部通过: + +| 命令 | 结果 | +| --- | --- | +| `php server/tests/RevisitRateContractTest.php` | 45 checks | +| `php server/tests/RevisitRateInheritSlotTest.php` | 57 checks | +| `php server/tests/RevisitVisitSlotServiceTest.php` | 20 checks | + +合计 122 项旧链路检查。本次未执行数据库测试。 + +## 主代理收尾需要覆盖的检查 + +1. 旧 Controller / 旧页面与 HEAD 比对无差异;原 Logic / Calculator / 诊次服务 / 配置 / 自动分配 / 个人业绩无差异。API 文件可以新增独立函数,但原函数签名、URL、参数类型及旧类型导出应保持 HEAD。 +2. 原四个 Controller action 明确调用 `RevisitRateLogic`;新增四个指派周期 action 调用 `RevisitCohortLogic` 的 new 类型;继承 action 固定 inherit 类型。禁止通过请求参数切换到另一报表类型。 +3. 重跑上述 122 项旧检查;新增队列纯函数检查继续通过。隔离数据库验证若运行,只能显式指定独立本机临时库,并扩展到新指派菜单的 4 项登记、幂等及逐权限复制。 +4. 三页及共享组件编译成功;旧页仍调用旧 API,新指派页调用 `stats.revisitAssignRate/*`,继承页调用 `stats.revisitInheritRate/*`。新共享组件迁移后不得留下从原页面目录导入队列实现的误导路径或错误权限前缀。 +5. 原页月份 / 默认 exclude / 手动 slot / 患者与订单下钻至少验证一次;新两页的日期、医助筛选及 scope 筛选保持原队列验证。已有 `artifacts/revisit-cohort-browser-check.json` 使用虚构接口响应,只证明旧一轮 UI 行为,不能证明此次入口分离或真实业务数据一致。 +6. 同步计划和前端说明中“原入口只统计新指派”“新指派使用 stats.revisitRate”的陈旧描述;原 MCP review 恢复按月参数,新两个 API 项按实际路由声明。运行现有 MCP 目录测试,确认没有旧路径重新被队列说明覆盖。 +7. `git diff --check`;构建输出到隔离目录;收尾报告明确本地恢复完成、验证范围及未发布状态,不以虚构数据浏览器测试声称生产数据已核对。 diff --git a/artifacts/revisit-legacy-restored-browser-check.json b/artifacts/revisit-legacy-restored-browser-check.json new file mode 100644 index 000000000..25179edac --- /dev/null +++ b/artifacts/revisit-legacy-restored-browser-check.json @@ -0,0 +1,26 @@ +{ + "passed": 20, + "checks": [ + "原页面仍使用原按月接口和参数", + "原月份筛选恢复", + "原口径选择恢复", + "原页面仍能切换原有slot口径", + "原患者明细保留month/kind/口径契约", + "新指派页可见且独立命名", + "默认新指派汇总加载", + "日期范围允许未来结束日", + "新增医助筛选使用独立指派周期接口", + "患者明细沿用日期和医助筛选", + "切换部门清空范围外医助", + "医助选项按部门限制", + "重置清空部门医助", + "独立继承页加载", + "继承页说明首单二诊", + "继承页人数列不混合新指派", + "继承订单使用独立接口", + "继承后诊次明细可见", + "底栏展示复诊合计率", + "浏览器无运行时异常" + ], + "errors": [] +} \ No newline at end of file diff --git a/artifacts/revisit-legacy-restored-compile.json b/artifacts/revisit-legacy-restored-compile.json new file mode 100644 index 000000000..804adcd93 --- /dev/null +++ b/artifacts/revisit-legacy-restored-compile.json @@ -0,0 +1,5 @@ +{ + "sfcFiles": 4, + "apiCalls": 12, + "errors": [] +} \ No newline at end of file diff --git a/artifacts/revisit-legacy-restored.png b/artifacts/revisit-legacy-restored.png new file mode 100644 index 000000000..79ce5735f Binary files /dev/null and b/artifacts/revisit-legacy-restored.png differ diff --git a/artifacts/revisit-legacy-source-proof.json b/artifacts/revisit-legacy-source-proof.json new file mode 100644 index 000000000..3681f228f --- /dev/null +++ b/artifacts/revisit-legacy-source-proof.json @@ -0,0 +1,56 @@ +{ + "baseline": "c67b3eff69d9a3ac2046861751f159692a0abcd7", + "files": [ + { + "file": "admin/src/views/stats/revisit_rate/index.vue", + "identical": true, + "sha256": "7bbd3e94e974e49367379ff69325ca93a7f903eb01020e4c7dfafd415a57a4df" + }, + { + "file": "server/app/adminapi/controller/stats/RevisitRateController.php", + "identical": true, + "sha256": "272f437a220f393f6b0c9c4833ccadaf30de1915777c25da906b6e9eb0c662e4" + }, + { + "file": "server/app/adminapi/logic/stats/RevisitRateLogic.php", + "identical": true, + "sha256": "86bac0184a8fda25e1b295527687568e5950b1c66a1a8f5a901617cc8624c3a1" + }, + { + "file": "server/app/adminapi/logic/stats/RevisitRateCalculator.php", + "identical": true, + "sha256": "c03d1440783695f6e094f16417697821fe8e793094f3070bca8461d5f2c9ddc6" + }, + { + "file": "server/app/common/service/tcm/RevisitVisitSlotService.php", + "identical": true, + "sha256": "2991166354b34964603557ab057ec1eb63e8c7cd09d42e6f525b9f89af93bb85" + }, + { + "file": "server/config/project.php", + "identical": true, + "sha256": "b2fb9c7f82ea57dfbe141a72c1f56f546db8206fc361b1b203470830cddd67a8" + }, + { + "file": "server/app/command/AutoAssignPendingDiagnosis.php", + "identical": true, + "sha256": "82366bc39f7d247dbdccdbe1b37c39f21cbeffb0fd46166455da81d97966ec2c" + }, + { + "file": "server/app/adminapi/logic/stats/AssistantPerformanceLogic.php", + "identical": true, + "sha256": "e81482157e8708d00da66f421416b1ba18de4dcec59443d22c9fb0ff28e21a72" + }, + { + "file": "server/app/adminapi/controller/stats/AssistantPerformanceController.php", + "identical": true, + "sha256": "a30f1e6cd2ab38c51fc7b6642d840278f465dca1c2b7542e8dfa912c15900ada" + }, + { + "file": "server/app/mcp/catalog/review/stats.php", + "identical": true, + "sha256": "cf2f97cb3e5d41c829f0d5c509e6ceba980297af69dfa061968a3a0bb55d5cc1" + } + ], + "original_api_bytes_preserved": true +} \ No newline at end of file diff --git a/artifacts/video-column-after-page.png b/artifacts/video-column-after-page.png new file mode 100644 index 000000000..ec7be3e9f Binary files /dev/null and b/artifacts/video-column-after-page.png differ diff --git a/artifacts/video-column-before-page.png b/artifacts/video-column-before-page.png new file mode 100644 index 000000000..f0aaa30bd Binary files /dev/null and b/artifacts/video-column-before-page.png differ diff --git a/artifacts/video-column-before.png b/artifacts/video-column-before.png new file mode 100644 index 000000000..c668459ce Binary files /dev/null and b/artifacts/video-column-before.png differ diff --git a/artifacts/video-column-layout-review.md b/artifacts/video-column-layout-review.md new file mode 100644 index 000000000..6c8a035ff --- /dev/null +++ b/artifacts/video-column-layout-review.md @@ -0,0 +1,87 @@ +# 桌面问诊列表:视频固定列布局审查 + +核对日期:2026-10-17。仅审查本地源码并运行离屏几何探针,未连接生产服务或使用 UI 账号;未修改业务代码和现有测试。已有 dirty 文件均保留。 + +## 规范与范围 + +- 已读取仓库根目录 `AGENTS.md`;本地 `.trellis/` 不存在,无法读取其 workflow/spec。`app/` 下未发现额外 `AGENTS.md`。 +- 对照 `app/research/diagnosis_index_visual_spec.md`:固定双表共享行高及垂直滚动,文本换行受实际列宽、字体和 DPR 影响。该文件记录的是早期后台复刻参考,当前桌面问诊列表的尺寸以实现和测试为准。 +- 任务提及的 `_call_cell` 在当前仓库不存在。实际入口为 `DiagnosisTableHost._video_widget()`,位于 `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1786`(以下行号为审查时快照;并行修复可能改变行号)。 + +## 根因 + +这是单元格内部可用宽度和标签高度协商不一致,不能单纯归因为行高不足或固定表滚动错位。 + +1. 问诊列表采用 tech-blue 固定列宽 `92 + 158 = 250px`,视频列声明宽度为 92px;见 `_BLUE_COLUMN_WIDTHS` 和 `DiagnosisTableHost.__init__()`。 +2. `app/src/doctor_workstation/ui/theme.py:701-703` 的全局 `QTableWidget::item, QTableView::item { padding: 6px 8px; ... }` 仍作用于固定表。Qt 为 index widget 分配的是 item 内容区,所以声明 92px 的视频列实际只给 `_FixedCell` 76px 宽。 +3. `_video_widget()` 内又有左右各 3px、上下各 2px 的布局边距,标签实际宽度进一步降至 70px。 +4. 视频布局整体设置 `QHBoxLayout.setAlignment(AlignCenter)`,状态标签同时 `setWordWrap(True)`。布局把标签约束到其 `sizeHint()` 高度,但受限宽度下实际 `heightForWidth()` 更高,产生标签内部上下裁切;`QLabel.AlignCenter` 使文字在不足的高度中居中,更容易出现首尾被切掉的观感。 +5. `_sync_row_heights()` 只根据前十列/挂号内容计算行高,不考虑视频列。它是未来异常长文案的潜在缺口,但在本次现有服务端文案中,即使整行已有足够空间,标签也仍会被裁切。单纯增高整行无法修复标签高度仍被限制的问题。 + +### 本地实测 + +环境:Windows、PySide6 6.11.1、`QT_QPA_PLATFORM=offscreen`,调用真实 `apply_theme()`,tech-blue `DiagnosisTableHost`,Microsoft YaHei UI 13px。使用已预约视频诊单、只读视频状态、挂号含最近渠道,使行高为 82px。 + +| 状态文案 | 表格 visualRect | index widget | 标签尺寸 | 标签实际所需高度 | +| --- | --- | --- | --- | --- | +| `视频已结束 · 10-17 13:30` | 92 × 82 | 76 × 69,偏移 (8,6) | 70 × 33 | 50 | +| `上次通话已取消` | 92 × 82 | 76 × 69 | 70 × 16 | 33 | +| `上次通话未接听` | 92 × 82 | 76 × 69 | 70 × 16 | 33 | + +因此 82px 行内仍有空间,但结束状态缺 17px 标签高度。未应用全局主题的独立 host 不会受到这层 item padding,结束状态可能恰好不裁切,容易给出错误的验证结论。 + +另一个无主题探针也确认:即便不受全局 padding 影响,整体 `AlignCenter` 仍能使长标签在宽度收缩后维持错误高度。例如含较长日期/标点的结束文案,标签 86 × 50,而 `heightForWidth(86)` 为 67;取消/未接听文案标签 86 × 16,实际需要 33。取消布局整体对齐后,82px 行中的标签可填满 86 × 78,已有文案能正常放下。 + +## 最小修复建议 + +1. 在固定表自己的局部 QSS 中清除 `::item` padding,让固定列的 index widget 使用真实列宽。范围应限定 `#DiagnosisFixedTable`,无需改变全局主题或临床数据主表的绘制规则。固定表两列均为 index widget,可同时避免操作按钮被额外压缩。 +2. 去掉视频 `QHBoxLayout` 的整体 `AlignCenter`,让只读 `QLabel` 填充布局可用宽高。继续保留标签自己的 `AlignCenter`、`wordWrap=True`;交互按钮使用单个布局项对齐来保持居中。 +3. 对服务端 `状态 · 时间` 文案,将这一明确分隔符变成展示换行,形成状态与时间层次;不要按任意字符截断,也不要丢弃时间。标签 tooltip 保存完整原文,便于查看完整时间或未来较长说明。 +4. 保留现有状态、角色/权限、点击流程、列宽和双表行高同步规则。无须为本次已知文案扩大固定区域、改全局字号或统一增高所有行。 + +### 是否需要动态行高 + +服务端 `server/app/adminapi/lists/tcm/DiagnosisLists.php:500-540` 的完整现有状态契约为: + +| state | 服务端 label / 桌面公开展示 | +| --- | --- | +| none | 空 label / `—` | +| live | `视频通话进行中` / 桌面简写 `通话中` | +| pending_room | `通话发起中,待同步房间` / 桌面简写 `接通中`,可进入但等待的医助路径为 `等待接通` | +| ended | `视频已结束 · m-d H:i`,无 end_time 时仅 `视频已结束` | +| missed | `上次通话未接听` | +| cancelled | `上次通话已取消` | + +清除外层 item padding 后,92px 列减去内部 6px 边距,标签宽 86px;已知最长状态最多需要约 33px(两行),tech-blue 当前最小 72px 行可提供 68px 标签高度。因此当前契约无需增加动态行高。 + +截图叙述中的完整年份/秒并非当前服务端 `m-d H:i` 的固定格式;它可以作为兼容性测试数据,但不应据此修改后端时间格式。对于未来任意超长 label,如果产品要求完整正文始终可见,则需让状态展示与高度测量复用同一文本和实际宽度,并把其高度纳入双表 `max(...)` 行高;这属于额外防御,不是修复已知状态裁切的前提。 + +## 现有测试缺口 + +- `app/tests/test_diagnosis_index_visual.py:92-94` 的 QApplication fixture 没有调用 `apply_theme()`,无法复现全局 item padding。 +- `test_dedicated_model_fixed_columns_selection_and_sort` 验证 92/158px 和 250px 的声明尺寸,未验证 index widget 真正得到的矩形。 +- `test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 已覆盖双表像素滚动和行顶对齐,但不检查标签内部几何。 +- `test_required_reference_artifacts_exist` 只验证参考图片存在和尺寸,没有检查状态字形或标签是否裁切。 +- `app/tests/test_consultations_parity_ui.py` 的角色/权限回归只断言按钮、标签文字和信号。医生只读状态矩阵目前覆盖 live/pending_room/none,未覆盖 ended/missed/cancelled 及实际显示尺寸。 +- `app/scripts/render_diagnosis_visual.py:72` 的常规样本使用字符串 `video_call_hint`,并没有 `ended + label + end_time` 的真实复合数据;已有 `artifacts/doctor-video-column.png` 也只展示三个短状态。 + +## 应覆盖的几何场景 + +| 场景 | 必要断言 | +| --- | --- | +| 真实全局主题 + ConsultationsPage + 82px 行 | 视频列 index widget 的宽度等于对应 visualRect 的宽度;不能只断言 columnWidth=92 | +| 最小 72px 行、不含最近渠道 | 状态标签处于单元格内部;`label.height() >= label.heightForWidth(label.width())`,并核对 contentsRect/文本所需高度 | +| ended 当前完整 label、无时间 label | 状态与时间均可读;全文 tooltip 保留;不生成可点击视频入口 | +| missed/cancelled 七字 label | 不因单行 sizeHint 只有 16px 而裁切两行正文 | +| full-year/seconds、显式换行、未知较长 label | 不出现横向越界;已承诺完整显示的文本高度满足要求,其余由全文 tooltip 可访问 | +| live/pending/none、未挂号、已完成、医助等待状态 | 原有状态及按钮规则保持;短文案仍居中 | +| 合法医助 live 按钮、医生 text 按钮、操作列 | 按钮完整可见且居中,操作项不被新布局拉伸或挤出 | +| 多预约/长挂号产生的较高行 | 标签居中不裁切;主表和固定表 rowHeight 仍相等 | +| 1024×640、1280×800、1366×768、1920×1080 | 窗口改变不压缩固定列;横向滚动条显隐前后视频 index widget 与 visualRect 一致 | +| 双表垂直滚动到中段/末尾 | 两表 viewport 和行顶保持同步;行本身被 viewport 正常截断应与标签内部错误裁切区分 | +| 100% / 125% / 150% 缩放及实际加载字体 | 使用真实主题与字体测量,允许平台像素取整误差,不能依赖单一截图像素数 | +| 静默相同刷新与状态变化刷新 | 同样数据继续保留既有 fixed widget 的零重建预算;变化后的标签按新文本重新布局 | + +如果修改共享 `_video_widget()`,可再补 legacy 96px 视频列与 54px 最小行的轻量几何检查,避免布局更改引入其他页面回归;当前问诊列表验收以 tech-blue 92px 固定列为主。 + +本轮未运行既有 pytest,也未修改现有测试;以上数值来自离屏几何探针,不是生产客户端验收结果。 diff --git a/artifacts/video-column-layout-validation.md b/artifacts/video-column-layout-validation.md new file mode 100644 index 000000000..988e221ae --- /dev/null +++ b/artifacts/video-column-layout-validation.md @@ -0,0 +1,36 @@ +# 视频问诊固定列错位修复验证 + +日期:2026-10-17。范围:桌面问诊列表固定列布局。 + +## 修复 + +`app/src/doctor_workstation/ui/diagnosis_index_widgets.py`: + +- 仅在固定表清除继承的 item padding,避免 92px 列被压成 76px 内容区。 +- 状态标签填充单元格并居中绘制,避免布局把标签限制在旧的 sizeHint 高度。 +- 将服务端 `状态 · 时间` 分成两行,完整原文留在 tooltip;使用纯文本显示。 +- 视频入口按钮继续单独居中;列宽、行高及双表滚动机制保持。 + +详细根因审查见 [审查记录](video-column-layout-review.md)。 + +## 截图核对 + +使用真实 `apply_theme()`、`ConsultationsPage`、医生角色和离线测试数据渲染;无真实账号或网络请求。 + +- [修复前](video-column-before-page.png):单元格为 76×69,结束状态标签为 70×33,而所需高度为 50;首尾被裁切。 +- [修复后](video-column-after-page.png):单元格为 92×81,标签可用区为 86×77;结束状态与时间两行所需高度 33,完整可读。 + +截图生成脚本保存在 `.codex-tasks/video-column-layout-check.py`,脚本当前读取修复后的源码。 + +## 实际回归结果 + +| 检查 | 结果 | +| --- | --- | +| 新布局回归修复前运行 | 4 个案例失败,复现标签高度不足/缺少全文提示 | +| `test_diagnosis_video_status_layout.py` | 8 passed:两种表格风格、1024×640 / 1710×920、有/无渠道最小行高;覆盖结束状态有/无时间、通话中、接通中、未接听、取消、空状态;检查真实可用尺寸、标签高度、居中、全文提示及滚动行顶同步 | +| `test_diagnosis_index_visual.py` | 26 passed | +| `test_consultations_parity_ui.py` + `test_text_consult_ui.py` | 87 passed,含医生/医助权限入口及图文流程 | +| Ruff:变更源码与新增测试 | 通过 | +| 变更源码 `git diff --check` | 通过 | + +共 121 个相关 UI 回归通过。未打包、未更新正在运行的客户端;桌面端更新后生效。 diff --git a/docs/plans/revisit-assignment-cohort-2026-10-16.md b/docs/plans/revisit-assignment-cohort-2026-10-16.md new file mode 100644 index 000000000..a349af498 --- /dev/null +++ b/docs/plans/revisit-assignment-cohort-2026-10-16.md @@ -0,0 +1,50 @@ +# 复诊接诊率:保留原算法,新增统计使用独立入口 + +2026-10-17 更正:用户要求原来的算法保持不变。原页面、原接口及原计算逻辑已恢复至修改前版本。10 月 16 日曾将原 Controller 接入指派周期计算器,虽然旧计算器仍保留,但原页面的数据口径已改变;本次恢复覆盖了完整调用链。 + +## 三个独立入口 + +| 页面 | 路径 | 接口 | 口径 | +| --- | --- | --- | --- | +| 原复诊接诊率 | `/stats/revisit_rate` | `stats.revisitRate/*` | 原按月算法、默认配置及 exclude/slot 切换保持原样 | +| 按指派周期复诊率 | `/stats/revisit_assign_rate` | `stats.revisitAssignRate/*` | 按新指派日期范围及医助选患者,复诊累计至查询当天 | +| 继承复诊接诊率 | `/stats/revisit_inherit_rate` | `stats.revisitInheritRate/*` | 按继承日期及医助选患者,继承首笔有效订单算二诊、次笔三诊 | + +原四个 action(overview、deptOptions、assignLines、visitOrderLines)继续直接调用原 RevisitRateLogic。原月份筛选、参数 month / inherit_mode / kind、分母、分子、全局诊次、订单持有人及部门汇总规则全部恢复。原 API 函数原字节保留,新接口函数以 revisitCohort 前缀追加,避免旧页面误用。 + +RevisitCohortLogic 使用独立 RevisitCohortDataSource 读取数据,不再继承或要求修改原 RevisitRateLogic 的方法可见性。自动指派、个人业绩、全局诊次服务和项目统计配置均与修改前逐字节一致。 + +## 独立新增报表 + +日期范围包含起止日,订单累计到查询时刻。继承前全局诊次及偏移不影响继承重新起算。转派/释放终止旧持有段;释放后重新分回原医助正常统计。新报表的去重及继承规则只作用于新接口。 + +两个新报表分别接受 start_date / end_date、dept_ids、assistant_id;明细可附 scope_assistant_id / dept_id,与顶层筛选取交集。继承类型由服务端固定,不能通过参数切换。 + +## 发布 + +原页面恢复需要发布恢复后的 PHP 与管理端构建,原菜单无需修改;本次未执行正式 release,未连接业务库,不能据本地证据声称线上已恢复。 + +启用独立新增页面时,依次执行: + +1. `server/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql` +2. `server/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql` +3. 按部署流程刷新 AdminAuthCache 并重新登录。 + +脚本幂等,按原各项权限逐项授权,原角色没有订单明细权限时不会额外获得。两个脚本均不改写原菜单、原权限记录或业务数据。 + +## 本轮验证 + +- 10 个原链路文件(原页面、Controller、Logic、Calculator、诊次服务、配置、自动指派、个人业绩及 MCP 目录)与 HEAD=c67b3eff69d9a3ac2046861751f159692a0abcd7 字节一致;原 API 文件完整原字节作为前缀保留。证据:artifacts/revisit-legacy-source-proof.json。 +- 原统计、继承诊次及诊次服务测试 122 项通过;新增队列纯计算 34 项通过。 +- 本机隔离 MySQL 5.7 集成测试 37 项通过:实际 Controller 调用验证原 exclude/slot 的汇总及两类明细与原 Logic 完全一致;新增查询不改变原响应;独立新接口固定类型;两个菜单脚本幂等、逐权限授权且不改写原菜单。 +- 四份 Vue SFC 编译及 12 个真实 API wrapper 路径调用通过;浏览器 20 项检查通过,包含原月筛选、原口径切换、原明细参数及新增两页入口隔离。浏览器使用虚构接口响应,数据库测试使用独立本机临时库。 +- MCP 目录单元测试及 PHP 语法检查通过。 +- 最终 Vite 生产构建通过,输出到 `.codex-tasks/revisit-legacy-restored-build`,未执行 release。 + +集成测试须在本机独立临时 MySQL 上显式启用: + +```powershell +$env:REVISIT_COHORT_TEST_DISPOSABLE = '1' +$env:REVISIT_COHORT_TEST_MYSQL_PORT = '33817' +php server/tests/RevisitCohortDatabaseTest.php +``` diff --git a/server/app/adminapi/controller/stats/RevisitAssignRateController.php b/server/app/adminapi/controller/stats/RevisitAssignRateController.php new file mode 100644 index 000000000..f2f7dd975 --- /dev/null +++ b/server/app/adminapi/controller/stats/RevisitAssignRateController.php @@ -0,0 +1,65 @@ +request->get(); + + try { + return $this->data(RevisitCohortLogic::overview($params, static::COHORT_TYPE)); + } catch (\InvalidArgumentException $e) { + return $this->fail($e->getMessage()); + } + } + + public function deptOptions() + { + return $this->data(RevisitCohortLogic::deptOptions()); + } + + /** 「被指派数」点击下钻:诊单维度明细 */ + public function assignLines() + { + @set_time_limit(120); + $params = $this->request->get(); + + try { + return $this->data(RevisitCohortLogic::assignLines($params, static::COHORT_TYPE)); + } catch (\InvalidArgumentException $e) { + return $this->fail($e->getMessage()); + } + } + + /** 「N 诊单数」点击下钻:具体订单明细 */ + public function visitOrderLines() + { + @set_time_limit(120); + $params = $this->request->get(); + + try { + return $this->data(RevisitCohortLogic::visitOrderLines($params, static::COHORT_TYPE)); + } catch (\InvalidArgumentException $e) { + return $this->fail($e->getMessage()); + } + } +} diff --git a/server/app/adminapi/controller/stats/RevisitInheritRateController.php b/server/app/adminapi/controller/stats/RevisitInheritRateController.php new file mode 100644 index 000000000..4eee7da26 --- /dev/null +++ b/server/app/adminapi/controller/stats/RevisitInheritRateController.php @@ -0,0 +1,11 @@ + $observedAt) { + continue; + } + // 从未分配重新指派时,写入侧可能把 from 回填为原医助,形成 A→A。 + // 以实际时间线判断持有人是否变化,保留释放后的重新承接。 + $previous = $lastTarget[$did] ?? null; + if (($previous !== null && $previous === $aid) || ($previous === null && $from === $aid)) { + continue; + } + $lastTarget[$did] = $aid; + $isRollback = isset($rollbackKeySet[RevisitRateCalculator::rollbackKey( + $did, $time, (int) ($r['operator_admin_id'] ?? 0) + )]); + $eventType = (int) ($r['is_inherit'] ?? 0) === 1 || $isRollback + ? self::TYPE_INHERIT : self::TYPE_NEW; + $selected = $aid > 0 && $time >= $startTs && $time <= $endTs + && $eventType === $type && !isset($refundRejectSet[$did]); + $timeline[$did][] = [ + 'assistant_id' => $aid, + 'time' => $time, + 'selected' => $selected, + 'type' => $eventType, + ]; + if (!$selected) { + continue; + } + if (!isset($pairs[$aid][$did])) { + $pairs[$aid][$did] = [ + 'diagnosis_id' => $did, + 'assistant_id' => $aid, + 'assign_type' => $type, + 'assign_count' => 0, + 'first_assign_time' => $time, + 'last_assign_time' => $time, + 'is_rollback' => 0, + ]; + } + $pairs[$aid][$did]['assign_count']++; + $pairs[$aid][$did]['last_assign_time'] = $time; + $pairs[$aid][$did]['is_rollback'] |= $isRollback ? 1 : 0; + } + + $orders = []; + $curDid = 0; + $globalSeq = 0; + $segmentSeq = 0; + $ptr = 0; + $offset = 0; + $event = null; + foreach ($orderRows as $r) { + $did = (int) ($r['diagnosis_id'] ?? 0); + $ct = (int) ($r['create_time'] ?? 0); + if ($did <= 0 || $ct > $observedAt) { + continue; + } + if ($curDid !== $did) { + $curDid = $did; + $globalSeq = 0; + $segmentSeq = 0; + $ptr = 0; + $event = null; + $offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap); + } + $globalSeq++; + $tl = $timeline[$did] ?? []; + while ($ptr < \count($tl) && $tl[$ptr]['time'] <= $ct) { + $event = $tl[$ptr++]; + $segmentSeq = 0; + } + if ($event === null) { + continue; + } + $segmentSeq++; + if (!$event['selected']) { + continue; + } + $slot = $type === self::TYPE_INHERIT ? $segmentSeq + 1 : $globalSeq + $offset; + if ($slot < 2 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) { + continue; + } + $aid = $event['assistant_id']; + // 重复承接同一患者时,明细保留首次达到该诊次的订单,与人数指标逐项对账。 + if (isset($orders[$aid][$slot][$did])) { + continue; + } + $r['assistant_id'] = $aid; + $r['visit_slot'] = $slot; + $r['global_visit_slot'] = $globalSeq + $offset; + $r['cohort_assign_time'] = $event['time']; + $orders[$aid][$slot][$did] = $r; + } + + return ['pairsByAssistant' => $pairs, 'ordersByAssistant' => $orders, 'cohort_type' => $type]; + } + + /** 部门与总计按诊单去重;每个诊次保留同一患者最早一笔订单。 */ + public static function scopedOrders(array $calc, array $assistantIds, int $slot = 0): array + { + $out = []; + foreach ($assistantIds as $aid) { + foreach ($calc['ordersByAssistant'][$aid] ?? [] as $s => $byDiag) { + if ($slot > 0 && $s !== $slot) { + continue; + } + foreach ($byDiag as $did => $r) { + $previous = $out[$s][$did] ?? null; + if ($previous === null || [(int) $r['create_time'], (int) $r['id']] + < [(int) $previous['create_time'], (int) $previous['id']]) { + $out[$s][$did] = $r; + } + } + } + } + + return $out; + } + + public static function metricPack(array $calc, array $assistantIds, array $slots): array + { + $diagSet = []; + foreach ($assistantIds as $aid) { + foreach ($calc['pairsByAssistant'][$aid] ?? [] as $did => $_) { + $diagSet[$did] = true; + } + } + $count = \count($diagSet); + $inherit = $calc['cohort_type'] === self::TYPE_INHERIT; + $pack = [ + 'assigned_count' => $inherit ? 0 : $count, + 'inherit_count' => $inherit ? $count : 0, + 'assigned_total_count' => $count, + ]; + $orders = self::scopedOrders($calc, $assistantIds); + $all = array_sum(array_map('count', $orders)); + foreach ($slots as $slot) { + $num = \count($orders[$slot] ?? []); + $pack['visit' . $slot . '_count'] = $num; + $pack['visit' . $slot . '_den'] = $count; + $pack['visit' . $slot . '_rate'] = $count > 0 ? round($num / $count * 100, 2) : null; + } + $pack['visit_all_count'] = $all; + $pack['visit_all_den'] = $count; + $pack['visit_all_rate'] = $count > 0 ? round($all / $count * 100, 2) : null; + + return $pack; + } + + /** 严格校验日期,避免非法日期被 strtotime 自动滚到次月。 */ + public static function dateRange(array $params, int $now): array + { + $start = trim((string) ($params['start_date'] ?? '')); + $end = trim((string) ($params['end_date'] ?? '')); + if ($start === '' && $end === '') { + $month = trim((string) ($params['month'] ?? '')); + if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) { + $start = $month . '-01'; + $end = date('Y-m-t', (int) strtotime($start)); + } else { + $start = date('Y-m-01', $now); + $end = date('Y-m-d', $now); + } + } + foreach ([$start, $end] as $date) { + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + if ($parsed === false || $parsed->format('Y-m-d') !== $date) { + throw new \InvalidArgumentException('请选择有效的开始日期和结束日期'); + } + } + if ($start > $end) { + throw new \InvalidArgumentException('开始日期不能晚于结束日期'); + } + + return [$start, $end, (int) strtotime($start . ' 00:00:00'), (int) strtotime($end . ' 23:59:59')]; + } +} diff --git a/server/app/adminapi/logic/stats/RevisitCohortDataSource.php b/server/app/adminapi/logic/stats/RevisitCohortDataSource.php new file mode 100644 index 000000000..05c2995fe --- /dev/null +++ b/server/app/adminapi/logic/stats/RevisitCohortDataSource.php @@ -0,0 +1,230 @@ +where('rollback_time', '>=', $startTs) + ->where('rollback_time', '<=', $endTs) + ->where('diagnosis_id', '>', 0) + ->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id']) + ->select() + ->toArray(); + $out = []; + foreach ($rows as $r) { + $out[RevisitRateCalculator::rollbackKey( + (int) ($r['diagnosis_id'] ?? 0), + (int) ($r['rollback_time'] ?? 0), + (int) ($r['rollback_admin_id'] ?? 0) + )] = true; + } + + return $out; + } + + private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator + { + foreach (array_chunk($diagIds, 2000) as $chunk) { + foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) { + yield $r; + } + } + } + + private static function buildAssistantDeptIndex(array $adminIds): array + { + if ($adminIds === []) { + return [[], []]; + } + // admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性 + $relRows = Db::name('admin_dept') + ->whereIn('admin_id', $adminIds) + ->order(['admin_id' => 'asc', 'dept_id' => 'asc']) + ->field(['admin_id', 'dept_id']) + ->select() + ->toArray(); + + $deptNames = Db::name('dept') + ->whereNull('delete_time') + ->column('name', 'id'); + + $canonical = []; + foreach ($relRows as $r) { + $aid = (int) ($r['admin_id'] ?? 0); + $deptId = (int) ($r['dept_id'] ?? 0); + if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) { + continue; + } + if (!isset($deptNames[$deptId])) { + continue; + } + $canonical[$aid] = $deptId; + } + + $names = []; + foreach ($deptNames as $id => $name) { + $names[(int) $id] = (string) $name; + } + + return [$canonical, $names]; + } + + private static function resolveDeptFilterSet(mixed $raw): array + { + $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); + if ($erSet === []) { + return []; + } + $deptFilterIds = self::parseDeptIds($raw); + if ($deptFilterIds === []) { + return $erSet; + } + $allowedRoots = []; + foreach ($deptFilterIds as $id) { + if (isset($erSet[$id])) { + $allowedRoots[] = $id; + } + } + if ($allowedRoots === []) { + return []; + } + $expanded = self::expandDeptSubtreeSet($allowedRoots); + $out = []; + foreach ($expanded as $id => $_) { + if (isset($erSet[$id])) { + $out[$id] = true; + } + } + + return $out; + } + + private static function parseDeptIds(mixed $raw): array + { + if ($raw === null || $raw === '' || $raw === []) { + return []; + } + $list = \is_array($raw) ? $raw : explode(',', (string) $raw); + + return array_values(array_unique(array_filter( + array_map('intval', $list), + static fn (int $v): bool => $v > 0 + ))); + } + + private static function expandDeptSubtreeSet(array $deptIds): array + { + $rows = Db::name('dept') + ->whereNull('delete_time') + ->field(['id', 'pid']) + ->select() + ->toArray(); + $childrenByPid = []; + foreach ($rows as $r) { + $childrenByPid[(int) $r['pid']][] = (int) $r['id']; + } + + $set = []; + $queue = $deptIds; + while ($queue !== []) { + $id = (int) array_shift($queue); + if ($id <= 0 || isset($set[$id])) { + continue; + } + $set[$id] = true; + foreach ($childrenByPid[$id] ?? [] as $childId) { + $queue[] = $childId; + } + } + + return $set; + } + + private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array + { + if ($diagIds === []) { + return []; + } + $out = []; + foreach (array_chunk($diagIds, 2000) as $chunk) { + $ids = Db::name('tcm_prescription_order') + ->whereIn('diagnosis_id', $chunk) + ->whereNull('delete_time') + ->whereIn('fulfillment_status', [9, 10]) + ->group('diagnosis_id') + ->column('diagnosis_id'); + foreach ($ids as $id) { + $out[(int) $id] = true; + } + } + + return $out; + } + + private static function fetchOrderSeqRows(array $diagIds, array $fields): array + { + // 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff + return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields); + } + + private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array + { + // 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff + return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds); + } + + private static function fetchDiagnosisInfo(array $diagIds): array + { + if ($diagIds === []) { + return []; + } + $rows = Db::name('tcm_diagnosis') + ->whereIn('id', $diagIds) + ->field(['id', 'patient_name', 'phone']) + ->select() + ->toArray(); + $out = []; + foreach ($rows as $r) { + $out[(int) $r['id']] = [ + 'patient_name' => trim((string) ($r['patient_name'] ?? '')), + 'phone' => trim((string) ($r['phone'] ?? '')), + ]; + } + + return $out; + } + + private static function cohortDeptOptions(): array + { + $erSet = DeptLogic::getErCenterSubtreeDeptIdSet(); + if ($erSet === []) { + return ['rows' => []]; + } + $rows = Db::name('dept') + ->whereNull('delete_time') + ->whereIn('id', array_keys($erSet)) + ->field(['id', 'pid', 'name']) + ->order('sort', 'desc') + ->order('id', 'asc') + ->select() + ->toArray(); + + return [ + 'rows' => array_map(static fn (array $r): array => [ + 'id' => (int) $r['id'], + 'pid' => (int) $r['pid'], + 'name' => (string) $r['name'], + ], $rows), + ]; + } +} diff --git a/server/app/adminapi/logic/stats/RevisitCohortLogic.php b/server/app/adminapi/logic/stats/RevisitCohortLogic.php new file mode 100644 index 000000000..dab844fda --- /dev/null +++ b/server/app/adminapi/logic/stats/RevisitCohortLogic.php @@ -0,0 +1,250 @@ +whereIn('id', $aids)->column('name', 'id'); + $maxSlot = 4; + foreach ($calc['ordersByAssistant'] as $bySlot) { + foreach ($bySlot as $slot => $_) { + $maxSlot = max($maxSlot, $slot); + } + } + $slots = range(2, $maxSlot); + $byDept = []; + foreach ($aids as $aid) { + $deptId = $ctx['assistantDept'][$aid]; + $byDept[$deptId][] = $aid; + } + $rows = []; + foreach ($byDept as $deptId => $deptAids) { + $children = []; + $deptName = (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId)); + foreach ($deptAids as $aid) { + $children[] = [ + 'row_key' => 'a' . $aid, + 'is_dept' => 0, + 'dept_id' => $deptId, + 'dept_name' => $deptName, + 'assistant_id' => $aid, + 'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)), + ] + RevisitCohortCalculator::metricPack($calc, [$aid], $slots); + } + usort($children, static fn (array $a, array $b): int => + ($b['assigned_total_count'] <=> $a['assigned_total_count']) + ?: strcmp($a['assistant_name'], $b['assistant_name'])); + $rows[] = [ + 'row_key' => 'd' . $deptId, + 'is_dept' => 1, + 'dept_id' => $deptId, + 'dept_name' => $deptName, + 'assistant_count' => \count($children), + 'children' => $children, + ] + RevisitCohortCalculator::metricPack($calc, $deptAids, $slots); + } + usort($rows, static fn (array $a, array $b): int => + ($b['assigned_total_count'] <=> $a['assigned_total_count']) + ?: strcmp($a['dept_name'], $b['dept_name'])); + + return self::metadata($ctx) + [ + 'slots' => $slots, + 'total' => RevisitCohortCalculator::metricPack($calc, $aids, $slots), + 'rows' => $rows, + ]; + } + + public static function assignLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array + { + $ctx = self::cohortContext($params, $type); + $aids = self::scopedAssistantIds($ctx, $params); + $byDiag = []; + foreach ($aids as $aid) { + foreach ($ctx['calc']['pairsByAssistant'][$aid] ?? [] as $did => $pair) { + if (!isset($byDiag[$did])) { + $byDiag[$did] = $pair + ['assistants' => []]; + $byDiag[$did]['assign_count'] = 0; + } + $byDiag[$did]['assistants'][$aid] = true; + $byDiag[$did]['assign_count'] += $pair['assign_count']; + $byDiag[$did]['first_assign_time'] = min($byDiag[$did]['first_assign_time'], $pair['first_assign_time']); + $byDiag[$did]['last_assign_time'] = max($byDiag[$did]['last_assign_time'], $pair['last_assign_time']); + $byDiag[$did]['is_rollback'] |= $pair['is_rollback']; + } + } + $diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag)); + $names = $aids === [] ? [] : Db::name('admin')->whereIn('id', $aids)->column('name', 'id'); + $rows = []; + foreach ($byDiag as $did => $pair) { + $assistantNames = []; + foreach ($pair['assistants'] as $aid => $_) { + $assistantNames[] = (string) ($names[$aid] ?? ('#' . $aid)); + } + $rows[] = [ + 'diagnosis_id' => $did, + 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), + 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), + 'assistant_names' => implode('、', $assistantNames), + 'assign_count' => $pair['assign_count'], + 'first_assign_time_text' => date('Y-m-d H:i:s', $pair['first_assign_time']), + 'last_assign_time' => $pair['last_assign_time'], + 'last_assign_time_text' => date('Y-m-d H:i:s', $pair['last_assign_time']), + 'assign_type' => $ctx['calc']['cohort_type'], + 'is_rollback' => $pair['is_rollback'], + ]; + } + usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']); + + return self::metadata($ctx) + ['count' => \count($rows), 'rows' => $rows]; + } + + public static function visitOrderLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array + { + $slot = (int) ($params['slot'] ?? 0); + if ($slot < 0 || $slot === 1 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) { + throw new \InvalidArgumentException('诊次须为 2~50,或 0(复诊合计)'); + } + $ctx = self::cohortContext($params, $type); + $orders = RevisitCohortCalculator::scopedOrders($ctx['calc'], self::scopedAssistantIds($ctx, $params), $slot); + $flat = []; + $adminIds = []; + $diagIds = []; + foreach ($orders as $byDiag) { + foreach ($byDiag as $did => $r) { + $flat[] = $r; + $diagIds[$did] = true; + $adminIds[(int) $r['assistant_id']] = true; + if ((int) ($r['creator_id'] ?? 0) > 0) { + $adminIds[(int) $r['creator_id']] = true; + } + } + } + $diagInfo = self::fetchDiagnosisInfo(array_keys($diagIds)); + $names = $adminIds === [] ? [] : Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id'); + $rows = []; + foreach ($flat as $r) { + $did = (int) $r['diagnosis_id']; + $aid = (int) $r['assistant_id']; + $cid = (int) ($r['creator_id'] ?? 0); + $rows[] = [ + 'order_id' => (int) $r['id'], + 'order_no' => (string) ($r['order_no'] ?? ''), + 'diagnosis_id' => $did, + 'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''), + 'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''), + 'amount' => round((float) ($r['amount'] ?? 0), 2), + 'create_time' => (int) $r['create_time'], + 'create_time_text' => date('Y-m-d H:i:s', (int) $r['create_time']), + 'assistant_id' => $aid, + 'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)), + 'creator_name' => $cid > 0 ? (string) ($names[$cid] ?? ('#' . $cid)) : '—', + 'visit_slot' => (int) $r['visit_slot'], + 'global_visit_slot' => (int) $r['global_visit_slot'], + 'cohort_assign_time_text' => date('Y-m-d H:i:s', (int) $r['cohort_assign_time']), + ]; + } + usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']); + + return self::metadata($ctx) + ['slot' => $slot, 'count' => \count($rows), 'rows' => $rows]; + } + + /** 保留部门下拉结构,补充二中心内医助;不影响复诊到期页对父类 deptOptions 的复用。 */ + public static function deptOptions(): array + { + $options = self::cohortDeptOptions(); + $deptSet = self::resolveDeptFilterSet(null); + if ($deptSet === []) { + return $options + ['assistants' => []]; + } + $ids = Db::name('admin_dept')->whereIn('dept_id', array_keys($deptSet))->group('admin_id')->column('admin_id'); + [$assistantDept] = self::buildAssistantDeptIndex($ids); + $admins = $ids === [] ? [] : Db::name('admin')->whereIn('id', $ids)->whereNull('delete_time') + ->field(['id', 'name'])->order('name', 'asc')->select()->toArray(); + $assistants = []; + foreach ($admins as $admin) { + $deptId = (int) ($assistantDept[(int) $admin['id']] ?? 0); + if (isset($deptSet[$deptId])) { + $assistants[] = ['id' => (int) $admin['id'], 'name' => (string) $admin['name'], 'dept_id' => $deptId]; + } + } + + return $options + ['assistants' => $assistants]; + } + + private static function cohortContext(array $params, string $type): array + { + $now = time(); + [$start, $end, $startTs, $endTs] = RevisitCohortCalculator::dateRange($params, $now); + // 先按指派周期圈定诊单,再读取其完整时间线;避免小范围查询拉取全库历史日志。 + $candidateQuery = Db::name('tcm_diagnosis_assign_log')->alias('lg') + ->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') + ->where('lg.create_time', '>=', $startTs)->where('lg.create_time', '<=', min($endTs, $now)) + ->where('lg.to_assistant_id', '>', 0)->where('lg.diagnosis_id', '>', 0); + $filterAid = (int) ($params['assistant_id'] ?? 0); + if ($filterAid > 0) { + $candidateQuery->where('lg.to_assistant_id', $filterAid); + } + $candidateIds = array_map('intval', $candidateQuery->group('lg.diagnosis_id')->column('lg.diagnosis_id')); + $logs = []; + foreach (array_chunk($candidateIds, 2000) as $chunk) { + $chunkLogs = Db::name('tcm_diagnosis_assign_log')->alias('lg') + ->whereIn('lg.diagnosis_id', $chunk)->where('lg.create_time', '<=', $now) + ->field(['lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id', + 'lg.operator_admin_id', 'lg.create_time', 'lg.is_inherit']) + ->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc']) + ->select()->toArray(); + array_push($logs, ...$chunkLogs); + } + $calc = RevisitCohortCalculator::compute( + $logs, + self::iterateOrderSeqRows($candidateIds, ['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']), + self::fetchRevisitSlotStartOffsetMap($candidateIds), + self::fetchRefundOrRejectDiagnosisSet($candidateIds), + self::fetchRollbackKeySet($startTs, min($endTs, $now)), + $startTs, $endTs, $now, $type + ); + [$assistantDept, $deptNames] = self::buildAssistantDeptIndex(array_keys($calc['pairsByAssistant'])); + $deptSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null); + foreach (array_keys($calc['pairsByAssistant']) as $aid) { + if (!isset($deptSet[$assistantDept[$aid] ?? 0]) || ($filterAid > 0 && $filterAid !== $aid)) { + unset($calc['pairsByAssistant'][$aid], $calc['ordersByAssistant'][$aid]); + } + } + + return ['calc' => $calc, 'assistantDept' => $assistantDept, 'deptNames' => $deptNames, + 'start_date' => $start, 'end_date' => $end, 'observed_until' => date('Y-m-d H:i:s', $now)]; + } + + private static function scopedAssistantIds(array $ctx, array $params): array + { + $aids = array_keys($ctx['calc']['pairsByAssistant']); + $scopeAid = (int) ($params['scope_assistant_id'] ?? 0); + if ($scopeAid > 0) { + return \in_array($scopeAid, $aids, true) ? [$scopeAid] : []; + } + if (isset($params['dept_id']) && $params['dept_id'] !== '') { + $deptId = (int) $params['dept_id']; + $aids = array_values(array_filter($aids, static fn (int $aid): bool => ($ctx['assistantDept'][$aid] ?? 0) === $deptId)); + } + + return $aids; + } + + private static function metadata(array $ctx): array + { + return ['start_date' => $ctx['start_date'], 'end_date' => $ctx['end_date'], + 'observed_until' => $ctx['observed_until'], 'cohort_type' => $ctx['calc']['cohort_type']]; + } +} diff --git a/server/app/adminapi/validate/tcm/DiagnosisValidate.php b/server/app/adminapi/validate/tcm/DiagnosisValidate.php index 702646987..2b0bdb4fc 100755 --- a/server/app/adminapi/validate/tcm/DiagnosisValidate.php +++ b/server/app/adminapi/validate/tcm/DiagnosisValidate.php @@ -108,9 +108,21 @@ class DiagnosisValidate extends BaseValidate public function sceneEdit() { - return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']); - } - + return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']) + // 脱敏或被业务锁定的基本信息可省略,省略表示保留原值;显式提交仍需非空且格式有效。 + ->remove(['patient_name' => 'require', 'phone' => 'require', 'gender' => 'require', 'age' => 'require']) + ->append(['patient_name' => 'requireWhenPresent', 'phone' => 'requireWhenPresent', 'gender' => 'requireWhenPresent', 'age' => 'requireWhenPresent']); + } + + /** 编辑基本信息允许缺省,但不能用空值覆盖;require 前缀使 ThinkPHP 也校验 null/空串。 */ + protected function requireWhenPresent($value, $rule, array $data = [], string $field = '') + { + if (!array_key_exists($field, $data) || $this->is($value, 'require')) { + return true; + } + return $this->message[$field . '.require']; + } + public function sceneId() { return $this->only(['id']); diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index 63073872e..cea3fdffa 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -950,7 +950,9 @@ class DifyChatService bool $final = false ): void { $buffer .= $chunk; - while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) { + // Keep CRLF atomic: otherwise regex backtracking can count a single CRLF + // as two line breaks and decode a multi-line event before it is complete. + while (preg_match('/(?>\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) { $delimiter = $match[0][0]; $offset = $match[0][1]; $frame = substr($buffer, 0, $offset); diff --git a/server/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql b/server/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql new file mode 100644 index 000000000..2a559fc5e --- /dev/null +++ b/server/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql @@ -0,0 +1,32 @@ +-- 保留原复诊率算法;新增独立「继承复诊接诊率」页面。 +-- 幂等执行;新增页面和接口分别继承原页面对应权限,执行后需清理 AdminAuthCache。 +SET @revisit_parent_id := (SELECT pid FROM zyt_system_menu WHERE perms = 'stats.revisitRate/overview' AND type = 'C' LIMIT 1); + +INSERT INTO zyt_system_menu + (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time) +SELECT @revisit_parent_id, 'C', '继承复诊接诊率', '', 12, 'stats.revisitInheritRate/overview', + '/stats/revisit_inherit_rate', '/stats/revisit_inherit_rate/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @revisit_parent_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'stats.revisitInheritRate/overview'); + +SET @inherit_rate_id := (SELECT id FROM zyt_system_menu WHERE perms = 'stats.revisitInheritRate/overview' AND type = 'C' LIMIT 1); + +INSERT INTO zyt_system_menu + (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time) +SELECT @inherit_rate_id, 'A', p.name, '', p.sort, p.perms, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM ( + SELECT '继承统计-部门医助选项' AS name, 1 AS sort, 'stats.revisitInheritRate/deptOptions' AS perms + UNION ALL SELECT '继承统计-患者明细', 2, 'stats.revisitInheritRate/assignLines' + UNION ALL SELECT '继承统计-订单明细', 3, 'stats.revisitInheritRate/visitOrderLines' +) p +WHERE @inherit_rate_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu m WHERE m.perms = p.perms); + +INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, target.id +FROM zyt_system_role_menu rm +INNER JOIN zyt_system_menu original ON original.id = rm.menu_id +INNER JOIN zyt_system_menu target ON target.perms = REPLACE(original.perms, 'stats.revisitRate/', 'stats.revisitInheritRate/') +WHERE original.perms IN ('stats.revisitRate/overview', 'stats.revisitRate/deptOptions', + 'stats.revisitRate/assignLines', 'stats.revisitRate/visitOrderLines'); diff --git a/server/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql b/server/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql new file mode 100644 index 000000000..0453ee826 --- /dev/null +++ b/server/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql @@ -0,0 +1,32 @@ +-- 原复诊接诊率页面和接口保持原算法,按指派周期统计使用独立菜单及权限。 +-- 可重复执行;逐项继承原报表角色的对应权限,执行后刷新 AdminAuthCache。 +SET @revisit_parent_id := (SELECT pid FROM zyt_system_menu WHERE perms = 'stats.revisitRate/overview' AND type = 'C' LIMIT 1); + +INSERT INTO zyt_system_menu + (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time) +SELECT @revisit_parent_id, 'C', '按指派周期复诊率', '', 13, 'stats.revisitAssignRate/overview', + '/stats/revisit_assign_rate', '/stats/revisit_assign_rate/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @revisit_parent_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'stats.revisitAssignRate/overview'); + +SET @assign_rate_id := (SELECT id FROM zyt_system_menu WHERE perms = 'stats.revisitAssignRate/overview' AND type = 'C' LIMIT 1); + +INSERT INTO zyt_system_menu + (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time) +SELECT @assign_rate_id, 'A', p.name, '', p.sort, p.perms, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM ( + SELECT '指派周期统计-部门医助选项' AS name, 1 AS sort, 'stats.revisitAssignRate/deptOptions' AS perms + UNION ALL SELECT '指派周期统计-患者明细', 2, 'stats.revisitAssignRate/assignLines' + UNION ALL SELECT '指派周期统计-订单明细', 3, 'stats.revisitAssignRate/visitOrderLines' +) p +WHERE @assign_rate_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu m WHERE m.perms = p.perms); + +INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, target.id +FROM zyt_system_role_menu rm +INNER JOIN zyt_system_menu original ON original.id = rm.menu_id +INNER JOIN zyt_system_menu target ON target.perms = REPLACE(original.perms, 'stats.revisitRate/', 'stats.revisitAssignRate/') +WHERE original.perms IN ('stats.revisitRate/overview', 'stats.revisitRate/deptOptions', + 'stats.revisitRate/assignLines', 'stats.revisitRate/visitOrderLines'); diff --git a/server/tests/DiagnosisValidateEditSceneTest.php b/server/tests/DiagnosisValidateEditSceneTest.php new file mode 100644 index 000000000..80230d253 --- /dev/null +++ b/server/tests/DiagnosisValidateEditSceneTest.php @@ -0,0 +1,181 @@ + $validator->setLang($testLang)); + +function diagnosisEditExpect(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +// Install in-memory boundaries before loading the real validator and logic. +// No application bootstrap, credentials, database, or external services are used. +final class DiagnosisEditModelDouble +{ + public static array $record; + public static array $lastUpdate = []; + public static int $updateCount = 0; + + public static function reset(): void + { + self::$record = [ + 'id' => 42, + 'patient_name' => '测试患者', + 'id_card' => '110101199001010011', + 'phone' => '13800138000', + 'gender' => 1, + 'age' => 36, + 'remark' => '原备注', + ]; + self::$lastUpdate = []; + self::$updateCount = 0; + } + + public static function findOrEmpty($id): object + { + return new class((int) $id !== 42) { + public function __construct(private bool $empty) {} + public function isEmpty(): bool { return $this->empty; } + }; + } + + public static function find($id): ?object + { + // The post-update TRTC lookup is deliberately empty, so no service runs. + return self::$updateCount === 0 && (int) $id === 42 ? (object) self::$record : null; + } + + public static function update(array $params): void + { + self::$lastUpdate = $params; + self::$record = array_replace(self::$record, $params); + self::$updateCount++; + } + + public static function where(...$args): object + { + // No duplicate patient exists in this isolated fixture. + return new class { + public function where(...$args): self { return $this; } + public function find() { return null; } + }; + } +} + +final class DiagnosisEditDbDouble +{ + public static int $orderLookups = 0; + + public static function name(string $table): object + { + diagnosisEditExpect($table === 'tcm_prescription_order', 'only the basic-info order lock queries storage'); + self::$orderLookups++; + return new class { + public function where(...$args): self { return $this; } + public function whereNull(...$args): self { return $this; } + public function order(...$args): self { return $this; } + public function field(...$args): self { return $this; } + public function find(): array { return ['fulfillment_status' => 1]; } + }; + } +} + +final class DiagnosisEditAuthDouble +{ + public static function getAuthByAdminId(int $adminId): array { return []; } +} + +diagnosisEditExpect(class_alias(DiagnosisEditModelDouble::class, 'app\\common\\model\\tcm\\Diagnosis'), 'model double is installed'); +diagnosisEditExpect(class_alias(DiagnosisEditDbDouble::class, 'think\\facade\\Db'), 'database double is installed'); +diagnosisEditExpect(class_alias(DiagnosisEditAuthDouble::class, 'app\\adminapi\\logic\\auth\\AuthLogic'), 'permission double is installed'); + +function diagnosisEditCheck(array $payload, bool $expected, string $label, string $scene = 'edit'): void +{ + $validator = (new DiagnosisValidate())->scene($scene); + $actual = $validator->check($payload); + diagnosisEditExpect($actual === $expected, $label . '; got: ' . json_encode($validator->getError(), JSON_UNESCAPED_UNICODE)); +} + +$partial = [ + 'id' => 42, + 'diagnosis_type' => 'follow_up', + 'local_hospital_name' => '测试医院', + 'remark' => '新备注', +]; +diagnosisEditCheck($partial, true, 'edit accepts omitted locked/basic/masked fields'); + +$basic = [ + 'patient_name' => '测试患者', + 'phone' => '13800138000', + 'gender' => 1, + 'age' => 36, +]; +diagnosisEditCheck($partial + $basic, true, 'edit still accepts full basic information'); +foreach (array_keys($basic) as $field) { + diagnosisEditCheck($partial + array_diff_key($basic, [$field => true]), true, "edit permits omitted {$field} while other basic fields are supplied"); +} +diagnosisEditCheck($partial + ['phone' => '13900139000'], true, 'edit accepts a valid supplied phone'); +diagnosisEditCheck($partial + ['gender' => 0, 'age' => 0], true, 'zero gender and age remain valid'); + +foreach (['', null, '156****7862', '123', ' 13800138000 ', false, []] as $phone) { + diagnosisEditCheck($partial + ['phone' => $phone], false, 'edit rejects invalid explicit phone ' . json_encode($phone)); +} +foreach (['patient_name', 'gender', 'age'] as $field) { + foreach (['', null] as $empty) { + diagnosisEditCheck($partial + [$field => $empty], false, "edit rejects explicitly empty {$field}"); + } +} +diagnosisEditCheck($partial + ['patient_name' => str_repeat('患', 51)], false, 'name length remains validated'); +diagnosisEditCheck($partial + ['gender' => 2], false, 'gender range remains validated'); +diagnosisEditCheck($partial + ['age' => 151], false, 'age range remains validated'); +diagnosisEditCheck($partial + ['age' => 'invalid'], false, 'age type remains validated'); +diagnosisEditCheck(array_diff_key($partial, ['id' => true]), false, 'edit still requires diagnosis ID'); +diagnosisEditCheck(array_replace($partial, ['id' => 404]), false, 'edit still requires an existing diagnosis'); +foreach (['diagnosis_type', 'local_hospital_name'] as $field) { + diagnosisEditCheck(array_diff_key($partial, [$field => true]), false, "edit still requires unrelated {$field}"); +} + +$add = array_diff_key($partial + $basic, ['id' => true]); +diagnosisEditCheck($add, true, 'add accepts valid full payload', 'add'); +foreach (array_keys($basic) as $field) { + diagnosisEditCheck(array_diff_key($add, [$field => true]), false, "add still requires {$field}", 'add'); +} +foreach (['', null, '156****7862'] as $phone) { + diagnosisEditCheck(array_replace($add, ['phone' => $phone]), false, 'add rejects empty or masked phone', 'add'); +} + +$doctor = ['admin_id' => 7, 'root' => 0]; +DiagnosisEditModelDouble::reset(); +$before = DiagnosisEditModelDouble::$record; +diagnosisEditExpect(DiagnosisLogic::edit($partial, $doctor), 'locked doctor can save clinical edits without basic fields'); +diagnosisEditExpect(DiagnosisEditDbDouble::$orderLookups > 0, 'the existing pending-order lock is evaluated'); +diagnosisEditExpect(DiagnosisEditModelDouble::$record['remark'] === '新备注', 'clinical edit reaches storage'); +foreach (['patient_name', 'id_card', 'phone', 'gender', 'age'] as $field) { + diagnosisEditExpect(!array_key_exists($field, DiagnosisEditModelDouble::$lastUpdate), "update does not synthesize {$field}"); + diagnosisEditExpect(DiagnosisEditModelDouble::$record[$field] === $before[$field], "omitted {$field} remains unchanged"); +} + +foreach (['patient_name' => '另一个患者', 'id_card' => '110101199001010029', 'phone' => '13900139000', 'gender' => 0, 'age' => 37] as $field => $value) { + DiagnosisEditModelDouble::reset(); + diagnosisEditExpect(!DiagnosisLogic::edit($partial + [$field => $value], $doctor), "pending order still locks {$field}"); + diagnosisEditExpect(DiagnosisLogic::getError() === '最近业务订单未完成,无法修改患者基本信息', 'basic-info lock keeps its error'); + diagnosisEditExpect(DiagnosisEditModelDouble::$updateCount === 0, 'denied change never reaches storage'); +} + +DiagnosisEditModelDouble::reset(); +diagnosisEditExpect(DiagnosisLogic::edit($partial + $basic, $doctor), 'unchanged supplied basic fields still pass the lock'); +DiagnosisEditModelDouble::reset(); +diagnosisEditExpect(DiagnosisLogic::edit($partial + ['phone' => '13900139000'], ['admin_id' => 1, 'root' => 1]), 'existing authorized basic-info editing remains available'); +diagnosisEditExpect(DiagnosisEditModelDouble::$record['phone'] === '13900139000', 'valid authorized phone edit reaches storage'); + +echo "Diagnosis edit-scene validation and masked/basic-field preservation: OK\n"; diff --git a/server/tests/DifyChatStreamContractTest.php b/server/tests/DifyChatStreamContractTest.php index fe05c63c1..f20fe3f85 100644 --- a/server/tests/DifyChatStreamContractTest.php +++ b/server/tests/DifyChatStreamContractTest.php @@ -172,6 +172,31 @@ difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles a difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only'); difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing'); +$pendingBuffer = ''; +$pendingState = callDifyStreamPrivate('newStreamState', []); +$liveDeltas = []; +$liveCallback = static function (string $delta) use (&$liveDeltas): void { + $liveDeltas[] = $delta; +}; +callDifyStreamPrivate('consumeStreamBytes', [ + 'dify', &$pendingBuffer, "data: {\"event\":\"message\",\"answer\":\"首字\"}\r\n", &$pendingState, $liveCallback, +]); +difyStreamExpect($liveDeltas === [], 'a data line alone is not a complete SSE event'); +callDifyStreamPrivate('consumeStreamBytes', ['dify', &$pendingBuffer, "\r\n", &$pendingState, $liveCallback]); +difyStreamExpect($liveDeltas === ['首字'], 'a complete delta is delivered before upstream EOF or a terminal event'); +difyStreamExpect($pendingState['finished'] === false, 'incremental delivery does not wait for message_end'); + +$multilineWire = "data: {\"event\":\"message\",\r\n" + . "data: \"answer\":\"逐帧\"}\r\n\r\n" + . "data: {\"event\":\"message_end\"}\r\n\r\n"; +foreach ([1, 2, strlen($multilineWire)] as $chunkSize) { + $multiline = callDifyStreamPrivate('decodeStreamChunks', ['dify', str_split($multilineWire, $chunkSize)]); + difyStreamExpect( + $multiline['deltas'] === ['逐帧'] && $multiline['finished'] === true, + 'CRLF multiline data stays one event across arbitrary transport boundaries' + ); +} + $malformed = callDifyStreamPrivate('decodeStreamChunks', [ 'dify', ["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"], diff --git a/server/tests/DifyChatStreamDeliveryTest.php b/server/tests/DifyChatStreamDeliveryTest.php new file mode 100644 index 000000000..e65e5aeaf --- /dev/null +++ b/server/tests/DifyChatStreamDeliveryTest.php @@ -0,0 +1,95 @@ + [], 'errno' => 0]; + } + + function curl_setopt_array(\stdClass $handle, array $options): bool + { + $handle->options = $options; + return true; + } + + function curl_exec(\stdClass $handle): string|bool + { + $GLOBALS['streamDeliveryExecuting'] = true; + $options = $handle->options; + $options[CURLOPT_HEADERFUNCTION]($handle, 'HTTP/1.1 ' . $GLOBALS['streamDeliveryStatus'] . " Fixture\r\n"); + foreach ($GLOBALS['streamDeliveryChunks'] as [$chunk, $expectedDeltas]) { + $written = $options[CURLOPT_WRITEFUNCTION]($handle, $chunk); + \deliveryExpect(count($GLOBALS['streamDeliveryDeltas']) === $expectedDeltas, + 'each complete upstream frame reaches the consumer before the next network read'); + if ($written !== strlen($chunk)) { + $handle->errno = CURLE_WRITE_ERROR; + break; + } + } + $GLOBALS['streamDeliveryExecuting'] = false; + return $handle->errno === 0 ? '' : false; + } + + function curl_errno(\stdClass $handle): int { return $handle->errno; } + function curl_getinfo(\stdClass $handle, int $option): int { return $GLOBALS['streamDeliveryStatus']; } + function curl_close(\stdClass $handle): void {} +} + +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + + use app\common\service\DifyChatService; + + function deliveryExpect(bool $condition, string $message): void + { + if (!$condition) { + fwrite(STDERR, "FAIL: {$message}\n"); + exit(1); + } + } + + function deliverFixture(string $protocol, array $chunks, int $status = 200, bool $accept = true): array + { + $GLOBALS['streamDeliveryStatus'] = $status; + $GLOBALS['streamDeliveryChunks'] = $chunks; + $GLOBALS['streamDeliveryDeltas'] = []; + $method = (new ReflectionClass(DifyChatService::class))->getMethod('sendStreamRequest'); + return $method->invoke(null, $protocol, 'https://offline.example.test', [], 'offline-fixture', 30, + static function (string $delta) use ($accept): bool { + deliveryExpect($GLOBALS['streamDeliveryExecuting'], 'delta callback runs during curl_exec, before transfer completion'); + $GLOBALS['streamDeliveryDeltas'][] = $delta; + return $accept; + }, null); + } + + $dify = deliverFixture('dify', [ + ["data: {\"event\":\"message\",\"answer\":\"首\"}\r\n", 0], + ["\r\n", 1], + ["data: {\"event\":\"message\",\r\ndata: \"answer\":\"字\"}\r\n\r\n", 2], + ["data: {\"event\":\"message_end\"}\r\n\r\n", 2], + ]); + deliveryExpect($dify['content'] === '首字' && $dify['finished'], 'Dify streams complete frames in order'); + + $openAi = deliverFixture('openai', [ + ["data: {\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n", 1], + ["data: {\"choices\":[{\"delta\":{\"content\":\"B\"}}]}\n\n", 2], + ["data: [DONE]\n\n", 2], + ]); + deliveryExpect($openAi['content'] === 'AB' && $openAi['finished'], 'OpenAI streams complete frames in order'); + + $rejected = deliverFixture('dify', [ + ["data: {\"event\":\"message\",\"answer\":\"private upstream error\"}\n\n", 0], + ], 401); + deliveryExpect(!$rejected['emitted'] && $rejected['content'] === '', 'HTTP errors never become downstream text'); + + $aborted = deliverFixture('dify', [ + ["data: {\"event\":\"message\",\"answer\":\"first\"}\n\n", 1], + ["data: {\"event\":\"message\",\"answer\":\"late\"}\n\n", 2], + ], 200, false); + deliveryExpect($aborted['callback_error'] && $GLOBALS['streamDeliveryDeltas'] === ['first'], + 'consumer refusal stops the current transfer before another delta'); + + echo "Dify chat stream delivery: OK\n"; +} diff --git a/server/tests/RevisitCohortCalculatorTest.php b/server/tests/RevisitCohortCalculatorTest.php new file mode 100644 index 000000000..f3caeb9db --- /dev/null +++ b/server/tests/RevisitCohortCalculatorTest.php @@ -0,0 +1,150 @@ + (int) strtotime($date); +$logId = 0; +$orderId = 0; +$log = static function (int $did, int $from, int $to, string $date, int $inherit = 0) use (&$logId, $t): array { + return ['id' => ++$logId, 'diagnosis_id' => $did, 'from_assistant_id' => $from, + 'to_assistant_id' => $to, 'create_time' => $t($date), 'is_inherit' => $inherit, 'operator_admin_id' => 7]; +}; +$order = static function (int $did, string $date) use (&$orderId, $t): array { + return ['id' => ++$orderId, 'diagnosis_id' => $did, 'create_time' => $t($date), + 'order_no' => 'TEST-' . $orderId, 'amount' => 100, 'creator_id' => 1]; +}; +$sort = static function (array $rows): array { + usort($rows, static fn (array $a, array $b): int => + [$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]); + return $rows; +}; +$start = $t('2026-09-01 00:00:00'); +$end = $t('2026-10-30 23:59:59'); +$now = $t('2026-12-01 12:00:00'); +$run = static fn (array $logs, array $orders, string $type, array $offsets = [], array $reject = [], array $rollback = []): array => + C::compute($sort($logs), $sort($orders), $offsets, $reject, $rollback, $start, $end, $now, $type); +$pack = static fn (array $calc, array $aids): array => C::metricPack($calc, $aids, [2, 3, 4]); + +// 新指派筛选的是指派日期:周期前指派的患者不混入分子,周期后的复诊累计至查询当天。 +$logs = [ + $log(10, 0, 1, '2026-09-01 00:00:00'), + $log(11, 0, 1, '2026-08-31 23:59:59'), + $log(12, 0, 1, '2026-10-30 23:59:59'), + $log(13, 0, 1, '2026-10-31 00:00:00'), +]; +$orders = [ + $order(10, '2026-09-02'), $order(10, '2026-11-15'), $order(10, '2026-12-01 12:00:00'), $order(10, '2026-12-02'), + $order(11, '2026-09-03'), $order(11, '2026-10-01'), + $order(12, '2026-11-01'), $order(12, '2026-11-20'), + $order(13, '2026-11-01'), $order(13, '2026-11-20'), +]; +$calc = $run($logs, $orders, 'new'); +$p = $pack($calc, [1]); +expect($p['assigned_count'] === 2 && $p['inherit_count'] === 0, '范围两端均包含,前后一天均排除'); +expect($p['visit2_count'] === 2 && $p['visit2_rate'] === 100.0, '11 月复诊计入9~10月指派队列'); +expect($p['visit3_count'] === 1 && $p['visit4_count'] === 0, '查询同刻订单包含,未来订单不计入'); +expect($p['visit_all_count'] === 3 && $p['visit_all_rate'] === 150.0, '合计是人均复诊单次,可以超过100%'); +expect(count(C::scopedOrders($calc, [1], 2)[2]) === $p['visit2_count'], '二诊明细数量等于二诊分子'); +expect($pack($calc, [99])['visit2_rate'] === null, '无患者分母为0,率为null'); + +// 继承前有三笔订单和偏移:继承后首单仍算二诊,原医助只领取转出前订单。 +$logs = [$log(20, 0, 1, '2026-09-01'), $log(20, 1, 2, '2026-10-10 10:00:00', 1)]; +$orders = [$order(20, '2026-09-02'), $order(20, '2026-09-03'), $order(20, '2026-09-04'), + $order(20, '2026-10-10 10:00:00'), $order(20, '2026-11-20')]; +$new = $run($logs, $orders, 'new', [20 => 5]); +$inherited = $run($logs, $orders, 'inherit', [20 => 5]); +expect(array_keys($new['pairsByAssistant']) === [1] && array_keys($inherited['pairsByAssistant']) === [2], '新指派、继承独立入组'); +expect(!isset($new['ordersByAssistant'][2]), '继承订单不得混入新指派分子'); +$p = $pack($inherited, [2]); +expect($p['assigned_count'] === 0 && $p['inherit_count'] === 1 && $p['visit2_count'] === 1, '继承首单计二诊'); +expect($p['visit3_count'] === 1 && $p['visit2_den'] === 1 && $p['visit3_den'] === 1, '继承次单计三诊,各列分母只含继承患者'); +$first = C::scopedOrders($inherited, [2], 2)[2][20]; +expect($first['create_time'] === $t('2026-10-10 10:00:00') && $first['global_visit_slot'] === 9, '同秒归新医助且不受全局诊次/偏移影响'); +expect($first['cohort_assign_time'] === $t('2026-10-10 10:00:00'), '订单明细带实际承接时间'); +expect($pack($new, [1])['visit_all_count'] === 3 && !isset($new['ordersByAssistant'][1][9]), '新指派保留全局诊次偏移,只计转出前3单'); + +// 转派、释放、再次继承、同一医助既新指派又继承:不混算,继承每段重新起算。 +$logs = [ + $log(30, 0, 1, '2026-09-01'), + $log(30, 1, 2, '2026-09-10', 1), + $log(30, 2, 2, '2026-09-11', 1), // 自指派忽略,不重置 + $log(30, 2, 0, '2026-09-15'), + $log(30, 0, 2, '2026-09-20', 1), + $log(30, 2, 1, '2026-10-01', 1), +]; +$orders = [$order(30, '2026-09-02'), $order(30, '2026-09-10'), $order(30, '2026-09-12'), + $order(30, '2026-09-16'), $order(30, '2026-09-21'), $order(30, '2026-09-22'), + $order(30, '2026-10-02')]; +$calc = $run($logs, $orders, 'inherit'); +expect($calc['pairsByAssistant'][2][30]['assign_count'] === 2, '多次真实继承计次数,自指派不计'); +expect($calc['pairsByAssistant'][1][30]['assign_count'] === 1, '同一医助既新指派又继承,继承独立统计'); +$p2 = $pack($calc, [2]); +expect($p2['inherit_count'] === 1 && $p2['visit2_count'] === 1 && $p2['visit3_count'] === 1, '重复继承患者分母、同一诊次分子均去重'); +expect(C::scopedOrders($calc, [2], 3)[3][30]['create_time'] === $t('2026-09-12'), '自指派不把后续订单重置到二诊'); +$total = $pack($calc, [1, 2]); +expect($total['inherit_count'] === 1 && $total['visit2_count'] === 1 && $total['visit2_rate'] === 100.0, '部门/合计按患者去重,不能把多医助同患者二诊重复相加'); +expect($pack($calc, [1])['visit3_count'] === 0, '按医助筛选时不能混入另一医助三诊'); +expect($total['visit_all_count'] === array_sum(array_map('count', C::scopedOrders($calc, [1, 2]))), '合计订单明细与去重分子逐项对账'); + +// 周期后重新继承的订单不应继续计入旧队列;释放也会终止归属。 +$logs = [$log(40, 0, 1, '2026-09-01', 1), $log(40, 1, 0, '2026-10-01'), + $log(40, 0, 1, '2026-11-01', 1)]; +$calc = $run($logs, [$order(40, '2026-09-02'), $order(40, '2026-10-02'), $order(40, '2026-11-02')], 'inherit'); +expect($pack($calc, [1])['visit_all_count'] === 1, '释放后及区间外再次承接的订单不归旧队列'); + +// 写入侧从待分配重新指派给原医助时会回填 from,不能误判为自指派。 +foreach (['new' => 0, 'inherit' => 1] as $type => $isInherit) { + $logs = [$log(41, 0, 1, '2026-08-01'), $log(41, 1, 0, '2026-08-10'), + $log(41, 1, 1, '2026-09-01', $isInherit)]; + $calc = $run($logs, [$order(41, '2026-08-02'), $order(41, '2026-09-02')], $type); + expect($pack($calc, [1])['assigned_total_count'] === 1 && $pack($calc, [1])['visit2_count'] === 1, + '释放后同医助重新承接正常入组并统计:' . $type); +} + +// 回退 is_inherit=0,通过回退键识别继承;拒收/退款剔除同时作用于分子、分母。 +$rollbackTime = $t('2026-09-15'); +$logs = [$log(50, 0, 1, '2026-09-01'), $log(50, 1, 2, '2026-09-15')]; +$orders = [$order(50, '2026-09-02'), $order(50, '2026-09-16')]; +$keys = [Legacy::rollbackKey(50, $rollbackTime, 7) => true]; +expect(!isset($run($logs, $orders, 'new', [], [], $keys)['pairsByAssistant'][2]), '历史回退不能计入新指派'); +expect($pack($run($logs, $orders, 'inherit', [], [], $keys), [2])['visit2_count'] === 1, '历史回退首单按继承二诊'); +$rejected = $run($logs, $orders, 'inherit', [], [50 => true], $keys); +expect($rejected['pairsByAssistant'] === [] && $rejected['ordersByAssistant'] === [], '拒收/退款诊单完全剔除,无孤立分子'); + +// 同秒多次转派按日志 id 升序,订单最终归最新承接事件;两笔同秒有效订单按 id 编号。 +$logs = [$log(60, 0, 1, '2026-09-01'), $log(60, 1, 2, '2026-09-10', 1), $log(60, 2, 3, '2026-09-10', 1)]; +$calc = $run($logs, [$order(60, '2026-09-10'), $order(60, '2026-09-10')], 'inherit'); +expect($pack($calc, [2])['visit_all_count'] === 0 && $pack($calc, [3])['visit_all_count'] === 2, '同秒转派和订单按稳定顺序计算'); + +// 日期解析支持跨月与未来结束日,不把月末或非法日期自动纠正。 +[$s, $e, $sTs, $eTs] = C::dateRange(['start_date' => '2026-09-01', 'end_date' => '2026-10-30'], $t('2026-10-16')); +expect($s === '2026-09-01' && $e === '2026-10-30' && $sTs === $start && $eTs === $end, '跨月范围保留未来结束日'); +expect(array_slice(C::dateRange([], $t('2026-10-16')), 0, 2) === ['2026-10-01', '2026-10-16'], '默认当月首日至当天'); +expect(array_slice(C::dateRange(['month' => '2024-02'], $now), 0, 2) === ['2024-02-01', '2024-02-29'], '兼容旧 month 参数和闰月'); +foreach ([['2026-02-30', '2026-03-01'], ['2026-09-10', '2026-09-01'], ['', '2026-10-30'], ['2026-9-01', '2026-10-30']] as [$s, $e]) { + $threw = false; + try { + C::dateRange(['start_date' => $s, 'end_date' => $e], $now); + } catch (InvalidArgumentException) { + $threw = true; + } + expect($threw, '拒绝非法/缺失/倒置日期:' . $s . ' - ' . $e); +} +echo "RevisitCohortCalculatorTest OK ({$checks} checks)\n"; diff --git a/server/tests/RevisitCohortDatabaseTest.php b/server/tests/RevisitCohortDatabaseTest.php new file mode 100644 index 000000000..4ae136caf --- /dev/null +++ b/server/tests/RevisitCohortDatabaseTest.php @@ -0,0 +1,161 @@ + PDO::ERRMODE_EXCEPTION]); +$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); +$pdo->exec("USE `{$database}`"); +new think\App(); +$manager = new think\DbManager(); +$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [ + 'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, 'database' => $database, + 'username' => 'root', 'password' => '', 'charset' => 'utf8mb4', 'prefix' => 'zyt_', + 'fields_strict' => true, 'trigger_sql' => false, +]]]); +Container::getInstance()->instance('think\DbManager', $manager); +Container::getInstance()->instance('config', new think\Config()); +$checks = 0; +$expect = static function (bool $condition, string $message) use (&$checks): void { + if (!$condition) { + throw new RuntimeException('FAIL: ' . $message); + } + $checks++; +}; +try { + $pdo->exec('CREATE TABLE zyt_dept(id INT PRIMARY KEY,pid INT,name VARCHAR(50),sort INT DEFAULT 0,delete_time INT NULL)'); + $pdo->exec("INSERT INTO zyt_dept(id,pid,name) VALUES(10,0,'一中心'),(11,10,'一组'),(20,0,'二中心'),(21,20,'二中心甲组'),(22,20,'乙组')"); + $pdo->exec('CREATE TABLE zyt_admin(id INT PRIMARY KEY,name VARCHAR(50),delete_time INT NULL)'); + $pdo->exec("INSERT INTO zyt_admin VALUES(1,'医助甲',NULL),(2,'医助乙',NULL),(3,'一中心员工',NULL),(4,'已删除',1)"); + $pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT,PRIMARY KEY(admin_id,dept_id))'); + $pdo->exec('INSERT INTO zyt_admin_dept VALUES(1,21),(2,22),(3,11),(4,21)'); + $pdo->exec('CREATE TABLE zyt_tcm_diagnosis(id INT PRIMARY KEY,patient_name VARCHAR(50),phone VARCHAR(20),revisit_slot_start_offset INT DEFAULT 0,delete_time INT NULL)'); + for ($id = 10; $id <= 17; $id++) { + $pdo->prepare('INSERT INTO zyt_tcm_diagnosis(id,patient_name,phone,delete_time) VALUES(?,?,?,?)') + ->execute([$id, '虚构患者' . $id, '13800000000', $id === 16 ? 1 : null]); + } + $pdo->exec('CREATE TABLE zyt_tcm_diagnosis_assign_log(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,from_assistant_id INT,to_assistant_id INT,operator_admin_id INT DEFAULT 7,create_time INT,is_inherit INT DEFAULT 0,inherit_visit_slot INT DEFAULT 0)'); + $pdo->exec('CREATE TABLE zyt_tcm_diagnosis_auto_assign_log(diagnosis_id INT,rollback_time INT,rollback_admin_id INT)'); + $pdo->exec('CREATE TABLE zyt_tcm_prescription_order(id INT PRIMARY KEY AUTO_INCREMENT,order_no VARCHAR(50),diagnosis_id INT,create_time INT,amount DECIMAL(10,2) DEFAULT 100,creator_id INT DEFAULT 1,fulfillment_status INT NULL,delete_time INT NULL)'); + $lg = $pdo->prepare('INSERT INTO zyt_tcm_diagnosis_assign_log(diagnosis_id,from_assistant_id,to_assistant_id,create_time,is_inherit) VALUES(?,?,?,?,?)'); + foreach ([[10,0,1,'2026-09-01',0],[10,1,2,'2026-09-20',1],[11,0,1,'2026-08-01',0], + [12,0,2,'2026-09-03',0],[13,0,3,'2026-09-03',0],[14,0,1,'2026-09-04',0], + [15,0,1,'2026-08-01',0],[15,1,2,'2026-09-15',1],[16,0,1,'2026-09-01',0], + [17,1,1,'2026-09-01',0]] as [$did,$from,$to,$date,$inherit]) { + $lg->execute([$did,$from,$to,strtotime($date),$inherit]); + } + $o = $pdo->prepare('INSERT INTO zyt_tcm_prescription_order(order_no,diagnosis_id,create_time,fulfillment_status) VALUES(?,?,?,?)'); + foreach ([[10,'2026-09-02',3],[10,'2026-09-05',3],[10,'2026-10-01',3],[10,'2026-10-05',3], + [11,'2026-08-02',3],[11,'2026-10-01',3],[13,'2026-09-05',3],[13,'2026-09-06',3], + [14,'2026-09-05',3],[14,'2026-09-06',9],[15,'2026-08-02',3],[15,'2026-08-03',3], + [15,'2026-08-04',3],[15,'2026-09-16',3],[15,'2026-09-17',4],[15,'2026-10-02',3]] as $i => [$did,$date,$status]) { + $o->execute(['SYNTHETIC-' . $i,$did,strtotime($date),$status]); + } + $q = ['start_date' => '2026-09-01', 'end_date' => '2026-09-30']; + $invoke = static function (string $class, string $action, array $params): array { + $controller = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + $request = (new think\Request())->withGet($params); + $property = (new ReflectionClass(app\BaseController::class))->getProperty('request'); + $property->setAccessible(true); + $property->setValue($controller, $request); + return $controller->$action()->getData()['data']; + }; + foreach (['exclude', 'slot'] as $mode) { + $legacyParams = ['month' => '2026-09', 'inherit_mode' => $mode]; + foreach (['overview', 'assignLines', 'visitOrderLines'] as $action) { + $params = $legacyParams + ['slot' => 0]; + $expect($invoke(RevisitRateController::class, $action, $params) === Original::$action($params), + '原Controller实际响应与原算法一致:' . $mode . '/' . $action); + } + } + $legacyBaseline = Original::overview(['month' => '2026-09', 'inherit_mode' => 'exclude']); + $expect($legacyBaseline['total']['assigned_count'] === 3 && $legacyBaseline['total']['visit4_count'] === 1, + '原算法保留原自指派分母及当月继承订单分子规则'); + $expect($invoke(RevisitRateController::class, 'deptOptions', []) === Original::deptOptions(), '原部门下拉契约不增加新报表字段'); + $new = L::overview($q); + $expect($invoke(RevisitAssignRateController::class, 'overview', $q + ['cohort_type' => 'inherit'])['cohort_type'] === 'new', '新指派入口固定new且使用独立Controller'); + $expect($invoke(RevisitInheritRateController::class, 'overview', $q + ['cohort_type' => 'new'])['cohort_type'] === 'inherit', '继承入口固定inherit'); + $expect(Original::overview(['month' => '2026-09', 'inherit_mode' => 'exclude']) === $legacyBaseline, + '查询新报表不改变原报表响应'); + $expect($new['total']['assigned_count'] === 2 && $new['total']['visit2_count'] === 1, '只圈定周期内新指派,排除区间外、继承、其他中心、自指派、软删、拒收'); + $expect($new['total']['visit2_rate'] === 50.0 && $new['total']['inherit_count'] === 0, '新指派接诊率仅用新指派分母'); + $inherit = L::overview($q, 'inherit'); + $expect($inherit['total']['inherit_count'] === 2 && $inherit['total']['visit2_count'] === 2 && $inherit['total']['visit3_count'] === 2, '继承首单二诊、次单三诊,累计周期后订单且排除取消单'); + $expect(L::overview($q + ['assistant_id' => 2])['total']['assigned_count'] === 1, '医助筛选作用于汇总'); + $expect(L::overview($q + ['assistant_id' => 2])['total']['visit2_count'] === 0, '医助名下继承成交不进入其新指派分子'); + $expect(L::overview($q + ['dept_ids' => '21'])['total']['assigned_count'] === 1, '部门子树过滤'); + $expect(L::overview($q + ['dept_ids' => '20'])['total'] === $new['total'], '二中心父级包含各组'); + $expect(L::overview($q + ['dept_ids' => '10'])['rows'] === [], '不能越过二中心范围'); + $expect(L::overview($q + ['assistant_id' => 3])['rows'] === [], '其他中心医助不能绕过部门范围'); + $expect(L::assignLines($q)['count'] === $new['total']['assigned_count'], '新指派患者明细与分母对账'); + $expect(L::visitOrderLines($q + ['slot' => 2])['count'] === $new['total']['visit2_count'], '新指派订单明细与分子对账'); + $expect(L::assignLines($q, 'inherit')['count'] === $inherit['total']['inherit_count'], '继承患者明细与分母对账'); + $expect(L::visitOrderLines($q + ['slot' => 0], 'inherit')['count'] === $inherit['total']['visit_all_count'], '继承合计订单明细与汇总对账'); + $expect(L::assignLines($q + ['assistant_id' => 2, 'dept_id' => 21])['count'] === 0, '下钻部门必须与页面医助取交集'); + $expect(L::visitOrderLines($q + ['assistant_id' => 2, 'scope_assistant_id' => 1, 'slot' => 0])['count'] === 0, '下钻不能覆盖页面医助筛选'); + $options = L::deptOptions(); + $optionIds = array_column($options['assistants'], 'id'); + sort($optionIds); + $expect($optionIds === [1, 2], '下拉排除其他中心及已删除账号'); + $expect(count($options['rows']) === 3, '部门选项仍是二中心完整子树'); + + foreach ([18 => 0, 19 => 1] as $did => $isInherit) { + $pdo->prepare('INSERT INTO zyt_tcm_diagnosis(id,patient_name,phone) VALUES(?,?,?)')->execute([$did, '返还患者' . $did, '13800000000']); + $lg->execute([$did,0,1,strtotime('2026-08-01'),0]); + $lg->execute([$did,1,0,strtotime('2026-08-10'),0]); + $lg->execute([$did,1,1,strtotime('2026-09-01'),$isInherit]); + $o->execute(['RETURN-OLD-' . $did,$did,strtotime('2026-08-02'),3]); + $o->execute(['RETURN-NEW-' . $did,$did,strtotime('2026-09-02'),3]); + } + $expect(L::overview($q)['total']['assigned_count'] === 3 && L::overview($q)['total']['visit2_count'] === 2, '释放后重新新指派给原医助正常统计'); + $expect(L::overview($q, 'inherit')['total']['inherit_count'] === 3 && L::overview($q, 'inherit')['total']['visit2_count'] === 3, '释放后重新继承给原医助正常统计'); + + // 在实际 MySQL 5.7 验证菜单脚本、幂等性与逐权限继承。 + $pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(1),name VARCHAR(80),icon VARCHAR(80),sort INT,perms VARCHAR(120),paths VARCHAR(120),component VARCHAR(120),selected VARCHAR(120),params VARCHAR(120),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)'); + $pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT,PRIMARY KEY(role_id,menu_id))'); + foreach (['overview','deptOptions','assignLines','visitOrderLines'] as $i => $action) { + $pdo->prepare('INSERT INTO zyt_system_menu(pid,type,name,perms) VALUES(100,?,?,?)') + ->execute([$i === 0 ? 'C' : 'A', $action, 'stats.revisitRate/' . $action]); + } + $pdo->exec('INSERT INTO zyt_system_role_menu VALUES(9,1),(9,2),(9,3)'); + $originalMenus = $pdo->query('SELECT * FROM zyt_system_menu ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); + $migration = file_get_contents(dirname(__DIR__) . '/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql'); + $migration = preg_replace('/^--.*$/m', '', $migration); + for ($repeat = 0; $repeat < 2; $repeat++) { + foreach (explode(';', $migration) as $sql) { + if (trim($sql) !== '') { $pdo->exec($sql); } + } + } + $expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_menu WHERE perms LIKE 'stats.revisitInheritRate/%'")->fetchColumn() === 4, '迁移可重复执行,页面及3个接口各登记一次'); + $expect((int) $pdo->query('SELECT COUNT(*) FROM zyt_system_role_menu WHERE role_id=9')->fetchColumn() === 6, '仅继承原有的3项对应权限'); + $expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_role_menu rm JOIN zyt_system_menu m ON m.id=rm.menu_id WHERE rm.role_id=9 AND m.perms='stats.revisitInheritRate/visitOrderLines'")->fetchColumn() === 0, '不扩大原角色明细权限'); + $assignMigration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql')); + for ($repeat = 0; $repeat < 2; $repeat++) { + foreach (explode(';', $assignMigration) as $sql) { + if (trim($sql) !== '') { $pdo->exec($sql); } + } + } + $expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_menu WHERE perms LIKE 'stats.revisitAssignRate/%'")->fetchColumn() === 4, '独立指派周期页面及3个接口幂等登记'); + $expect((int) $pdo->query('SELECT COUNT(*) FROM zyt_system_role_menu WHERE role_id=9')->fetchColumn() === 9, '两个独立报表均逐权限继承原角色授权'); + $expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_role_menu rm JOIN zyt_system_menu m ON m.id=rm.menu_id WHERE rm.role_id=9 AND m.perms='stats.revisitAssignRate/visitOrderLines'")->fetchColumn() === 0, '独立指派周期接口不扩大订单明细权限'); + $expect($pdo->query('SELECT * FROM zyt_system_menu WHERE id<=4 ORDER BY id')->fetchAll(PDO::FETCH_ASSOC) === $originalMenus, '新增菜单不会改写原菜单及权限记录'); + echo "RevisitCohortDatabaseTest OK ({$checks} checks)\n"; +} finally { + $pdo->exec("DROP DATABASE `{$database}`"); +}