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

88 KiB
Raw Blame History

二中心医助复诊跟进体系方案:诊次接诊率 / 患者触达节点 / 到期标红 / 到期弹窗与延迟升级

日期: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
方案 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)
首次触达日 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)

-- 诊次序号 / 每诊单最新有效订单 的批量查询覆盖索引(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)

-- 复诊到期快照:每诊单一行(当前周期 = 最新有效业务订单)。
-- 写入: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)

-- 复诊到期事件流水:供审计与「越点越上心」统计(主管视图可按延迟/升级次数排序)。
-- 注意:弹窗轮询不写流水(只更新 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)

-- 依赖:先执行 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)

-- 复诊到期提醒接口按钮权限,挂在「中医诊单」(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)

-- 继承指派时患者所处诊次快照(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)

-- 自动分配日志同时记录两套口径的二诊率,便于回溯分档依据
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)同风格)

'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
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
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
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
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)
既有 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,预计数千数万诊单 15 分钟,需在测试库压测;可先 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 条企微,未绑定账号记录不中断。