Files
zyt/docs/plans/assistant-revisit-reach-reminder-2026-10-12.md
T
2026-10-15 11:03:54 +08:00

641 lines
88 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 二中心医助复诊跟进体系方案:诊次接诊率 / 患者触达节点 / 到期标红 / 到期弹窗与延迟升级
日期:2026-10-12。状态:用户已确认实施(2026-10-13);P0、P1 代码已落地(工作区未提交),P2 开发中。
**实施记录(2026-10-14 更新)**
- P0 需求 2/3、P1 需求 4、P1 需求 1 均已落地并经多路静态复核修复;本机 phpstudy PHP 8.2 `php -l` 全部通过,单测 `RevisitDueCalculatorTest`(66) / `RevisitVisitSlotServiceTest`(20) / `RevisitDueLogicTest`(67) / `RevisitRateInheritSlotTest`(57) / `RevisitRateContractTest`(45) / `AiMcpUnitTest` 全部通过。
- 与本文差异:业务钩子集中在 `app\common\service\tcm\RevisitDueHook`(而非 adminapi 的 RevisitDueLogic);快照表增加事实列 `appointment_state / appointment_date`;`DiagnosisLogic::delete` 也接了钩子;`tcm:revisit-due-scan` 防重入用 Cache 标记、`--date` 与今天不同时强制演练;弹窗 level 3 用 el-dialog 实现;`PrescriptionOrderLists` 改用 `fetchOrderSeqRows` 一次取序列(而非 batchCurrentSlot)。
- **2026-10-14 用户决定:复诊接诊率保持原来的逻辑不变。** 处理:`revisit_rate.default_inherit_mode` 改为 `exclude`(页面、个人页卡片、MCP 查询默认均为原口径,数值与改动前逐值一致),「新口径(继承按继承时诊次计入)」仅作页面上的手动切换项;原口径下页面列名、各列、下钻弹窗与原来一致(仅多出「复诊合计」两列);撤回 D1-d(指派弹窗不再默认勾选继承、不再二次确认)与 D1-e(自动分配回退行仍写 `is_inherit=0`);业绩看板 tooltip 恢复原文案;自动分配分档继续使用原口径字段。
- **2026-10-14 本地真库验证**:SQL 01~10 已在本地库执行;全量快照 4721 行与独立 SQL 推算逐字段一致(锚点/天数/预计复诊日/挂号状态/诊次/关闭状态),读取侧 9 态推导一致。发现并修复一处口径 bug:原规则把「下单当天及之后」的挂号算作复诊,导致 1096 个周期被开药前的同日面诊误关闭;改为「下单日 + `appointment_min_gap_days`(默认 1,次日起)」之后的挂号才计入(`RevisitDueCalculator`、弹窗实时校验、配置、单测同步),重算后到期未挂号由 328 升至 1185,已面诊关闭 609(459 条在预计复诊日附近、150 条为提前复诊)。该次重算在流水表写入了 1096 条「重新打开(7)」事件,属规则修正产生的痕迹。
- P2 已落地:`tcm:revisit-due-notify`(daily/tick,`wecom_enabled` 默认关闭)、主管视图 `tcm.revisitDue/lists|logs` + `/stats/revisit_due` 页面、医助个人页「我的复诊接诊率」卡片(`stats.assistantPerformance/revisitRate`,缓存 600 秒)、SQL 08~10;`generated.php` 已重生成。单测 `RevisitDueNotifyTest`(44) 通过。P2 代码只经实现代理自检与 `php -l`/单测,未做独立复核。
- 尚未完成:follow 页处置待产品确认(D2-c);本机无 Node,`npm run lint` / `type-check` 未跑;真库验证(SQL 01~10 按序执行、`tcm:revisit-due-scan --mode=full --dry-run` 压测、`tcm:backfill-inherit-visit-slot --dry-run`、`tcm:auto-assign-pending --dry-run` 分档对照)待有环境后执行;`generated.php` 重生成顺带暴露了 4 个尚无审核条目的 `tcm.followupAudio/*` 只读接口(既有缺口,非本次范围)。
分支:`tongji-10-12`(所有行号以该分支 2026-10-12 快照为准)。文档路径:`docs/plans/assistant-revisit-reach-reminder-2026-10-12.md`。
SQL 迁移目录:`server/sql/1.9.20261012/`(新增;仓库最新为 `server/sql/1.9.20260928/`,无自动迁移器,按文件序号手工执行)。
> 本稿由多代理工作流产出:7 个子系统调研 → 3 份独立方案(口径一致性 / 医助体验 / 风险分期优先)→ 3 位评审打分 → 以「医助体验优先」方案为骨架综合,移植口径一致性方案的审计机制与风险分期方案的配置化/口径开关,按评审修正事实错误(见 §0.2),并吸收完整性批评的 9 项补充(快照事实列、二中心过滤、偏移量钩子、exclude 模式语义、自愈上限、轮询语义、过号 cron 钩子包裹、D1-h)。本稿尚未修改任何业务代码;「已核实」仅指源码行号核对,不代表线上已验证。
---
## 0. 需求原文与解读
### 0.1 逐条解读
| # | 产品原文 | 解读 | 本方案落地点 |
|---|---|---|---|
| 1 | 医助的所有诊数接诊率、继承的患者继承的时候是几诊,就算几诊的接诊率 | 两件事:(a) 复诊接诊率页除逐诊次列外增加「复诊合计」(所有诊次之和的接诊率);(b) 继承指派当前只转移持有人、不进分母(`server/app/adminapi/logic/stats/RevisitRateLogic.php:380-383`),导致继承者「只得分不担责」。改为:继承时患者已完成第 k 诊 → 该患者从第 k+1 诊列起计入继承者分母(每列独立分母) | §1.1 口径、§3.1 `RevisitRateLogic` 改造、§4.1 复诊接诊率页;统计实时重算 + `assign_log.inherit_visit_slot` 快照列用于展示/对账 |
| 2 | 患者在我们医院的触达时间——开药天数、到几诊 | 在医助每天看的「中医诊单」列表上,直接显示患者当前第几诊(与接诊率同口径)、本轮开药天数与来源、预计复诊日、首次触达日/在管天数 | §1.2 口径、§2.1 快照表、§4.2 「复诊节点」列(`RevisitNodeCell` 共用组件) |
| 3 | 患者该挂号了,但是没有挂号、接诊,系统标红显示(按照开药天数 14、21、28、30) | 预计复诊日 = 用药起算日 + 实际开药天数;到期仍无有效挂号/未面诊 → 行级标红 + 状态 chip;14/21/28/30 理解为常见疗程档位(用于筛选/展示),不是只对这四档提醒,也不是逾期阈值 | §1.3 状态机、§4.2 行高亮/chip/筛选/排序、§5.1 cron |
| 4 | 到时间未面诊的会有一个弹窗功能,让员工去点一下延迟处理,越点越上心 | 持有医助登录后台即见右下角汇总卡片;可「去挂号 / 已联系(填承诺日) / 延迟」;延迟间隔随次数递减、卡片形态随次数升级到强制模态;延迟次数在列表与主管视图持续可见 | §1.4 升级规则、§3.3 接口、§4.3 全局组件、§5.2 企微(P2) |
### 0.2 方案来源与事实修正(评审意见已吸收)
| 评审指出的问题 | 本稿处理 |
|---|---|
| 三份方案均写 `.row-unconfirmed` 样式在 `index.vue:2742-2749` | 已核实:`:deep(.el-table__row.row-unconfirmed)` 在 `admin/src/views/tcm/diagnosis/index.vue:2771-2777`,`.row-no-appointment` 在 `:2778-2782`(`:2745` 为 `.video-watch-trigger`) |
| 方案 1 用 `operator_name='回退自动分配'` 豁免回退行 | 已核实不可行:`server/app/adminapi/logic/stats/AutoAssignLogLogic.php:167` 写 `$adminName !== '' ? $adminName : '回退自动分配'`,正常为操作人姓名。改为:回退写入时 `to>0` 则 `is_inherit=1`(§3.1),历史行用 `tcm_diagnosis_auto_assign_log.rollback_time`(列见 `server/sql/1.9.20260717/add_auto_assign_log_rollback.sql:6-7`)关联豁免 |
| 方案 0 回退改继承未限定 `to>0` | `AutoAssignLogLogic.php:165` 的 `to_assistant_id = $prevAssistantId` 可能为 0;本稿要求 `is_inherit = $prevAssistantId > 0 ? 1 : 0`,与 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:671` 约定一致 |
| 方案 2 仅用 `matchPermissionAlias` 别名、不登记菜单 | 已核实无效:`server/app/adminapi/http/middleware/AuthMiddleware.php:111-117` 对不在 `$allUri` 的 URI 直接 `return $next($request)`,`:124` 的别名判断永不执行。本稿全部新 action 登记 A 类菜单并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予(模板 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`),别名只作兜底 |
| 方案 1 把 `assigned_count` 重定义为 `|NI∪IH|` | 保留 `assigned_count` = 新指派数(`RevisitRateLogic.php:129/148` 排序、`revisit_rate/index.vue:107`、`server/app/mcp/catalog/review/stats.php:190-197`、`YejiStatsLogic` 被指派数均依赖该语义),新增 `inherit_count`、`assigned_total_count`、`visit{N}_den`、`visit_all_*` |
| 方案 1 「Den_2 差异 = 继承时尚无成交历史的继承数」 | 修正:`entry=max(2,k+1) ≤ 2` 含 k=0 与 k=1 两类继承 |
| 行号偏差:POLL_INTERVAL、setInterval、UpdateAppointmentStatus 追加点、Db::commit、甘草回调 | 已核实:`admin/src/components/chat-notify-toast/index.vue:89`(`POLL_INTERVAL = 5000`)、`:127`(`setInterval`)、`:140-141`(`top:72px; right:24px`);`server/app/common/command/UpdateAppointmentStatus.php:55-56` 为两条 `Db::execute`(`:52` 是 dry 分支 return);`DiagnosisLogic.php:742` 为 `Db::commit()`;`server/app/api/controller/GancaoCallbackController.php:327-329` case 90 仅写 `gancao_remark`,`:331-341` case 91/92 置 `fulfillment_status=4`,`:380-383`/`:406-409` 推 5 |
| 方案 0 `visit2_rate_legacy decimal(6,2)` | 现有 `visit2_rate decimal(8,2)`(`server/sql/1.9.20260715/add_tcm_diagnosis_auto_assign_log.sql:20`),本稿同精度 |
| 方案 0 `idx_to_inherit_time` 对 `buildStatsCore` 无效 | `RevisitRateLogic.php:356-364` 仅按 `create_time<=月末` 过滤、按 `diagnosis_id` 排序;本稿不加该索引 |
| 方案 1 `RevisitRateLogic.php:362` 字段列表缺 `from_assistant_id` | 本稿要求 field 增 `lg.from_assistant_id`(用于统计时忽略 from==to) |
| 方案 2「exclude 模式逐字节一致」 | 修正表述:既有键的**值**一致,响应新增 `visit{N}_den/visit_all_*/inherit_count` 键 |
| 方案 1 H5 `formData :89-101` | 已核实 `admin/src/views/tcm/diagnosis/index_h5.vue:678`;复诊块 `:174-181`;`getCardClass :901-903`;`dh5-tag--warn :1762` |
| 方案 0/1 工期标题与正文不一致 | 本稿统一为 §8 的数字 |
### 0.3 现状关键事实(已核对源码)
| 事实 | 位置 |
|---|---|
| 分母 = 当月 `is_inherit=0 && to>0` 的医助×诊单去重;继承行进时间线与分子候选、不进分母 | `RevisitRateLogic.php:374-385`(`:380` 为 is_inherit 过滤) |
| 分母剔除名下任何时期存在 9/10 订单的诊单 | `RevisitRateLogic.php:387-395`、`fetchRefundOrRejectDiagnosisSet :687` |
| 分子:有效订单按 `(create_time,id)` 升序,`effectiveSlot = seq + offset`,持有人 = 时间线上 `t <= ct` 的最后一条 `to`;`slot<2 或 >50` 跳过 | `RevisitRateLogic.php:426-457`(`:438` seq++,`:444-447` 指针,`:448` slot 过滤,`:451` 月份过滤,`:454-456` 归属);`fetchOrderSeqRows :716`、`fetchRevisitSlotStartOffsetMap :736`、`resolveRevisitSlotStartOffset :760` |
| 单一分母 `buildMetricPack(int $assigned, array $slotCounts, array $slots)` | `RevisitRateLogic.php:805-817` |
| 列范围只看分子命中 `range(2, max(4, maxHitSlot))` | `RevisitRateLogic.php:71-80` |
| 自动分配读上月 `visit2_rate`,null 或 <50 跳过 | `server/app/command/AutoAssignPendingDiagnosis.php:224-233` |
| 业绩看板「被指派数」三处 `is_inherit=0`,自称与复诊接诊率同口径 | `server/app/adminapi/logic/stats/YejiStatsLogic.php:24, 1781, 1812, 1874, 1894, 2848`;`admin/src/views/fans/yeji.vue:401` |
| 业务订单有效口径常量 `[4,9,10]` 与统一过滤方法 | `YejiStatsLogic.php:61`、`:3886-3894` |
| 业务订单 `medication_days int(11) NULL`,创建/编辑不校验 | `server/database/migrations/2026_04_07_create_tcm_prescription_order.sql:12`;`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:1080-1104, 1898-1917`;`server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php:71-82` 场景不含该字段 |
| 处方 `usage_days` 默认 7;完成面诊自动生成系统代开处方 `usage_days=7, is_system_auto=1` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:373, 1114, 1214, 1226` |
| 挂号 `patient_id` 存诊单 id;status 1 已预约 / 2 已取消 / 3 已完成 / 4 已过号 | `server/sql/doctor_appointment.sql`;`server/app/common/model/doctor/Appointment.php:34-39` |
| 过号:status=1 超 35 分钟 → 4;status=4 超 8 小时 → 2 | `UpdateAppointmentStatus.php:38-44`,执行 `:55-56` |
| 面诊完成 = 人工点「完成」 | `server/app/adminapi/logic/doctor/AppointmentLogic.php:576`(通知 `:603`,系统代开 `:616`);创建 `:280`、取消 `:420`、后台编辑 `:978` |
| 指派:事务 + 行锁,写 `assign_log`(12 列,无 delete_time),`to=0` 强制 `is_inherit=0` | `DiagnosisLogic.php:665-746`(`:671`、`:697`、`:722-734`、`Db::commit :742`);基表 `server/database/migrations/2026_03_31_tcm_diagnosis_assign_log.sql`(索引仅 `idx_diagnosis_id`、`idx_create_time`);`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql` |
| 诊单列表:医助角色强制本人;筛选在 `lists()`/`count()` 双写;排序相关子查询 | `server/app/adminapi/lists/tcm/DiagnosisLists.php:70-173, 531-612`(`:88/:546` role 2;`:94/:551` DataScope;`:77/:539` pendingWideSearch);`resolveListOrderRaw :719`;`unservedAnchorExpr :754`;处方聚合 `:343-366`,`followup_prescription_count :406`;未服务天数 `:459` |
| 全局弹窗 `ChatNotifyToast`:5s 轮询、Cache 读即消费 | `admin/src/App.vue:11, 19-23, 60`;`chat-notify-toast/index.vue:89, 127, 140-141`;`server/app/api/logic/ChatNotifyLogic.php:106-117` |
| 定时任务调度器:命令抛异常即把 `dev_crontab.status` 置 ERROR 停跑 | `server/app/common/command/Crontab.php:81-87` |
| 缓存驱动 File,无分布式锁 | `server/config/cache.php:9` |
| 权限:未登记 perms 的接口对所有登录账号放行 | `AuthMiddleware.php:111-117`;`matchPermissionAlias :171`(dailyRecord 别名 `:202-206`) |
| 签收时间只在物流库,批量解析函数纯读库 | `server/app/common/service/ExpressTrackingService.php:1642`(`batchResolveSignUnixFromDbForOrders`);签收推 6 `:559`、`auto_sign` 日志 `:567` |
| 发货日志 `action='ship'` 仅人工发货写入;甘草回调推 5 不写日志 | `PrescriptionOrderLogic.php:2143`(ship);`GancaoCallbackController.php:380-383, 406-409` |
| 三套「几诊」口径并存 | 接诊率:订单序号+offset(`RevisitRateLogic.php:438`);诊单列表:处方张数(`DiagnosisLists.php:406`);一中心:已完成挂号−1(`server/app/adminapi/lists/firstvisit/MyPatientLists.php:283-284`) |
---
## 1. 口径定义
### 1.0 统一「诊次」语言(四项需求共用)
- `E(D)`:诊单 D 名下**有效业务订单** = `tcm_prescription_order` 中 `delete_time IS NULL` 且 `fulfillment_status IS NULL OR NOT IN (4,9,10)`(统一用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`,`YejiStatsLogic.php:3886-3894`),按 `(create_time, id)` 升序;
- `off(D)` = `tcm_diagnosis.revisit_slot_start_offset` 钳到 0~20(`RevisitRateLogic.php:760-771`;列定义 `server/sql/1.9.20260709/add_diagnosis_revisit_slot_start_offset.sql`);
- 第 n 笔有效订单的诊次 `slot = n + off(D)`;
- **患者当前已完成诊次** `current_slot(D) = |E(D)| + off(D)`;**待复诊诊次** `next_slot = current_slot + 1`(该笔订单产生时会落入复诊接诊率的 `visit{next_slot}_count`,列表与报表数字一致);
- **某时刻 t 患者处于第几诊** `slot_at(D, t) = |{o ∈ E(D) : ct(o) < t}| + off(D)`(严格小于,与持有人规则 `t <= ct`(`RevisitRateLogic.php:444`)对偶:同一秒的订单视为「指派之后」、归新持有人)。
以上全部由新服务 `RevisitVisitSlotService`(§3.1)单点实现;`PrescriptionOrderLists::appendDiagnosisEditVisitSeqFields`(`server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524`)改为调用它;`DeptLogic::fetchErCenterRevisitCandidateOrders`(`server/app/adminapi/logic/dept/DeptLogic.php:240-258`,看板复诊 2/3 列,区间内重排、创建人归属、无 offset)是另一套口径,**本次不改**。
### 1.1 需求 1:复诊接诊率 v2(定义 A,累计型,每列独立分母)
记月窗 `M=[startTs,endTs]`,诊单 D,医助 A,`R` = 名下存在履约 9/10 订单的诊单集合(不限月份,`RevisitRateLogic.php:687`)。
**集合**
- `NI(A)` = {D : ∃ 指派日志 l,`is_inherit=0`、`to=A`、`from≠to`(仅 slot 模式)、`t_l∈M`} \ R  (现状分母,字段 `assigned_count` 语义不变)
- `IH(A)` = {D : ∃ 指派日志 l,`is_inherit=1`、`to=A`、`from≠to`、`t_l∈M`,且 D ∉ NI(A)} \ R  (新增:继承分母)
- 同一 (A, D) 当月多条继承取**最早**一条 `t_l`;D 同月既被非继承又被继承指派给同一 A 时只算 NI;
- `from == to` 的日志(继承/指派给自己,`DiagnosisLogic::assign` 无校验)统计时忽略;
- 回退自动分配行(`AutoAssignLogLogic.php:162-174`):新数据按 §3.1 写 `is_inherit=1`;历史行通过 `tcm_diagnosis_auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id)` 与 `assign_log(diagnosis_id, create_time, operator_admin_id)` 匹配后按继承处理。
- **继承时诊次** `k_D = slot_at(D, t_l)`;**起算诊次** `entry_D = max(2, k_D + 1)`(患者已完成 k 诊,继承者只对第 k+1 诊及以后负责;k ≤ 1 的继承等同新指派,从二诊列起算,不产生一诊列);`entry_D > 50` 的继承行不进任何诊次列、只进合计分母。
**每列独立分母 / 分子 / 比率**(s = 2..S)
```
Den_s(A) = |NI(A)| + |{ D ∈ IH(A) : entry_D ≤ s }|
Num_s(A) = 现状不变:当月下单、slot = s、2 ≤ s ≤ 50、下单时点持有人为 A 的订单数(RevisitRateLogic.php:438-456)
Rate_s(A) = Num_s(A) / Den_s(A) × 100;Den_s = 0 → null(AutoAssignPendingDiagnosis.php:233 依赖 null 语义)
```
**复诊合计列(「所有诊数接诊率」)**
```
Den_all(A) = |NI(A) ∪ IH(A)| (按诊单去重,输出 assigned_total_count)
Num_all(A) = Σ_{s=2}^{50} Num_s(A)
Rate_all(A) = Num_all / Den_all × 100 (可超过 100%,语义「人均复诊成交单数」)
```
**部门行 / 合计行**(按诊单去重)
- `NI_dept = ∪_A NI(A)`;`IH_dept = (∪_A IH(A)) \ NI_dept`,`entry_D` 取该部门内各继承者中的**最小值**;
- `Den_s(dept) = |NI_dept| + |{D ∈ IH_dept : entry_D ≤ s}|`;`Num_s(dept) = Σ_A Num_s(A)`(每笔订单唯一归属);
- 列范围 `S = min(50, max(4, 当月分子最大 slot, max entry_D))`(修复 `RevisitRateLogic.php:71-80` 只看分子导致继承行静默丢失)。
**旧口径开关**:请求参数 `inherit_mode=exclude|slot`(移植方案 2)。`exclude` 时完全复现 `RevisitRateLogic.php:380` 现状:`IH(A)=∅`,**不**忽略 `from==to` 行、**不**豁免历史回退行,`Den_s = assigned_count` 对所有 s 成立,`visit{N}_count/rate`、`assigned_count` 的值与线上一致(响应会多出 `visit{N}_den/visit_all_*/inherit_count` 键)。`from≠to` 忽略与回退行按继承处理**仅在 `slot` 模式生效**;`YejiStatsLogic` 三处被指派数与下文 `visit2_rate_new_assign` 均沿用 `:380` 口径(仅 `is_inherit=0`,不做 from≠to/回退豁免),保证「与新指派数同口径」文案成立、过渡期自动分配分档不变。页面默认取 `config('project.revisit_rate.default_inherit_mode')`(建议 `slot`)。
**供自动分配使用的旧口径字段**:无论模式,`overview` 都输出 `visit2_rate_new_assign = Num_2 / |NI_exclude| × 100`(`NI_exclude` 为 `:380` 现状口径的新指派集合;为 0 → null)。
**示例计算**(offset=0)
| 时间 | 事件 | 说明 |
|---|---|---|
| 05-03 | D 第 1 笔有效订单(slot 1) | 持有人 A |
| 06-12 | 第 2 笔(slot 2) | A 名下,6 月 `visit2_count(A)+1` |
| 08-20 | 第 3 笔(slot 3) | A 名下 |
| 09-10 10:00 | A → B,勾选继承 | `k_D = 3`,`entry_D = 4` |
| 09-25 | B 名下第 4 笔(slot 4) | 9 月 `visit4_count(B)+1` |
9 月报表(slot 模式):B 的 `inherit_count=1`、`visit2_den/visit3_den` 不含 D、`visit4_den` 含 D、`visit4_count=1`(若 B 只有这一位患者则 `visit4_rate=100`、`visit2_rate=null`)、`assigned_total_count=1`、`visit_all_count=1`、`visit_all_rate=100`;A 的 9 月数字不变。exclude 模式:B `assigned_count=0`、`visit4_count=1`、`visit4_rate=null`(与线上现状一致)。
变体:若 09-10 继承时 D 尚无任何有效订单(k=0)→ `entry=2`,与新指派等价;若 09-25 的订单与继承同一秒 → 订单归 B 且不计入 k。
### 1.2 需求 2:触达节点字段
| 字段 | 定义 | 来源 / 说明 |
|---|---|---|
| 最新有效订单 `O*` | `E(D)` 中 `(create_time, id)` 最大的一笔 | 退款/取消的订单自动退回上一笔有效单 |
| 开药天数 `medication_days` | `COALESCE(NULLIF(O*.medication_days,0), NULLIF(rx.usage_days,0))`,rx = `O*.prescription_id` 对应处方且 `is_system_auto=0 AND void_status=0 AND delete_time IS NULL`;两者皆空 → `days_src=0`(缺天数),**不兜底 7**,列表显示橙色「缺开药天数」促补填 | 配置 `revisit_reach.days_fallback_rx=true`、`days_default=0` |
| 天数来源 `days_src` | 0=缺失 1=订单 2=处方 | — |
| 档位 `rule_days` | `medication_days ∈ {14,21,28,30}` 取该值,否则 0=其它;仅用于筛选/展示,**到期按实际天数算**(15 天不归 14) | 配置 `revisit_reach.day_buckets` |
| 用药起算锚点 `anchor_ts` / `anchor_src` | `COALESCE(签收时间, 发货时间, O*.create_time)`,`anchor_src` 1 签收 / 2 发货 / 3 下单 | 签收:`ExpressTrackingService::batchResolveSignUnixFromDbForOrders`(`:1642`,纯读库);发货:`tcm_prescription_order_log.action='ship'` 最早 `create_time`(仅人工发货,`PrescriptionOrderLogic.php:2143`);下单:`create_time`(可被 `editTime :1152` 修改)。链顺序由 `revisit_reach.anchor_chain` 配置,可收窄为 `['create']` |
| 预计复诊日 `due_date` | `DATE(anchor_ts) + medication_days + grace_days(默认 0)` | 缺天数时 NULL |
| 当前诊次 / 待复诊诊次 | `current_slot = |E(D)| + off(D)`;`next_slot = current_slot + 1` | §1.0 |
| 首次触达日 `first_touch_date` / 在管天数 | `min(首笔有效订单日, 首条 to>0 指派日志日)`;`today − first_touch_date` | 展示在诊次 tag 的 tooltip |
| 累计开药天数 `total_medication_days` | Σ 有效订单的(订单天数 ?: 处方天数) | tooltip 可选 |
### 1.3 需求 3:到期判定状态机(读取时推导,列表 / 弹窗 / 筛选共用)
`A*` = 下单日之后(`appointment_date >= DATE(O*.create_time) + appointment_min_gap_days`,默认 1 即次日起;**2026-10-14 本地数据验证发现**同一天的面诊几乎都是产生这笔药的那次面诊而非复诊,若计入会把 1096/1705 个周期误关闭)的挂号中,按 `(appointment_date, appointment_time, id)` 最新一条、`status IN (1,3,4)`(`doctor_appointment.patient_id = 诊单 id`,`appointment_time` 空串用 `IFNULL(NULLIF(TRIM(...),''),'00:00:00')`)。`A*` 的状态与日期由 `RevisitDueService::refresh` 与挂号钩子写入快照表**事实列** `appointment_state`(0=无 1=已约 3=已面诊 4=过号)与 `appointment_date`;下表 `state` 由纯函数**只读快照行 + today** 推导,列表 / `pending()` / 主管视图不再各自 join 挂号表,避免口径分叉。
| 优先级 | `state` | 判定 | chip | 行高亮 | 进弹窗 |
|---|---|---|---|---|---|
| 1 | `visited` 已面诊 | `A*.status=3` | 绿「已面诊 MM-DD」 | 否 | 否(`closed_state=3`) |
| 2 | `booked` 待面诊 | `A*.status=1 AND appointment_date >= today` | 蓝「已约 MM-DD」 | 否 | 否(`closed_state=2`) |
| 3 | `missed` 过号未面诊 | `A*.status=4`,或 `status=1 AND appointment_date < today`(cron 未跑兜底) | 红「过号 MM-DD」 | **是** | **是** |
| 4 | `contacted` 已联系 | `promised_date >= today` | 蓝描边「已联系·约 MM-DD」 | 否 | 否(`next_remind_at = promised_date 09:00`) |
| 5 | `overdue` 到期未挂号 | 无 A*(或仅 status=2)且 `today >= due_date` | 红「超期 N 天」(N=0「今日应挂号」;N ≥ `severe_overdue_days(7)` 深红加粗) | **是** | **是** |
| 6 | `warning` 即将到期 | `0 < due_date − today <= warn_days(3)` | 橙「剩 N 天」 | 否 | 否 |
| 7 | `pending` 未到期 | `due_date − today > warn_days` | 灰「剩 N 天」 | 否 | 否 |
| 8 | `no_days` 缺天数 | 有有效订单但天数缺失 | 橙描边「缺开药天数」 | 否 | 否 |
| 9 | `none` | 无有效订单 / 无医助 / 诊单禁用 | — | 否 | 否(`closed_state=5`) |
补充规则:
- `missed`(过号)**不受** `due_date <= today` 限制:患者在到期日前挂号并过号(如 28 天疗程第 10 天过号)同样标红并进弹窗;重新打开(event=7)时 `next_remind_at = now`,当天即弹;
- 「已挂号」解除标红只认 `status IN (1,3)`;`2` 不算;`4` 算「过号未面诊」继续标红。过号 8 小时后被 cron 改 2(`UpdateAppointmentStatus.php:42-44`),因此「过号」只在当天可见,次日统一落入 `overdue`(患者确需重新挂号),`missed_count` 保留「曾过号」事实;
- `booked` 行的挂号后来变成 2/4 → 由钩子/cron **重新打开**(event=7),`missed_count+1`;
- 「接诊」闭环隐含:新有效订单成为新的 `O*`,锚点与到期日重算,旧周期关闭(event=6);
- 多笔疗程重叠以最新有效订单为准;
- 若产品实际意图是「逾期 14/21/28/30 天逐级加深」,只需把 `severe_overdue_days` 改为阶梯数组,不改结构(移植方案 2)。
**示例**:订单 09-20 下单、09-22 签收、28 天 → `anchor_src=1`、`due_date=10-20`;10-17~10-19 橙「剩 N 天」;10-20 红「今日应挂号」+ 红行;10-27 深红「超期 7 天」;10-21 挂 10-23 的号 → 蓝「已约 10-23」;10-23 过号 → 当天红「过号 10-23」,次日 cron 置 2 → 红「超期 N 天」、`missed_count=1`。
### 1.4 需求 4:延迟升级规则(「越点越上心」)
```
snooze():
n = snooze_count + 1
interval = snooze_intervals[n] ?? snooze_intervals.default // 4h → 2h → 1h → 30min
next_remind_at = now + interval,钳入 work_hours 09:00~20:00(落到 20:00 之后 → 次日 09:00)
escalation_level = f(n):n=0→0;1~2→1;3~4→2;≥5→3
```
| level | 延迟次数 | 卡片形态 |
|---|---|---|
| 0 | 0 | 白底红色左边条,可最小化为右下角小气泡 |
| 1 | 1~2 | 橙色标题栏「已延迟 N 次」,可最小化 |
| 2 | 3~4 | 红色标题栏、出现时轻微抖动、不可最小化、「全部延迟」隐藏,只能逐条处理 |
| 3 | ≥5 | 该患者单独弹居中模态(`ElMessageBox`,`closeOnClickModal=false`、无 X),按钮仅 [去挂号][已联系][再延迟 30 分钟];P2 同时企微推本人、每日 1 次抄送主管 |
「已联系」(移植方案 1 并加约束):填患者承诺复诊日 `promised_date`(today ~ today+14),`next_remind_at = promised_date 09:00`,**不增加 `snooze_count`**;每个周期最多 2 次,第 3 次起按一次延迟计入 `snooze_count`;承诺日仍无挂号 → 再弹并清空 `promised_date`。
「人工关闭」:医助可填原因(`lost/other` + 备注必填)关闭,但在主管视图标记;新周期自动重开。
换医助(含继承):`snooze_count/escalation_level/promised_date` 清零、`next_remind_at=now`,写 event=9(新医助不背前任的账;决策点 D4-c)。
---
## 2. 数据模型变更(SQL 草稿,目录 `server/sql/1.9.20261012/`,均为新增文件)
### 2.1 `01_add_prescription_order_diag_ct_index.sql`(P0)
```sql
-- 诊次序号 / 每诊单最新有效订单 的批量查询覆盖索引(RevisitVisitSlotService、tcm:revisit-due-scan、RevisitRateLogic::fetchOrderSeqRows)
-- 现有仅 idx_diagnosis_id(2026_04_07_create_tcm_prescription_order.sql:33)。ADD INDEX 不幂等,重复执行报 1061 可忽略;执行前 SHOW INDEX 确认
ALTER TABLE `zyt_tcm_prescription_order`
ADD KEY `idx_diag_del_fs_ct` (`diagnosis_id`, `delete_time`, `fulfillment_status`, `create_time`);
```
### 2.2 `02_add_tcm_diagnosis_revisit_due.sql`(P0)
```sql
-- 复诊到期快照:每诊单一行(当前周期 = 最新有效业务订单)。
-- 写入:php think tcm:revisit-due-scan --mode=full(每日)/ --mode=incremental(每 30 分钟)+ 业务钩子(订单/挂号/指派)即时刷新单条。
-- 诊次口径与复诊接诊率一致:有效业务订单(剔 4/9/10、未软删)序号 + tcm_diagnosis.revisit_slot_start_offset(RevisitVisitSlotService)。
-- 时间相关状态(到期/预警/超期/过号/已约)在读取时由本表事实列(due_date、appointment_state、appointment_date、promised_date、closed_state)与 CURDATE() 推导,不再 join 挂号表。
-- 索引:uk_diagnosis 一诊单一行/幂等 upsert;idx_assistant_open 弹窗拉取与列表 chip 计数;idx_open_due cron 与主管视图;idx_source_order 订单变更钩子。
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID(zyt_tcm_diagnosis.id)',
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '当前持有医助(随 tcm_diagnosis.assistant_id 同步,(int) 强转)',
`patient_name` varchar(64) NOT NULL DEFAULT '' COMMENT '患者姓名快照',
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '本周期锚点业务订单 zyt_tcm_prescription_order.id(最新有效订单)',
`order_create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单下单时间',
`valid_order_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '有效订单数',
`anchor_ts` int unsigned NOT NULL DEFAULT 0 COMMENT '用药起算时间戳',
`anchor_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '起算来源:1=签收 2=发货 3=下单',
`medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '开药天数(订单 medication_days,缺省回落处方 usage_days;0=缺失)',
`days_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数来源:0=缺失 1=订单 2=处方',
`rule_days` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数档位:14/21/28/30,0=其它',
`total_medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '累计开药天数',
`current_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '患者当前已完成诊次(有效订单数+偏移)',
`next_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '待复诊诊次 = current_slot+1',
`first_touch_date` date NULL COMMENT '首次触达日(首笔有效订单/首条指派取早)',
`due_date` date NULL COMMENT '预计复诊日 = DATE(anchor_ts)+medication_days+grace;缺天数为 NULL',
`closed_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0=进行中 2=已挂号(自动) 3=已面诊(自动) 4=人工关闭 5=失效(无医助/诊单删除或禁用/无有效订单);缺开药天数的行保持 0,由读取侧推导为 no_days',
`booked_appointment_id` int unsigned NOT NULL DEFAULT 0 COMMENT '触发关闭或当前待面诊的挂号单 id',
`appointment_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单之后最新有效挂号状态(事实列):0=无 1=已约 3=已面诊 4=过号',
`appointment_date` date NULL COMMENT '上述挂号的预约日期(事实列)',
`missed_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期过号/已约未到次数',
`contact_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期「已联系」次数(上限 2,之后按延迟计)',
`contacted_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次「已联系」时间',
`promised_date` date NULL COMMENT '患者承诺复诊日期',
`snooze_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '延迟处理次数(越点越上心的依据)',
`escalation_level` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '升级等级 0~3 = f(snooze_count)',
`next_remind_at` int unsigned NOT NULL DEFAULT 0 COMMENT '下次弹窗时刻;弹窗只取 next_remind_at<=now',
`first_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '首次弹出时间(仅写一次)',
`last_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近弹出时间(统计用,不做节流)',
`last_snoozed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟时间',
`last_snoozed_by` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟人',
`handled_at` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭时间',
`handled_by` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭人',
`handle_reason` varchar(16) NOT NULL DEFAULT '' COMMENT '人工关闭原因 lost/other',
`handle_remark` varchar(200) NOT NULL DEFAULT '' COMMENT '人工关闭说明',
`wecom_self_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微推送本人(P2)',
`wecom_self_count_day` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '当日已推送本人次数(P2)',
`wecom_supervisor_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微抄送主管(P2)',
`computed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近重算时间',
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_time` int unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_diagnosis` (`diagnosis_id`),
KEY `idx_assistant_open` (`assistant_id`, `closed_state`, `appointment_state`, `due_date`, `next_remind_at`),
KEY `idx_open_due` (`closed_state`, `due_date`),
KEY `idx_source_order` (`source_order_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期快照(每诊单一行,列表标红/弹窗/延迟升级共用)';
```
### 2.3 `03_add_tcm_diagnosis_revisit_due_log.sql`(P0)
```sql
-- 复诊到期事件流水:供审计与「越点越上心」统计(主管视图可按延迟/升级次数排序)。
-- 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`due_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'zyt_tcm_diagnosis_revisit_due.id',
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID',
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时持有医助',
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时周期订单',
`event` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=延迟 2=已联系 3=人工关闭 4=自动关闭-已挂号 5=自动关闭-已面诊 6=周期重置(新订单) 7=重新打开(挂号取消/过号) 8=企微推送 9=医助变更 10=形态升级 11=首次弹出',
`snooze_count_after` smallint unsigned NOT NULL DEFAULT 0 COMMENT '事件后延迟次数',
`level_after` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '事件后升级等级',
`overdue_days` smallint NOT NULL DEFAULT 0 COMMENT '事件时超期天数(负=未到期)',
`payload` varchar(500) NOT NULL DEFAULT '' COMMENT 'JSON:next_remind_at/promised_date/appointment_id/order_id/remark/from_assistant 等',
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作人 admin.id,0=系统',
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_due_time` (`due_id`, `create_time`),
KEY `idx_assistant_event_time` (`assistant_id`, `event`, `create_time`),
KEY `idx_diagnosis_id` (`diagnosis_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期事件流水';
```
### 2.4 `04_add_revisit_due_crontab.sql`(P0,幂等,模板 `server/sql/1.9.20260805/upgrade_qywx_customer_acquisition_customers.sql:53-67`)
```sql
-- 依赖:先执行 02/03;服务器 crontab 每分钟 php think crontab;首次 last_time 为空时调度器只记下次时间不执行(Crontab.php:46-54)
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期快照-每日全量', 1, 0, '全量重算 tcm_diagnosis_revisit_due(签收时间/过号变化/钩子漏刷兜底)', 'tcm:revisit-due-scan', '--mode=full', 1, '30 8 * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=full' AND `delete_time` IS NULL);
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期快照-增量', 1, 0, '仅重算近 2 小时有订单/挂号/指派变更的诊单,并重评 closed_state=2 的行', 'tcm:revisit-due-scan', '--mode=incremental', 1, '*/30 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=incremental' AND `delete_time` IS NULL);
```
### 2.5 `05_add_revisit_due_menu.sql`(P1,幂等;列清单照抄 `server/sql/1.9.20260505/add_diagnosis_todo_menu.sql:10-20`,授权照抄 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`)
```sql
-- 复诊到期提醒接口按钮权限,挂在「中医诊单」(perms=tcm.diagnosis/lists) 下。
-- AuthMiddleware.php:111-117:未登记的接口对所有登录账号放行,因此 pending/summary/snooze/snoozeAll/contact/close 全部登记;
-- 末尾自动授予所有已拥有 tcm.diagnosis/lists 的角色,避免「登记后医助失权」。「全部菜单」角色若已是 * 需人工复核。
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' 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 @diag_menu_id, 'A', '复诊到期-待办', '', 70, 'tcm.revisitDue/pending', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM DUAL
WHERE @diag_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/pending');
-- 同法插入:tcm.revisitDue/summary(71) / snooze(72) / snoozeAll(73) / contact(74) / close(75)
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, m2.id
FROM zyt_system_role_menu rm
JOIN zyt_system_menu m1 ON m1.id = rm.menu_id AND m1.perms = 'tcm.diagnosis/lists'
JOIN zyt_system_menu m2 ON m2.perms IN ('tcm.revisitDue/pending','tcm.revisitDue/summary','tcm.revisitDue/snooze','tcm.revisitDue/snoozeAll','tcm.revisitDue/contact','tcm.revisitDue/close');
```
### 2.6 `06_add_diagnosis_assign_log_inherit_visit_slot.sql`(P1,移植方案 0)
```sql
-- 继承指派时患者所处诊次快照(RevisitVisitSlotService::slotAtTime 口径:继承时刻之前的有效实单数 + 偏移)。
-- 统计以实时重算为准(事后退款/取消/改 offset 会联动);本列用于指派记录面板展示与对账(php think tcm:backfill-inherit-visit-slot --verify)。
-- ALTER 不幂等,重复执行报列已存在可忽略。
ALTER TABLE `zyt_tcm_diagnosis_assign_log`
ADD COLUMN `inherit_visit_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '继承时患者已完成诊次(实单数+偏移);0=非继承/未计算' AFTER `is_inherit`,
ADD COLUMN `inherit_visit_slot_calc_time` int unsigned NOT NULL DEFAULT 0 COMMENT '快照计算/回填时间' AFTER `inherit_visit_slot`;
```
### 2.7 `07_alter_auto_assign_log_rate_version.sql`(P1,移植方案 0,精度对齐 `add_tcm_diagnosis_auto_assign_log.sql:20`)
```sql
-- 自动分配日志同时记录两套口径的二诊率,便于回溯分档依据
ALTER TABLE `zyt_tcm_diagnosis_auto_assign_log`
ADD COLUMN `visit2_rate_new_assign` decimal(8,2) NULL DEFAULT NULL COMMENT '旧口径二诊率(分母仅新指派)' AFTER `visit2_rate`,
ADD COLUMN `rate_version` varchar(8) NOT NULL DEFAULT 'v1' COMMENT '分档所用口径:v1=仅新指派 v2=每列独立分母' AFTER `visit2_rate_new_assign`;
```
### 2.8 P2:`08_add_revisit_due_team_menu.sql`(C 类页面 `/stats/revisit_due`,perms `tcm.revisitDue/lists`、A 类 `tcm.revisitDue/logs|export`,模板 `server/sql/1.9.20260715/add_auto_assign_log_menu.sql`)、`09_add_revisit_due_notify_crontab.sql`(`tcm:revisit-due-notify --mode=daily` 09:00 与 `--mode=tick` 每 30 分钟)。
### 2.9 配置(`server/config/project.php`,追加两段,与 `data_scope`(`:146`)同风格)
```php
'revisit_rate' => [
'default_inherit_mode' => 'slot', // 页面默认口径:slot=继承按继承时诊次计入 | exclude=继承不计分母(旧)
'auto_assign_tier_field' => 'visit2_rate_new_assign', // AutoAssign 分档字段:过渡期用旧口径,产品确认后改 'visit2_rate'
'inherit_min_next_slot' => 2,
],
'revisit_reach' => [
'limit_er_center' => true, // 候选诊单限二中心子树医助
'anchor_chain' => ['sign', 'ship', 'create'],
'days_fallback_rx' => true,
'days_default' => 0, // 0=缺天数不计算不标红
'day_buckets' => [14, 21, 28, 30],
'grace_days' => 0,
'warn_days' => 3,
'severe_overdue_days' => 7, // 可改为阶梯数组
'row_highlight_priority' => 'overdue_first', // overdue_first | unconfirmed_first
'toast_enabled' => false, // 弹窗总开关(上线先 false,由主管用 chip 清存量)
'toast_limit' => 20,
'snooze_intervals' => [1 => 14400, 2 => 7200, 3 => 3600, 'default' => 1800],
'work_hours' => ['09:00', '20:00'],
'contact_max_per_cycle' => 2,
'promise_max_days' => 14,
'wecom_enabled' => false,
'wecom_daily_cap' => 2,
'supervisor_admin_ids' => [], // dept 表无 leader 字段(server/app/common/model/dept/Dept.php),P2 用配置过渡
],
```
---
## 3. 后端改动(按文件)
### 3.1 需求 1 与诊次公共服务
| 文件 | 改动 |
|---|---|
| `server/app/common/service/tcm/RevisitVisitSlotService.php`(新增) | `declare(strict_types=1)`,无 likeadmin 头。从 `RevisitRateLogic` 搬出 `fetchOrderSeqRows`(`:716-729`)、`fetchRevisitSlotStartOffsetMap`(`:736-753`)、`resolveRevisitSlotStartOffset`(`:760-771`)为 public static(原方法保留为委托,减少 diff);新增 `batchCurrentSlot(array $diagIds): array<int,{seq,offset,current_slot,latest_order,first_order_time,valid_order_count,total_medication_days}>`(MySQL 5.7 无窗口函数:`GROUP BY diagnosis_id` 取 `COUNT/MAX(create_time)` 后按 `(diagnosis_id, create_time) IN (...)` 回表、PHP 按 id 取最大)、`slotAtTime(int $diagId, int $ts): int`、纯函数 `slotBefore(array $orderCreateTimes, int $offset, int $atTs): int`。 |
| `server/app/adminapi/logic/stats/RevisitRateCalculator.php`(新增) | 纯函数 `compute(array $logRows, array $orderRows, array $offsetMap, array $refundRejectSet, array $rollbackKeySet, int $startTs, int $endTs, string $inheritMode): array{diagsByAssistant, inheritEntryByAssistant, slotOrdersByAssistant, pairsRaw, inheritPairs, maxEntry}`,承接 `buildStatsCore :366-458` 的内存计算。继承诊次算法:订单循环中处理每笔订单(时间 ct)**之前**,把该诊单所有 `t_l <= ct` 且未定值的继承日志定为 `k = seq_before + offset`(即在 `:438` `$seq++` 之前取值);诊单切换/循环结束补 `k = seq_total + offset`;无任何有效订单的继承诊单循环后补 `k = offset`;`entry = max(inherit_min_next_slot, k+1)`。忽略 `from == to` 行;`rollbackKeySet`(`diagnosis_id|create_time|operator_admin_id`)命中的历史回退行按继承处理。 |
| `server/app/adminapi/logic/stats/RevisitRateLogic.php` | 类注释 `:10-31` 重写口径;`buildStatsCore` 查询字段 `:362` 增 `lg.from_assistant_id`;收集 `is_inherit=1 && aid>0 && t∈M` 行;`:387-395` 拒收/退款剔除集合并入继承诊单;查询 `tcm_diagnosis_auto_assign_log` 中 `rollback_time>0` 的 `(diagnosis_id, rollback_time, rollback_admin_id)` 组成 `rollbackKeySet`;调用 Calculator;`overview :71-80` slots 上界加入 `maxEntry`;`buildStatsCore :461` 的 `universeIds` 并集加入 `inheritEntryByAssistant` 的键,`:470-473` 二中心子树过滤时同步 `unset($inheritEntryByAssistant[$aid])`,返回体 `:477-488` 新增 `inheritEntryByAssistant / inheritPairs / maxEntry`,`assignLines` 的 `applyRowScope :176` 使用同一过滤后的集合(否则当月仅有继承行的医助会从 rows 静默丢失,或逃过二中心过滤);`:98-123/:125-153` 部门/合计行按 §1.1 去重;`buildMetricPack(int $newAssigned, array $inheritEntryByDiag, array $slotCounts, array $slots)` 输出 `assigned_count`(=新指派,语义不变)、`inherit_count`、`assigned_total_count`、`visit{N}_count/_den/_rate`、`visit_all_count/_den/_rate`、`visit2_rate_new_assign`;返回体增 `inherit_mode`;`assignLines :173-228` 遍历 `pairsRaw ∪ inheritPairs`,支持 `kind=new|inherit|all`,行增 `assign_type`、`inherit_visit_slot(k)`、`entry_slot`;`visitOrderLines :240` 允许 `slot=0` 表示全部 s≥2;`resolveInheritMode()` 白名单读参数/配置。排序 `:129/:148` 仍按 `assigned_count`。 |
| `server/app/adminapi/controller/stats/RevisitRateController.php` | 无新方法(`:20-50` 的 `$this->request->get()` 整体透传 `inherit_mode/kind/slot=0`)。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | `assign :665-746`:`$isInherit===1` 时在事务内调用 `RevisitVisitSlotService::slotAtTime($id, $now)` 写 `inherit_visit_slot/inherit_visit_slot_calc_time`(`:722-734` insert 增两列);`from == to` **不硬拦**(避免打断批量指派流程;`assign :666` 返回 bool,`DiagnosisController::assign :296-315` 与 `MyPatientController::assign :69-93` 均不透传 data,故不改后端返回结构),由前端在指派弹窗内用当前行 `assistant_id` 与目标比较后提示;`:849-851` 指派记录列表输出 `inherit_visit_slot_text`(「继承时第 k 诊」);`Db::commit() :742` 之后调 `RevisitDueLogic::onAssistantChanged($id, $toAssistantId)`(try/catch);`setRevisitSlotStartOffset :756-795`(入口 `DiagnosisController.php:104-110`)成功后调 `RevisitDueLogic::onDiagnosisChanged($id, 'offset')`——偏移量会直接改变 `current_slot/next_slot`,否则列表「第 N 诊」要等次日全量 cron 才更新。 |
| `server/app/adminapi/logic/stats/AutoAssignLogLogic.php:162-174` | 回退写 `'is_inherit' => $prevAssistantId > 0 ? 1 : 0` 并写 `inherit_visit_slot`;跨消费者影响:业绩看板「被指派数」(`YejiStatsLogic.php:1812/1894/2848` 按 `is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。回退后调 `RevisitDueLogic::onAssistantChanged`。 |
| `server/app/command/BackfillInheritVisitSlot.php`(新增) | 命令 `tcm:backfill-inherit-visit-slot`,选项 `--dry-run`、`--recalc`、`--verify`(输出快照 vs 实时漂移清单:log_id/diagnosis_id/snapshot/realtime/原因)、`--month=`;遍历 `is_inherit=1 AND to_assistant_id>0`,按诊单分块 2000 调 `RevisitVisitSlotService`;整体 try/catch 返回 0。 |
| `server/app/command/AutoAssignPendingDiagnosis.php` | `:224` 改为 `RevisitRateLogic::overview(['month'=>$statMonth, 'inherit_mode'=>'slot'])`,`:231` 读 `config('project.revisit_rate.auto_assign_tier_field')` 指定字段;写 `tcm_diagnosis_auto_assign_log` 时同时写 `visit2_rate`、`visit2_rate_new_assign`、`rate_version`;`--dry-run` 输出两套口径的分档名单对照;类注释 `:26-42` 更新。 |
| `server/app/adminapi/lists/stats/AutoAssignLogLists.php:50-51` | 输出 `visit2_rate_new_assign`、`rate_version_text`。 |
| `server/app/adminapi/logic/stats/YejiStatsLogic.php` | 仅改注释/文案 `:24, 858-861, 1781, 1874, 2620, 2912-2913`:「与复诊接诊率的『新指派数』(assigned_count) 同口径」;三处 `where('lg.is_inherit', 0)` 不动。 |
| `server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524` | `appendDiagnosisEditVisitSeqFields` 改用 `RevisitVisitSlotService::batchCurrentSlot`,消除逐诊单 N+1,`global_visit_seq/counts_for_revisit_rate` 规则不变。 |
| `server/config/project.php`、`server/config/console.php` | 配置段(§2.9);注册 `'tcm:backfill-inherit-visit-slot' => 'app\\command\\BackfillInheritVisitSlot'`(放 `:56-58` 旁)。 |
| `server/tests/RevisitRateInheritSlotTest.php`(新增) | 纯单元(模式 1):k 计算、同一秒对偶、from==to 忽略、多次继承取最早、NI 与 IH 同诊单只算 NI、k=0 归二诊、部门去重与 entry 取 min、slots 上界、exclude 模式退化。`server/tests/RevisitRateContractTest.php`(新增):ReflectionMethod 守住「分子过滤仍用 `applyPrescriptionOrderNotCancelledForPerformanceQuery`、`slot<2` 跳过、holder `t <= ct`」。 |
### 3.2 需求 2/3:快照与列表
| 文件 | 改动 |
|---|---|
| `server/app/common/service/tcm/RevisitDueCalculator.php`(新增) | 纯函数:输入 `latestOrder, validOrderCount, offset, rxUsageDays, signTs, shipTs, appointmentsAfterOrder[], firstTouchTs, today, cfg` → 输出 §1.2 事实字段 + §1.3 `state`。无 DB,可单测。 |
| `server/app/common/service/tcm/RevisitDueService.php`(新增) | `refreshDiagnoses(array $diagIds, int $operatorId = 0, string $reason = '')`:批量取最新有效订单(`batchCurrentSlot`)、处方 `usage_days`(`is_system_auto=0 AND void_status=0 AND delete_time IS NULL`)、签收(`batchResolveSignUnixFromDbForOrders`)、发货(`tcm_prescription_order_log action='ship' MIN(create_time)`,`anchor_chain` 不含 `ship` 时跳过)、挂号(`patient_id IN`,`status IN (1,3,4)`,`appointment_date >= 最小锚点日`),调用 Calculator,`Db::name('tcm_diagnosis_revisit_due')->duplicate([...])->insert()`(用法参照 `server/app/adminapi/logic/qywx/CustomerLogic.php:1058`);`source_order_id` 变化 → 重置延迟字段 + event=6;`assistant_id` 变化 → 重置延迟字段 + event=9;`to=0`/诊单删除/无有效订单 → `closed_state=5`;`closeByAppointment / reopenByAppointment / reevaluateBookedRows()`;所有写入 (int) 强转 `assistant_id`。 |
| `server/app/common/model/tcm/DiagnosisRevisitDue.php`、`DiagnosisRevisitDueLog.php`(新增) | 仿 `server/app/common/model/tcm/DiagnosisTodo.php:31, 37, 40-53`:`autoWriteTimestamp=true`、`dateFormat=false`、状态常量 + `*_text` 访问器。 |
| `server/app/adminapi/logic/tcm/RevisitDueLogic.php`(新增,需求 3/4 共用) | `extends BaseLogic`。统一钩子入口 `onDiagnosisChanged(int $diagnosisId, string $reason)`、`onAssistantChanged(int $diagnosisId, int $to)`、`onAppointmentChanged(int $diagnosisId, int $appointmentId)`,全部 `try { RevisitDueService::... } catch (\Throwable $e) { Log::warning(...) }`,不改变主流程返回值(模式同 `AppointmentLogic.php:597-611`);需求 4 的 `pending/snooze/contact/close` 业务(§3.3)。 |
| `server/app/adminapi/lists/tcm/DiagnosisLists.php` | ① `lists()` 在未服务天数段(`:431-461`)之后批量读 `tcm_diagnosis_revisit_due`(`whereIn diagnosis_id`),为每行追加 `revisit_due` 对象(`current_slot, next_slot, medication_days, days_src, rule_days, anchor_src, anchor_date, order_date, due_date, days_left, overdue_days, state, state_text, snooze_count, escalation_level, promised_date, missed_count, first_touch_date, in_care_days, closed_state`);**自愈**(移植方案 1/2):本页诊单若快照缺行、或 `batchCurrentSlot` 得到的最新有效订单 id ≠ 快照 `source_order_id`,仅当请求 `page_size ≤ 100` 时,对本页前 50 行内的不一致行调用 `RevisitDueService::refreshDiagnoses` 后再返回,其余交给增量 cron(`ListsValidate` 允许 `page_size` 直到 `project.lists.page_size_max`=25000,避免一次 GET 刷新成千行并逐批解析签收);比对条件同时包含 `batchCurrentSlot.current_slot ≠ 快照 current_slot`(覆盖偏移量修改)。替代「待计算」占位。② 新增私有方法 `applyRevisitDueFilters($query, bool $pendingWideSearch)`(移植方案 0),在 `lists()`(`:106-160` 段)与 `count()`(`:561-610` 段)各调用一次,参数:`revisit_state`(due=overdue∪missed / warning / booked / visited / contacted / no_days / pending)、`revisit_days_bucket`(14/21/28/30/other);实现为 `whereExists(SELECT 1 FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND ...)`,如 `due`(= overdue ∪ missed):`rd.closed_state=0 AND (rd.appointment_state=4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE())) AND (rd.promised_date IS NULL OR rd.promised_date < CURDATE())`。③ `resolveListOrderRaw :719-752` 新增 `sort_revisit_due=asc|desc` 分支,锚点 `(SELECT rd.due_date FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND rd.closed_state = 0 LIMIT 1)`,NULL 置后写法同 `:724-731`,与 `sort_unserved_days` 互斥。 |
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | 详情/只读接口附带同一 `revisit_due` 对象(供 `readonly.vue`)。 |
| 钩子接入(P0,均经 `RevisitDueLogic` 包裹) | `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`:`create :978`(成功后)、`editTime :1152`、`withdraw :2250`、`complete :3234`(目标状态 ∈ {9,10} 时)、`refund :3351`;`server/app/api/controller/GancaoCallbackController.php:331-341`(case 91/92 置 4 后);`server/app/adminapi/logic/doctor/AppointmentLogic.php`:`create :280`(写入后)、`cancel :420`、`complete :576`、`adminEdit :978`;`server/app/common/command/UpdateAppointmentStatus.php:55-56` 两条 `Db::execute` 之后调 `RevisitDueLogic::onAppointmentStatusCronDone()`(内部 try/catch + `Log::warning`;该命令 `execute()` 本身无 try/catch,若直接调 Service 抛异常会让 `Crontab::start` 把既有 `update_appointment_status` 任务置 ERROR 停跑);`DiagnosisLogic::assign :742` 之后;`DiagnosisLogic::setRevisitSlotStartOffset` 成功后;`server/app/command/AutoAssignPendingDiagnosis.php:507-518` 写日志后;`AutoAssignLogLogic.php:162-174` 回退后。甘草推 5(`:380-383, 406-409`)与签收推 6(`ExpressTrackingService.php:559`)不加钩子,由增量 cron 覆盖(锚点由下单日切到签收日最长滞后 30 分钟)。 |
| `server/app/command/RevisitDueScan.php`(新增) | 命令 `tcm:revisit-due-scan`,选项 `--mode=full|incremental`、`--dry-run`、`--diagnosis-id=`、`--date=`(基准日回放)。候选:`tcm_diagnosis.delete_time IS NULL AND status=1 AND CAST(assistant_id AS UNSIGNED) > 0`(`limit_er_center` 时再限 `DeptLogic::getErCenterSubtreeDeptIdSet`(`DeptLogic.php:214`)+ `admin_dept`);incremental 只取近 2 小时 `tcm_prescription_order.update_time`、`doctor_appointment.update_time`、`tcm_diagnosis_assign_log.create_time`、`tcm_diagnosis.update_time`(偏移量/医助变更)有变化的诊单 + `closed_state=2` 的行重评;分块 500 调 `RevisitDueService::refreshDiagnoses`;候选还并入 `closed_state<>5` 的既有快照行(诊单删除/禁用/释放医助后由全量重算推进为失效);防重入:按 mode 在 Cache 记录上次开始时间,5 分钟内跳过(钩子/自愈会持续刷新 `computed_at`,不能用它判断);`--date` 与今天不同时强制演练模式;**整体 try/catch 并 `return 0`、按块隔离异常**(`Crontab.php:81-87` 异常即停任务);输出刷新/新增/关闭/缺天数/耗时统计。注册到 `server/config/console.php`。 |
| `server/tests/RevisitDueCalculatorTest.php`(新增) | 状态机 9 态、锚点回退链三种、天数回落三种、15 天不归 14 档、承诺日/过号重开、新周期重置。 |
### 3.3 需求 4:接口
控制器 `server/app/adminapi/controller/tcm/RevisitDueController.php`(新增,URL 自动映射 `/adminapi/tcm.revisitDue/<action>`,权限串 `tcm.revisitDue/<action>`):
| 方法 | 接口 | 入参 | 行为 |
|---|---|---|---|
| GET | `tcm.revisitDue/pending` | — | 本人 `assistant_id = adminId AND closed_state=0 AND (appointment_state=4 OR (due_date IS NOT NULL AND due_date <= CURDATE())) AND (promised_date IS NULL OR promised_date < CURDATE()) AND next_remind_at <= now`,≤ `toast_limit` 条,按 `escalation_level desc, overdue_days desc`;返回前对每条做轻量实时校验(EXISTS 新挂号 1/3 → 顺手关闭并剔除;最新有效订单 id ≠ `source_order_id` → refresh);`first_shown_at` 为 0 时写一次 + event=11,`last_shown_at` 每次更新;**不做服务端软节流**,每次返回当前全集,前端以响应为准整体替换卡片列表(被他人处理、换医助、cron 关闭的行自然消失,不需要 removed_ids);`toast_enabled=false` 返回空;`$this->data()`(show=0)。返回 `{items:[...], summary:{due_total, max_overdue_days, warning_total}}` |
| GET | `tcm.revisitDue/summary` | — | 仅计数(供右下角小气泡/顶栏),不更新 shown |
| POST | `tcm.revisitDue/snooze` | `id` | 条件更新 `UPDATE ... SET snooze_count=snooze_count+1, escalation_level=?, next_remind_at=?, last_snoozed_at=?, last_snoozed_by=? WHERE id=? AND assistant_id=? AND closed_state=0 AND next_remind_at<=?`;`affected=0` 也返回当前行(幂等);写 event=1,等级变化再写 event=10;`success('已延迟,HH:mm 再提醒', $data, 1, 1)` |
| POST | `tcm.revisitDue/snoozeAll` | `ids[]` | 仅 `escalation_level <= 1` 的项,逐项同 snooze |
| POST | `tcm.revisitDue/contact` | `id, promised_date, remark?` | `promised_date ∈ [today, today+promise_max_days]`;`contact_count < contact_max_per_cycle` 时不增 `snooze_count`,否则按一次延迟计;`next_remind_at = promised_date 09:00`;event=2 |
| POST | `tcm.revisitDue/close` | `id, reason in {lost,other}, remark(必填,≤200)` | `closed_state=4`(条件 `closed_state=0`),event=3;医助本人可操作,主管视图标记 |
| GET | `tcm.revisitDue/lists`(P2) | 分页 + `assistant_id/dept_id/state/days_bucket/min_snooze/keyword` | `RevisitDueLists`(`HasDataScopeFilter::applyDataScopeByOwner('assistant_id')`,`server/app/common/lists/Traits/HasDataScopeFilter.php:25`),支持导出 |
| GET | `tcm.revisitDue/logs`(P2) | `due_id` | 流水 |
配套:`server/app/adminapi/validate/tcm/RevisitDueValidate.php`(新增,`id require|number|gt:0`、`promised_date date`、`reason in:lost,other`、`remark max:200`,scene `snooze/snoozeAll/contact/close`,写法同 `server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php`);`server/app/adminapi/lists/tcm/RevisitDueLists.php`(P2,新增)。所有写操作在 Logic 内校验 `assistant_id === adminId` 或超管(role_id=1)/数据范围可见,不依赖路由层。File 缓存无锁(`server/config/cache.php:9`),互斥全靠条件 UPDATE;cron 不改 `snooze_count/next_remind_at`(除新周期/换人),接口不改 cron 字段,列级隔离。
---
## 4. 前端改动
### 4.1 复诊接诊率页(需求 1)
`admin/src/views/stats/revisit_rate/index.vue`
- 筛选栏加 `el-radio-group`「口径:继承按继承时诊次计入(新)/ 继承不计分母(旧)」→ `inherit_mode`,默认取后端返回;切换即 `loadData`。
- 「当月被指派数」列(`:107`)标题改「新指派数」;新增「继承数」列(`inherit_count`,slot 模式显示,点击下钻 `kind=inherit`);新增「被指派合计」(`assigned_total_count`)。
- 每个 N 诊「接诊率」单元格(`:151-157`):主显示 `62.5%`,下方灰色小字 `5 / 8`(`visit{N}_count / visit{N}_den`);表头 tooltip 改为「分母 = 当月新指派数 + 起算诊次 ≤ N 的继承数」。
- 追加「复诊合计单数」「复诊合计接诊率」(`visit_all_count/visit_all_rate`),点击打开 `visitOrderLines(slot=0)`。
- KPI 卡(`:351-358`)增「当月复诊合计接诊率」;`getSummaries`(`:367-383`)正则扩为 `^visit(\d+|all)_(count|den|rate)$` 并加 `inherit_count/assigned_total_count`;`:402` 过滤 `s >= 2` 保留。
- 被指派明细弹窗(`:162-236`,列 `:196-197`)新增「指派类型」(新指派/继承 tag)、「继承时诊次 k」、「起算诊次」。
- 口径说明 popover(`:41-60`)按模式切换两段文案,注明「继承分母自 2026-06(is_inherit 列加入)起生效」。
- `admin/src/api/stats.ts:208-235`:参数类型加 `inherit_mode?: 'exclude'|'slot'`、`kind?`、`slot` 允许 0;注释更新。
- `admin/src/views/fans/yeji.vue:401` tooltip 改「与复诊接诊率『新指派数』同口径」;`admin/src/views/stats/auto_assign_log/index.vue:69,140` 显示口径版本与两套率。
- 指派弹窗 `admin/src/views/tcm/diagnosis/index.vue:520-521`、`admin/src/views/tcm/follow/index.vue:329-330`、`admin/src/views/first_visit/my_patients/index.vue:255-258`:当「当前医助>0 且 目标≠当前」时默认勾选「继承」并弹确认(文案:该患者当前第 k 诊,继承后从第 k+1 诊起计入您的接诊率;k 来自列表行 `revisit_due.current_slot`);目标 == 当前时提示「目标医助与当前相同」(D1-d,P1)。`admin/src/views/tcm/diagnosis/components/AssignLogPanel.vue:15-17` 继承列旁显示「继承时第 k 诊」。
### 4.2 诊单列表(需求 2/3)
**PC `admin/src/views/tcm/diagnosis/index.vue`**
- 「复诊」列(`:281`,min-width 120)改造为「复诊节点」列(min-width 180),吞掉「开方」列(`:304`,width 72),净增宽度≈0。单元格抽成共用组件 `admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue`(新增;PC / H5 / 只读共用),四行:
```
[第3诊] 已开方 · 王医生 ← 诊次 tag(tooltip:有效订单 3 笔 + 偏移 0;待复诊第 4 诊;首次触达 03-02 · 在管 224 天;累计开药 84 天)
09-20 开药 · 28 天 (签收起) ← 最新有效订单日 · 开药天数 · 锚点来源;缺天数时橙色「缺开药天数」
药尽 10-18 [超期 3 天] ← 预计复诊日 + 状态 chip(§1.3)
已延迟 2 次 · 已联系 约 10-15 ← 仅 snooze_count>0 或已联系时显示
```
- 状态文案/class 映射放 `admin/src/utils/revisitDue.ts`(新增);chip 色复用 `unserved-red #dc2626 / unserved-orange #ea580c / unserved-green #16a34a`(`:2665-2681`)与 `#4A5DFF`;el-tag 用法同 NEW 标签(`type="danger" size="small" effect="dark"`)。
- 行级标红:`getRowClassName`(`:1347-1350`)新增 `row-revisit-overdue`(state ∈ {overdue, missed})、`row-revisit-overdue-deep`(≥ `severe_overdue_days`),优先级按配置默认 **复诊超期 > 未确认 > 未挂号**;样式复制 `:deep(.el-table__row.row-unconfirmed)`(`:2771-2777`)把 `--el-color-warning` 换 `--el-color-danger`,deep 版本背景 10%。
- 顶部 chip 区(`:7-72`,「待预约」chip `:27`)后新增「复诊到期 N」(danger 色,样式仿 `.chip-pending-booking` `:2301`),点击设 `formData.revisit_state='due'` 并默认 `sort_revisit_due='asc'`;角标在 `fetchDateCounts`(`:1061-1082`)的 `Promise.all` 追加 `buildDateCountRequestPayload({ revisit_state: 'due' })`;chip active 互斥条件加入 `formData.revisit_state === ''`。
- 「更多筛选」(`:107-152`)新增「复诊状态」下拉 → `revisit_state`、「开药天数档」下拉(14/21/28/30/其它)→ `revisit_days_bucket`。
- 「复诊节点」列 `sortable="custom" prop="revisit_due_date"`;`handleTableSortChange`(`:1248`)改为按 prop 分派、互斥清空 `sort_unserved_days / sort_revisit_due`。
- `formData`(`:787`)新增 `revisit_state / revisit_days_bucket / sort_revisit_due`,同步 6 处:`handleReset :1302`、`clearSecondaryFiltersWhenPendingAssignWideSearch :938`、`buildSharedDiagnosisFilterPayload :889`、`buildDateCountRequestPayload :906`、chip active 判定、各 Tab 点击处理器(`:984-1057`)。
- `?id=` 处理(`:2232`)扩展 `action=appointment` → `appointmentRef.value?.open(row)`(`appointmentRef :1356`、`handleAppointment :1955-1961`),供弹窗「去挂号」深链。
**H5 `admin/src/views/tcm/diagnosis/index_h5.vue`**:复诊块(`:174-181`)替换为 `RevisitNodeCell` 紧凑模式;`formData`(`:678`)加 `revisit_state`;chip 区加「复诊到期」;`getCardClass`(`:901-903`)加 `dh5-card--overdue`(accent 色同 `:1676`);tag 复用 `dh5-tag--warn`(`:1762`)。
**只读页 `admin/src/views/tcm/diagnosis/readonly.vue:23-27`**:在「未服务」徽标旁加「第 N 诊 · 药尽 MM-DD · 超期 N 天」徽标,着色复用 `:200-205` 的 class。
**`admin/src/views/tcm/follow/index.vue`**(diagnosis 旧副本,组件同名 `tcmDiagnosis` `:548`,固定 `only_has_prescription=1` `:578`,「第 N 次复诊」= 处方张数 `:155-157`):待产品确认是否仍在用(D2-c);若在用则改用 `RevisitNodeCell` 并改组件名。
### 4.3 全局弹窗(需求 4)
新组件 `admin/src/components/revisit-due-toast/index.vue`(新增),在 `admin/src/App.vue:60` 与 `ChatNotifyToast` 并列挂载,复用 `showChatNotifyToast` 条件(`:19-23`,避免未绑企微 code=10 反复打断)。**不放诊单页内**(菜单 `is_cache=0`,`server/sql/tcm_diagnosis_menu.sql:13`,离开页面即卸载)。
- 位置:固定**右下角**(`bottom:24px; right:24px; z-index:3000`),避免与右上角 `ChatNotifyToast`(`chat-notify-toast/index.vue:140-141` `top:72px; right:24px`)叠放;`body.h5-mode` 下改为底部全宽。
- 形态:**一张汇总卡**(不是一人一张):标题「⚠ N 位患者到期未挂号」+ 每行「患者 · 第 N 诊·X 天 · 药尽 MM-DD · 超期 N 天 / 过号 · 曾过号 N 次」+ 按钮 [去挂号][已联系][延迟 Xh];超出 5 条折叠「还有 N 位 → 查看全部」(跳诊单页并激活「复诊到期」chip);底部「全部延迟 1 小时」仅当所有项 level ≤ 1 时显示;最小化仅 level ≤ 1 可用。
- 去挂号:`router.push({ path:'/tcm/diagnosis', query:{ id, action:'appointment' } })`,本地乐观移除,挂号成功由钩子关闭。
- 已联系:内联日期选择(默认今天+3,范围今天~+14)+ 备注 → `contact`。
- 延迟:`snooze` → 项消失,`ElMessage`「已延迟,14:30 再提醒(第 2 次)」。
- level 3:该患者单独 `ElMessageBox`(`admin/src/utils/feedback.ts` 已封装)。
- 轮询:60 秒(对比 chat 的 5 秒),首载立即一次,`visibilitychange` 回前台立即一次,`document.hidden` 跳过(参照 `index.vue:1117`);多标签页:`localStorage['revisit_due_toast:last_fetch']` 跨标签 30 秒节流 + `BroadcastChannel('revisit-due-toast')` 广播 snooze/contact/close 结果同步移除;按 `id` 去重。
- API:`admin/src/api/revisitDue.ts`(新增)`revisitDuePending/Summary/Snooze/SnoozeAll/Contact/Close/Lists/Logs`。
- P2 主管视图 `admin/src/views/stats/revisit_due/index.vue`(新增,菜单 `/stats/revisit_due`,component `/stats/revisit_due/index`):筛选 + 表格(患者/医助/部门/第 N 诊/开药天数/预计复诊/超期天数/延迟次数/已联系/承诺日/状态)+ 流水抽屉;`v-perms="['tcm.revisitDue/lists']"`。
---
## 5. 定时任务与通知
| 命令 | 频率 | 职责 | 期 |
|---|---|---|---|
| `tcm:revisit-due-scan --mode=full`(`server/app/command/RevisitDueScan.php` 新增) | 每日 08:30 | 全量重算快照(签收时间落库、过号变化、钩子漏刷兜底) | P0 |
| `tcm:revisit-due-scan --mode=incremental` | 每 30 分钟 | 近 2 小时有订单/挂号/指派变更的诊单 + `closed_state=2` 行重评 | P0 |
| `tcm:backfill-inherit-visit-slot`(`server/app/command/BackfillInheritVisitSlot.php` 新增) | 一次性手工 + 需要时 `--verify` | 回填/校验 `inherit_visit_slot` | P1 |
| `tcm:revisit-due-notify --mode=daily|tick`(`server/app/command/RevisitDueNotify.php` 新增) | 09:00 / 每 30 分钟 | 企微推送(§5.2) | P2 |
| 既有 `update_appointment_status`(`UpdateAppointmentStatus.php`) | 线上频率需确认 | `:55-56` 之后追加 `RevisitDueLogic::onAppointmentStatusCronDone()`(try/catch 包裹),使当天过号即进标红/弹窗 | P0 |
| 既有 `tcm:auto-assign-pending` | 每日 09:00 | 读 `auto_assign_tier_field` 指定字段分档,日志双记录 | P1 |
命令约定:`setName/setDescription/addOption('dry-run')`,`execute()` 整体 try/catch 返回 0、按块隔离异常(`Crontab.php:81-87`);`dev_crontab.params` 仅 64 字符;注册于 `server/config/console.php`(`:56-58` 旁)。
### 5.2 企微联动(P2,`wecom_enabled=false` 默认关闭)
- daily:每位有 `overdue/missed` 行的医助一条汇总「您有 N 位患者到期未挂号,最久 X 天」;`WechatWorkAppMessageService::sendTextToUser`(`server/app/common/service/wechat/WechatWorkAppMessageService.php:130`),接收人 `admin.work_wechat_userid`(取法同 `server/app/command/DiagnosisTodoNotify.php:94-98`),未绑定跳过并写 event=8 payload 记录,不算失败。
- tick:`escalation_level>=3 AND next_remind_at<=now` 的行推本人(每条每日 ≤ `wecom_daily_cap`);`snooze_count>=5` 首次达到时抄送 `supervisor_admin_ids` 1 次/日。
- 推送只在命令内发(接口不同步 curl);逐条 `usleep(200ms)`;异常不得抛到 `Crontab::start`。
---
## 6. 权限、菜单与 MCP 目录登记
1. **新 action 全部登记 A 类菜单**(§2.5),并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予已拥有 `tcm.diagnosis/lists` 的角色;原因:`AuthMiddleware.php:111-117` 对未登记 URI 直接放行,`:124` 的 `matchPermissionAlias` 只对已登记 URI 生效。可在 `matchPermissionAlias :171-251` 增加「拥有 `tcm.diagnosis/lists` 即视为拥有 `tcm.revisitdue/pending|summary|snooze|snoozeall|contact|close`」作为兜底(写法同 dailyRecord 别名 `:202-206`)。
2. 需求 1 不新增接口(复用 `stats.revisitRate/overview|assignLines|visitOrderLines|deptOptions`,`server/sql/1.9.20260610/add_revisit_rate_menu.sql`)。
3. P2 主管视图 C 类菜单 `tcm.revisitDue/lists` + A 类 `logs/export`(§2.8);执行后 `AdminAuthCache::clearAuthCache`(`server/app/common/cache/AdminAuthCache.php:115`)或等缓存过期;「全部菜单」角色需复核。
4. MCP 目录:`server/app/mcp/catalog/review/stats.php:173-209` 四条 revisitRate 的 note/params_allow 加 `inherit_mode/kind/slot=0`,`:46-62` autoAssignLog 补口径说明;`server/app/mcp/catalog/review/tcm.php` 为 `tcm.revisitDue/lists|logs` 写 `status: pending`(含患者姓名,待评审),写接口/轮询端点 `excluded`;执行 `php app/mcp/cli/catalog.php --write` 重生成 `generated.php`(`server/app/mcp/catalog/review/README.md`);`php server/tests/AiMcpUnitTest.php` 通过。
---
## 7. 兼容、回填与灰度/回滚
| 项 | 做法 |
|---|---|
| 需求 1 历史数据 | 口径实时重算,历史月份切换 `inherit_mode` 即可对照;`is_inherit` 2026-06-01 加列前的转接全为 0,无法回溯(popover 注明)。`php think tcm:backfill-inherit-visit-slot --dry-run` → 正式回填 → `--verify` 漂移为 0。 |
| 自动分配分档 | `auto_assign_tier_field` 默认 `visit2_rate_new_assign`(旧口径),上线日分档不变;`--dry-run` 输出两套分档名单给产品对照,确认后改配置为 `visit2_rate`,不发版;历史 `auto_assign_log.visit2_rate` 不回溯,`rate_version='v1'` 标识。回滚 = 改回配置。 |
| 需求 1 页面回滚 | `default_inherit_mode='exclude'`,字段值回到现状。 |
| 快照首次初始化 | 部署后 `php think tcm:revisit-due-scan --mode=full`(分块 500,预计数千~数万诊单 1~5 分钟,需在测试库压测;可先 `anchor_chain=['create']` 跳过签收/发货查询);表为空或缺行时列表自愈(§3.2),不影响展示。 |
| 首日弹窗雪崩 | `toast_enabled=false` 上线 → 主管用「复诊到期」chip 清存量 → 开启;初始化时对 `due_date < 上线日−30 天` 的历史逾期行把 `next_remind_at` 置为上线日+1 09:00(移植方案 0)。 |
| `medication_days` 缺失 | 回落处方 `usage_days`(剔除系统代开/作废),仍缺不标红并显示「缺开药天数」;上线前核查 `SELECT COUNT(*) FROM zyt_tcm_prescription_order WHERE delete_time IS NULL AND (medication_days IS NULL OR medication_days=0)`;补填入口复用 `PrescriptionOrderDetailDrawer.vue:996` 的服用参数修补(`PrescriptionOrderLogic.php:5802-5869`)。 |
| `tcm_diagnosis.assistant_id` 列类型未知 | 候选查询用 `CAST(assistant_id AS UNSIGNED) > 0`,写入 (int) 强转;上线前 DESC 确认。 |
| P0/P1/P2 回滚 | P0:停两条 crontab、前端隐藏「复诊节点」新内容(恢复「复诊/开方」列),表保留;P1:`App.vue` 去掉挂载一行 + `toast_enabled=false`;P2:停 notify crontab。 |
---
## 8. 分期交付与工期(P0/P1/P2)
| 期 | 内容 | 工期(人日) | 上线步骤 |
|---|---|---|---|
| **P0 需求 2/3** | `RevisitVisitSlotService`、`RevisitDueCalculator/Service`、模型、快照表 + 流水表 + 索引、`tcm:revisit-due-scan`(full/incremental)、钩子接入、`DiagnosisLists` 字段/自愈/筛选/排序、`RevisitNodeCell` + PC 列改造 + 行标红 + chip + 筛选、H5/只读页同步、配置段、单测 | 7(后端 4、前端 2.5、联调 0.5) | SQL 01~04 → 部署后端 → `php think tcm:revisit-due-scan --mode=full` → 部署前端 → 抽样核对 20 个诊单(`--dry-run --diagnosis-id=X` 与页面一致) |
| **P1 需求 4 + 需求 1** | 接口 6 个 + Validate/Logic、全局组件(阶梯/升级/已联系/多标签去重)、`action=appointment`、菜单 SQL 05;需求 1:Calculator 重构、每列分母、合计列、下钻扩展、`inherit_mode`、快照列 + 回填命令、AutoAssign 可配字段 + 双记录、文案与 MCP 目录、指派弹窗默认勾继承 | 8(后端 4.5、前端 3、联调 0.5) | SQL 05~07 → 部署 → 清权限缓存 → `tcm:backfill-inherit-visit-slot` → `tcm:auto-assign-pending --dry-run` 对照 → `toast_enabled=false` 清存量 → 开启弹窗 |
| **P2 增强** | 企微 daily/tick + 主管抄送、主管团队视图页 + `RevisitDueLists`、触达时间线接口、follow 页处置、「补天数」快捷入口 | 4 | SQL 08~09 → 部署 → 角色勾权限 → `wecom_enabled` 观察一周后开启 |
合计 19 人日;含 15% 缓冲约 22 人日(前后端并行约 3 周)。P0 可独立上线;P1 依赖 P0 的服务与表;P2 依赖 P1 的接口。
每期前置自检:`php -l` 全部改动文件;`php server/tests/RevisitDueCalculatorTest.php`、`RevisitRateInheritSlotTest.php`、`RevisitRateContractTest.php`、`AiMcpUnitTest.php`;`admin` 下 `npm run type-check && npm run lint`;docker 环境跑一次 `php think tcm:revisit-due-scan --dry-run`。
---
## 9. 风险
1. **口径变更连带**:`visit2_rate` 在 slot 模式下变化,影响 `AutoAssignPendingDiagnosis` 分档;过渡期用旧口径字段,页面与分档「对不上」需在自动分配日志页标注口径版本。
2. **继承勾选质量**:继承靠人工勾选、无默认推断(`DiagnosisLogic.php:671`),漏勾则仍按新指派计;P1 换人场景默认勾选 + 确认缓解;2026-06 前转接无法回溯。
3. **回退行改继承的跨消费者影响**:`AutoAssignLogLogic` 写 `is_inherit=1` 会使业绩看板「被指派数」(`is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。
4. **快照与实时漂移**:事后退款/取消/改 offset 会让 `inherit_visit_slot` 与实时值不一致;统计以实时为准,`--verify` 定期跑、`--recalc` 可重算。
5. **`medication_days` 缺失**:不兜底 7 会有「缺天数」无 due_date 的行,依赖医助补填;上线前查分布。
6. **签收时间异步**:`express:auto-update` / `tcm:backfill-sign-time`(`server/config/console.php:52`)在线上是否启用未知;锚点由下单日切到签收日时 due_date 会后移 2~5 天;UI 标注来源,增量 cron 30 分钟内刷新。甘草自动发货不写 `ship` 日志,锚点会回退到下单日。
7. **过号 8 小时自动变取消**(`UpdateAppointmentStatus.php:42-44`),且该任务线上是否启用未知;「过号未面诊」只在当天可见,次日统一归「到期未挂号」。
8. **`DiagnosisLists` 性能与双写**:已有处方聚合 + 三表未服务天数 + 3 个相关子查询排序;新增筛选用 EXISTS 单表索引、排序用单行子查询;自愈最多刷新 25 行;`applyRevisitDueFilters` 单方法两处调用防遗漏。验收门槛:列表 P95 增幅 ≤150ms。
9. **全量 cron 耗时无压测依据**:含签收解析(`ExpressTrackingService.php:1642`,200/批)与 `tcm_prescription_order_log`(DDL/索引不在仓库)聚合;先在测试库压测,必要时 `anchor_chain=['create']`。
10. **权限模型**:未登记接口默认放行(`AuthMiddleware.php:111-117`);本稿全部登记 + 自动授予 + Logic 内归属校验;自建角色仍需人工复核。
11. **Crontab 异常即停**(`Crontab.php:81-87`):新命令必须吞异常返回 0;File 缓存无锁,一切并发控制靠条件 UPDATE。
12. **弹窗疲劳与「无脑点延迟」**:首发开关、限 20 条、「已联系」免计次但有上限、延迟次数主管可见;上线后一周调参。
13. **多标签页/多设备**:BroadcastChannel/localStorage 只覆盖同浏览器;跨设备靠 `next_remind_at` 去重,可能各弹一次。
14. **`tcm_diagnosis.assistant_id` 列类型未知**、`tcm_prescription_order_log` 无 DDL、MySQL 5.7 无窗口函数:DBA 上线前核对。
15. **follow 页**(`tcm/follow/index.vue`)为旧副本且组件同名 `tcmDiagnosis`,不同步会出现两页「复诊」口径不一致。
16. **钩子分散**(订单 5 处 + 甘草 1 处 + 挂号 4 处 + 过号 cron + 指派/自动分配/回退 3 处):任一漏接由 30 分钟增量 cron、列表自愈、`pending()` 实时校验三层兜底。
17. **MCP 目录滞后**:参数/接口变更后未重生成 `generated.php` 会导致 AI 查询参数校验落后,列入每期上线清单。
---
## 10. 需产品确认的决策点(每条给推荐选项)
| 编号 | 问题 | 选项 | 推荐 |
|---|---|---|---|
| D1-a | 需求 1 继承分母采用哪种定义 | A 累计型:继承行按继承时诊次 k 进入 slot ≥ k+1 的每一列分母,分子不变 / B2 单列型:只进 slot=k+1 列且分子只计继承后首单 / C 取消 is_inherit 剔除 | **A**。分子不动、历史 N 诊单数不变、可与订单明细逐笔对账;B2 改分子语义,C 不体现「几诊」 |
| D1-b | 「几诊」指已完成第 k 诊还是即将发生的第 k+1 诊 | 从第 k+1 诊列起计入 / 从第 k 诊列起计入 | **k+1**。第 k 诊订单归前任持有人,继承者只能产生 k+1 及以后成交;k ≤ 1 的继承与新指派等价 |
| D1-c | 「所有诊数接诊率」含义;是否展示一诊 | 新增复诊合计列、不展示一诊 / 合计列 + 一诊列 / 仅动态展示所有诊次列(现状) | **合计列、不展示一诊**。二中心患者多已在一中心下首单(offset 机制即为此) |
| D1-d | 换人(from>0 且 to>0)时前端是否默认勾选「继承」并确认;目标==当前是否拦截 | 默认勾选 + 二次确认,目标相同仅提示 / 保持手动勾选 / 后端硬拦截目标相同 | **默认勾选 + 确认;不硬拦**(避免打断批量指派流程,统计侧忽略 from==to) |
| D1-e | 自动分配回退行如何计 | 回退写 is_inherit=1(to>0 时)+ 历史行按 rollback_time 关联豁免 / 保持 is_inherit=0 / 单独排除 | **第一项**。原医助重新接手不是新增工作量;副作用是业绩看板被指派数少计一条 |
| D1-f | AutoAssign 分档是否立即跟随新口径 | 过渡期用 visit2_rate_new_assign,一个月后切换 / 立即切换 / 永久旧口径 | **过渡一个月**,`--dry-run` 对照后改配置 |
| D1-g | 业绩看板「被指派数」语义 | 保留仅新指派、改 tooltip 文案 / 同步纳入继承 | **保留并改文案** |
| D1-h | 医助本人能否看到「我的 N 诊接诊率」 | 在医助个人业绩页(`admin/src/views/stats/assistant-performance/index.vue`,后端 `AssistantPerformanceLogic`)加「我的 N 诊接诊率」卡片,复用 `RevisitRateLogic::overview` 按本人 assistant_id 过滤 / 仅管理层在复诊接诊率页查看 / 诊单页顶部展示 | **个人业绩页加卡片(P2)**。复诊接诊率页权限不按 DataScope 过滤(`RevisitRateLogic.php:460-475` 仅限二中心子树),不宜直接开放给医助 |
| D2-a | 「触达时间」展示字段 | 列内:第 N 诊/最新开药·天数·锚点/预计复诊;tooltip:首次触达日/在管天数/累计开药 / 全部进列 / 仅天数 + 诊次 | **列 + tooltip**(控制表格宽度) |
| D2-b | 用药起算锚点 | 签收 → 发货 → 下单 回退链并标注来源 / 只用下单日 / 只用签收日 | **回退链**,`anchor_chain` 可收窄 |
| D2-c | follow 页处置 | 仍在用则同步 RevisitNodeCell 并改组件名 / 下线合并 / 不处理 | **先确认线上菜单是否挂该页;无人用则下线** |
| D3-a | 「14/21/28/30」含义与缺天数处理 | 按实际天数算到期,四档仅作标签/筛选;缺天数回落处方 usage_days(剔除系统代开),仍缺显示「缺开药天数」不标红 / 只对四档提醒 / 缺天数兜底 7 / 四档为逾期阈值阶梯 | **第一项**;若实为阈值阶梯,仅改 `severe_overdue_days` 为数组 |
| D3-b | 预警/严重阈值 | 到期前 3 天橙、到期红、≥7 天深红(可配) / 无预警 / 其它数值 | **3/7 天**,走配置 |
| D3-c | 行高亮优先级 | 复诊超期 > 未确认 > 未挂号 / 未确认 > 复诊超期 > 未挂号 / 只标单元格 | **复诊超期优先**,`row_highlight_priority` 可切 |
| D3-d | 「已挂号」解除标红标准 | status∈{1,3} 且 appointment_date ≥ 锚点订单日即解除;4/2 不算 / 必须 status=3 / 1/3/4 均算 | **第一项** |
| D3-e | 提醒范围 | 限二中心子树且 assistant_id>0 / 全院有医助诊单 / 含未指派诊单 | **限二中心 + 有医助**,`limit_er_center` 可放开 |
| D4-a | 弹窗对象 | 仅持有医助本人;主管看 P2 列表 + 企微抄送 / 主管也弹所辖 / 其它部门纳入 | **仅本人** |
| D4-b | 延迟阶梯与形态 | 4h→2h→1h→30min,第 5 次起居中模态,工作时段 09:00~20:00 / 24h→12h→4h→1h / 固定 24h 不升级 | **第一项**,全部走配置 |
| D4-c | 换医助后延迟次数 | 清零并立即对新医助提醒 / 保留迁移 / 保留但不提醒 | **清零**(度量个人态度,新医助不背前任账),event=9 留痕 |
| D4-d | 「已联系」约束 | 每周期 ≤2 次、承诺日 ≤+14 天、第 3 次起按延迟计 / 不限制 / 已联系等同一次延迟 | **第一项** |
| D4-e | 医助能否自行关闭 | 可填原因关闭,主管视图标记 / 仅主管可关闭 / 可直接关闭 | **可填原因关闭并标记** |
| D4-f | 企微与「主管」定义 | P2 默认关闭,开启后 daily 推本人 + level≥3 推本人 + snooze≥5 每日抄送配置名单主管 / dept 表新增 leader 字段 / 不做企微 | **P2 配置方式起步**(`Dept.php` 无 leader 字段) |
| D5 | 同一诊单当月多条继承/既新指派又继承给同一医助 | 只算新指派;多次继承取最早 / 取最晚 / 各算一次 | **第一项**(与现有医助×诊单去重一致) |
| D6 | 同一秒指派与订单归属 | 沿用 `t<=ct` 归新持有人,k 用 `ct<t` 严格小于 / 订单先于指派 | **沿用** |
---
## 11. 验收标准
### 需求 1
1. 示例(§1.1):9 月 slot 模式 B 的 `inherit_count=1`、`visit4_den` 含 D、`visit2_den/visit3_den` 不含、`visit4_count=1`、`assigned_total_count=1`;exclude 模式 B `assigned_count=0`、`visit4_rate=null`,且 `from==to` 行与历史回退行按现状计入,`assigned_count/visit{N}_count/visit{N}_rate` 与线上逐值一致。
2. 同一诊单当月既新指派又继承给同一医助 → 只计 NI;多次继承取最早。
3. 继承时无任何有效订单(k=0)→ 起算二诊,与新指派等价;继承与订单同一秒 → 订单归新持有人且 k 不含该单。
4. slots 覆盖 `max entry`:继承时第 6 诊的患者在只有二~四诊成交的月份也显示七诊列(分母 1、单数 0)。
5. 部门行 `visit{N}_den` ≤ 子行之和(去重);合计行同理;`visit_all_count = Σ visit{N}_count`。
6. 任一医助任一列:`visit{N}_den` = 被指派明细(`kind=all`)中「新指派行数 + entry≤N 的继承行数」;`visit{N}_count` = 该列下钻订单行数。
7. `php think tcm:auto-assign-pending --dry-run` 在默认配置下分档与上线前一致,并输出两套口径对照;日志行含 `visit2_rate_new_assign/rate_version`。
8. `tcm:backfill-inherit-visit-slot --verify` 漂移为 0;`AssignLogPanel` 显示「继承时第 k 诊」与下钻一致。
9. `RevisitRateInheritSlotTest`、`RevisitRateContractTest`、`AiMcpUnitTest` 通过;`generated.php` 重生成。
10. 当月仅有继承行的非二中心医助不出现在 overview/assignLines;仅有继承行的二中心医助出现且 `assigned_count=0、inherit_count≥1`。
### 需求 2
1. 诊单 3 笔有效订单(第 2 笔已退款)→ 「第 2 诊」,最新有效订单为第 3 笔;列表「第 N 诊」= 业务订单 tab `global_visit_seq` 最大值 = 接诊率该诊单最后一笔 slot。
2. `medication_days=NULL`、处方 `usage_days=14`(非系统代开)→ 天数 14、`days_src=2`;处方为系统代开 → 「缺开药天数」且无 due_date。
3. 有签收 → `anchor_src=1`,due = 签收日 + 天数;仅人工发货日志 → 2;都无 → 3 并标注「按下单日估算」。
4. 新建业务订单后刷新列表,节点即刻切到新周期(钩子/自愈),不等 cron。
### 需求 3
1. §1.3 示例全路径(剩 N 天 → 今日应挂号 → 超期 7 天深红 → 已约 → 过号 → 次日超期且 `missed_count=1`)行色与 chip 正确;患者在到期日前挂号并过号 → 当天红「过号」并进弹窗(不受 due_date 限制)。
2. 医生点「完成接诊」→ 绿「已面诊」;之后新订单 → 新周期灰「剩 N 天」。
3. 顶部「复诊到期 N」角标 = 筛选后列表 `count`(lists/count 双写一致);医助只看本人;组长按 DataScope;待分配宽搜下新筛选/排序被跳过。
4. 排序「复诊节点」升序 → 超期最多在前,NULL 在后;与「未服务天数」排序互斥。
5. 列表 P95 响应增幅 ≤150ms;`tcm:revisit-due-scan --dry-run --diagnosis-id=X` 输出与页面一致;命令抛 DB 异常时 `dev_crontab.status` 不被置 ERROR;`onAppointmentStatusCronDone` 抛异常时 `update_appointment_status` 任务同样不被置 ERROR。
6. 业务订单 tab 修改诊单偏移量后,刷新列表「第 N 诊」即时更新(钩子/自愈),与业务订单 tab `global_visit_seq` 一致。
### 需求 4
1. 医助 A 有 3 位超期患者 → 登录 2 秒内右下角一张汇总卡,按超期天数降序;主管 B 不弹;`toast_enabled=false` 时 `pending` 返回空。
2. 点「延迟」→ 项消失,`snooze_count=1`、`next_remind_at ≈ now+4h`(钳入 09:00~20:00,20:30 点则次日 09:00);再弹时标题变橙「已延迟 1 次」;第 3 次红色不可最小化;第 5 次居中模态仅三按钮。
3. 点「已联系」填 10-15 → 项消失、列表 chip「已联系·约 10-15」、`snooze_count` 不变;第 3 次已联系按延迟计;10-15 09:00 仍无挂号再弹。
4. 点「去挂号」→ 跳诊单页自动打开挂号弹窗;挂号成功后卡片与红行同时消失(`closed_state=2`)。
5. 两个标签页同时打开仅一处可见新项;一处延迟另一处同步消失;重复点击 `snooze` 不双计;另一浏览器挂号成功后,本浏览器下一轮轮询卡片消失。
6. 诊单改派给 C → A 不再弹、C 立即弹且 `snooze_count=0`,流水 event=9。
7. 非医助账号(无 `tcm.diagnosis/lists`)调 `pending` 返回权限不足;医助 B 对 A 的行调 `snooze` 失败。
8. `wecom_enabled=true` 时 level≥3 行每日 ≤2 条企微,未绑定账号记录不中断。