1. 02 Aug, 2026 7 commits
    • feat(plan): 召回分配 P0 地基 —— 权限/退回原因/引擎记账/写路径边界 · 3cbb3899
      分配功能开工前的前置修复。这一刀**不含任何新功能**,但把四个
      「不修就会静默出错」的洞补上 —— 分配一上线它们会同时被放大。
      
      ## P0.1 新增 PLAN_DISPATCH 权限(主管判据)
      
      PLAN_ASSIGN 连 staff 都有(自助认领语义),STATS_VIEW 是零端点零组件的
      死权限 —— 现有权限没一条能区分主管,新立一条。只授 leader + admin。
      
      ️ 顺带修了 R7,且**原方案不够**:规划只说前端 auth-store 补 merge
      permissions,但 GET /auth/session 是把 JWT 里那份快照原样回传的,
      merge 了也还是旧清单。真正的口径是 —— **ROLE_PERMISSIONS 是真理源,
      JWT 只是缓存**,三处统一改成按 role 现算:
        · PermissionsGuard   (后端判定)
        · GET /auth/session  (前端拿新权限)
        · McpAuthService     (工具条件注册,少一个工具模型只会说"我没这能力")
      不改的话,发版当天所有已登录的 leader 在 token 到期前都是 staff 待遇,
      不报错不告警。已用伪造的「发版前 token」实测:JWT 里没有 plan:dispatch,
      session 仍返回 true。
      
      ## P0.2 ReleaseReason(8 值)+ PlanEventReason
      
      退回原因结构化,每个值绑一根**主管可调的杠杆**(lever)——
      否则原因分布只是一张好看的饼图。
      
       类型里**不给 suppressDays 字段**:照抄放弃原因那套抑制窗会把被退回的
      患者静默压 30~90 天,池子里凭空少一批人。用类型系统拦住,再加运行时断言。
       不复用 RECALL_FEEDBACK_OPTIONS 的 bad_timing:同名不同义是统计事故的
      标准配方(那个指"召回时机不对",这里会被读成"时效太紧")。
      
      PlanEventReason 给 plan_event_logs.reason 列做登记 —— 该列即将同时承载
      系统原因与 8 个退回原因,不登记就是第二个「随手写字符串」的地方。
      
      ## P0.5 引擎丢归属补记账 —— 实测是 4 处,规划里漏了最大的那处
      
      规划列的是单刷路径 closeStaleActivePlan,但**每日全量跑的批量收尾**
      (runAllForHost 的 updateMany)才是量级最大的:它同样会关掉 assigned 的单,
      同样零记账。补完四处:
        · unchanged 分支的诊所重归属        → reason=clinic_moved
        · 升版本 clinicMoved 不继承归属     → reason=clinic_moved(planId 记**旧版本**)
        · closeStaleActivePlan(单刷)      → reason=signals_cleared
        · runAllForHost 批量收尾(全量)    → reason=signals_cleared
      
      ️ 判定必须在事务**之前**定好:supersede 那句 update 之后 latest.status
      已经是 superseded,进了事务再读条件当场失效(内存 mock 暴露了这个别名陷阱,
      真 Prisma 返回脱离副本看不出来)。
      ️ 批量路径顺带修了一个既有隐患:原来是一条 `id: { in: staleIds }`,
      PG bind 变量上限 32767,池子上三万条就直接报错。改成 1000 一片、每片自成事务。
      ️ 无人认领的关闭**一条事件都不写**,否则每日全量会造事件洪峰;
      真有洪峰时打一行 warn 说明"这不是 bug"。
      
      ## P0.6 recycle 收退回原因 —— T7 此前根本落不了地
      
      controller 写的是 `@Body() _dto`,下划线,收了就扔。客服填了等于没填。
      链路三处打通(schema → controller → service),原因落 plan_event_logs.reason、
      说明落 details。other 不带说明**服务端**拒绝(不能只信前端)。
       全程不动 snoozedUntil,并在 update 处写死注释 + 单测钉住。
      
      ## P0.7 assign/recycle 补诊所硬边界(F4)
      
      findFirst 只校验 host/tenant/sourceUnit,没有 targetClinicId ——
      A 诊所 leader 拿到 B 诊所的 planId 就能跨诊所写入。他**看不到**那些单
      (读路径的 buildListWhere 明确挡着),但写入不受挡:隔离在写路径上比读路径
      弱一档。批量分配会把它从「知道 planId 才能利用」放大成「有 UI、一次几百条」。
      
      ## P0.3 / P0.4 / F3
      
      · 删 5 个未挂载的死文件(1,524 行),顺手改 3 处指向它们的过期注释
      · MCP toolsCache 从单例改成按**能力指纹**分桶 —— 这是工具条件注册(P3.5)的
        安全前置:不分桶会串号,进程重启后第一个进来的若是客服,全公司主管都拿
        客服清单,反之更糟,且两种错法都不报错。企微那条合成身份路径显式走 basic 桶。
      · schema.prisma 的 recycle_at 注释指向不存在的 tenant.rules_config,删掉
      
      ## 验证
      
      · 846 单测通过(新增 23 条断言:权限红线 / 抑制窗红线 / 四处记账 / 边界)
      · 本地真实数据(5,825 患者 / 2,724 plan)端到端实测:
          跨诊所 assign → 10004 not found;同诊所 → ok
          other 缺说明 → 10001 拒;非法枚举 → 10002 拒
          退回落库 event=release reason=over_capacity held=12s note 在 details
          plan 回到 active,snoozed_until 未被动
      · 文档两处实测更正:迁移是 37 个不是 38;data/jvs-dw/users.json 存在且已入库
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(design): 三处裁决 + 阶段性开放规划 + 开发起点 · f1b0b4e8
      ## 三处裁决(产品 2026-08-02)
      
      1) MCP 写工具:**最终必须实现,但分期** —— 原规划 D-4 建议「v1 不做」,
         改为「v1 不注册、但设计不得堵死」。MCP 现为 @Public(PermissionsGuard 短路),
         护栏会退化成 handler 自查 + 模型自填布尔,故 v1 写路径走 REST;
         requirePermission / rejectSyntheticIdentity 两个 helper S1 就写好,S4 直接挂。
      
      2) 矩阵留第一刀 —— 规划建议「分两刀、矩阵推后」的理由**已证伪**:
         原称「温度轴需 persona 全量重算,挂钟不受人日控制」,生产实测两个轴数据全现成
         (X 轴 590,427 条特征 / Y 轴 566,365 条 signals),矩阵直接跑通 25 秒零重算。
         25 秒对交互不可接受 → 转为明确的性能任务(索引/物化),不是排期依赖。
      
      3) 撤销判据以 view 事件为主 —— 规划建议叠加 contactAttempts,实测该字段
         **与 plan_executions 同源、同为 7 条**,帮不上忙;可用信号只有 view(已 1,024 条)。
      
      ## 新增 T21:撤销 ≠ 退回
      
      撤销=主管收回整批,退回=客服退单条(必须带原因)。两者被混谈过,现分开定义。
      撤销的「已动过」判据不能用 plan_executions —— 那是执行阶段产物而回写率仅 11%,
      客服打了电话没填表就会被当成「没动过」收走,那通电话永久蒸发。
      今天刚上的 PV/UV 埋点成了唯一可用信号,属意外收益。
      
      ## 新增两节(上下文压缩后的接续入口)
      
      - 七之二 阶段性开放规划:S1 闭环 → S2 完备 → S3 自优化 → S4 MCP 写 → S5 客服主动性,
        每阶段标「开放给谁 + 技术前提」;三条跨阶段硬约束(assign_strategy 必须 S1 立柱、
        写路径不得堵死 S4、S3 不能在 n<50 时提前)。
      - 七之三 开发起点:本地环境已就绪的清单、Gate 0 未闭合项、
        以及「第一件事不是写新功能而是修 F1-F5 前置洞」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(design): 召回分配设计教条 + 开发规划 —— 需求未定稿前不开工的沉淀 · bb019d32
      新开 docs/design/(与 docs/adr/ 平级):ADR 记「一个已定的架构决策」,
      design 记「一个还在长的产品教条集」。设计冻结后可整体提升成 ADR。
      
      ## plan-assignment-doctrine.md — 设计教条(21 条 T1-T20)
      
      讨论过程中逐条经产品确认才写入的教条,以及**为什么这么定**的依据。
      关键几条:
      - T1  分配是批次运营,不是工单派发 —— 从不追求把池子分完
      - T6a 初选 X 轴用画像的「潜在治疗」8 类,不用 focusCategory
            (后者会把 81 个早矫机会埋进 152 个正畸里,而两者话术/沟通对象完全不同)
      - T6  温度 = 该治疗项目自身的临床周期(黄金/周期内/超周期),各用自己尺度归一化后横向可比
      - T13 全景确认单直出不追问,意图由助手推导;只微调「指定客服 / 时效」两项
      - T14 助手不出没有证据的结果 —— 缺数据就标默认值,有数据后反推替换
      - T17 保守立柱:会用来筛的才立柱,其余进 JSON(沿用 plan_event_logs.details 已立的口径)
      - T18 通用表不得业务化(曾提议给 plan_event_logs 加 assignment_id,已撤回)
      - T19 权限由 permission 控制,role 只给人看
      - T20 跟踪的目的是自优化,不是找对照组(认领将隐藏 → 没有对照组)
      
      第四节「关键数据事实」把生产实测数字钉住,避免后人重新推导 ——
      含为什么温度选临床窗口而非 RFM/生命周期(三者分布对比)、专属客服 83.5% 覆盖
      但在岗仅 30-39 人、task_date 有 2033/2121 年脏数据等。
      
      ## plan-assignment-dev-plan.md — 开发规划
      
      四层并行方案 → 双路对抗批判 → 汇总(7 agent)。含 15 条跨层契约裁决、
      Gate 0、前置修复、六阶段计划(MVS ≈ 19.25 人日)、风险登记、验证与回滚策略。
      
      批判环节抓出四份分层方案**都漏掉**的五条,已同步回教条 §4.36:
      - assign_strategy 必须立柱 —— dedicatedCs 是 upsert 覆盖的当前值,
        分配当时不记就永久没了,事后反推不出来
      - 撤销判据不能只看 plan_executions —— 回写率仅 11%,会把已打过电话的单静默收走
      - 归因列须无条件继承,与 carryAssignment 解耦 —— 退回后 plan 是 active,
        走不到那个分支,归因会连分子带分母静默归零
      - 「引擎丢归属不记账」实测是 5 处不是 1 处
      - assign/recycle 不校验 scope.clinicIds;plan_event_logs 不在 SCOPED_MODELS
      
      G0.1(go/no-go)已实测闭合:JWT.sub 与 task_director_id 重合 47/58 = 81%,
      同一 id 空间成立。但 11 个只在登录侧的回访数全为 0 → 新入职/只做召回不做回访的
      客服名册里查不到,故名册之外仍须允许主管显式指定。
      
      ## 两处规划与教条冲突,已列入待产品裁决
      
      - MCP 是否注册写工具(教条定 A 方案走 MCP;规划因 @Public 短路建议写路径只走 REST)
      - 矩阵是否放第一刀(取舍表定 v1;规划建议分两刀,因温度轴需 persona 全量重算)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(monitor): push 断流告警加显式开关 —— FRIDAY 联调期关掉,别把真告警淹了 · 5aef6252
      现象:FRIDAY 每小时报一次 [PAC CRITICAL] push 断流(28.9h / 29.9h 无推送),
      但它根本还没正式上线推送。
      
      根因在触发条件 —— 监控只跳过"从未推过"的宿主(!lastPush → continue),
      而 FRIDAY 联调期推过几次就停了,于是被当成"已上线、现在挂了"。
      "推几次就停"恰恰是联调的正常节奏,不是数据在漏。
      
      加 monitoring.push_lag_alert(默认 true):
        - FRIDAY manifest 置 false,并写明**正式上线推送后删掉该段即恢复**
        - 不配 → true,既有宿主行为不变,不会静默失去监控
        - 命中时打一行 log( 已按 manifest 关闭),不是无声跳过
      
      【为什么不用"把阈值调大"】那样语义是"容忍 10 万小时不推",读的人分不出是故意关掉
      还是填错了;显式布尔把意图留在 yaml 里,上线时删一行即可。
      
      测试 818 项(+3)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 宿主纯日期按当地零点解释 —— 直通会被 JS 当成 UTC 零点,整整偏 8 小时 · 45498176
      normalizeDatetime 旧版对纯日期('2026-05-10')直通不补 offset,下游 new Date() 按 ISO
      规范解析成 **UTC 零点**;而宿主给的是**当地日期**(jvs-dw 的 DW 是 Asia/Shanghai):
          DW created_date = 2022-09-30(北京)
            旧:2022-09-30T00:00:00Z = 北京 09-30 08:00  
            新:2022-09-29T16:00:00Z = 北京 09-30 00:00  
      
      【怎么发现的】回访补 source_created_at 时,手写回填(按 +08:00)与摄入路径写出的值
      差整 8 小时。查下去发现不是新字段的问题,是这条既有路径 —— 测试服实测
      diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(占 6%),正是它;
      落在 UTC 16:00(正确形态)的只有 5 条。
      
      【边界:为什么不怕补 offset 导致跨日】本函数只作用于 CanonicalResourceMeta.datetimeFields
      声明的字段,那些目标列全是 timestamptz。真正的纯日期列(patient_return_visit.taskDate /
      patient.birthDate)刻意不在该清单里,走各自 new Date() 直解 —— 对它们补 offset 会让 PG
      取 UTC 日期时退一天。新增字段按"目标列类型"归类,不能凭字段名像不像时间。
      
      【存量】老数据不会自动修正(需 reparse,会触发一次性 fact 版本波)。偏差是 8 小时、
      日期级判断基本不受影响,故不随本次修 —— 但新摄入的数据从此正确。
      ️ 若不修,回访刚回填好的正确值会被下一轮增量覆盖成错的。
      
      测试 815 项(+7),含"旧行为偏 8 小时"的固化回归。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 01 Aug, 2026 14 commits
    • feat(sync): 回访再补宿主侧创建/更新时间 —— 名册时间窗不能用 task_date · 2516596f
      DW fact_returnvisit_out 有 created_date / updated_date(均非空),之前没摄。
      
      【为什么不复用本表已有的 created_at / updated_at】那两个是 **PAC 入库时间**
      (@default(now()) / @updatedAt),存量回填和每次重摄都会把它们刷成"现在",
      反映不了业务时间。命名沿用 patient_facts.source_updated_at 的既有口径(source* = 宿主侧)。
      
      【为什么名册需要它】task_date 含**未来排程** —— 生产实测最远到 2033-11-12。
      名册按"近 N 月在岗"筛人时若拿 task_date 卡窗口,会把"排了远期任务但早已不干活"的人
      算成在岗。用 created_date(任务何时被创建)才是真实的行为时间。
      
      datetimeFields 注册这两个字段(走无时区补 offset 归一);taskDate 刻意不进 ——
      它是 @db.Date 纯日期,补 offset 会跨日。
      
      顺带修正上一条 commit 的措辞:DW 给 task_director 的 comment 写的是"专属客服",
      但实测近 12 月 367 万对回访,它与患者主档 current_task_director 仅 **23.1%** 相同 ——
      本字段是"该任务当时派给谁",患者主档那个是"此刻挂在谁名下",确实是两回事。
      
      测试 808 项(+2)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 回访摄入带上执行客服 —— 按诊所反推客服名册供主管指派 · e3d4b3ef
      主管指派工单要选人,而 host **不提供「客服归属诊所」字段**,只能按行为反推
      「该诊所近 N 月有过回访记录的客服」。DW fact_returnvisit_out 一直带 task_director_id/name,
      PAC 侧此前没映射 —— 花名册只能靠离线快照 data/jvs-dw/users.json(已陈旧、无刷新机制,
      之前排查生产操作人时 10 个 id 有 8 个查不到姓名就是这个原因)。
      
      【与「专属客服」是两回事,刻意不合并】
        patients.preferences.dedicatedCs  ← fact_client_out.current_task_director(患者挂谁名下)
        patient_return_visits.task_director_* ← 本次回访是谁做的
      两者可以是不同人;口径差 2.7 倍(回访表 5,110 个 distinct 客服 vs 患者表 1,865 个)——
      回访是操作留痕,覆盖更全,97.2% 的专属客服在此出现过。合并成一列会让名册少掉大半人。
      
      改动(三层,通用代码零改动):
        - manifest query 补 SELECT 两列(漏了则映射静默失效 → 落 null)
        - assembler 映射 taskDirectorId / taskDirectorName
        - canonical 加两字段(id 用 z.coerce.string:host 是 Int64,PAC 的 external 标识一律字符串)
        - schema + migration:可空两列(166.7 万存量加列不重写表)+ 名册复合索引
          (host, tenant, clinic, task_director, task_date desc) —— 单列不够,clinic 基数仅 64
        - upsert 落库经 emptyToNull:空串 / "0" 归 null,否则名册会多出假的"无名客服"分组
      
      【存量怎么补(本次不含)】reparse **无效** —— 回访是 upsert 资源、不进 transaction,
      没有 rawPayload 可重放;整表重摄又会连带重摄这批患者的病历/结算/预约(2026-08-01 实测
      那条路把测试服磁盘写满)。存量走一次性 DW 回填:按 external_id 批量 UPDATE 两列。
      
      测试 806 项(+7),锁住"两处客服字段不互相挪用"与源 query 必须选这两列。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 定向名单支持命名空间维 —— 患者主键只在命名空间内唯一 · 82d782dd
      测试服实测:7 万个纯 id 的定向名单列出 **140,566** 个 cohort key,整整翻倍 ——
      cohort key 是 (patient_key, tenant_key) 复合键,而 PAC_COHORT_ONLY_PATIENT 只承载
      patient_key,于是每个 id 在两个命名空间下各命中一次,一半是无关的同号患者:
      白摄一倍数据、批次翻倍。结果不错(另一命名空间的患者算出 false,不会误标),但纯属浪费。
      
       通用性:这一维**不叫 brand**。整条 cohort 链路(CohortKey / tenant_key_column /
      injectCohortFilter)本来就只认 manifest 声明的列名,代码里不出现宿主字样 —— jvs-dw 恰好
      填的是 brand,别的宿主可能是区域 / 诊所 / 不设。漏的只有 ONLY_PATIENT 这个运维参数,
      它返回 string[] 把第二维丢了。
      
      改:新增 resolveOnlyPatientKeys() → OnlyPatientKey{key, tenant?},名单每项支持
        `1855960`         纯 key(单命名空间宿主 / 该 id 在所有命名空间下都要)—— 行为不变
        `261067|瑞尔`      显式分隔
        `261067<TAB>瑞尔`  TSV(SQL dump 可直接喂)
      全部带命名空间且宿主配了 tenant_key_column → 拼复合键 IN;否则退回单键 IN,
      混写(只有部分带)不做部分匹配,warn 一次后整体退回 —— 半精确比全模糊更难排查。
      分片路径同步支持三种起手形态。resolveOnlyPatientIds() 保留为 key 投影,旧调用点
      (cold-import 判"是否定向模式 → 不推进游标")零改动。
      
      测试 799 项(+7)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 定向名单分片查询 —— 7 万 id 撑爆 ClickHouse max_query_size · c7d5b4c4
      测试服定向补数(PAC_COHORT_ONLY_PATIENT=@file,70,283 个 id)实跑 fatal:
        Syntax error: failed at position 262142
      262142 = 256 KiB,ClickHouse max_query_size 默认上限。7 万 id 拼 IN (...) 约 630KB,
      超 2.4 倍;重试 3 次全败,patients upserted: 0(没写坏任何数据)。
      
      ️ 这与 resolveOnlyPatientIds 的 `@file` 是**两个不同的上限**,之前混为一谈了:
        · @file 解的是环境变量 128KB(E2BIG)—— 传参侧
        · 本次是 SQL 文本长度 —— 服务端解析侧
      文件读进来了,SQL 照样超。注释里"大名单走文件"的承诺此前并不成立。
      
      改为按 10k 分片跑(≈90KB/片,离上限有充足余量),其余条件(cursor / clinics / union 分支)
      每片原样带上,结果用 Map 按 (key, tenant) 去重合并 —— 与单条 SQL 的结果集等价。
      名单未超阈值时仍走单条 SQL,行为不变。
      
      分片时不套 orderTail 的 LIMIT:PAC_COHORT_LIMIT 采样与分片叠加会"每片各取 N"而超量,
      且定向重摄本就是显式点名,不该再被采样截断。
      
      补 3 项回归(含"7 万单条必超上限"的反证),共 18 项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(sync): 更正增量感知的机制描述 —— cohort 模式走 UNION 分支,不走反向拉 · 835edcc9
      前两次 commit 把机制写成"靠 reverse_pull_from 反向拉主档"。测试服实测日志里
      cohort 模式**没有**出现反向拉那一行:reversePullPatientMaster 只在 loadAllTables
      (single-shot)里调用,日常增量走 loadTablesForCohort,不经过它。
      
      真正生效的是 listPatientPairs 把每张「配了 cursor 且有水位」的表拼成 UNION 分支来列患者
      —— 也就是 incremental.per_query 里给 fact_complex_cases_out 配的 changed_at。
      reverse_pull_from 的声明仍保留(single-shot 路径要用,且把硬编码挪进 yaml 本身是目的),
      但注释已写明它在 cohort 模式下不参与。
      
      同时补一条上线须知:**新表首轮不生效** —— 无历史水位 → cursorValue 为空 → 该分支被跳过,
      第一轮只建水位,第二轮起才感知变化。实测第一轮 UNION 是 6 张、第二轮才 7 张。
      
      最终验证(第三轮增量,10153aab 之后):6 个"末次就诊在 2025-09 ~ 2026-04、但复杂病例刚变"
      的患者全部写上 host_follow_up_active=t,时间戳与该轮一致 —— 主档 cursor 不可能够到他们,
      只能是复杂病例表的变化带进来的。全库 t=1164 / f=14553 / null=323576。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): cohort 模式下患者主档不注入 cursor —— 被别的表带进来的患者主档拉不到 · 8d21b26f
      测试服实测(第二轮增量):cohort 列出 15,107 人,其中「复杂病例变了但人没来诊」的
      19 人确实被 UNION 分支正确带进了 cohort,但他们的 host_follow_up_active 始终没更新。
      
      根因在 loadTablesForCohort:主档 query 同时被注入 cursor 和 cohort 过滤 →
        WHERE last_visit_time > '2026-07-30 09:36' AND (patient_id,brand) IN (<本批 tuples>)
      这些人末次就诊在几个月前 → 被 cursor 挡掉 → 主档整轮拉不到 → 不 upsert。
      batch 日志早有征兆:cohort 726 人,fact_client_out 只回 675 行。
      
      cohort 已经是比 cursor 更强的限定(它就是"本轮要处理哪些患者"的答案),
      主档再叠 cursor 是多余且有害的。非 cohort 模式靠 reversePullPatientMaster 兜这个场景,
      cohort 模式此前无兜底 —— 与其再补一次反向拉,不如从源头去掉这个条件。
      
      ️ 这是**既有缺陷**,不是本次接入引入的:任何"事实变了但 last_visit_time 没变"的患者
      (EMR 补写、预约改期),其主档在 cohort 模式下本来就整轮不更新。此前只表现为主档字段
      偶尔陈旧、被 stub 兜住不易察觉;到了派生列(主档不拉 = 列不重算)才致命。
      
      cursorAdvances 不受影响:bundle 水位写的是 run_start baseline,不取 max。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 增量列患者的 UNION 分支包裹 manifest query —— 别名列 + 顶层表名 · 4cfecec8
      测试服 dry-run 实测翻车(未落库,水位未污染):
        Missing columns: 'last_visit_time' 'patient_id' while processing query:
        'SELECT patient_id, brand FROM dw_group.fact_complex_cases_out WHERE last_visit_time > …'
      
      listPatientPairs 构造 UNION 分支时是第三处朴素正则 `/FROM\s+([\w.]+)/`:
        ① 主档 query 现在带 SELECT 列子查询 → 抓到子查询的 FROM,表名取成 fact_complex_cases_out,
           cursor 列却仍是主档的 last_visit_time,两个错叠在一起
        ② 分支直查物理表 → 读不到 query 里的 `customer_id AS patient_id` 别名(该表物理列名是
           customer_id),patient_id 根本不存在
      
      改为**包裹 manifest 的 query**(复用已按顶层 FROM 解析的 injectIncrementalCursor):
        SELECT <keys> FROM (SELECT … AS patient_id … FROM <表> WHERE <cursor> AND <业务过滤>)
      别名在子查询里成立;顺带继承该 query 的业务过滤,与真实拉取同口径 —— 否则"游标之后但
      会被业务条件过滤掉"的行会把无关患者拖进 cohort(同 extractBusinessFilters 那次的教训)。
      解析不了则回退旧形态(表名改用顶层解析),不比改动前差。
      
      补 2 项回归锁这两条,共 21 项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): jvs-dw 接上宿主跟进闸 —— DW「潜在治疗池」的患者不进召回 · 257ba23b
      DW 新表 fact_complex_cases_out(comment 原文「潜在治疗ID」)= FRIDAY 侧
      complex_case_info「复杂病例」:宿主自己在跟的患者,PAC 不该再发起电话召回。
      落点复用 FRIDAY 那次已建好的 patient_profiles.host_follow_up_active,
      canonical / 副表 / 召回 SQL 一行未动 —— 本次全部改动落在 yaml + 通用引擎。
      
      口径与 FRIDAY 对齐(实测 113,657 行 / 88,300 患者):
        未删除(is_del=1;98.8% 为 1,反直觉但两家源库一致)
        且 case_stage ∈ 1待跟进/2已咨询/3已预约/5诊疗中 → 在跟(70,275 患者)
        6已成单 / 7已丢单 = 宿主停手 → 交还召回
      
      【判定为什么内联进主档 SQL,而不是 lookup / join】
      FRIDAY 的演进方向是「宿主侧 join,PAC 侧不 lookup」(其 manifest 四处注明),
      jvs-dw 是 pull、SQL 由 PAC 自己写,故用纪律允许的 `IN (SELECT …)` 形状,
      且 tuple 版天然带 brand —— 集团内同号跨品牌是两个人,单键会误标瑞泰同号患者。
      更关键的是这样判定是**实时全量**的:每次拉到主档都重算,不依赖增量窗口。
      若改成"拉变化的病例再 join",窗口外的人会 lookup 未命中 → full upsert 落 null
      → 正在跟进的患者被静默洗回召回池。
      
      【通用层两处改动 —— 都是既有脆弱性,被这个 SQL 形状第一次触发】
      1. SQL 改写器改按括号深度定位顶层关键字(新增 topLevelIndexOf / splitSelectFrom):
         主档 query 头一次出现 SELECT 列里的标量子查询,而
           · injectIncrementalCursor 的 /SELECT (.+?) FROM (\w+)/ 非贪婪会撞上子查询的 FROM
             → 切出半截列表 + 错误表名,增量拉错表
           · extractBusinessFilters 抓第一个 WHERE → 把子查询的 is_del=1 当成主档业务过滤
             搬到外层 → 主档无该列 → CH 报错(增量空转探针一并错)
         两者都是静默错法,故补 13 项测试锁死。
      2. 反向拉主档:表名从 manifest.cohort.reverse_pull_from 读(不配 → 历史四张默认,
         行为不变),键列/主档表名同步改读 cohort 配置。原先四张 jvs-dw 表名硬编码在通用
         代码里,新增一张就要改 service —— 与「yaml 是宿主唯一差异」相悖。
          同时修一处真 bug:反向拉写死 `SELECT *`,会丢掉主档 query 的派生列 ——
           该列缺席 → canonical 无此键 → full 分支落 null → 在跟患者被反向拉这步洗回池。
           改为复用主档 query 的 SELECT 列。
      
      【增量怎么感知】主档 cursor 是 last_visit_time,人不来诊就拉不到 → 病例开/关 PAC 不知道。
      故把该表作为独立 query 拉(不产 fact、无 assembler),仅为把变化的患者带进 cohort,
      再由反向拉补出主档、重算派生列。cursor 用 SELECT 别名 changed_at =
      coalesce(updated_gmt_at, created_gmt_at):updated 有 44% 为 NULL(建后没改过,
      恰是刚入池的新病例),直接当 cursor 会 `NULL > x` 恒 UNKNOWN 静默漏掉这批。
      
      测试服 DW 实跑验证:主档/增量两条改写后的 SQL 均可执行,标记数 70,275 与直接统计一致。
      790 tests / 51 suites 全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(monitor): DW 滞后告警只管 pull 宿主 —— push 宿主不再被误报 · e83d0ac7
      测试服务器每小时告警一次「friday DW 数据滞后 37 小时」,是误报:
      friday 是 **push 宿主**,数据由宿主主动推,sync_logs 的 cursor_before/after
      按设计恒为 null,根本没有游标可推进。它之所以有游标,是历史上误跑过一次增量、
      留下一条 fetched=0 的 incremental_bundle 记录,游标就永久停在 2026-07-30T10:00Z。
      
      而同期 friday 正常 push 了 614 批 10.79 万行(含新接的跟进闸字段),数据流毫无问题
      —— 拿"游标多久没推进"衡量 push 宿主,是**指标本身用错了**。
      
      危害不只是烦人:该告警**永不自愈**(游标不可能再推进),每小时响一次,
      最终把真告警淹掉 —— 一个永远在响的黄灯,看的人很快就不看了。
      
      改法:遍历宿主时按 manifest 是否声明 sql_source 短路。
        - 判据选 sql_source 而非 auto_sync:前者与 files **二选一**(manifest.schema 原话),
          是"数据从哪来"的定义即摄入模式本身;auto_sync 只是"要不要自动跑",可临时关,
          跟模式是两回事。
        - 短路放在「还没跑过增量(无 cursor)」那条 warn **之前** —— 否则 push 宿主
          只是换个姿势继续刷日志。
        - getHostOpsConfig 读不到 manifest 时 hasSqlSource 兜底 false:宁可不报,
          也不要对着未知宿主刷告警。
      
      回归闸 tests/dw-lag-monitor-scope.spec.ts(5 项),已验证去掉守卫会报 2 项失败。
      
      注:push 宿主的健康度应看「距上次成功 push 的时长」,是当前的监控盲区
      (FRIDAY 断推三天也不会有任何告警),本次按要求不做,单独评估。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 接上 FRIDAY 跟进闸映射 + 脏值不拖垮患者主档 · 6693bc11
      按「文档即契约」推进,不再等宿主口头确认:契约已写明 has_active_complex_case,
      PAC 侧按此接好映射;宿主未推该列时 canonical 无此键 → 副表不写 → 闸保持未启用,
      行为与接入前完全一致,所以提前接是零风险的。
      
      【is_del 悬案已查源库坐实,不必再问宿主】
      complex_case_info.is_del 注释「未删除1/已删除0」——**反直觉但是对的**,is_del=1 才是未删除:
        - 同注释的 complex_potential_demand 7 行全为 1(全存活,且被 30 行明细引用),
          若 1=已删则全表皆删,不合理
        - complex_case_info 47:8 ≈ 85% 存活是正常比例,反过来不是
      之前之所以看着可疑,是因为**该库同时存在两种相反惯例**(customer_gift 是「未删除0/已删除1」)
      → 取数必须逐表看注释,不能套惯例。结论已写进 yaml 注释与契约文档。
      
      【防护:脏值降级,不牵连主数据】
      canonical hostFollowUpActive 加 .catch(null)。该字段是**可选的召回闸信号**,
      而 patient 是**主数据**:若宿主推来无法识别的值(如 "Y2"/"待定"),不加 catch 会让
      整条患者主档被 zod 拒收 —— 姓名/电话/生日全丢,为一个 nice-to-have 信号赔上主数据,
      代价完全不成比例。脏值降级成 null(= 信号未提供 → 不启用闸),失败方向朝「照常召回」,
      而不是「静默把人挡在池外」。0/1/true/false/y/n 等常见写法仍由 booleanFields 先行 coerce。
      
      测试增至 21 项:新增映射存在断言 + 脏值不拖垮主档(校验 name 仍完好)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 宿主跟进闸 —— 宿主正在跟进的患者不进召回池 · da9c80c8
      宿主自己已建复杂病例 / 工单、客服或咨询师在推进的患者,PAC 不再发起召回:
      两边同时联系同一个人,患者体验差,也显得两个系统各说各话。
      
      落点 patient_profiles.host_follow_up_active(canonical hostFollowUpActive),
      与 do_not_contact / deceased 并列在召回②合规硬过滤,但性质不同:
        合规闸 = 法务/风险,永久,需人工解除
        跟进闸 = 协作分工,临时,宿主停手下次推主档即回池
      
      【本次最关键的决定:三态而非两态】
        null  = 宿主未提供该信号(jvs-dw 未接入)→ 不启用该闸
        false = 宿主明确说"没在跟"
        true  = 正在跟 → 排除
      由此三条纪律,全部有测试锁住:
        1. 列可空且**无 @default** —— 给默认值等于把存量 38 万患者一次性断言成某种状态,
           且将来分不出"没接入"和"接了但没跟"
        2. SQL 一律 `IS NOT TRUE`,**绝不能** `= false` —— 后者遇 NULL 恒 UNKNOWN,
           会把未接入宿主的患者全部静默挡在池外(不报错、不留痕,只表现为池子空了)
        3. upsert full 分支不能 `?? false`(那是替宿主表态);partial 分支未提供则不进 update 集合
      
      改动:
        - canonical: hostFollowUpActive(可空无默认)+ 挂进 patient.booleanFields,自动吃 0/1
        - prisma: 副表加列 + 索引;migration 加可空列不重写表,存量行为不变
        - scenario SQL: 入池闸 + 注释框图同步
        - recall-debug: compliance 增补该项,否则排查时会显示"合规通过"却查不到人
        - 测试 host-follow-up-gate.spec.ts(18 项)
      
      FRIDAY 侧对应 has_active_complex_case,assembler 映射待宿主确认列名与 is_del 语义后再接,
      故本次**未接 FRIDAY 映射** —— 该闸对所有宿主当前均为 NULL,行为与上线前完全一致。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(recall): 宿主跟进闸改推布尔 —— has_active_complex_case,不要条数 · 021414b9
      上一版定的是 active_complex_case_count(计数),多余:
        - 闸门只判有无,条数 PAC 不消费,纯属未被消费的精度
        - complex_case_info 带 organization_id,跨诊所累加的条数在多品牌 SaaS 里语义含糊;
          EXISTS 反而是干净、且宿主易保证正确的口径
      改为 has_active_complex_case(接受 1/0 或 true/false,PAC 归一层 coerce 成布尔)。
      
      PAC 侧字段 hostFollowUpActive 不变(本就是布尔)。
      luoqi committed
    • docs(recall): 补「宿主跟进闸」契约 —— 宿主正在跟进的患者不进召回池 · b707b1c4
      宿主自己已建复杂病例、客服/咨询师在推进的患者,PAC 不该再发起召回:
      两边同时联系同一个人,患者体验差,也显得两个系统各说各话。
      
      【落点】patient_profiles.host_follow_up_active(canonical: hostFollowUpActive),
      与 do_not_contact / deceased 并列在②合规硬过滤,但性质不同:
        合规闸  = 法务/风险,永久,需人工解除
        跟进闸  = 协作分工,临时,宿主停止跟进即回池
      
      判定用 `IS NOT TRUE` 而非 `= false` —— 该列可空,NULL 表示宿主未提供该信号
      (如 jvs-dw 暂未接入),不能因 NULL != false 把这些患者全挡在池外。
      
      【FRIDAY 侧口径】customer_basic_info 新增 inline 列 active_complex_case_count =
      该患者未删除且 case_stage ∈ {待跟进,已咨询,已预约,诊疗中} 的 complex_case_info 条数;
      已成单(需求已闭环)/ 暂停跟进(宿主已停手)不计,交还 PAC 召回。
      
      为什么由宿主算而非整表推 complex_case_info:
        1. case_stage 的码→中文映射不在库里(column_comment 只写「病例阶段:」值为空),PAC 无从翻译
        2. 一患者可有多条病例,PAC 要的是患者粒度聚合结论
        3. 与既有 inline 纪律一致(同 contacts_tel / std_code / class_name)
      
      ️ 两处待 FRIDAY 确认,确认前不启用该闸:
        ① 列名 active_complex_case_count 是 PAC 建议名(源表无此列,属新定义);
           宿主若另有习惯叫法以宿主为准 —— 契约纪律是列名随宿主
        ② complex_case_info.is_del 注释写「未删除1/已删除0」与惯例相反,实际数据 0/1 都有,
           取数前须确认哪个值代表未删除,否则计数整体反掉
      
      顺手修一处已与线上代码矛盾的文档:customer_referee_circle.referee_relationship
      的方向说明还是旧的「本行 customer 是 referee 的 X」,而 a38f1b60 已按源库年龄实测
      改成同向直译。文档同步为「本行 referee 是 customer 的 X」。
      
      本次仅文档,无代码/schema 改动。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 30 Jul, 2026 14 commits
    • merge: fix/host-embed-popup-and-copy → main(本周第二批迭代) · dd6795c0
      ## 嵌宿主可用性(修一个已上线的回归)
      - 宿主槽位跳转三级兜底:新标签页 → 顶层跳转 → 本 frame。上一版只有一行 window.open,
        宿主 iframe 缺 allow-popups 时**点了没反应**(生产 actionUrls 全配着,路径可达)。
      - 一步 open 带 URL,不再"先开 about:blank 再导航" —— 那个写法在 sandbox 下会因
        「断了 opener 就无权导航该弹窗」抛 SecurityError。
      - 交付文档 docs/integration/postmessage-actions.mdx(可直接给宿主开发)。
      
      ## 宿主对接
      - 打开潜在治疗的 postMessage 补 desc / treatments / stage 三字段(treatments 发项目名,不带「治疗」后缀)。
      - 顶栏「回访」→「跟进」;去掉「已在新标签页打开」toast。
      
      ## 画像 / 详情页
      - 标签按业务字典 A/B/C/D 类区上色 + 定序(C→B→D→A),首屏与画像详情抽屉收成一套(删原三分组)。
      - 话术头部新增「关联客户」:亲戚姓名 / 关系 / 年龄 + 档案链接(配了 VIEW_PATIENT 跳宿主,否则回落 PAC 工单页)。
      - 亲戚关系方向按年龄实时纠正 —— 兜的是人工录错(瑞尔 4%),与 friday 摄入侧根治是两件事,两者都要。
      - 潜在治疗中文一律 code 查表,修「潜在补牙」vs「充填治疗」口径不一致。
      - 选患者列 / 详情左栏 300 → 320;召回池卡片去掉优先级五色点。
      
      ## 数据 / 权限
      - 「机会识别不准确」补必填多选:哪几类推荐治疗不准 → plan_executions.inaccurate_treatments
        (存 code / 仅统计 / 不参与抑制)。
      - 医生名单缓存 6h → 10min + 重算收尾主动清;客服可自助返池(带归属闸,只能退自己的)。
      luoqi committed
    • fix(friday): 患者关系边方向反了 —— enum_mapping 去掉多余的逆映射 · a38f1b60
      patient_relation.yaml 的注释断言「码 = 本人(customer)是对方(referee)的 X」(依据
      RecommendRelationshipEnum.reverseValue() 源码 + 早期年龄差推断),于是 enum_mapping
      整体取逆:码2 爸爸→child、码3 子女→father/mother、码5 爷爷→grandchild……
      数据把这个假设推翻了:码本来就是「对方是本人的 X」,与 PAC 契约同向,不该反转。
      
      【证据一:PAC 侧方向矛盾率】按「关系人比本人年长/年轻是否合理」判定,只算两边都建档
      且都有生日的边:
        friday   1187/1255 = 94.6%   ← 系统性反转
        jvs-dw    432/10842 =  4.0%   ← 人工零星录错的正常水平(其 yaml 是对的,不动)
      互反对更硬:motherchild 326 对里只有 8 对方向正确(2.5%);fatherchild 231 对里 9 对。
      实样:秦佳(47) --mother--> 韩秦瑜(21),同时 韩秦瑜(21) --child--> 秦佳(47)
      —— 两条边互相自洽但都与年龄矛盾,去掉逆映射两条就都对了。
      
      【证据二:源库独立复核(不经 PAC 摄入)】join customer_basic_info 两侧 birthday:
        码10 妈妈  关系人年长 419/430(97%),平均大 27.4 岁
        码 3 子女  本人年长   643/675(95%),平均小 26.8 岁
        码 2 爸爸  关系人年长 252/277(91%),平均大 26.1 岁
        码15 外公  关系人年长  20/22 (91%)  |  码11 奶奶 17/19(89%)  |  码16 外婆 15/17(88%)
        码17 外孙  本人年长    31/36 (86%)  |  码 6 孙辈 32/46(70%)
      方法学对照:对称码(1配偶 348/354、4兄弟 56/55、7朋友 265/264)全是 50/50,说明这个
      年龄判据本身干净,有方向码上的强烈偏斜不是判据偏差。
      剩余 3–5% 符合逆读法的边判为源侧人工录错(与 jvs-dw 的 4% 同量级),不为它们反转全局。
      
      顺手修掉一个静默 bug:旧版码3 只配了 "3|1"/"3|2",没有 "3|" —— 对方性别缺失的边
      (源库 15 条)会掉进 _default: other。本版每个码的 |1 / |2 / | 三个变体都列全。
      
      同向映射后性别不再参与判定(码2 自带 father、码10 自带 mother、码3→child 不分性别),
      transforms 里的 _referee_with_sex + push_fallback 成了死配置 —— 留待单独一次清理,
      不跟方向修正混在一起改。
      
      回归闸 tests/friday-relation-direction.spec.ts:不比对字符串字面量,而是用源库实测的
      辈分方向锁语义(长辈码不得映射成晚辈词,反之亦然)。已验证该闸对旧 yaml 报 21 项失败。
      
      ️ 未含数据修复:patient_relations 唯一键是 (patient_id, related_external_id,
      relationship),relationship 变了就是新行而非更新 —— 重摄只会在错边旁边新增对边。
      必须先删 friday 存量关系边再重摄。该步骤待部署后单独执行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 潜在治疗的中文一律 code 查表 —— 修详情页「潜在补牙」vs 卡片「充填治疗」 · d90ea308
      同一个患者两处口径不一致(测试服实证):
        召回池卡片   充填治疗   ← 拿 types=['filling'] 查 POTENTIAL_TREATMENT_CARD_LABEL
        详情页 chip   潜在补牙   ← 读 data.labels,那是**算画像那一刻烤进 JSON 的**旧措辞
      
      画像 JSON 长这样:{"types":["filling"],"labels":["潜在补牙"]} —— 措辞这几天改过三轮
      (潜在种植→种植治疗→种植),烤死的那份自然全是旧词,要改回来得全量重算画像(百万级、几小时)。
      labels.ts 里当初就写了这条预警,卡片按它改了,详情页那两处漏改。
      
      改:首屏 chip + 画像标签云 + 画像详情抽屉,potential_treatment 的中文统统从 code 查表。
      compactPersonaValue / personaValueLabels 多收一个 featureKey 参数,只对 potential_treatment
      生效 —— 其余多值特征(治疗史/权益/禁忌/时间偏好…)没有 code 表、措辞也没改过,继续读 labels。
      
      纪律写进注释:**凡是中文措辞可能改的标签,展示一律 code → 查表,别读 data.labels。**
      
      本地实测(赵欣冉,types=[endo,filling]):首屏 chip 从「潜在根管 +1」变成「根管治疗 +1」,
      与卡片一致。717 tests / 47 suites + web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(plan-detail): 亲戚关系方向按年龄实时纠正 —— FRIDAY 侧 97% 的父母/子女方向是反的 · 0ad1ab4a
      ## 测试服全量实证(不是本地样本)
      jvs-dw  43,279 条亲戚边,与年龄矛盾 428 条 → 1.0%,方向基本可信
      friday   2,427 条;只看父母/子女**对**更刺眼:
               motherchild 326 对里只有 8 对方向对(2.5%)
               fatherchild 231 对里只有 9 对(3.9%)   → 97% 反了
      
      ## 根因
      data/friday/assemblers/patient_relation.yaml 的 enum_mapping 假设源码语义是
      「本人是对方的 X」,于是映射时取了**逆关系**(码2 爸爸 → child)。数据说这个假设是错的:
      FRIDAY 存的码本来就是「对方是本人的 X」,跟 PAC 契约同向 —— 多反转了一次。
      互反边可以证:秦佳(47)--mother-->韩秦瑜(21) 与 韩秦瑜--child-->秦佳 两条边互相自洽、
      但都与年龄矛盾;去掉那次反转两条就都对了。
      ️ 摄入侧的修法(改 yaml + 重摄该资源)另开一件事 —— patient_relation 是 upsert 资源、
         不进 transaction,没有原文可 reparse,得从源重拉。
      
      ## 这次做的是展示侧:年龄定方向,不降级
      按你的要求不降级成「亲属」,而是**实时算出真实关系**:
        · 标成长辈但对方更年轻 → 子女 / 孙辈
        · 标成晚辈但对方更年长 → 按对方性别拆父/母(性别缺 → 中性「父母」,不硬猜)
        · 配偶 / 兄弟姐妹是对称关系,没有方向可纠,原样返回(同岁配偶很正常,不许被误标)
        · 任一方缺生日 → 原样返回,不猜(会显示的边 98.8% 两边都有生日)
      纠正过的在关系后打一个 `*`,hover 显示「源数据记的是「mother」,与双方年龄不符,已按年龄纠正」——
      客服跟宿主对账时能看出差异在哪,而不是以为 PAC 显示错了。载荷同时留 relationshipRaw。
      
      口径收在 @pac/types/kin-relationship.ts(纯函数,附实证数字),11 条用例锁住,
      其中"同岁也算矛盾""配偶不许被纠""缺生日不猜"三条是最容易被后来人改坏的。
      
      本地实测(王红兵 58 岁):源里的「妈妈 王迪 31岁」现在显示「子女* · 31岁」,配偶 59 岁不动。
      717 tests / 47 suites + 两端 typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 「机会识别不准确」补必填多选 —— 哪几类推荐治疗不准(仅统计) · e4ace755
      落表:plan_executions 加一列 inaccurate_treatments TEXT[] NOT NULL DEFAULT '{}'。
        · **加列不建表**:它是 abandon_reasons 里 'inaccurate' 那项的限定词,不是独立事实 ——
          同一次提交、同一条 execution、基数 ≤8。单独建表只多一次 join。
        · **存 code 不存中文**:这列唯一价值是可统计(哪类召回最容易被判不准 → 回去改规则)。
          中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时措辞烤进历史数据。
          (同类教训:原 abandon_other 自由文本列就是因为"只能取到字面量、统计不了"被删的。)
        · **不塞 notes**:自由文本统计不了,正好废掉这个字段的唯一价值。
      
      ️ 按业务口径明确两条,都写进 schema 注释免得后来人误解:
        ① **不参与抑制**。抑制仍是信号级、按 plan 全部 reason 一起压 —— 客服选"只有种植不准"
           不会只放过根管那条。看到这列别以为闸接上了。
        ② **跟「其他原因」不构成关联**。只在勾了 inaccurate 时收集/落库;后端落库时再判一次,
           免得前端残留勾选污染统计口径。
      
      必填校验前后端各一道。服务端那道不是冗余:这条反馈事后补不回来(没人会为已结案的单再来一遍),
      收进来一批空的就等于白填。
      
      候选项 = 本患者画像 potential_treatment.types(同召回池卡片那排标签的来源),所以每人不同;
      中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL(改措辞即时生效)。
      患者没有潜在治疗标签时不卡必填 —— 理论上不该发生,真遇到宁可放行,不让客服卡在填不了的必填项上。
      
      708 tests / 46 suites + 两端 typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(plan-detail): 关联客户档案跳转对齐「原始档案」—— 配了走宿主,没配回落 PAC 工单页 · ea66930a
      原来只跳 PAC 自己的工单页。宿主档案才是客服真正要看的那一页(有真手机号、有全量病历),
      PAC 工单页只是兜底 —— 所以跟本人的「原始档案」同一套逻辑:
      
        配了 actionUrls.VIEW_PATIENT → 跳宿主档案,占位换成**这位关系人**的
                                       {patientId} / {medicalRecordNumber}
        没配 → 回落 /plans/<planId>(本 scope 内的活跃工单)
        都没有 → 只显示信息,标「无档案入口」(原来叫「无工单」,现在两条路都可能缺,措辞跟着改)
      
      为此后端 include 补了关系人的 externalId / medicalRecordNumber —— 注意 VIEW_PATIENT 模板里的
      {patientId} 是**宿主侧 id**,不是 PAC 的 uuid,拿 relatedPatientId 去填会得到一个查不到人的链接。
      关系人没建档时 externalId 退回边上的 relatedExternalId(那个始终有)。
      
      本地两条路都实测:
        未配 → /plans/2669df95…
        配了 → …/patient?pid=115199&mrn=JN0A016246 —— pid/mrn 是**关系人的**(本人是 110959/JN0A025573),
               没有串成本人的 id
      708 tests / 46 suites + 两端 typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(plan-detail): 话术头部插入「关联客户」—— 亲戚姓名/关系/年龄 + 跳该客户工单(新标签页) · f187fb97
      一家人常在同一家诊所看牙。客服打电话前看到"这人的配偶也是我们的客户",既能顺口关心、
      也能顺手跟进那一位,所以放在话术头部(AI 简报下方)而不是收进抽屉。
      
      patient_relations 表本来就有(摄自 fact_customer_referee_out,family_structure 特征在用),
      详情接口原来也已经返回 contacts,这次补三件:
      
       只列**亲戚**。新增 KIN_RELATIONSHIPS 白名单(配偶/子女/孙辈/父母/祖辈/兄弟姐妹),
        friend 和 other **刻意不进** —— 那条边表摄自推荐关系,other 占了一半以上(本地 4043/6222),
        混的是推荐人、代付人这类"认识但不是亲属"。字典把 other 译成「亲属」,但照它列出来
        客服会把推荐人当家属去问病情,比不显示更糟。
        (sibling 这里算亲戚,而 family_structure 把它算"非直系" —— 两处判的不是同一件事,口径不同是有意的)
      
       年龄:从关系人 birthDate 现算(include 补 birthDate)。
      
       链接目标 = PAC 自己的工单页,**且必须过 scope**。关系人常和本人不同品牌/诊所,不过滤就会
        给出一个点进去 404 的链接 —— 那比"没有链接"更糟,客服会以为系统坏了。
        批量一条 SQL 查(loadRelatedPlanIds),查不到 → planId=null → 只显示信息、位置上标「无工单」。
        新标签页走 openHostUrl(带 sandbox 三级兜底),不顶掉当前工单。
      
      未建档(linked=false / 无姓名)的关系不出行:一行只有关系没有人,客服拿不到任何可用信息。
      
      本地实测(王红兵):渲染出「王希亮 配偶·59岁 → 关联客户档案」「王迪 妈妈·31岁 → …」,
      href=/plans/<id> target=_blank rel=noopener noreferrer。
      708 tests / 46 suites + 两端 typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 标签按业务字典 A/B/C/D 类区上色+定序,抽屉与首屏收成一套;两栏宽 320 · 114e7fdc
      ## ① 类区 = 唯一一套分类
      A/B/C/D 本来就在 PERSONA_FEATURE_SPECS 每个标签上方的注释里(《客户画像标签字典 v3.0》的
      编号 A.1.1 / B.1.1 / C.1.1 / D.2.4…),16 个全有,只是没结构化。现在提成 display.category:
      
        C 临床需求(琥珀)  治疗史 · 潜在治疗 · 急迫等级
        B 价值与阶段(靛蓝) 价值分群 · 生命周期 · 权益身份 · 转介绍达人
        D 行为与偏好(玫红) 折扣锚点 · 禁忌标签 · 特别关注 · 治疗敏感 · 时间偏好
        A 基础属性(灰)    获客渠道 · 年龄段 · 性别 · 家庭构成
      
      类区序 C→B→D→A(业务定,不是字母序 —— 字母序会把"基础属性"排首屏开头,客服最不需要的
      东西占最显眼的位置)。类区内序沿用 7-29 业务给的相对顺序。
      
       **删掉了原来那套三分组**(跟进要点/价值与阶段/基础属性)。两套分类并存的代价是加标签时
      要想"归哪组"两遍、而且两边各自漂;更直接的问题是同一批标签在首屏和抽屉里是**两个顺序**,
      客服在抽屉里得重新找一遍。现在首屏 chip 和抽屉共用 personaFeatureSortKey,颜色共用
      PERSONA_FEATURE_CATEGORY_META —— 真正同出一源。
      
      首屏 chip 从统一素色改成**按类区 4 色**(不是按标签 16 色 —— 一标签一色等于没有层次);
      抽屉分组标题补同色小圆点,两处认的是同一套色。
      
      ## ② 两栏宽度 300 → 320(选患者列 + 详情左栏)
      ️ 副作用:xl 断点(1280)恰好那档,中栏被压到 196px(原 236px)。宽屏无影响,
      1280 附近本来就挤,这次更挤了 —— 要治得动断点,不在本次范围,已在回话里说明。
      
      ## ③ Tailwind 陷阱记在注释里
      chip 的颜色类必须整串来自 TONE 表(字面量),别写 `'hover:' + C.bg` —— Tailwind 静态扫源码,
      拼出来的类名不会被生成,表现为"颜色没生效"且难查。
      
      新增 5 条防漂移测试:每个标签必有合法类区、类区序覆盖四类且等于 C→B→D→A、
      每类有中文名+颜色、同类区内 order 不重复、首屏白名单排完类区下标单调不减。
      708 tests / 46 suites + web typecheck 通过。本地实测首屏三色与抽屉分组色一致。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 新标签页改一步 open,别再"先开 about:blank 再导航" · 47c079ef
      宿主加上 allow-popups 后炸了两条,都是上一版那个两步走导致的:
        Unsafe attempt to initiate navigation for frame with URL 'about:blank' … The frame
        attempting navigation is sandboxed and is trying to navigate a popup, but is not the
        popup's opener and is not set to propagate sandboxing to popups.
        Uncaught SecurityError: Failed to execute 'replace' on 'Location': The current window
        does not have permission to navigate the target frame to '<host url>'.
      
      因果:**opener 一断,我们就不再是那个弹窗的 opener**,而沙箱化的 frame 只有作为 opener 才有权
      导航它 → 第二步 location.replace 被拒,新标签页停在空白页。顺序反过来也不行(导航到跨源之后
      再设 opener 会抛)。上一版之所以两步走,是想"手工断 opener 以等效 noopener、同时保留可检测性"
      —— 这个组合在沙箱下不成立。
      
      改成一步 `window.open(url, '_blank')`:
        · 可检测性仍在 —— 被拦时返回 null(features 里**不写** noopener;写了成功也返回 null,
          那才是分不清被拦和成功的写法)
        · opener 只做尽力而为的切断(跨源会抛,catch 掉)。可接受:目标是宿主管理页配好的自家地址,
          不是用户输入的任意站点,tabnabbing 面本来就很窄。
      三级兜底(新页 → 顶层 → 本 frame)保持不变。
      
      交付文档补上:只给 allow-popups 时新标签页会**继承沙箱**,宿主自己的页面在里面可能功能不全,
      建议连 allow-popups-to-escape-sandbox 一起加,并给出完整推荐的 sandbox 串。
      
      ️ 内嵌浏览器面板自身拦弹窗,"正常开出新标签页"这一支我这边仍证不了;
      但两步导航已从结构上去掉,SecurityError 那类报错不会再有。web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 召回池卡片去掉优先级那排五个色点,只留分数 · 5abea1b8
      业务 2026-07-30。分数本身已经带档位信息(而且还按档着色),五个点是同一件事的
      第二种编码 —— 一行里两处讲优先级,反而要人对照着看。
      
      档位色保留在数字上(极低/低 绿 → 中/高 琥珀 → 极高 玫红),扫一眼仍分得出轻重;
      算分明细仍走外层 PriorityHover,没动。
      
      ️ 只改左栏列表卡片。详情页顶栏那个「优先级 ●●○○○ 低」是 shared.tsx 的 PriorityBar
      (带文字档位标签),不是同一个组件,业务没要求动。
      
      本地实测:卡片只剩 3.12 / 2.74,色点已无;web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(embed): postMessage 载荷字段名简化成 desc / treatments / stage;早期矫治 → 早矫 · 2b179c7c
      字段名按对接方意见简化:
        pendingTreatmentDesc → desc
        potentialTreatments  → treatments
        caseStage            → stage
      信封(source/type/action)不动。这三个字段还没交付给任何宿主,现在改是零成本;
      之后再改就是毁约(宿主的解构会拿到 undefined),所以测试里把三个名字锁死、
      并断言旧名字不许还留在交付文档里(留着对方会照旧名写)。
      
      早矫的项目名从「早期矫治」改「早矫」。它砍后缀砍不出来 —— 加一张**只有一条**的例外表,
      注释写明"能靠砍后缀得到的别往这里堆",免得又退化成两张手维护的中文表。
      界面上仍叫「早期矫治」(卡片措辞不动)。
      
      ️ 与列表 API 的 PlanPatientBrief.potentialTreatments 无关 —— 那是 PAC 自己的 REST 字段,
      不是宿主契约,不跟着改。
      
      703 tests / 46 suites + web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(embed): potentialTreatments 发项目名,去掉「治疗」后缀 · 9392a676
      发给宿主的是 ["种植","修复"] 而不是 ["种植治疗","修复治疗"] —— 宿主拿它当**项目名**落单,
      带后缀读起来像句子、不像项目。界面上客服看到的仍是「种植治疗」(业务 7-29 定的展示措辞),
      两处措辞不同是有意的。
      
       从卡片措辞**推导**而非另立一张表(potentialTreatmentItemName = 砍掉结尾的「治疗」):
      两张手维护的表必然漂。early_ortho 的「早期矫治」本来就没这个后缀,原样保留 ——
      它不叫「早矫治疗」,也不该被砍成「早矫」。
      
      交付文档同步:字段说明 / 示例 JSON / 8 类取值表 / 监听示例注释全部换成项目名,
      并加一段 Callout 说清"界面带后缀、载荷不带"是有意的。
      
      测试跟着改成查**项目名**而不是卡片标签(发出去的是前者,文档要跟载荷一致、不是跟界面一致),
      再加一条:砍后缀不许砍出空串、不许换词、early_ortho 必须原样。
      
      702 tests / 46 suites + web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(embed): 打开潜在治疗的 postMessage 补三个字段 + 出一份宿主侧交付文档 · 9ba57bc9
      载荷从 `{patientId}` 扩到:
        pendingTreatmentDesc  待治疗描述 —— 取页面顶部那句 AI 召回简报;还没生成好则退回
                              结构化召回原因文本;都没有 → 空串(字段一定在,别让宿主判 undefined)
        potentialTreatments   关联治疗项目 —— 中文标签数组,与召回池卡片同一套措辞(8 类)
        caseStage             病例阶段 —— 恒「已咨询」
      
       三个字段一律取**客服此刻在页上看到的东西**,不另算一套:宿主建出来的单要和客服刚读的
      那句话对得上,否则对账时说不清是谁改的。所以简报由 RecallBriefLine 拿到后回报给父层
      (它本来就负责 get-or-generate),不再单独查一次 —— 否则会出现"页面显示 A、发过去 B"。
      
       契约收在 @pac/types/host-action-message.ts 而不是组件里:它是**对外接口**,宿主照它写监听。
      放在 types 意味着改字段过类型检查 + 有文档同源,而不是某个组件里悄悄多塞一个 key。
      兼容纪律写进注释:只增字段、不改已有语义、不删字段。
      
      ️ 这三个字段**刻意不进 URL 模式**:长文本 + 数组塞 query string 会撞长度上限,
      还会把病情描述写进浏览器历史和宿主 access log。要 URL 模式也带得宿主改成 POST。
      
      交付文档 docs/integration/postmessage-actions.mdx(可直接给对接方):
      信封 / 字段表 / 完整示例 JSON / 8 类治疗项目的中文code 对照 / 宿主监听示例
      (含必须校验 e.origin)/ 注意事项(URL 模式不带、单向无回调、sandbox 别漏 allow-popups)。
      
      加防漂移测试:信封字面、caseStage 恒值、8 类标签与 code 必须在交付文档里列全 ——
      这份文档是发给外部照着写的,漂了要等联调才暴露。
      
      701 tests / 46 suites + web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 嵌宿主 sandbox iframe 时新标签页被拦 → 逐级兜底;去掉预约成功 toast;回访→跟进 · 1402bb9f
      ## ① 内嵌宿主时控制台报错、点了没反应
      线上(嵌在宿主 iframe 里)实测:
        Blocked opening '<url>' in a new window because the request was made in a sandboxed
        frame whose 'allow-popups' permission is not set.
      宿主的 <iframe sandbox> 没给 allow-popups → window.open 直接被浏览器拦掉。
      上一版(7-29 改新标签页)只写了一行 window.open,**连"被拦了"都不知道**,表现就是点了没反应。
      
      openHostUrl 改成三级兜底:新标签页 → 顶层跳转(老 _top 行为)→ 本 frame 内跳转。
      能开新页就开,开不了也得让人到得了那个页面。
      
      ️ 检测"被拦"不能靠 window.open 的返回值配 noopener —— features 里带 noopener 时
      **成功也返回 null**,永远分不清。改成先开同源 about:blank、拿到句柄手工断 opener 再导航,
      效果同 noopener 但可检测。
      
      ️ 两个 a 标签(原始档案 / 原始病历)也改成 onClick 走 openHostUrl:声明式的
      target="_blank" 在 sandbox 下同样被拦,而那条路我们既拦不到也补不了。href 保留让
      右键"复制链接"仍可用。
      
      根治仍在宿主侧 —— 注释和 console.warn 都写明了:请对接方给 iframe 加 allow-popups
      (想让新页不继承沙箱再加 allow-popups-to-escape-sandbox)。
      
      ## ② 去掉「已在新标签页打开宿主预约页」toast
      新标签页开出来用户自己看得见,再报一句是噪音。失败路径的提示都还在。
      兜底路径也刻意不弹 toast:下一行就导航走了,提示根本来不及被看见。
      
      ## ③ 顶栏「回访」按钮文案改「跟进」
      这个按钮跳的是宿主侧动作页,落到宿主那边不一定叫回访;而 PAC 里「回访」已被
      "诊所回访记录 / 历史联系"占着,同一个词指两件事。
      ️ 只改按钮字面,槽位 key(OPEN_RETURN_VISIT)不动 —— 那是宿主配置里的键名。
      
      本地实测:打桩让 window.open 返 null(模拟 sandbox)→ 确实走兜底跳到了宿主页;
      按钮 DOM 是 title="跟进" / 文案"跟进";预约不再弹成功 toast。
      ️ 未实测的:真 Chrome 里"正常开出新标签页"这一支 —— 内嵌浏览器面板本身拦弹窗,
      两种 window.open 形式都返 null,证不了。697 tests / 45 suites + web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  4. 29 Jul, 2026 5 commits
    • merge: 本周迭代 → main(含 perf/ingest-recompute-hotspots + 医生名单缓存/客服返池) · f23bfefe
      - 品牌:主色换 PANTONE 286 C(#0032A0),teal-* 全站改名 brand-*
      - 话术:自报家门改「我是{诊断医生}医生的助理X」(三档 promptVersion 全 bump)
      - 详情页:关键画像标签上首屏;宿主槽位跳转改新标签页;隐藏「牙位」「历史联系·详情」
      - 召回池:到诊派生字段落 patient_profiles + 筛选索引;卡片信息重排;医生筛选可搜索
      - 修复:排序键精度、筛选 chip 显示原始 code、筛选面板 React 重复 key、
              医生名单缓存 6h→10min、客服可自助返池(带归属闸)
      luoqi committed
    • merge: fix/source-unit-resolver-race → main(跨宿主串味根治 + 关系边不再静默丢) · 098424bd
      冲突解法:两处 ensurePatientStub 之后的 stats.patientStubsCreated++ 保 main 侧 ——
      那是 host-id-format-defense 加的空壳计数(回执要透出空壳患者数,否则宿主看不出
      自己推早了);本分支只是没有这行,不是要删它。
      luoqi committed
    • fix(plan): 医生名单缓存改 10 分钟 + 重算收尾主动清;客服也能自助返池 · 729a7709
      ## ① 医生名单搜不到(测试服实证)
      「上次医生 / 偏好医生」的候选名单缓存 6 小时,**且没有失效钩子**。三个派生列刚上线
      全是 NULL,第一次打开筛选面板就把 `[]` 缓存了 6 小时;之后重算把值填上了,客服那边
      照旧「暂无医生名单」,输入框搜谁都搜不到 —— 表现成"这个医生搜不到",没人会想到是 Redis。
      
      缓存久的真实代价不是"名单旧几小时",是**排查成本**。两处改:
        · TTL 6h → 10min(仍挡得住"每开一次面板跑一次 DISTINCT",44 万行有索引,不贵)
        · recompute-persona 收尾主动删该 host 各 tenant 的 key → 刚跑完就能搜到,不用等 10 分钟
      key 拼法导出成 doctorOptionsCacheKey,CLI 不再手写字符串(写歪了是静默删空)。
      
      ## ② staff 自助返池
      认错人 / 打不通 / 不该由我跟,客服自己就该能退回池,否则只能挂着占位,或者硬走
      「关闭机会」—— 那会污染放弃原因统计。
      
      ️ 光给权限是有洞的:客服 A 能把客服 B 手里的单退回池再自己认领,认领闸挡的是
      "没认领就作业",挡不住"先把别人的单退了"。所以配套加了归属闸 assertCanRecycle:
        · 有 PLAN_VIEW_ALL(leader/admin)→ 可退任意人的单,这是"组长回收"的原义
        · 没有(staff)→ 只能退自己认领的,否则 PLAN_CLAIMED_BY_OTHER(带占用人,前端能提示是谁)
      判据取权限不取角色字符串 —— 权限模型的真理源是 ROLE_PERMISSIONS,service 不该再认一次角色。
      
      顺带订正 recycle-scheduler 里"staff 无返池权限所以自动回收是唯一释放路径"的注释(已不成立)。
      
      663 tests / 42 suites 通过(新增 6 条:归属闸四态 + 权限矩阵两条)。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(web): PAC 主色换成 PANTONE 286 C(#0032A0),teal-* 整体改名 brand-* · a8687e0a
      ## 换色
      globals.css 里新增品牌色阶 brand-50…900(同色相 H=221.25 推出),
      主色 #0032A0 坐 600 档 —— 全站 `bg-brand-600` 是主按钮、`--primary` 也指这档,
      主色必须落这里才算真换了。--primary / --ring 同步改成 hsl(221.25 100% 31.4%)。
      实测 `bg-brand-600` 计算值 rgb(0,50,160),与色卡 RGB 0/50/160 一致。
      
      ## 为什么顺手改名
      没有走"偷偷把 Tailwind 的 teal 调色板改成蓝色"这条捷径 —— 那样类名写着 teal、
      渲染出来是蓝的,下一个人读代码会以为自己看错了。品牌色就该叫 brand:
      - 263 处 `teal-N` → `brand-N`(28 个文件,纯类名替换)
      - tone 色板键 'teal' → 'brand'(labels.ts 的 PERSONA_FEATURE_META 一并改;
        tone 值全在代码里,不来自 DB,改名没有兼容问题)
      - 宠物 SVG 里手挑的 3 个十六进制(11 处)按色阶位置 1:1 映射过去,不然吉祥物
        还是青色、跟全站撞色
      
      以后换色只改 globals.css 那 10 行。
      
      web typecheck + 657 tests / 42 suites 通过;本地实测列表/详情/表单全站已是蓝。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed