更新
@@ -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 路径。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
验证均离线进行,没有调用生产模型、读取凭据或测试生产代理配置;因此不提供生产首字时延数值。
|
||||
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 141 KiB |
@@ -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` 通过。未修改服务端、打包或重启正在使用的桌面端进程。
|
||||
|
After Width: | Height: | Size: 144 KiB |
@@ -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,无输出 |
|
||||
|
||||
本报告证明离线编辑校验、更新参数保留与原有业务锁行为;未进行生产登录或真实数据库写入。
|
||||
@@ -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 退出。本次未修改相关媒体代码。
|
||||
|
||||
未登录生产、未写真实数据库、未执行部署。本修复需服务端更新后生效,医生无需补录明文手机号。
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -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 接收入口直接替换成旁观拉流实现。
|
||||
|
||||
本文件为静态核对结果,无运行或生产授权验证结论。
|
||||
@@ -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 接诊模块后,在同一主题条件下仍复现该断言失败;接诊模块单独运行全部通过。该问题未在本次姓名显示修改中扩展处理。
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -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 列入分母”等旧实现,新队列实现需要合理更新这些断言,不能仅为通过保留过时语义。建议纯函数覆盖:区间指派后跨月成交、区间外患者同月成交不计入、自指派、转派终止归属、多次继承与重置、回退继承、同秒排序、医助筛选汇总和两类明细对账;保留全局诊次服务现有测试。
|
||||
@@ -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 已阅读源码,本次未重跑其临时数据库。数据库测试目前没有“释放后返还原医助”场景。
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"passed": 15,
|
||||
"checks": [
|
||||
"新指派页可见且独立命名",
|
||||
"默认新指派汇总加载",
|
||||
"日期范围允许未来结束日",
|
||||
"医助筛选传入汇总请求",
|
||||
"患者明细沿用日期和医助筛选",
|
||||
"切换部门清空范围外医助",
|
||||
"医助选项按部门限制",
|
||||
"重置清空部门医助",
|
||||
"独立继承页加载",
|
||||
"继承页说明首单二诊",
|
||||
"继承页人数列不混合新指派",
|
||||
"继承订单使用独立接口",
|
||||
"继承后诊次明细可见",
|
||||
"底栏展示复诊合计率",
|
||||
"浏览器无运行时异常"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -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`,未发布。浏览器与真实后端联调由主代理继续完成。
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -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`;构建输出到隔离目录;收尾报告明确本地恢复完成、验证范围及未发布状态,不以虚构数据浏览器测试声称生产数据已核对。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"passed": 20,
|
||||
"checks": [
|
||||
"原页面仍使用原按月接口和参数",
|
||||
"原月份筛选恢复",
|
||||
"原口径选择恢复",
|
||||
"原页面仍能切换原有slot口径",
|
||||
"原患者明细保留month/kind/口径契约",
|
||||
"新指派页可见且独立命名",
|
||||
"默认新指派汇总加载",
|
||||
"日期范围允许未来结束日",
|
||||
"新增医助筛选使用独立指派周期接口",
|
||||
"患者明细沿用日期和医助筛选",
|
||||
"切换部门清空范围外医助",
|
||||
"医助选项按部门限制",
|
||||
"重置清空部门医助",
|
||||
"独立继承页加载",
|
||||
"继承页说明首单二诊",
|
||||
"继承页人数列不混合新指派",
|
||||
"继承订单使用独立接口",
|
||||
"继承后诊次明细可见",
|
||||
"底栏展示复诊合计率",
|
||||
"浏览器无运行时异常"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sfcFiles": 4,
|
||||
"apiCalls": 12,
|
||||
"errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -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,也未修改现有测试;以上数值来自离屏几何探针,不是生产客户端验收结果。
|
||||
@@ -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 回归通过。未打包、未更新正在运行的客户端;桌面端更新后生效。
|
||||