# 二中心医助复诊跟进体系方案:诊次接诊率 / 患者触达节点 / 到期标红 / 到期弹窗与延迟升级 日期: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`(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/`,权限串 `tcm.revisitDue/`): | 方法 | 接口 | 入参 | 行为 | |---|---|---|---| | 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