1. 29 Jul, 2026 30 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
    • feat(ai): 话术自报家门改「我是{诊断医生}医生的助理X」,不再是客服身份 · 93b5ccab
      业务口径(2026-07-29):回访挂医生的名义,患者更愿意接、也更贴事实(话术全程就是
      以诊断医生的关怀为主线);"客服"听起来像销售,开场就掉信任。
      
      改动收在两处口径源,三档共用:
      - agent-identity.ts:岗位称呼 staff/leader/admin 一律「助理」(患者不关心内部层级,
        原来 leader 报"客服主管"),无姓名兜底也从"客服"改"助理"。
      - prompt 里的自报家门句:`我是{clinicName}的【回访客服】` → `我是{诊断医生}医生的【回访客服】`
        (稳健档 prompt / 标准+深度档 fact-block / 稳健档 LLM 失败兜底文案,三处全改)。
        医生姓取诊断医生,取不到时 deidentifyDoctor 给"您的主治" → "我是您的主治医生的助理X"。
      
      占位符字面量 `【回访客服】` **故意不动** —— 线上 plan_scripts 已有正文都含这串,
      改字面量等于老缓存回填不上、患者会看到光秃秃的占位符。它现在只是个 token,
      回填出来已经是"助理X";老话术因此平滑变成「我是XX诊所的助理李倩」,不会出洋相。
      
      诊所名不再进自报家门,但仍作为一行事实给 LLM(标注"只在需要提到诊所时用,别自己编
      XX口腔")—— 原来它就是防编造用的,直接删会让 LLM 自己造诊所名。
      
      三档 promptVersion 全 bump(inputHash 含 promptVersion → AI 缓存自然失效,新生成走新话术;
      已生成的老话术不会自动重跑,要新措辞得点「重新生成」)。
      
      657 tests / 42 suites 通过(agent-identity + script-facts 两个 spec 同步改口径)。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 隐藏「关键事实·牙位」与「历史联系·详情」两个入口 · 1e6103b0
      业务要求(2026-07-29)。两处都做成**不传回调即不渲染**,抽屉本体
      (kind='teeth' / 'return-visits')一行没动 —— 想恢复就是把 onOpenTeeth /
      onOpenDetail 传回去,注释里写好了原样。
      
      顺带改了历史联系的兜底文案:原来是「N 条历史联系 · 点「详情」查看」,
      详情入口一隐藏就成了指路指向不存在的东西,现在只留「N 条历史联系」。
      
      web typecheck 通过;本地实测关键事实卡只剩「详情 →」。
      (历史联系卡本地这个患者没有回访记录、不渲染,那处未实测。)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 首屏标签按业务定序 + 点标签直达画像详情对应那一条 · 5c8dcff4
      三处按业务意见调整:
      
      ① 顺序由业务定,写死在 PERSONA_KEY_FEATURE_KEYS 的数组序:
         治疗史 → 潜在治疗 → 价值分群 → 生命周期 → 权益身份 → 折扣锚点 →
         获客渠道 → 禁忌标签 → 特别关注
         (原来跟抽屉共用 personaFeatureSortKey,那是按"跟进要点/价值与阶段/基础属性"
          分组排的;首屏没有分组标题,照那个序排客服看不出章法)
      
      ② 急迫等级下首屏 —— 顶栏「优先级」条已经表达同一件事(急迫是它权重最大的一项),
         同屏两处讲一件事会被当成两个指标。
      
      ③ 去 hover,改点击:chip 变真 button,点了开画像详情抽屉并**滚到那张卡、加重描边**。
         扫首屏时鼠标划过就弹浮层是干扰;真想知道"这标签怎么算的"的人,该看的是完整那张卡。
         抽屉里十几张卡,要找的常在折叠线以下 —— 不定位的话点标签和点「详情 →」没区别。
         走「详情 →」进仍是看全量,不定位。
      
      滚动等一帧再执行:抽屉是本次渲染才挂上的,同步滚时容器还没布局,scrollIntoView 是空操作。
      label 视觉上去掉了,button 补 aria-label(读屏仍念得出标签名)。
      
      657 tests / 42 suites 通过;web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 宿主 id 去浮点尾巴 + 回执透出空壳患者数 —— 挡住幽灵患者 · cd8ee4c8
      事故(2026-07-28 19:19,测试服务器):FRIDAY 推 med_emr_info 时 patient_id 变成了
      浮点字符串 "676600.0"(典型 pandas 症状:列含空值 → int64 提升 float64 → 导出带 .0)。
      PAC 原样收下,患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub
      建出无名幽灵患者。3 个幽灵档吸走 4224 条事务,其中一个带着 2246 条诊断进了召回池、
      算了画像、生成「应治未治」计划排队等客服打电话 —— 而推送回执是干净的 failed=0,
      宿主和我们都看不出来,直到有人在池子里看见一个没名字的患者。
      
      两层防御:
      
      1) 归一层去尾巴(field-mapper.normalizeCanonical)
         `Id` 后缀的 canonical 字段,值形如 "676600.0" / "676600.00" → 截成 "676600"。
         用后缀约定而非逐资源枚举,新增 canonical 字段自动纳入。只处理**整数值**的浮点
         写法,不动 "676600.5" —— 那是另一种问题,应该显形而不是被悄悄改写。
         放在 normalizeCanonical 意味着 cold-import / push / pull / reparse 行为一致。
      
      2) 回执透出 patientStubsCreated(通用信号)
         空壳 >0 本身不是错(「推送顺序无要求」正是靠它兜底),但**持续 >0 说明患者号
         对不上** —— id 格式漂移只是其中一种表现形式,主档漏推同样会触发。宿主据此自检,
         不必等人肉在召回池里发现幽灵。同时 logger.warn 提示常见成因。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 关系边/回访的本人缺席改建空壳 —— 兑现「推送顺序无要求」 · d928dfe1
      同一情况三条路给了三个答案:
        processSubject              → ensurePatientStub(),照常落库
        processPatientRelations     → `if (!patientId) continue`,静默丢
        processPatientReturnVisits  → `if (!patientId) continue`,静默丢
      
      那句 continue 的注释写着「非 active client,无处挂靠」,但 cold-import 的 cohort 路径
      经 injectPatientFilter 对每张表都按患者 id 过滤、patients 又先跑,它几乎永不触发;
      真正踩中的是 push —— 宿主先推 customer_referee_circle(07-27)、后推
      customer_basic_info(07-28~29),10169 条关系边只落 257 条(2.5%),
      且不计 failed、不进回执,宿主看到的是 accepted=N / failed=0,完全无从察觉。
      
      契约文档承诺「推送顺序无要求」,兑现它靠的就是空壳兜底(pull 侧的等价物是
      ClickHouseSourceService 的「反向拉主档」)。空壳只建**本人**这一侧;关系的对方
      (relatedPatientId 可空)不建,靠 upsert 的 `update: { relatedPatientId }`
      在对方入库后重推时回填 —— 那段回填代码本来就在。
      
      加源码闸 ingest-patient-stub-consistency.spec.ts 锁住「三条路对齐」,
      将来加第四个 process* 方法不至于再漏。已验证该闸对修复前的代码报
      processPatientRelations / processPatientReturnVisits 各 1 处静默丢弃、
      且两者都缺 ensurePatientStub。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): source_unit 解析器不再挂单例实例字段 —— 根治跨宿主串味建出的重复主档 · 4421bc6f
      ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段,每次摄入开头
      覆写、在长 async 循环里读。原注释「cold-import 按 host 串行,实例字段安全」在 push
      (webhook,按请求并发)接入后失效:
      
        friday  identity_namespace_field = tenant_id
        jvs-dw  identity_namespace_field = brand
      
      两者并发摄入互相覆写 resolver → 对方的行解析不出命名空间列 → source_unit=''
      → 患者索引 (source_unit, external_id) 未命中 → 建出空命名空间的重复患者主档。
      
      测试服务器实测(2026-07-29 查证):
        - friday 首推 07-23 16:24:26,jvs-dw 首个空品牌患者 16:24:31(5 秒内);
          此前 jvs-dw 自 06-28 起 39 万患者零个空品牌。
        - jvs-dw 空品牌患者 51063 条,其中 51037(99.95%)与真主档同 external_id;
          friday 空品牌 498 条,496 条落在 jvs-dw pull 窗口内,354 条是重复档。
      
      改法照抄 tenantResolver 一贯的纪律:局部 const 构建 + 逐层传参。涉及 4 个入口
      (reparse / ingestRawTables / importDirectory / importPatient)与 5 个读取方法
      (processPatients / processPatientRelations / processPatientReturnVisits /
      processSubject / hydratePushLookupTables)。纯管道改造,无行为变更。
      
      并加源码闸测试 ingest-resolver-no-instance-state.spec.ts:这类 bug 单跑任何一条
      路径都正确,只有并发交错才炸,单测抓不到,只能在源码层禁止 `this.*Resolver =`。
      (已验证该闸对修复前的代码报 4 处赋值 / 9 处读取。)
      
      注:线上已污染的 5.1 万条空命名空间主档需另行归并,不在本次范围。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 首屏关键标签扩到 10 个 + 去 label、改素色 · 3c3c4cf0
      按业务意见调整:
      ① 治疗史 / 折扣锚点 / 获客渠道 / 权益身份 进首屏(现 10 个,16 个里留 6 个不进:
         时间偏好、治疗敏感 —— 通话中才用;年龄段、性别 —— 姓名行已有;
         家庭构成、转介绍达人 —— 不改变开场)。
      ② chip 去掉标签名,只留取值:「急迫等级 紧急」→「紧急」。
         取值本身自解释(紧急 / 种植禁忌 / 储值会员),label 是冗余;十来颗标签
         每颗都带名字会把身份卡撑满。要看标签名或口径 → hover / 进抽屉。
      ③ 去掉按 tone 的多彩着色,统一素色,与左栏列表卡片的通用标签同一套
         (rounded + slate 描边)。首屏十几颗各一种颜色 = 满屏彩条,反而看不出轻重。
      
      新增 plain 模式挂在 PersonaTagCloud 上,画像标签卡兜底/抽屉仍是原来的带 label 彩色版
      —— 那两处标签少、要的是辨识度,口径不同不强行统一。
      
      657 tests / 42 suites 通过。本地实测张红渲染 潜在种植+1 / 紧急 / 低活跃 / 流失客 一行素色 chip。
      ️ 本地库这个宿主没有治疗史/折扣锚点/权益/获客渠道的数据,这四颗的观感未实测。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 关键画像标签直接展示在患者姓名下 · a21d74c1
      业务(2026-07-29):关键标签在完整客户名字下直接显示,可快速查看,要细看再进详情。
      
      标签云组件(PersonaTagCloud)一直在,只是自从画像卡改成 LLM 一句话摘要后
      被降级成"摘要生成失败"的兜底 —— 客服想看标签得点开抽屉。现在把**关键那几个**
      提到身份卡姓名下方,复用同一套 chip(hover 仍能看「怎么算的」),不另起渲染。
      
      关键 = PERSONA_KEY_FEATURE_KEYS(types 里定,附选取理由):
        禁忌标签 / 特别关注 / 急迫等级 / 潜在治疗 / 价值分群 / 生命周期
      按「能不能治 → 能不能打 → 多急 → 聊什么 → 给多大力度」排。
      时间偏好、折扣锚点、治疗敏感是通话**中**才用的,基础属性姓名行已有,都不进首屏。
      16 个标签一个不少,仍在画像详情抽屉全量可查 —— 取舍的是首屏不是信息。
      
      排序沿用 personaFeatureSortKey(与抽屉同一套序),白名单只管"哪些进"。
      加防漂移测试:白名单里的 key 必须还活着 —— 改 key 名不会报错,
      只会静悄悄少一个 chip,而少的若是禁忌/免打扰就是事故。
      
      657 tests / 42 suites 通过;本地实测张红渲染出 潜在治疗·急迫等级·价值分群·生命周期 四颗。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 宿主 id 去浮点尾巴 + 回执透出空壳患者数 —— 挡住幽灵患者 · 04964b4e
      事故(2026-07-28 19:19,测试服务器):FRIDAY 推 med_emr_info 时 patient_id 变成了
      浮点字符串 "676600.0"(典型 pandas 症状:列含空值 → int64 提升 float64 → 导出带 .0)。
      PAC 原样收下,患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub
      建出无名幽灵患者。3 个幽灵档吸走 4224 条事务,其中一个带着 2246 条诊断进了召回池、
      算了画像、生成「应治未治」计划排队等客服打电话 —— 而推送回执是干净的 failed=0,
      宿主和我们都看不出来,直到有人在池子里看见一个没名字的患者。
      
      两层防御:
      
      1) 归一层去尾巴(field-mapper.normalizeCanonical)
         `Id` 后缀的 canonical 字段,值形如 "676600.0" / "676600.00" → 截成 "676600"。
         用后缀约定而非逐资源枚举,新增 canonical 字段自动纳入。只处理**整数值**的浮点
         写法,不动 "676600.5" —— 那是另一种问题,应该显形而不是被悄悄改写。
         放在 normalizeCanonical 意味着 cold-import / push / pull / reparse 行为一致。
      
      2) 回执透出 patientStubsCreated(通用信号)
         空壳 >0 本身不是错(「推送顺序无要求」正是靠它兜底),但**持续 >0 说明患者号
         对不上** —— id 格式漂移只是其中一种表现形式,主档漏推同样会触发。宿主据此自检,
         不必等人肉在召回池里发现幽灵。同时 logger.warn 提示常见成因。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 宿主槽位跳转一律新开标签页,不再顶掉当前页 · 951fb26d
      业务约定:客户档案 / 病历 / 预约等宿主页面「打开新页,不在当前页面做跳转」。
      
      原来四处槽位跳转全用 `_top` —— PAC 嵌在宿主 iframe 里时会把整个宿主页顶掉,
      客服看完档案回不到刚才的工单(召回上下文、已填的通话结果全丢)。
      
      四处统一收口到 action-url.ts 的两个出口,免得以后再各写各的 target:
        a 标签  → HOST_LINK_PROPS (target=_blank + rel=noopener noreferrer)
        JS 派发 → openHostUrl()
      
      覆盖:VIEW_PATIENT(原始档案)、VIEW_MEDICAL_RECORD(原始病历)、
      CREATE_APPOINTMENT(新建预约)、OPEN_POTENTIAL_TREATMENT / OPEN_RETURN_VISIT
      的 URL 模式(postMessage 模式不涉及跳转,不动)。
      
      本地实测:两个 a 标签渲染成 target="_blank" rel="noopener noreferrer";
      新建预约走 openHostUrl 打开宿主页。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 已选筛选 chip 显示原始 code 而非中文;MCP 维度名同样漏改 · b5fdb1b9
      现象:上次时间已选后显示「18m_plus ×」「12_18m ×」而不是「18 个月以上 ×」「12-18 个月 ×」。
      
      根因:消费方按 `d.key` 查维度,而 source='patient' 的三个维度(上次时间 / 上次医生 / 偏好医生)
      key 是空串、筛选串用的是 `id` —— 查不到维度 → 退回显示 value 本身。
      加 `id` 时改了组装侧(plan.service)和面板渲染,漏了这两处消费点:
      
      - patient-picker-rail 的已选 chip:改用 personaTagDimId 查维度;
        顺带把 `kv.split(':')` 改成按**首个冒号**拆(取值理论上可含冒号,如医生姓名)。
      - mcp-server.factory 的 personaTags 说明:同样按 key 生成,会给 LLM 一个**空维度名**,
        它照着发就永远筛不出人。改用 personaTagDimId;开集维度(医生)options 为空,
        提示改成"自由文本 + 指向 GET /pac/v1/plans/doctors",免得 LLM 瞎猜姓名。
      
      测试:persona-spec-drift 补一条 —— 闭集维度的每个取值都必须能按 **id** 查回中文,
      锁住"用 id 一定查得回来"这个不变量。656 tests / 42 suites 全过。
      
      本地实测:已选 chip 显示「0-3 个月 / 12-18 个月 / 18 个月以上」,
      hover 提示「上次时间:0-3 个月(点击移除)」;请求带
      personaTags=last_visit_bucket:0_3m,… 返回 200。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • refactor(plan): 到诊派生字段落 patient_profiles —— 不进画像、不加宽主表 · d05d4b0f
      返工上一版:visit_recency 曾做成 persona 特征,当天被否 ——「上次到诊 2022-06-19」是
      **原始事实**,而画像该是「重要价值 / 流失客 / 禁忌」这类判断。做成特征会混进患者详情的
      画像标签抽屉、并喂给画像摘要 LLM。该特征一行都没写进生产就撤了。
      
      【落在哪】patient_profiles(1:1 副表),不是 patients:
        profile 本来就装派生 / 摄入属性(获客渠道、转介绍统计),patients 是主数据。
        派生的归派生,主表不被越搞越宽,也不用新建一张表。
      
      【为什么必须物化】跨全池筛选没有不物化的:否则「上次到诊在 3-6 个月」要对 2,500 万行
        patient_facts 做 GROUP BY … HAVING max(occurred_at),交互式扛不住。
        画像筛选也一样物化了 —— 它的柱子就叫 persona_features,只是立得早、又用 JSONB 做成了
        免 DDL 的扩展点,所以加维度时感觉不到成本。这三个字段不属于画像,没有现成柱子可搭。
      
      【 存日期不存分档 —— 零定时任务】
        存 `0_3m` 这种分档,时间流逝自己就会错(今天 2 个月、下月变 3 个月)→ 得配到期重扫
        (上一版为此实现了 nextBoundaryAt);存日期则**只有患者又来了才会变**,而"又来了"必然
        先写到诊 fact → 推进事实水位 → 触发该患者画像重算 → 三列顺路刷新。触发时机与既有机制
        天然重合,自洽。0-3 / 3-6 / … 的区间在**查询时**按 now 现算(visitRecencyRange)。
      
      【 到诊口径 = encounter + 实际治疗 + 挂号 + 病历】
        本地实测:17,637 个有事实的患者里 **8,586 个(48.7%)只有病历、没有 encounter/治疗/挂号**
        (宿主 appointment.in_time 缺失等)。只用 visitFactsOf 会让近一半患者「上次到诊」为空 ——
        本轮实测正是如此(487 → 加病历后 2,408,近 5 倍)。医生写了病历,患者必然到过场;
        详情页 lastVisit 早就是这个并集口径,这里与它对齐。
        ️ 顺带发现:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,
          同一批患者会因此少算就诊次数 —— 值得另开一件事核实,本次不动。
      
      【卡片与筛选同源】两者都读这三列,不做"取不到就现算"的兜底 ——
        那会让展示值与筛选口径再次分叉,正是本次返工要消灭的问题。
        首次重算前为 NULL:卡片留空、筛选筛不到、医生名单为空,都是可接受的降级。
      
      其余:PersonaTagFilterDim 加 source/patientField 分流查询;医生名单接口改查 patient_profiles;
      写入挂在画像重算遍历上(顺路,失败只告警不中断 —— 它不是画像的一部分);
      persona-spec-drift 的 key 校验跳过 source='patient' 维度。
      
      655 tests / 42 suites 全过;types / service / web typecheck 通过;
      本地重算实测卡片显示「2022-06-19 · 张 敏」,与筛选同源。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 筛选面板 React 重复 key —— 三个维度共用 visit_recency 这个 key · 5885da71
      `<div key={dim.key}>` 在 visit_recency 出三个可筛字段(上次时间/上次医生/偏好医生)后
      产生重复 key,React 报 "Encountered two children with the same key"。
      改用 personaTagDimId(dim)(维度唯一标识,正是为这种情况加的),并就地注释说明。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 医生筛选做成可搜索输入框(方案 A:前端模糊 + 后端等值) · ab0d4595
      业务要「上次医生 / 偏好医生」筛选。医生是开集(单宿主几百个),chip 平铺放不下。
      
       核心取舍:**模糊只发生在前端**。
        输入框在已加载的医生名单里做子串匹配,选中后发**精确姓名** → 后端仍是等值查询,
        吃得到偏索引。若改成后端 ILIKE '%x%',这三条索引立刻失效、退回全分区扫 ——
        就是 2026-07-29 那个「圈人 20 秒」。要真·后端模糊得先上 pg_trgm + GIN,
        并确认托管 PG 允许装扩展(见迁移注释)。
        附带好处:客服不用记全名、不会因打错字得到空结果还以为"真没人"。
      
      改动:
      - PersonaTagFilterDim 加两个字段:
        · `id?` —— 筛选串 `id:value` 的维度标识。**同一特征出多个可筛字段时必填**:
          visit_recency 一个特征出 bucket / lastDoctor / preferredDoctor 三个,只靠 key 分不开。
          不填回落 key,存量维度零影响。新增 personaTagDimId() 统一取值。
        · `dynamic?` —— 取值是开集,跳过闭集校验(维度 id 仍校验,取值走参数化 equals,无注入面)。
      - plan.service 的筛选组装改为**按维度 id 分组**(原按特征 key 分组,三个 visit_recency
        维度会被串成一条 OR,筛出错的人)。
      - 新增 GET /pac/v1/plans/doctors:本 scope 医生名单(visit_recency 的 lastDoctor ∪
        preferredDoctor 去重),Redis 缓存 6h —— 医生变动以月计,不该每次开面板都跑 DISTINCT 聚合。
        ️ 路由声明在 @Get(':id') **之前**,否则 'doctors' 会被当成 planId。
      - 前端 DoctorPicker:已选可点掉;输入为空**不铺全量名单**(几百个会把面板撑爆);
        名单拉取失败则输入框仍可用,只是没候选。
      - 迁移 20260729080000 补两条医生索引(与 bucket 同形态:btree(表达式, persona_id),
        第二列让内层 EXISTS 走 index-only scan)。
      
      测试:persona-spec-drift 补两条 ——
        · 开集维度 options 必须**空**(留样例值会让前端误以为是闭集,把没列出的医生筛没了);
        ·  维度 id 必须唯一(重复 id 会指向错的 dataPath,筛出错的人)。
      655 tests / 42 suites 全过;types / service / web typecheck 通过;
      本地 GET /plans/doctors 实测 200。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 新增 visit_recency 特征 —— 上次到诊分档/医生;筛选项改隐藏而非删除 · c241da85
      按 2026-07-29 四点反馈做:
      
      【1】下线的 5 个筛选项改成**隐藏**而非删除。
        PersonaTagFilterDim 加 `hidden?: boolean`,面板 `.filter(d => !d.hidden)`。
        这样 API / MCP 仍接受这些维度 —— 已保存的筛选链接、机器人调用不会突然失效;
        persona_features 照常写、偏索引照常在,复活只需删一行。
        (上一版直接从字典摘掉,连 API 一起禁了,比"前端隐藏"狠。)
      
      【2】【4】新增画像特征 visit_recency —— 一个特征喂三处:
        · 列表卡片的「2022-06-19 · 张敏」
        · 圈人筛选的「上次时间」(0-3/3-6/6-12/12-18/18+ 月),已接面板
        · 「上次医生 / 偏好医生」数据已就位,面板未接(医生是高基数,静态 options 的 chip
          面板放不下,需可搜索下拉 —— 已在 persona-tag-filters 注释说明)
      
         偏好医生用**主治医生**代替(病历里出现频次最高的医生)——
          DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;换成真值时消费方不用改。
      
         到诊口径用 visitFactsOf(encounter + actual treatment + 挂号),与 rfm / lifecycle /
          urgency **同一口径**。我上一版卡片是现算 encounter ∪ emr —— 两套口径会算出两个"末诊",
          卡片与筛选打架。本次把卡片改成读同一份画像行,fetchLastVisits 删除,
          两条查询合并成 fetchCardFeatures 一条(顺带少一次往返)。
      
         lastDoctor 只认**末诊当天**那份病历的医生,跨天不认 —— 否则"日期是A次、医生是B次",
          客服照着说就露馅,宁可留空。本地实测填充率:主治医生 1133/1133,上次医生 392/1133(34.6%)
          —— 因为末诊常落在没有当天病历的到诊上(挂号/治疗)。这是刻意的准确性取舍。
      
         实现 nextBoundaryAt:纯时间流逝就会跨桶,不报的话水位闸永远不放行、分档停在算的那一刻。
      
      【3】筛选字段加索引:迁移 20260729080000,visit_recency.bucket 的偏索引
        (btree(表达式, persona_id),第二列让内层 EXISTS 走 index-only scan)。
        这是 persona-tag-filters 顶部那条纪律的兑现 —— 加维度必须同时加索引,
        漏了就是 2026-07-29 那个「圈人 20 秒」。新特征建索引时表里还没数据,故无需 CONCURRENTLY。
      
      登记:PersonaFeatureKey / FeatureRegistry / PERSONA_FEATURE_SPECS / PERSONA_FEATURE_META
        四处齐全(测试 persona-spec-drift 会卡漏登记,本次就被它卡住两次)。
      
      654 tests / 42 suites 全过;types / service / web typecheck 通过;
      本地重算实测特征写入正常。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • merge: fix/source-unit-resolver-race → test(跨宿主串味根治 + 关系边不再静默丢) · 56f4d557
      两个 P0(2026-07-29 查验 FRIDAY 测试环境推送时定位):
        1. source_unit 解析器挂在 Nest 单例的实例字段上,push(并发)与 pull 互相覆写
           → 建出空命名空间的重复患者主档(jvs-dw 51063 条、friday 498 条)。
        2. 关系边/回访在本人未入库时静默丢弃,不计 failed 不进回执
           → 宿主先推 customer_referee_circle 时 10169 条边只落 257 条(2.5%)。
      
      部署后需让 FRIDAY 重推 customer_referee_circle 补齐关系边;
      线上已有的空命名空间重复主档另行归并,不在本次范围。
      luoqi committed
    • fix(sync): 关系边/回访的本人缺席改建空壳 —— 兑现「推送顺序无要求」 · 076a92db
      同一情况三条路给了三个答案:
        processSubject              → ensurePatientStub(),照常落库
        processPatientRelations     → `if (!patientId) continue`,静默丢
        processPatientReturnVisits  → `if (!patientId) continue`,静默丢
      
      那句 continue 的注释写着「非 active client,无处挂靠」,但 cold-import 的 cohort 路径
      经 injectPatientFilter 对每张表都按患者 id 过滤、patients 又先跑,它几乎永不触发;
      真正踩中的是 push —— 宿主先推 customer_referee_circle(07-27)、后推
      customer_basic_info(07-28~29),10169 条关系边只落 257 条(2.5%),
      且不计 failed、不进回执,宿主看到的是 accepted=N / failed=0,完全无从察觉。
      
      契约文档承诺「推送顺序无要求」,兑现它靠的就是空壳兜底(pull 侧的等价物是
      ClickHouseSourceService 的「反向拉主档」)。空壳只建**本人**这一侧;关系的对方
      (relatedPatientId 可空)不建,靠 upsert 的 `update: { relatedPatientId }`
      在对方入库后重推时回填 —— 那段回填代码本来就在。
      
      加源码闸 ingest-patient-stub-consistency.spec.ts 锁住「三条路对齐」,
      将来加第四个 process* 方法不至于再漏。已验证该闸对修复前的代码报
      processPatientRelations / processPatientReturnVisits 各 1 处静默丢弃、
      且两者都缺 ensurePatientStub。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): source_unit 解析器不再挂单例实例字段 —— 根治跨宿主串味建出的重复主档 · 62bcc0af
      ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段,每次摄入开头
      覆写、在长 async 循环里读。原注释「cold-import 按 host 串行,实例字段安全」在 push
      (webhook,按请求并发)接入后失效:
      
        friday  identity_namespace_field = tenant_id
        jvs-dw  identity_namespace_field = brand
      
      两者并发摄入互相覆写 resolver → 对方的行解析不出命名空间列 → source_unit=''
      → 患者索引 (source_unit, external_id) 未命中 → 建出空命名空间的重复患者主档。
      
      测试服务器实测(2026-07-29 查证):
        - friday 首推 07-23 16:24:26,jvs-dw 首个空品牌患者 16:24:31(5 秒内);
          此前 jvs-dw 自 06-28 起 39 万患者零个空品牌。
        - jvs-dw 空品牌患者 51063 条,其中 51037(99.95%)与真主档同 external_id;
          friday 空品牌 498 条,496 条落在 jvs-dw pull 窗口内,354 条是重复档。
      
      改法照抄 tenantResolver 一贯的纪律:局部 const 构建 + 逐层传参。涉及 4 个入口
      (reparse / ingestRawTables / importDirectory / importPatient)与 5 个读取方法
      (processPatients / processPatientRelations / processPatientReturnVisits /
      processSubject / hydratePushLookupTables)。纯管道改造,无行为变更。
      
      并加源码闸测试 ingest-resolver-no-instance-state.spec.ts:这类 bug 单跑任何一条
      路径都正确,只有并发交错才炸,单测抓不到,只能在源码层禁止 `this.*Resolver =`。
      (已验证该闸对修复前的代码报 4 处赋值 / 9 处读取。)
      
      注:线上已污染的 5.1 万条空命名空间主档需另行归并,不在本次范围。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 卡片换行序 + 标签不折叠;筛选面板改「筛选标签」并按 Tier 重排 · 487041a9
      卡片(业务 2026-07-29):
      - rail 行序调换:「潜在治疗 + 上次到诊·医生」升到第二行,手机/诊所/操作按钮沉到第三行。
        理由记在注释里:客服扫列表是先看「这人有什么可谈」(治疗项 + 多久没来),
        决定要跟进了才看联系方式、按认领。
      - 潜在治疗标签**不再折叠**(原 rail 封顶 2 个、卡片封顶 3 个 + N)。标签多的患者本来就该优先跟,
        让它换行占地方是对的;折成 +N 等于逼客服多点一次。两处口径一致,过时注释一并订正。
      
      筛选面板:
      - 文案「画像标签」→「筛选标签」,面板标题「按画像圈人」→「按标签圈人」(空态文案同步)。
      - PERSONA_TAG_FILTER_DIMS 按业务 Tier 重排:
          Tier1 治疗维度:潜在治疗、治疗史
          Tier3 属性维度:价值分群、紧迫度、年龄段、权益身份、转介绍达人、家庭构成、性别
      - 下线 5 个维度:时间偏好、生命周期、治疗敏感、特殊关注、禁忌。
        **注释保留而非删除** —— 业务口径反复过不止一次,留着比重写便宜;persona_features 仍在写
        这些特征、偏索引(迁移 20260728020000)也还在,随时可复活。
      - Tier2(偏好医生 / 上次医生 / 上次时间)留了位置和原因,未实现:三者都缺数据基础,
        不是加个 option 能解决的(详见文件内注释与给用户的说明)。
      
      ️ 禁忌下线的**只是「圈人筛选」这一处**:它仍在患者详情画像卡展示、仍参与 persona 计算。
         安全信号不因为不能拿来圈人就消失。这一条与 2026-07-27 用户「禁忌要做成四类筛选」的
         指示相反,是本次业务文档要求,已在给用户的回复里单独点出待确认。
      
      654 tests / 42 suites 全过;types / service / web 三处 typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • refactor(plan): 潜在治疗标签改「XX治疗」措辞 + 卡片字段顺序对齐详情页 · 6248a4f8
      - 措辞:潜在种植 → 种植治疗、潜在补牙 → **充填治疗**、潜在早矫 → **早期矫治**,余类推。
      - 顺序:姓名 → 病历号 → 性别·年龄(原来病历号在最后),与详情页患者卡一致。
      
       中文改在**展示层字典**(POTENTIAL_TREATMENT_CARD_LABEL),不改画像 extractor 的 zh:
        画像里的 labels 是算画像那一刻写死进 JSON 的,改措辞得**全量重算画像**(百万级,几小时)
        才看得到。所以接口改成透 potential_treatment.**types**(code),中文由前端查表 →
        改词即时生效、全站一处。未知 code 原样显示,不静默吞标签。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 召回池卡片信息重排 —— 补病历号/上次到诊/上次医生/潜在治疗,诊所名按权限收起 · 844d19be
      业务给的调整方向:去掉诊所名,加病历号、上次时间、上次医生、潜在治疗标签。
      诊所名改成**按登录数据权限判断** —— 单诊所权限的人每行都是同一家,是噪音;跨诊所才有区分价值。
      
      改的是两个组件(你截图的 10457 人那个列表是 patient-picker-rail,不是 plans-list-app 的卡片,
      两处都按同一套字段改了,保持一致):
      
      后端 plan.service.list —— 两条**整页批量**查询,不逐行发(本页 ≤25 人,都走 patient_id 索引):
      - fetchLastVisits:DISTINCT ON 取每人最近一次 encounter_record ∪ emr_record。
        · 并集是必须的 —— 部分宿主 appointment.in_time 缺失,只有 EMR 能证明到过场
          (jvs-dw 实测有患者 0 encounter / 39 emr),口径与详情页 lastVisit 一致。
        ·  医生取**同一次就诊**的医生,不是"历史最常见医生",否则会「日期是A次、医生是B次」错配,
          客服照着说就露馅。doctor_name 只有 emr_record 带(encounter_record 实测两宿主都 0% 有名),
          故同一时刻并列时优先取 emr;取不到名返 null,不拿 doctor_id 顶替(工号对客服无意义)。
          测试服实测覆盖率:池内 2,990 个有就诊记录的患者里 2,966 拿得到医生名(99.2%)。
      - fetchPotentialTreatments:取当前版画像 potential_treatment.labels。
        它与卡片上的"召回理由"高度重叠但不等价:画像覆盖该患者**全部**潜在治疗,
        召回理由只是其中"这次值得召回"的子集。业务要的是前者。
      - patient select 补 medicalRecordNumber(jvs-dw 覆盖 91% / friday 63%,缺则前端不占位)。
      
      前端:
      - 行1 追加病历号(mono,弱色);行2 的诊所名加 showClinic 闸;
      - rail 新增第三行:潜在治疗 chips(左)+ 上次到诊·医生(右),两者全缺则整行不渲染、不虚占行高;
      - 列表卡片:chips 接在场景/状态之后,上次到诊放底栏 meta 位。
      - PotentialTreatmentChips 刻意比 ScenarioChip 弱一档(无底色、细描边):场景 chip 是
        "这次为什么召"=主信息,潜在治疗是"这人还有哪些没做"=背景信息,抢色会让客服分不清主次。
        卡片铺 3 个、rail 铺 2 个,其余折 +N(卡片是扫读用的,标签一多就不是信息是墙)。
      - ️ showClinic 判据是 `visibleClinics(user).length !== 1` 而不是 `> 1`:
        诊所字典未加载时 visibleClinics 返回 0(集团级用户 clinicIds 为空 → 回退全字典 → 字典空则 0),
        按 `> 1` 会把集团级用户也误隐藏。只在**确知单诊所**时隐藏,未知一律显示。
      
      验证(本地):rail 行实测渲染为
        「张红 女·43 岁 WY0A007548 / 177****4845 / [潜在种植][潜在根管]  2022-06-19 · 张 敏」
      诊所名「元和王永口腔」在该单诊所账号下已隐藏。654 tests / 42 suites 全过;
      @pac/service 与 @pac/web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 召回池排序键恢复精度 + 「应治未治」改文案为「潜在治疗」 · 1966d351
      本地这一轮的两处展示层修复(同一批文件有重叠,合成一个提交)。
      
      ━━ 一、排序键精度(真 bug)━━━━━━━━━━━━━━━━━━━━━━━━
      
      用户截图:召回池按优先级降序,看到的却是「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」。
      
      根因是两个数不同源:
        排序:ORDER BY priority_score DESC, created_at ASC
              而 score = Math.round(raw * 10) —— 0-100 的**整数**
        展示:(raw ?? score/10).toFixed(2) —— 取 breakdown.priority.raw,0-10 的**两位小数**
      排序精度比展示精度粗 10 倍 → 同一整数档里塞着一堆不同展示分,档内只能按 created_at
      兜底排。测试服实测:priority_score=31 一档有 21 种展示分(3.20~2.59);
      全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。
      
      - priority-scorer:不再取整。raw 先定到展示精度(两位小数),score = 该值 × 10,
        breakdown.raw 复用同一变量 → **score === raw × 10** 成为不变量。
        列本来就是 Float(schema 未动);量纲仍 0-100,queueStats 的 70/40 分档、
        前端进度条 score/100 均不受影响。
      - PriorityBar(列表页 + 患者轨):展示改用 score/10,删掉 raw 入参。
        原来「优先读 breakdown.raw、否则 score/10」是双源,正是分叉起点;两处都加了注释。
      - 迁移 20260729060000:一次性回填存量。plan-engine 的 unchanged 分支本来就会就地改
        priorityScore,下一轮全量重算(每 2h)能自愈 —— 但在那之前页面所有分会显得"变整"
        (2.74 → 2.70),像精度倒退。用 numeric 运算避免 3.12*10=31.200000000000003 的
        浮点噪声;带「值确实不同」判断,幂等。
      
      ━━ 二、文案:应治未治 → 潜在治疗 ━━━━━━━━━━━━━━━━━━━━
      
      只改**展示层**,共 8 处:场景 chip(labels.ts)、话术段落标题(服务端 SECTION_META +
      深度档兜底标题 + 前端流式/空态两处 label)、列表与详情的「其余 N 项…」、助手建议问句、mock。
      
      ️ SECTION_HEAD_TO_ID 那个 markdown 解析键**一个字没动**,仍是「告知应治未治」——
      它匹配的是已落库话术正文里 AI 写下的 `## 告知应治未治`,以及 prompt 当前仍在输出的标题。
      改它 = 存量话术全部解析不出 informMissed 段,打开就空白。已在该常量上写了注释说明。
      prompt 侧的 {应治未治项} 等约 60 处占位符同样未动(动了要 bump 版本 + 全量重生成)。
      
      ━━ 验证(全部本地)━━━━━━━━━━━━━━━━━━━━━━━━━━━━
      
      - 新增 priority-score-precision.spec:锁 score===raw×10;断言「按 score 排序」与
        「按展示值排序」结果一致且单调不增;并在邻近分里自动找出一对「旧口径判等、展示不同」
        的样本,证明新口径分得开。
      - recall-suppression.spec 那条 toBe(45) 锁的是被丢掉的精度,改为 raw=4.49 / score=44.9。
      - 本地重算 4 条 friday plan:score 31 → 31.2 / 31,与 breakdown.raw 1:1。
      - 迁移本地试跑 13,473 reason + 7,923 plan;复跑 0 行(幂等);
        「排序键 ≠ 展示值×10」的行数 0;页面分数从 2.70 恢复 2.74。
      - 文案:特意找一条**正文仍是老标题**的存量话术挂到当前 plan 上验证 —— chip 显示
        「潜在治疗」、段落标题「告知潜在治疗」、且段落内容完整渲染(老正文照样解析得出)。
      - 654 tests / 42 suites 全过;@pac/service 与 @pac/web typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(push): 一行脏数据不再拖垮整批 + 补第三种退费表达 + 回执可自诊断 · 9c4065d8
      2026-07-29 线上:FRIDAY 推 patient_settlement 反复 SocketTimeoutException。
      查下来是一条链,三处都得补。
      
      【事故链】
      ① manifest 的退费识别只认两种表达:status=4、status=3 且 receivable_this<0。
         FRIDAY 还有第三种 —— 应收不动、只把钱退回去(status=3 / 应收 0 或 6000 / 实收 -500~-6000),
         测试服 57,939 条结算里 4 行。它们漏进 payment_rows → payment_record 的
         amount_cents<0 撞 zod nonnegative。
      ② 当时 FactWriter.bulkWrite 是**整批 zod 校验、一条不合格整批抛错**,ParserPipeline
         捕获后降级 per-entry —— 而 per-entry 是**每 fact 一个 $transaction**。
         单批 200 行:bulkWrite 3 秒 → 逐行事务 63.5 秒(实测 199 次事务累计 79 秒;
         对照组:空事务 2.7ms、那条 findFirst 4.1ms —— 数据库不慢,慢在往返次数)。
      ③ 63.5s 超过宿主 60s 读超时,对方收不到响应就重推同一批;而 PAC 侧回执是
         status=success / failed=0,对方也看不出是自己哪一行的问题。
         实测从 07-28 20:00 到 07-29 09:00,同一批 200 行推了 13 小时,一条没进。
      
      【改动】
      - data/friday/manifest.yaml:
        · payment_rows 加 net_receipts_this>=0(负实收不算消费)
        · 新增 _refund_neg_receipt(status∈{1,3} 且 应收>=0 且 实收<0)并入 refund_full_rows。
          与既有两条零重叠(全库无 status=4、无应收<0),refund 的 amount 本就取 net_receipts_this。
      - fact-writer.service.ts:bulkWrite 由「整批抛」改为「隔离违规条目」,
        返回 { results, rejected };新增 FactReject / BulkWriteOutcome。
        违规条目只带定位信息(type / subjectId / transactionId / 字段级 issues),
        **不带 content 原文** —— 回执会进对方日志,患者姓名金额不该顺着外流。
      - parser-pipeline.service.ts:消费 rejected 计入 factsFailed + factRejects;
        外层 catch 从此只兜真正的批级故障(DB 异常/事务超时),不再因一行脏数据降级。
      - cold-import.service.ts / push.schema.ts / push-receiver.service.ts:
        factsFailed + factRejects 一路透到 push 回执(封顶 10 条,同种错整批同因)。
      - channel-push.mdx §9.1:补 factsFailed / factRejects 字段说明与实例,
        讲清 failed(行没进) vs factsFailed(行进了但派生不出事实)的区别。
      
      【验证】新增 3 个回归用例锁住隔离语义(好行照常写 / 全违规不抛错 / 正常路径不受影响,
      并断言回执不含 content 原文);650 tests / 41 suites 全过;typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  2. 28 Jul, 2026 10 commits
    • fix(push): lookup 的 from 表缺席时从 PAC 库重建 —— 兑现「推送顺序无要求」 · 2fd1cdf2
      接入文档 channel-push.mdx §7.3 承诺「推送顺序无要求;业务数据先于患者主档到达时,
      PAC 先建档、主档到达后补全同一人」。实际做不到:push 是**单表**请求,
      ingestRawTables 只把被推那张表放进 tables
      
          const tables = { [opts.source]: opts.rows };
          for (const tf of transforms) if (tf.input && !tables[tf.input]) tables[tf.input] = [];
      
      只兜底了 input,没兜底 lookup 的 from。于是 transform-engine 拿不到 from 表,
      warn 一句就当空表、select 字段全填 null —— 换什么推送顺序都没用,单表请求里
      from 表永远不在场。
      
      2026-07-28 FRIDAY 开发推测试数据时踩到:
        customer_referee_circle 的规则要 lookup customer_basic_info 取**对方性别**,
        用来把关系码 3(本人是对方子女)拆成 father / mother。拿不到性别 → rel_sex_key="3|"
        → patient_relation.yaml 里码 3 唯独没配缺性别兜底 → 掉 _default: other。
      
        实测(测试服):
          friday  257 条关系边:father=0  mother=0  other=211
          jvs-dw  76,999 条(走 cold-import,from 表齐全):father=6,837 mother=10,715(占 23%)
        连带 family_structure 的「多代之家」系统性漏判 —— friday multigen 只剩 2 个,
        且全靠 grandparent 撑,该识别成多代之家的都被压成 single。
      
      改动:
      - transforms.schema.ts:lookup 新增可选 push_fallback { entity, select{column,map} }。
        不配 = 保持旧行为,存量 host 零影响。
      - push-lookup-fallback.ts(新):按 input 行的 left_key 去 PAC patients 捞实体,
        拼成"长得像 from 表"的合成行 { [right_key]: external_id, ...select }。
        · source_unit 消歧:external_id 在集团型宿主跨品牌撞号(FRIDAY 22 个 source_unit,
          实测 310 个 id 撞号)→ 捞取按 input 行解析出的 source_unit 圈定;仍落到多个品牌
          则**整条跳过**填 null 走 _default,不硬猜。
        · map 把 PAC 归一值反写回宿主原编码(male→'1'),保证 push 与 cold-import 产出
          **同一个** rel_sex_key —— 否则两条通道各配一套 enum_mapping,迟早漂移。
        · map 未命中 / 空串性别 一律当"取不到",不把 PAC 归一值漏给只认宿主编码的下游。
        · IN 列表按 5000 分批(bind-var 32767 上限,存量补摄踩过)。
      - cold-import.service.ts:transform 前调 hydratePushLookupTables。
        只在 tables[from] 缺席或为空时接管 → cold-import / reparse 不受影响。
      - friday manifest:给那条 lookup 配上 push_fallback。
      
      残留(有意为之):对方**从未进过 PAC** 时仍取不到性别,落 other。这是"不知道"而不是
      "猜错",且存量首推收尾跑一次 reparse 即可补齐(重放时库已全)。
      
      验证:新增 7 个用例连着 runLookup 一起断言最终值(中间对了下游错了没意义);
      647 tests / 41 suites 全过;service typecheck 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(migrate): 多语句迁移里不能写 CONCURRENTLY —— 解测试服部署卡死 · 8dba340f
      现象:测试服 deploy 挂在 migrate 步,pac-service 起不来,任何人往测试服部署
      都会挂在同一处(跟部署内容无关)。
      
        ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)
      
      根因是我在 20260728020000 里写了 14 条 CREATE INDEX CONCURRENTLY。
      Prisma 把整份 migration.sql 用**一次 simple query** 发给 Postgres,而 Postgres 只对
      「一个查询串里多条语句」隐式开事务块 —— 分界线是**语句条数,不是 Prisma 版本**:
      
        20260701060607 / 20260727070000 / 20260727110000  各 1 条语句 → 无隐式事务 → 跑得通
        20260728020000                                    14 条语句   → 隐式事务   → 必挂
      
      而 20260727070000 里那句「Prisma 6.19 已不成立(指事务包裹)」是**错的归因** ——
      单条语句侥幸跑通被总结成了「新版没有事务包裹」,我照抄进 20260728020000,于是踩爆。
      
      失败后果比失败本身更重:_prisma_migrations 留下一条 finished_at / rolled_back_at 全空、
      applied_steps_count=0 的记录,Prisma 从此拒绝执行**任何**后续迁移(P3018),
      整条部署流水线被堵死。同一颗雷已随 6333137a 合进 main,生产下次部署会撞一模一样的错。
      
      改动:
      - 20260728020000:14 条改为**普通** CREATE INDEX IF NOT EXISTS(可在事务里跑)。
        大表环境靠「部署前手工 CONCURRENTLY 先建」保证不锁表(两台机器 2026-07-28 已建完,
        故本迁移在两台上都是 IF NOT EXISTS 空跑);从零建库时表为空,普通 CREATE INDEX 瞬间完成。
        文件头补上完整复盘,明写"别再改回 CONCURRENTLY"。
      - 20260727070000 / 20260727110000:订正那两处错误归因,改成"因为本文件只有 1 条语句"。
      
      验证:
      - 把修好的整份文件用 `psql -1 -v ON_ERROR_STOP=1`(显式单事务,正是 Prisma 那种场景)
        在测试服跑通,14 条全 skip,无 25001。
      - 测试服已 `prisma migrate resolve --rolled-back 20260728020000_...` 解除 P3018 阻塞,
        14 条索引仍在(pg_indexes 计数 14),库结构未被破坏。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(stats): plan 详情页 PV/UV 埋点 —— plan_event_logs 加 view 事件 · 1bccd47f
      背景:生产此前**完全统计不了 PV/UV** —— 前端无任何分析埋点(gtag/umami/plausible
      全无),服务端无 request log,生产 ECS 上更没有 nginx(pac.friday.tech 走独立网关
      lb1.friday.tech,访问日志不在我们这台机器上)。Sentry 只有 10% tracing 采样,
      做 UV 会系统性偏低。
      
      范围限定在 plan 详情页(切合 plan_event_logs 表名):plan_id / patient_id 本就是
      该表的必填列,浏览事件天然填得上,不必为它放宽约束或另立新表。
      
      三种入口按用户口径**全收**:直接进/刷新 · 患者列表 rail 切换 · /plans 落地自动跳转。
      (自动跳转确实是系统行为,但业务侧确认要收 —— 它也代表这条 plan 被展示过。)
      
       唯一的雷:byHuman 不是"是不是人做的",是**口径开关**
        HUMAN_TOUCH_EVENTS 靠 filter(byHuman) 派生。view 按字面语义该写 true,但那会让
        「客服处理过哪些患者」静默膨胀成"看一眼也算处理"——刚交付的 65 人统计与整张
        客服明细 Excel 全部失真,**而且不会有任何编译错误或类型报错**。
        故 view 写 byHuman:false,并把该字段注释从"是否人工动作"改写成明确的口径定义;
        spec 里用**精确相等**(非 arrayContaining)锁死 HUMAN_TOUCH_EVENTS 的四个成员,
        以后新增事件必须显式决策要不要计入,改错即红。
      
      改动:
      - enums: PlanEventType.VIEW + PLAN_EVENT_META 项(group 联合类型扩 'view')
      - controller: POST /pac/v1/plans/:id/view。**刻意不加认领闸**(区别于 recall-feedback)——
        浏览发生在认领之前,池子里任何一条都能点开;加闸则埋点只剩已认领的,PV/UV 直接废掉。
        plan 不存在静默返回 not_found:埋点是旁路,不能因它失败而影响页面。
      - 前端: 埋点挂在 use-plan-aggregate 现成的去重守卫里 —— 该守卫原为防 StrictMode 双调,
        其语义恰好等于"每进入一次详情页";refresh() 直接调 load() 走不到这里,故手动刷新不重复计。
        失败 .catch(()=>{}) 吞掉。
      
      验证(本地起真实前后端 + 浏览器实操):
        /plans 自动跳转 → 记 1 条 ✓    手动点刷新 → 仍 1 条(不重复计)✓
        重新进入页面   → 累计 2 条 ✓    未认领查看 → 能记 ✓
        plan 不存在 → not_found 不报错 ✓  无 token → 401 ✓
         只有 view 事件的患者,在「处理过」口径下计 0(全事件口径会误计 2)✓
        640 tests passed,service/web typecheck 通过。
      
      无 DB 迁移(event 是既有 text 列,加的是取值不是列)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed