1. 11 Aug, 2026 7 commits
    • feat(中间表): 分页改滚动加载 + 姓名/病历号/医生/客服 四个筛选(服务端过滤) · b3334df7
      翻页按钮换成滚到底继续加载(与批次列表同一套:scroll 事件 + inFlight ref 守卫 +
      按 planId 去重, 不用 IntersectionObserver —— 零高度哨兵不回调、内置浏览器面板里
      整个不工作,那条路没法在我们的验证环境里证伪)。
      
      筛选四个:姓名/病历号(防抖 300ms)、医生(主治**或**上次接诊任一命中,候选走
      /plans/doctors)、专属客服(候选走本诊所名册,另有「无专属」一档)。
      🔴 全部在**服务端**过滤:滚动加载只加载了前几页,放前端过滤就成了"在已加载的那几页里搜",
         第 7 页的张三搜不到还不报错。
      
      🔴 **筛选只影响"看",不影响"分"**:确定按钮上永远写整格人数(「确定,交给助手(420 人)」),
         筛选条旁边再明说一遍。这是"筛到 12 人却分出去 420"这个误解唯一的拦截点。
         要让筛选参与圈人是另一件事(医生/客服本就是画像维度,得一并传给助手), 别偷偷用 filtered。
      
      🔴 途中真炸过一次,注释钉住了:`input.agentUserId === NO_OWNER` 这种写法,在服务进程
         抱着旧 @pac/types dist 时 `NO_OWNER` 是 undefined,而不传筛选时 agentUserId 也是
         undefined → 恒等成立 → **每次查询都被悄悄加上"只看无主的"**,420 人的格子只回 20 行,
         而 count 仍是 420(它不走这段)。改成先判"传没传"再判哨兵。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(矩阵): 点格子先弹中间表看全量患者,确定才交给助手;助手不再默认最大化 · 07e5cabf
      主管点的是一个数字(「充填 · 半年到1年 420」),而他要为这批人负责。
      现在多一步:摊开这一格圈定的**全部**患者(姓名/病历号/性别/年龄/主治医生/
      上次就诊医生/专属客服),看过再点「确定,交给助手」。
      
      🔴 与出确认单**同源**:GET /plans/assignments/cohort 用的是 propose 那段
         **同一个** cohortWhereSql + 同一个 SELECTION_ORDER。 别为它另写查询 ——
         排序一旦不同,第 1 页看到的人 ≠ 助手真会挑走的前 N 个,而且两边都不报错(T14)。
         实测:中间表标题 420,助手随后说「这批 50 人从符合条件的 420 人里挑」,对得上。
      
      分页取,不一次拉全:最大的格子四千多人(测试服 充填·3年以上 4,159),
      一次拉回来 DOM 撑不住,主管也不会逐行看完。
      ️ 这里**用 offset 不用游标**(与列表页那条纪律相反,刻意):候选集是静态快照、
         排序键确定性、而且主管要「第 3 / 84 页」这种可回跳的翻页。
      
       助手**默认不最大化**了(handoff 原来传 maximize: true):
         前面已经有中间表让他看过人,再铺满整屏等于把他刚看完的工作台盖掉,
         而他多半还要回去点下一格。要看大图,窗口自己有最大化按钮。
      
      ️ 粒子起点从"格子"改成中间表上的「确定」按钮 —— 从一个已被弹窗盖住的格子
         起飞,看起来像凭空出现。
      
      越权闸两层都有:controller 一次(让"收 clinicId 必过闸"在源码上可数)+
      service 一次(换任何入口进来都拦得住);测试两条都锁。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(矩阵): 档位两版口径 —— 按诊断 / 按末诊,主管现切 · 291a3cdc
      「机会有多老」不可观测(onset_date 宿主 0 填充),手上只有两个观测时间:
        · diagnosis(默认) 医生最后一次写下该诊断 → 患者最后一次被提醒多久了(话术)
        · last_visit      患者末诊               → 这人多久没露面(接通率)
      
      改口径的由头是实测出来的偏差:复诊时医生常不重复写同一诊断 ——
      41.3% 的机会在诊断后患者又来过(平均 6 次)却一次都没再写,诊断日与末诊日
      中位差 526 天(矩阵上差 1-2 档);各医生重复记录率 20.7%~43.2%,差一倍。
      ⇒ diagnosis 档位里含着"这个医生爱不爱重复写"的成分,不该被读成临床新鲜度。
      
      🔴 **这是口径开关不是样式开关**,它决定分到的是哪批人。所以三处同时到位:
        ① 看(/plans/matrix?anchor=)与圈人(cohort-filter ← criteria.anchorMode)同 mode;
        ② 随确认单进 plan_assignments.criteria 快照;
        ③ 批次名标「(按末诊)」—— 只标非默认那版,老批次不用改名。
      少任何一处就是「主管看到 87 人、确认单给另一批」,两个数都对且不报错(T14)。
      
      助手侧判据只有一条:那句话里带「按末诊」三个字才传 last_visit
      (ANCHOR_MODE_TOOL_DESC);矩阵的移交措辞与它必须同进同退。
      
      两版**只换锚点,不换档位切分**(90/180 天 + 自然年),测试锁着这条。
      本地实测(朝阳):按末诊后「3 年以上」整列清空、人数整体左移
      (充填 半年到1年 326→420),因为诊断三年前没做的人其实一直在来。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(矩阵): 第三档改叫「半年到 1 年」—— 前一档到半年为止,「1 年内」会被读成从 0 起 · a3e31288
      六档改成绝对时间轴之后,第三档的实际区间是 180 天–1 年,而列头还写「1 年内」——
      挨着「三个月到半年」看过去,很容易读成"从 0 到 1 年"(那就跟前两档重叠了)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(召回): 就诊冷静 14→90 天;矩阵六档改成一条绝对时间轴 · ad4c7a44
      ① 就诊冷静(⑤f)14 天 → **90 天**。这不是文案改动:近三个月到过诊的人整批退出
         召回池,而引擎跑全量时会把他们**已经在跑的单一并关掉**(0 命中 → supersede,
         连 assigned 的也关,补 auto_release 账)。本地库实测:池子 2,711 条里 276 条(10.2%)
         会被新闸挡掉,其中 3 条已认领。
      
      ② 矩阵前两档从「该治疗自己的临床周期」改成**固定 90/180 天**,列头改叫
         「三个月内」「三个月到半年」——六档从此共用一个锚点、一把尺子。
         · 常量单一真理源 HOT_BUCKET_DAYS / WARM_BUCKET_DAYS(@pac/types)
         · 判档 SQL 里那张按码的 (code,urg,wnd) VALUES 表连同 JOIN 一起删了 ——
           它当年顺带起的过滤由 labelCase 全额承担,行集不变(测试锁着)
         · ️ DiagnosisTreatmentMap 的 urgency/window **仍然**管入池 cooldown 与打分衰减,
           只是不再管矩阵档位,两者已脱钩
         本地实测重分布:3,678 个 (单×码) 里 409 个(11.1%)换档,新的第一档 +20%、第二档 +25%。
      
      ③ 矩阵去掉「潜在治疗」标题行,底部说明缩成一句(前两档那半句解释没有了)。
      
      🔴 `make_interval(days => $n)` 必须带 `::int`:Prisma 把 JS number 绑成 double,
         而这个重载不存在 → /plans/matrix 直接 500(改的过程中实炸过一次)。
      
      不需要重算画像、也不需要手动重跑引擎:档位是读时从召回单证据算的,
      新闸随每日增量 cron 的全量 runAllForHost 自然生效。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 全局 border 规则放进 @layer base —— 全站 258 处彩色 border 类此前一个都没生效 · 4eb9d804
      globals.css 里那条 `* { border-color: hsl(var(--border)) }` **不在任何 @layer 里**。
      无层级样式整体胜过 `@layer utilities`(与优先级无关),于是它压过了 Tailwind
      **全部** border 颜色工具类:写 `border-l-brand-400` / `border-rose-300` 不报错、
      也不生效,算出来永远是那个灰 #D6DDE6。实测登录框 91 个有边框的元素,91 个都是灰的。
      
      被吃掉的不只是"某处颜色不对":
        - `border-t-transparent` —— 两处转圈 loading 的缺口被填成灰,整圈同色,转了看不出来
        - `border-current` —— 本该跟随文字色调,恒为灰
        - ~32 处 hover:/focus: 边框反馈全哑,配着 `transition-colors` 动画一个空
        - chain-viz / facts-timeline 整套语义色板的 border 分支形同虚设
      
      改法:把这条规则包进 `@layer base`。
      ️ Tailwind v4 没有 `--default-border-color` 主题变量(v3 的 borderColor.DEFAULT
      已移除,preflight 改成 `border: 0 solid`),分层的 `*` 规则就是 v4 官方写法。
      
      同时删掉 147 处中性 `border-slate-*` / `divide-slate-*` 类(31 个文件):
      它们此前零效果,而分层后会**开始**生效 —— `border-slate-100`(114 处)会从
      #D6DDE6 变 #F1F5F9(对白底对比度 1.30→1.06,发丝线几乎消失),`slate-50` 直接看不见。
      这些类当初就是对着灰写的(该 `*` 规则从首次提交就在),删掉后落回 base 层默认灰,
      **构造上保证零像素变化**。三个页面实测:中性边框数量与颜色与改前逐一对齐,
      彩色边框恢复(brand-600 = rgb(0,50,160)、brand-400 = rgb(59,118,247))。
      
      batch-tracking 那条竖轨收回工具类(`border-l-2 border-l-brand-400`),
      连同解释这条地雷的注释一起删;`past` 保留 2px 透明边以维持两组首列对齐。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 矩阵摊开常驻,历史批次退成灰调 · 73cf9501
      「分一批新的」不再是按钮 + 浮层:
      · 删掉「样式2 里世界」整版(传送门 / 锤子光标 / 敲击动效)与那个样式开关,
        连同 globals.css 的 .pac-portal-* 一起清掉 —— 只留矩阵那版。
      · 矩阵直接摊在「我分的批次」上方:左栏是上下结构(挑格子 → 看这些格子
        分出去之后跑成什么样),整栏再与「团队现在什么状态」左右并排。
      · 矩阵在助手确认/撤销后跟着重拉 —— 刚分走的人就是从这些格子里出去的。
      
      「我分的批次」区分在跑与历史:
      · 在跑的 = 主色竖轨 + 白底 + 满饱和语义色;历史 = 灰底 + 同色相低饱和。
      · 语义色**保留**(还看得出哪列是退回哪列是约上), 不做成纯灰。
      
      🔴 顺带记下一个全站地雷:globals.css 的 `* { border-color: … }` 不在任何
      @layer 里,压过 Tailwind 所有 border 颜色工具类 —— `border-l-brand-400`
      写了不报错也不生效(竖轨最初就是这么哑掉的)。彩色边框只能走 inline style。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 10 Aug, 2026 13 commits
    • feat(主管工作台): 助手确认/撤销后,两张表自己刷新 · 0ffe757b
      产品:确认分配后主管工作台的两个表格能不能做到刷新。
      
      确认单在**助手浮层**里、两张表在页面上,两者没有父子关系 —— 谁也拿不到谁的 setState。
      所以走一个极简全局信号(与 plan-sync-store「详情页动作同步左栏」同一套路):
        确认分配 / 撤销这批 → notifyChanged()
        两张表的 fetch effect → 把 seq 放进依赖,重新拉
      
       **只发 seq,不带任何数据**( 不学 plan-sync 那样带 planId/status):
         那边改的是"某一行的角标",能就地打补丁;这里落库的是**一整批** ——
         批次列表多一行、每个客服的在手/分到全变、退回率的分母也变。
         想靠事件把这些补丁算准,等于把服务端的聚合口径在前端再实现一遍
         (必漂,而且不报错)。⇒ 一律重拉,让服务端说了算(T14:口径只有一份)。
      
      🔴 批次表重拉必须**回到第一页**(setItems([]) + setCursor(null) 一起做):
         只调 load() 会把新的一页**追加**在旧列表后面,而新批次排最前 ——
         它会出现在列表中段,这是游标分页的天然后果。
      ️ 发信号用 getState() 不订阅:确认单只发不收,订阅了会因 seq 自增把卡片自己也重渲染。
      
      实测(全程没刷页面):
        确认前            全部 27 · 在手 35
        确认「早矫 2 条」  全部 28 · 在手 37
        点「撤销这批」     全部 28 · 在手 35   ← 批次仍在(标已撤销,列表保留做回顾),在手正确退回
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(执行页): 撤掉「召回池」tab 与「分配」按钮 —— 分配已经搬去主管工作台 · a50ad3ff
      产品:主管进客服执行页时,召回池和分配隐藏。
      
      T16 原版把池子和分配挂在这一页,是因为那时主管和客服共用它。
      08-04 评审推翻了后半句、分配搬进独立的主管工作台之后,
      这一页就该回到它唯一的职责:**把手上的任务打完**。留着就是同一个动作两个入口。
      
      ️ 藏 tab 不等于做到了 —— view 还有两条会自己滑到 pool 的路,一起堵掉:
        ① 左栏「我的为空 → 自动回落召回池」的 effect
        ② /plans 落地页「② 召回池第一个(仅主管)」的兜底
           —— 它是最后一条还能把人滑进池子的路:主管的「我的」一空就被送进
           池子里某个患者的详情页,而左栏根本没有那个 tab,他会以为这条单是分给自己的。
      
      顺带清掉随之而来的死代码:矩阵浮层(openMatrix / pickCell / matrix 三个 state)、
      canDispatch 判据、以及 PoolMatrix / Button / useAssistantStore 等 6 个 import。
      ️ view='pool'/'all' 的**取数逻辑保留**,将来要放回 tab 时不用重接。
      
      空态文案跟着改:原来主管那句写着「我的任务与召回池都是空的,可以在左侧调整筛选(视图/…)」
      —— 那两样东西现在都没有了,照说就是让人去找一个不存在的地方。
      改成「这一页只显示分给你自己的任务。要给团队分新的一批,去主管工作台」+ 一个入口。
      
      实测:主管 / 客服进执行页,左栏都只剩「我的」,无分配按钮;
      主管空态给出「主管工作台 」,且不再被自动滑进召回池。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(执行页): 「主管工作台 」嵌入宿主时不该藏 —— 藏了就又变回单向 · 586828ca
      上一个提交刚把工作台的「客服执行页 」在嵌入态保留下来,
      这边却给「主管工作台 」加了 `isEmbedded()`,两条改动自相矛盾:
      主管在宿主里进了执行页就回不去,而"必须双向"正是这条链接存在的全部理由。
      
      当时写的理由是「宿主自有导航」——那个理由是错的:
      宿主提供的是**它自己的**导航,不是 PAC 这两个页面之间的路。
      
      ⇒ 按嵌入态藏的只有**宿主已经给了的那些**:品牌、面包屑标题、身份三件套。
        功能区(诊所切换、两个页面互跳)不藏。
      
      实测(本地宿主外壳 iframe):
        嵌入 · 主管 · 执行页   → 有入口,点了落工作台,那边有「客服执行页 」,双向通
        嵌入 · 客服 · 任务详情 → 入口不出现(权限闸未动)
        非嵌入                → 两边都不受影响
      
      注释里钉了「 别加 isEmbedded(),2026-08-10 加过一次当天就被打回」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(主管工作台): 嵌入宿主时只隐藏品牌与标题, 不再整栏藏掉 · 87b5c373
      产品:「标题栏不要全部隐藏,学一下客服工作台,保持一致」。
      
      执行页的规矩是整栏保留、嵌入时只藏 PAC 徽标 + 面包屑(宿主已提供品牌与导航),
      功能区照常。工作台这边写成了 `if (isEmbedded()) return null` —— 整栏没了。
      
      ️ 代价不只是不好看:嵌入态下**诊所切换**和**「客服执行页 」**一起被藏掉了,
         主管在宿主里再也回不到执行页 —— 那正是上一个提交刚补上的那条路。
      
      ⇒ 与 plan-detail 的 `embedded` 同一条规矩:藏品牌与标题,留功能区。
      ️ 分隔线跟着身份三件套一起藏 —— IdentityCluster 嵌入态返 null,
         不一起藏就剩一条悬空的竖线。
      
      实测(本地起了个宿主外壳 iframe):
        嵌入 · 工作台 → 只剩「北京朝阳公园诊所」+「客服执行页 」,与执行页同构
        嵌入 · 执行页 → 场景 chip / 召回反馈 / 刷新 / 预约 照常
        非嵌入        → PAC | 主管工作台 | 诊所 | 身份,未受影响
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(执行页): 补上回主管工作台的入口 —— 之前只有去、没有回 · 9d5e7862
      工作台头部那条「客服执行页 」旁边的注释早就写了
      「两个页面是双向的, 别做成有去无回」,但实际只做了去的那一半:
      主管点进执行页之后,除了改地址栏没有回去的路(产品发现)。
      
      ️ 判据用 PLAN_DISPATCH,与 /supervisor 那条路由**同一把闸** ——
          不用 role 判断:权限按 role 现算,而 token 里的 role 是签发时固化的,
         两处判据不同源就会出现"看得见入口、点进去被弹回来"。
      ️ 嵌入宿主时隐藏,与 IdentityCluster 同一条规矩(宿主自有导航)。
      
      实测:主管进执行页有入口、点了落 /supervisor;客服进任务详情不出现。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(团队负载): 「分到 N 条一条都没动」把主管自己撤回去的也算进去了 · 59378564
      实测:姜茜「这 7 天分到 53 条,一条都还没动」,拆开是
        20(8/4 17:31 那批,**已撤销**)+ 20 + 13。
      
      头一个 20 是主管自己在撤销窗口里收回去的,她根本没机会碰。
      摆在「分到…一条都没动」里,等于拿主管自己的操作去指责客服。
      
      ⇒ assign 统计排掉落了 `auto_release/revoked` 的那些。
      
      ️ 判据是**逐条 + 同批次**, 不是"整批 status=revoked 就全扣":
         · 撤销刻意不收已被客服打开过(view 事件)的单 —— 那些仍在他手上、仍该算他的
         · 同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣
      ️ 到期回池(assignment_expired)**照样算** —— 单子在他手上放到过期,
         正是「没动」要表达的东西, 别一起排掉。
      
      测试机实测(同一条 SQL 直查):李银兰 62→42、张悦 61→41、康慧捧 58→40、
      刘艳阳 55→35,各减掉自己在撤销批里的那一份;不在该批的(钱俏虹 397)不动。
      
      回归锁 SQL 文本 —— 口径写在原生查询里,$queryRaw 一 mock 就是"喂什么返什么",
      行为测试证明不了任何事。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(登录): 带 code 进 /plans 时卡在「正在进入召回工作台…」 · 16e2134b
      复现条件:localStorage **已经有票**(第二次进来)+ URL 带 ?code=。
      干净浏览器不复现,所以第一次测没发现。
      
      时序:
        ① store 反水合 → isAuthenticated 立刻 true → AuthGate 放行 children
        ② /plans 落地解析器跑起来 → 主管 → router.replace('/supervisor')
        ③ `await exchangeCode()` 才回来,然后 replaceState 把地址写回 /plans
      
      ③ 把 ② 那次跳转冲掉了 —— Next 的路由树被拉回 plans(实测 history.state 里
      `__PRIVATE_NEXTJS_INTERNALS_TREE` 停在 plans),而解析器已经 return、不会再试,
      于是永远停在占位文案上。
      
      ⇒ 剥 code 与 exchange 成不成功无关,提到 await **之前**做。本 effect 跑在首次提交上
      (那时 mounted 还是 false、children 尚未挂载),任何页面的落地跳转都必然在它之后,
      不会再被回写覆盖。
      ️ replaceState 第二个参数改传 window.history.state 而不是 `{}` ——
         App Router 的路由树就存在 history state 里,传空对象等于把它抹掉。
      
      实测三种都过:
        已登录 + /plans?code=        → 落 /supervisor
        已登录 + /plans?exec=1&code= → 走执行页(exec=1 没被剥 code 时一起吃掉)
        清空 storage + /plans?code=  → 落 /supervisor
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(登录): 走宿主 SSO 拿到票之后,还在多发一发 /auth/mock-users · 2aedd46d
      产品带 ?code= 走授权登录,控制台已经 code-ok、票也拿到了,
      浏览器里却还有一发 `mock-users` —— 那是 dev「快速登录」的探测请求。
      
      真因:AuthGate 里那个探测是**空依赖的 useEffect,每次挂载无条件发**,
      结果只在"未登录"那条分流里才用得上。
      
      ️ 它不只是多一个请求:`/auth/mock-users` 是 @Public() 的,
         返回**全部客服的真实姓名 + 所属诊所**(测试机 116 人,公网无凭证可拉)。
         挂在每次加载上 = 每开一次页面就把花名册取一遍,嵌在宿主 iframe 里也照发。
      
      ⇒ 收紧到"下面那条分流真的会用到它":已登录 / 还在 bootstrap / 嵌入态,
        三种都一次不发。️ hooks 不能条件调用,所以在 effect 里判、条件进依赖。
      
      实测:已登录重新加载 → mock-users 0 次,只剩
      session / client-diag / assignments / workload;
      未登录(dev)→ 快速登录框照常弹,没被误伤。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(越权): propose/refill 的 clinicId 走 body,上一轮按 query 参数扫时漏了 · db7e0dbf
      上一个提交补完 /agents、/workload、/plans/matrix 三个 query 参数的读接口,
      **紧接着就漏了这条** —— `POST /plans/assignments/propose/refill` 的 clinicId
      在请求体里,按 `@Query('clinicId')` 去数根本数不到。
      
      实测比前三个都重:朝阳公园的主管带杭州大厦的 id POST 过来,
      拿回了**那家诊所患者的真实姓名**(前三个只到员工名册与人数分布)。
      
      ⇒ 闸钉进 `assignment-proposal.service.propose()` 而不是 controller:
         提案是唯一会吐患者名单的读路径,谁调都得拦得住(REST / MCP / 以后的新入口),
          不指望每个 controller 记得加 —— 这一轮漏的就是"记得"。
      ️ 幂等:MCP 那边已经 resolve 过一次,合法 id 原样返回,再过一次无副作用。
      
      回归同步加一条:锁 service 层那句 resolveClinicId,并禁掉
      `const { clinicId } = input` 这种直接解构(那正是漏的写法)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(越权): 三个读接口不校验 clinicId —— 主管能看到别家诊所的名册/负载/池子矩阵 · a0f846f2
      产品在测试机上发现工作台发了两个 workload 请求、clinicId 不一样。
      查下去不只是"多发一个",是**那一发真的取回了别家的数据**。
      
      ── 服务端(真问题)──────────────────────────────────────────
      `/plans/assignments/agents`、`/plans/assignments/workload`、`/plans/matrix`
      三个读接口拿着查询串里的 clinicId 直接查,不校验是否在 scope 内。
      实测:北京朝阳公园的主管(clinicIds=[66701845…])带杭州大厦的 id 请求,
      拿到 200 + 那家 26 位客服的**姓名与在手负载**;矩阵同样能拿到完整患者量分布。
      
      ️ 写路径一直是拦的(create 里那句 includes 判断),所以分不走别家的人 ——
         但名册是员工姓名、矩阵是患者量分布,读一样不能敞。
      
      闸抽成 common/decorators/resolve-clinic-id:不传取第一个诊所,传了必须在范围内,
      范围外抛 Forbidden(10107)并列出真实 id —— 不能"当成这个诊所没人"返回 0,
      0 是合法答案,静默返回会让助手拿着 0 去解释"为什么这批人是空的"(违 T14)。
      集团级(clinicIds 为空)原样放行, 别把空数组当成没权限。
      
      MCP 里原来抄了一份一模一样的实现 —— 抄一份的直接后果就是补的时候只补了一边。
      现在两边共用一份。
      
      ── 前端(触发源)────────────────────────────────────────────
      第一帧 user.clinicIds 还是 undefined(JWT 里没有这一项,只能等 /auth/session),
      visibleClinics 回落到"字典里的全部诊所" → 锁了第一个「杭州大厦」→ 发出那一发。
      session 回来后自己纠正成朝阳公园,所以肉眼只看见"发了两个请求"。
      
      新增 clinicScopeReady():undefined=还没加载 / []=集团级,两者必须分开 ——
       别改 visibleClinics 的回落语义去顺手修,那会让集团级用户的筛选器空掉。
      实测改后:进工作台只发 1 个 workload,clinicId 正确。
      
      ️ 前端等待与服务端闸是**两层**,缺一不可:查询串是用户可改的。
      
      测试:回归从"grep MCP 源码"改成锁**闸只有一份实现** + **每个收 clinicId 的
      REST 读接口都过闸**(按 @Query('clinicId') 出现次数比对),这正是漏掉的那一类。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 独立路由 /supervisor —— 我分的批次 / 团队现在什么状态 / 分一批新的 · 15b347e3
      🔴 独立路由推翻了 T16 的后半句(2026-08-04 评审定,doctrine 已改写并保留病史):
      需求方三次讲同一件事「场景和思路不应该去混」。T16 没被推翻的那半句仍作数
      (主管也要打电话),所以头部常驻「客服执行页 」,两个页面双向。
      
      后端四处聚合:
      · workload —— 名册走 T10 同源;「当前在手」是此刻,超期/完成/退回/没动都在窗口内
      · 窗口内超期走**账本**:回收器每 10 分钟扫,"当前超期"结构上几乎永远是 0
        (实测账本 378 条 vs 当前 0)。归属回捞到到期前最后一次 assign,
        并上"仍在手且窗口内到期"那一小撮,按 plan_id 去重
      · agentStats[].done 改执行口径(有 plan_executions 才算):池子口径的 resolved
        是引擎判定需求没了,客服一根手指没动也会被算进他的「已处置」
      · 列表补 booked/handled + 游标分页( 不是 offset)
      
      界面:
      · 落地规则双向 —— 有派单权的 /plans→/supervisor(?exec=1 是明确导航意图的出口);
        没派单权的 /supervisor→/plans。原来只有单向,客服落到工作台是**纯白页**
        (Can 无 fallback 渲染 null),不报错也没出口
      · 批次跟踪:滚动位置翻页( IntersectionObserver 在零高度 sentinel 上不触发)、
        inFlight ref 防重入、IDLE_HEAVY=0.2 一个常量两处共用
      · 团队状态:超期上琥珀(好事有色坏事没色,眼睛只会被绿色勾住)
      · 分一批新的两版并存;传送门改 transform:scale 的小圆
        (clip-path 的 at 按 reference box 算,祖先一有 transform/filter 就整体偏掉、
         而且不报错;且 clip-path 不上合成层,那半秒每帧重画整屏)
      
      通话记录:
      · plan_executions.notes 此前**全线读不到** —— 接口一直在,前端没调、MCP 没开工具
      · 逐条明细 + 纪要 + 客服勾的子选项(放弃原因 / 判断不对的治疗 / 约的回访日 / 渠道)
         只回 outcome 等于把客服填的一半烂在库里
      · 删掉 outcomes.note:它把给人看的数和给模型的指令焊在一根 string 里,
        而工具说明还写着"照抄最稳" → 模型把「不要算成功率、不要画图」原样贴进了对话框
      
      测试:$queryRaw 的桩改成**认 SQL 不认调用次序**(加第三条原生查询时次序错位,
      17 条一起红,报的还是 toISOString 这种跟真因无关的错)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 主管说的话用品牌主色 + 「待分配」封高度自己滚 · b9603d77
      气泡:bg-slate-800 → bg-brand-600。助手那侧是白底,主管这侧是这一屏
      唯一的实色块,中性深灰把这个位置浪费了。
      
      待分配:实测 30 条撑到 997px,底下的「确认分配」被顶到三屏开外,
      而主管八成是扫两眼就去点确认的。max-h-60(约 7 条)+ overscroll-contain
      (滚到底不把消息区一起带着滚,手上正拖着人的时候尤其乱)。
       没用固定高度:人少时留白会像"还有内容没加载出来"。
      拖拽自动滚动不受影响 —— scrollerRef 从卡片 parentElement 往上找,找不到这个 ul。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(错误处理): 响应 schema 漂了不再是一句没有日志的「Internal Server Error」 · ee382c07
      ZodSerializationException 继承的是 InternalServerErrorException, 不是
      ZodValidationException —— 名字像,血缘不同。于是它掉进 filter 的通用
      HttpException 分支:code=90000、HTTP 保持 200、**一条日志都不写**
      (那条分支不打日志,"5xx 兜底日志"也因为 status 是 200 而不触发)。
      
      实测代价:workload 接口挂了,接口只回「Internal Server Error」,
      服务日志从头到尾干干净净,查了很久才定位到是响应少了一个字段。
      
      单独拦下并排在通用分支之前,打印 zod issues(哪个字段/期望什么/收到什么),
      非生产环境连 details 一起回;单独打 Sentry tag(这是 PAC 自己的 bug)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 07 Aug, 2026 3 commits
    • fix(plan-detail): 「历史联系」的「详情 →」放回来 · 0dce436d
      卡片上那句是 LLM 压出来的摘要,而摘要天然会丢东西 —— 客服看到「未留结果」
      「未接通」这种结论时,第一反应就是"到底原文写了什么"。没有入口他只能信摘要,
      或者去问别人。抽屉里是逐条原文(日期/类型/内容/结果),正是那个反问的答案。
      
      ️ 抽屉 kind='return-visits' 一直都在(drawer.tsx),2026-07-29 那次只是
      把回调摘掉了 —— 改动就一行, 别当"功能没做"重写一遍。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(简报): 第②句补「跟我们熟不熟」—— 联系过几次 + 被挂断过几次 · a01716f9
      产品 2026-08-04:「第二句是他跟我们以前的关系亲密度是什么样的,就是联系他
      多不多,然后有没有过不好的体验」。原来第②句只写"最近一次联系是什么事",
      "多不多"和"不好的体验"整个缺失。
      
      「不好的体验」只有一种形态(全量查证):
      - `type` 只有 常规回访/咨询回访/术后回访 —— 没有投诉类
      - `result` 自由文本 3 万行 6765 种取值,长尾里 投诉/不满/纠纷/态度/退费 **一条都没有**
      - 真负面只有**「挂断」**:688 次 / 595 位患者(占有回访记录患者的 11.5%)
      ️ 第一遍扫的时候「无不适」被"不适"接住,假阳性 5816 条 —— 负面词表必须排除否定式
      
      三个刻意的设计:
      ① 总次数**单独聚合**:回访历史只给最近 3 条,让模型拿那 3 条数总量必然低估
         (实测 1131 位患者被联系过 10 次以上,全会被说成"联系过 3 次");
      ② 挂断为 0 时**整段不出现**。 不是输出「被挂断 0 次」——一旦出现那个 0,
         模型必然翻译成"沟通一直顺畅"。**没有记录 ≠ 关系好**,那是拿没证据当反面用
         (与 noTag 铁律同一件事);
      ③ schema 与 prompt **必须同时改** —— 只改一边 = 定义了但没喂,静默失效。
         单独一条测试锁两边。
      
      promptVersion → 2026-08-07-closeness(不升的话老患者读到的还是旧简报,
      而本地怎么测都是新的 —— 那种"改完看不到效果"最难查)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(确认单): 给运营结论 —— 这活多大 / 谁加了担子 / 哪些不用看 · e9387833
      产品 2026-08-04 评审原话:「我不可能挨个去看他分的对不对,你在这儿应该给的是
      **结论性的东西**…如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须
      一条一条看过去才能确认。」
      
      卡片顶部新增 opsNote,只答三个问题:
      - 这活多大:本批每人拿到 1~4 条;分完手上共 57~62 条 ——
        按每人每天 15 通算,最满的那位要 5 天打完
      - 谁的批次全是自己的老客户(dedicated === count)→ 点名,主管可以直接过
      
      顺带修掉一个真问题:卡片原来只写「分完每人 57~62 条」——那是**在手总量**,
      而那一批实际每人只给 1~4 条。拟分 16 条的单子上写着 57~62,很容易读成
      "这批好大一坨"。现在增量与总量分开说,并把这句从 basisNote 里挪走
      (它答的是"凭什么这么分",不是"这活多大";两处都说就是复述)。
      
      ️ DAILY_CALLS_PER_AGENT=15 是评审口头经验值不是实测,所以文案写成
      「按每天 15 通算」而不是「需要 5 天」,让主管看得见这个前提;
       常量注释钉死不许接进落人算法 —— 那等于把删掉的"容量"从后门放回来。
      ️ 结论钉在卡片上不进助手那段话:助手的话会被后续对话冲走,而重排换卡片时
      它也不会重说 —— 与 refillNote 同一条理由。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 06 Aug, 2026 7 commits
    • fix: 画像圈人性别返回 0 + 列表姓名搜索失效 + jest 吃满 CPU · 6e79b48d
      - 🔴 姓名搜索/phoneVerified/画像标签全部静默失效:温度重构时把
        `where.patient = {...}` 的挂载整段删掉了,tsc 绿、单测绿、界面无报错,
        只是筛选条件从此不生效。改回合并式挂载(顺带修掉 sourceUnit 被覆盖的隐患),
        补了会在缺这行时变红的回归测试
      - 画像圈人性别男女都返回 0
      - jest 吃满 CPU:transform 里的 isolatedModules 被挪进 tsconfig 后**更慢**
        (18.1s/165s vs 6.9s/43s),因为 pac-service 的 tsconfig 把它设成了 false。
        按弃用警告改反而变慢 —— 以实测为准。配 maxWorkers 50%
      - 新增 tsconfig.typecheck.json(单测不覆盖类型,提交前得单独跑)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(回访结果): 秒挂归到不成功类 + 30 天抑制期 · 3b735314
      秒挂原来算在「保持」里,而它明明是没接通的一种。改 group 之后
      tone 和提示文案没跟着改 —— 边框还是 slate、提示还写着「等下次跟进」,
      而实际已经压 30 天。教训:一个字段辐射到多处渲染时,每一处都要跟着查。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(简报/话术): 回访摘要说事实,别念状态字段;天数不再乱飘 · 0f767c19
      - 回访摘要改读 result/followContent, 不再念「任务已完成」这类状态字段 ——
        主管要的是事实不是流程
      - 「—」这类空占位不再成行:列不出来的就不展示
      - daysText:Math.round(74/30)=2 而 Math.round(75/30)=3,相差一天的两次回访
        被渲染成 2 个月 vs 3 个月。改成按天分档 + 30.44,再修 floor(365/30.44)=11
        让整年读成 11 个月
      - 🔴 标签泄漏三次同一形状(cold_3y → 本批 → 备注):**输入里出现的任何标签
        都可能被原样抄进输出**。逐一堵掉
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 确认单 —— 待分配组、姓名可复制、重排按钮、同批指令不再互相覆盖 · 77bf6bd1
      - 「待分配」单列一组(amber + 顶部粗边),可拖可删可收拢、默认展开,
        带医生列与已分配一致; 不处理就是不分,文案必须说清
      - 顶栏「共 N 人 · 拟分 X(Y 位客服) · 待分配 Z 人」—— 先给总数再拆,
        光写「拟分 21 · 待分配 39」读不出 21 含不含 39
      - 患者/客服姓名点击复制(draggable=false + stopPropagation,否则吃掉整行拖拽)
      - 重排后卡片自己交代换了什么 —— 重排走 HTTP 只换卡片,助手不会重新说一遍,
        不交代就是"上面写着 34 人要您定、卡片显示 0 待分配"两份打架的口径
      - 🔴 同批指令后一条读不到前一条:「李汝明给李闻,剩下的都给高瑞珍」——
        第二条读的还是旧 state,李汝明被连同其余 31 人一起铺给了高瑞珍,
        而两条都回报"已执行"。改成 moveDraft/dropDraft 草稿,最后一次性提交
      - findPatients 要搜到「待分配」、findAgent 要搜到「本批未分到」——
        卡片上看得见的人,指令就必须能指到
      - 拖给「未分到」的客服后组标题不再退化成 #678 · 在手 0
      - 铺平算水位时排除本次要改派的这批(否则算了两遍)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 补齐工具契约缺的那些格子 —— 温度枚举/诊所id/确认单指令 · a07b3691
      同一个形状一天栽了三次:**工具契约少一格,模型必然填错且静默**,
      返回值都是合法的(一个数字、一个 0、一次成功的操作),只有肉眼比对才发现。
      追提示词修不好 —— 得补格子。
      
      - 温度枚举只有 hot/warm/cold(缺 cold_2y/cold_3y)→ 主管点「2–3 年」67 人,
        助手报 324。改成从 TEMPERATURE_ORDER 派生 + 中文对照表 +  不许把码说出口
      - clinicId 必填且无兜底 → 模型编了 CL001,SQL 正确、返回 0,
        助手转头去解释"这批人为什么是空的"。改成可选 + resolveClinicId 兜底,
        范围外**抛错**并列出真实 id, 绝不当成"这个诊所没人"返回 0
      - 确认单指令 8 条并列字面量 → **三个正交的轴**:
        select(patients/agent/pending/batch) × action × to(owner/balance)
        由来:主管说「把待分配的患者各自分给各自的专属客服」,而
        「待分配 × 改派 × 各自的专属」这一格是空的 —— 只有铺平可用,
        18 个人被散给了 17 位别人。而那恰恰是助手唯一不许自作主张干的事。
        正交化不消灭"枚举漏值",但缺的组合变成**表格里的空格**(看得见),
        测试里是一张 test.each 的「说话 → 拼法」对照表,加一行比加一条指令便宜
      
      另:助手第 0 条「说人话」—— 禁说取值码/字段名/工具名/「温度」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 取消自动改派 → 待分配交主管;重排改成「重挑这批人」 · 2dc6c19f
      三趟落人改成两趟 + 待分配:专属客服排满的人不再被自动改派给别人,
      单列一组交主管决策。把患者从他的专属客服手里挪走是关系层面的决定。
      
      「重新排一版」当天推翻两版才定稿,病史都写进注释了:
      - v1 预先下发「顶替名单」→ 名单只能从取数窗口挑,池子 19 位无主、
        窗口里只落进 6 位,界面只敢说"顶 3 位"
      - v2 把整个窗口丢给落人 + stopAt 跳过 → 水位按 chosen.length 算被窗口撑大
        (⌈(1001+50)/17⌉=62 → ⌈(1001+165)/17⌉=69),34 个"专属排满"的人原地进了
        同一位客服手里(她 59→68,别人 58)。底线没破,但负载塌了、口径全错
      - v3(定稿)只换"挑谁":池子里无主的全换进来,其余按优先级用有专属的补满 N,
        然后走完全一样的三趟。有专属的那部分**按客服轮着取** —— 直取前 N 名会把
        名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着。
        实测 拟分 16·待分配 34 → 拟分 31·待分配 19
      
      顺带:确认后可补挂/改/撤福利(此前只改 state 亮角标,DB 一个字没变)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  5. 05 Aug, 2026 2 commits
    • fix(security): Bull Board 面板默认不挂载 —— 它是公网无鉴权的可写入口 · 85b3cc2c
      bull-board.module.ts 原注释称「本路由由 NestJS 路由系统接管;需要走全局
      JwtAuthGuard」—— 不成立。@bull-board/nestjs 用 ExpressAdapter 自己挂独立
      Express handler,不经过 Nest 的路由与守卫。
      
      2026-08-05 从公网实测旧生产(网关把 /admin/queues 转到了 3101):
        GET /admin/queues              → 200,面板 HTML
        GET /admin/queues/api/queues   → 200,队列数据
      返回体带 "readOnlyMode":false / "allowRetries":true —— 未鉴权即可翻 job
      payload(含 patientId / hostId / tenantId)并重试、清理任务。
      
      加守卫要下沉到 Express 中间件层;而这个面板平时没人用(前端无任何入口链接,
      只在 docs/monitoring 里作为排障手段提过),故取成本最低的解法:默认不挂载,
      排障时 PAC_BULL_BOARD=1 临时开。
      
      关掉的只是**面板**:队列本身、定时任务、job 消费全部照常,它只是查看器。
      
      同步订正三处文档里「需登录 admin」「豁免前缀」的旧描述。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 04 Aug, 2026 8 commits
    • fix(docs): OpenAPI server 地址不再硬编码 —— 生产 docs 一直指向测试服 · ab0f7101
      docker-compose.prod.yml 里写死 DOCS_API_URL: https://pac.jarvismedical.asia,
      而这份 compose 是**测试服与生产共用**的 —— 于是生产 docs 页上点 Scalar 的"发送请求",
      实际打到测试服去。当前生产就是这个状态(不影响文档阅读,但"试一试"跑错环境)。
      
      改为复用 NEXT_PUBLIC_API_BASE_URL:它就是同一个东西(API 的对外地址),
      各环境 apps/pac-web/.env 里本来就有 —— 零额外配置,且不会再随环境漂移:
          DOCS_API_URL: ${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}}
      要单独指定时才设 DOCS_API_URL 覆盖;都没有则退回本地。
      
      ️ 它是 **build-time** 参数(server 在 next build 期烘进 SSG 页),
      改了必须重新构建 pac-docs 镜像,不是改 env 重启就行。
      
      测试锁住:值必须是变量插值、且不得出现任何具体域名。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(deploy): 部署停机写实 —— 「秒级重启」实测是 ~16 秒 · 5950b2fa
      README 原文「期间 service 秒级重启」会让人以为一两秒,实测三次都在 15.97~16.86s。
      force-recreate 不是滚动更新,就是停旧起新,中间必然有真空期。
      
      补三点容易误解的:
        · build 阶段不影响服务(老容器一直跑)→ 部署总耗时长短与停机无关
        · 影响面:API 报错、刷新即恢复;登录态不掉(JWT 在浏览器);无数据风险
        · 要真零停机得网关层蓝绿,不在本脚本范围
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(deploy): force-recreate 失败才回退 stop —— 只在 rootless 上多做一步 · 8b41273c
      rootless podman 下 `up -d --force-recreate` 要删**运行中**的容器,而 rootless 杀不掉
      它的网络进程,直接失败:
          rootless netns: kill network process: permission denied
      失败还留下 <hash>_<proj>-<svc>-1 半成品容器,不清则重建报名字冲突。
      
      改为:主路径不变(直接 force-recreate),**仅在它失败时**回退 ——
      清残留 → stop → 重建。docker 侧一行路径都没变,podman 侧多走一次回退。
      
      【停机实测,顺带纠正我自己两次错判】2026-08-04 三组对照(0.5s 间隔探 /health):
          docker + 直接 force-recreate   ~16s
          docker + 先 stop 再 recreate   15.97s
          podman + 先 stop 再 recreate    5.44s
      即 **部署本来就有约 16 秒停机,一直如此** —— force-recreate 不是滚动更新,
      它就是停旧起新。
      
      ️ 第一轮曾测出 docker「0 停机(535 样本全 200)」,那是**测量错误**:
      探测有 300s 上限,而那次是全量构建、耗时更长,容器重建发生在探测结束之后
      (实测 probe 比 deploy 早结束 29s),535 个样本全采自"还在 build、老容器好好跑着"的阶段。
      我据此先判「本改动引入退化」、后判「修正后恢复零停机」,两次都错。
      教训:探测窗口必须覆盖被测阶段,否则"全绿"只是没看见。
      
      本改动保留的理由因此不是"避免退化",而是**改动面最小**:docker 走原路径,
      只有 rootless 才多一步。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(deploy): 部署先 stop 再 force-recreate —— rootless podman 删不掉运行中容器的网络进程 · b49d3e14
      2026-08-04 新生产机(Ubuntu 26.04,运维刻意配的 rootless podman:非 root 账号 +
      netavark/pasta,攻击面更小)实测:
        rootless netns: kill network process: permission denied
        Error: ... up -d --force-recreate pac-service: exit status 1
      失败还留下 <hash>_pac-pac-service-1 半成品容器,要手工清。
      
      根因:force-recreate 要删**运行中**的容器,rootless 杀不掉其网络进程。
      手动 stop → rm 实测正常,说明不是普遍权限问题,只是删运行中容器这条路走不通。
      
      改为先 stop 再 up --force-recreate:
        · rootless podman 下通过(网络进程正常退出后再删)
        · docker 上语义等价甚至更彻底(stop 后必然重建,不依赖 compose 判定)
        · 仍保留 --force-recreate,不退回裸 up -d —— 那正是本脚本要绕开的 compose diff 缺陷
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed