Commit ad4c7a44 by luoqi

feat(召回): 就诊冷静 14→90 天;矩阵六档改成一条绝对时间轴

① 就诊冷静(⑤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>
parent 4eb9d804
...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = ` ...@@ -27,7 +27,7 @@ const DISPATCHER_EXTRA = `
**⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)** **⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)**
- **取值码**:\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` … - **取值码**:\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` …
→ 说矩阵上的中文:「2–3 年」「1–2 年」「窗口内」「黄金期」「充填」「种植」「牙周」。 → 说矩阵上的中文:「2–3 年」「1–2 年」「三个月到半年」「三个月内」「充填」「种植」「牙周」。
- **字段名 / 参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\` - **字段名 / 参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\`
\`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` … \`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。 → 用中文说那件事:「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。
......
...@@ -77,7 +77,12 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../.. ...@@ -77,7 +77,12 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../..
/// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。 /// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。
/// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。 /// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。
/// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。 /// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。
const POST_VISIT_COOLDOWN_DAYS = 14; ///
/// 🔴 **90 天**(2026-08-11 产品定,原 14 天)。这一条不是文案改动,它直接决定池子大小:
/// 凡是最近三个月到过诊的人整批退出召回池 —— 而且引擎跑全量时会把这些人**已经在跑的单
/// 一起关掉**(0 命中 → supersede,连 assigned 的也关,见 plan-engine 的 staleRows 收尾),
/// 已认领的会补 auto_release 账。⛔ 改这个数之前先量一遍影响面,别在主管刚分完批次时改。
const POST_VISIT_COOLDOWN_DAYS = 90;
/// v3.0 打分上下文(每患者 active persona 投影) /// v3.0 打分上下文(每患者 active persona 投影)
interface PersonaScoreCtx { interface PersonaScoreCtx {
......
...@@ -2,9 +2,10 @@ import { Prisma } from '@prisma/client'; ...@@ -2,9 +2,10 @@ import { Prisma } from '@prisma/client';
import { import {
COLD_BUCKET_YEARS, COLD_BUCKET_YEARS,
COLD_TEMPERATURES, COLD_TEMPERATURES,
DiagnosisTreatmentMap, HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES, POTENTIAL_LABEL_RULES,
Temperature, Temperature,
WARM_BUCKET_DAYS,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
...@@ -66,23 +67,30 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql ...@@ -66,23 +67,30 @@ export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql
} }
/** /**
* 窗口配置表(code, urgencyDays, windowDays)—— 从 `DiagnosisTreatmentMap` 生成的 VALUES。 * 前两档的边界表达式(锚点 + 固定天数)。
* 只含 8 类标签实际用到的组主码;⛔ 别手写数字。 *
* 🔴 2026-08-11:天数从「该码自己的 `urgencyDayThreshold` / `windowDays`」改成**固定 90/180**
* (口径与理由见 `@pac/types` 的 temperature.ts 文件头)。原来这里有一张
* `windowValuesSql()` 生成的 `(code, urg, wnd)` VALUES 表并 JOIN 到事实的诊断码上,
* 现在整张表连同那次 JOIN 一起删了 ——
* ⚠️ 删 JOIN **不改变行集**:它连的是 `POTENTIAL_LABEL_RULES` 那 8 个码,
* 而这两个函数的 WHERE 里 `labelCaseSql(...)` 只认同一批码(K00/K09 落 NULL 已被排掉)。
* (`tests/potential-label-rules.spec.ts` 锁着这层等价。)
* ⚠️ 仍然是**参数注入**,⛔ 不许把 90/180 拼进 SQL 文本 —— 常量在 `@pac/types`,
* 这里只引用;`make_interval(days => $n)` 是为了避开 `int || text` 的隐式转换。
* 🔴 `::int` **不能省**:Prisma 把 JS number 绑成 `double precision`,而
* `make_interval(days => double precision)` **这个函数不存在** → 整个 /plans/matrix 报 500
* (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。
*/ */
export function windowValuesSql(): Prisma.Sql { const HOT_UNTIL_SQL = Prisma.sql`max(anc.at + make_interval(days => ${HOT_BUCKET_DAYS}::int))`;
const codes = [...new Set(POTENTIAL_LABEL_RULES.map((r) => r.code))]; const WARM_UNTIL_SQL = Prisma.sql`max(anc.at + make_interval(days => ${WARM_BUCKET_DAYS}::int))`;
const rows = codes const ANCHOR_MAX_SQL = Prisma.sql`max(anc.at)`;
.map((c) => ({ c, rule: (DiagnosisTreatmentMap as Record<string, { urgencyDayThreshold: number; windowDays: number }>)[c] }))
.filter((x) => !!x.rule)
.map((x) => Prisma.sql`(${x.c}, ${x.rule!.urgencyDayThreshold}, ${x.rule!.windowDays})`);
return Prisma.sql`(VALUES ${Prisma.join(rows, ', ')}) AS w(code, urg, wnd)`;
}
/** /**
* 六档判定的 CASE。 * 六档判定的 CASE。
* *
* ⭐ **一个锚点走到底**:`anchor` = 该标签最新的未治疗诊断。 * ⭐ **一个锚点走到底**:`anchor` = 该标签最新的未治疗诊断。
* 前两档按该治疗项自己的临床窗口、后四档按绝对年数,量的都是「距同一个锚点多久」。 * 前两档按固定 90/180 天、后四档按绝对年数,量的都是「距同一个锚点多久」。
* ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义, * ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义,
* 365 天算法在闰年会漂,两边就对不上数。 * 365 天算法在闰年会漂,两边就对不上数。
* ⚠️ 锚点/边界为 NULL → 落 NULL(调用方显式呈现"未知"),⛔ 不许默认塞进某个冷档。 * ⚠️ 锚点/边界为 NULL → 落 NULL(调用方显式呈现"未知"),⛔ 不许默认塞进某个冷档。
...@@ -123,9 +131,9 @@ export function temperatureBucketCaseSql( ...@@ -123,9 +131,9 @@ export function temperatureBucketCaseSql(
export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
return Prisma.sql` return Prisma.sql`
SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label, SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
max(anc.at + (w.urg || ' days')::interval) AS hot_until, ${HOT_UNTIL_SQL} AS hot_until,
max(anc.at + (w.wnd || ' days')::interval) AS warm_until, ${WARM_UNTIL_SQL} AS warm_until,
max(anc.at) AS anchor_at ${ANCHOR_MAX_SQL} AS anchor_at
FROM followup_plans fp FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id JOIN plan_reasons pr ON pr.plan_id = fp.id
...@@ -133,7 +141,6 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { ...@@ -133,7 +141,6 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab CROSS JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab
JOIN ${windowValuesSql()} ON w.code = f.content->>'code'
WHERE ${planFilter} WHERE ${planFilter}
AND lab.lbl IS NOT NULL AND lab.lbl IS NOT NULL
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
...@@ -175,15 +182,10 @@ export function labelTemperatureExistsSql( ...@@ -175,15 +182,10 @@ export function labelTemperatureExistsSql(
CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active' JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc CROSS JOIN LATERAL (SELECT COALESCE(f.occurred_at, f.planned_for) AS at) anc
JOIN ${windowValuesSql()} ON w.code = f.content->>'code'
WHERE pr.plan_id = fp.id WHERE pr.plan_id = fp.id
AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label} AND ${labelCaseSql('f', AGE_YEARS_SQL_P)} = ${label}
AND anc.at IS NOT NULL AND anc.at IS NOT NULL
HAVING ${temperatureBucketCaseSql( HAVING ${temperatureBucketCaseSql(HOT_UNTIL_SQL, WARM_UNTIL_SQL, ANCHOR_MAX_SQL)} IN (${Prisma.join(
Prisma.raw("max(anc.at + (w.urg || ' days')::interval)"),
Prisma.raw("max(anc.at + (w.wnd || ' days')::interval)"),
Prisma.raw('max(anc.at)'),
)} IN (${Prisma.join(
buckets.map((t) => Prisma.sql`${t}`), buckets.map((t) => Prisma.sql`${t}`),
', ', ', ',
)}) )})
......
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { TEMPERATURE_META, TEMPERATURE_ORDER } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service'; import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
...@@ -416,7 +417,8 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手' ...@@ -416,7 +417,8 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手'
}); });
const svc = await build(prisma); const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
expect(d.label).toBe('8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服'); // ⚠️ 档名取自 TEMPERATURE_META(warm)——2026-08-11 从「窗口内」改成「三个月到半年」
expect(d.label).toBe('8/3 21:33 · 牙周治疗 · 三个月到半年 · 9 人 · 2 位客服');
}); });
test('⭐ 时间按**宿主时区**,⛔ 不是服务器本地时区', async () => { test('⭐ 时间按**宿主时区**,⛔ 不是服务器本地时区', async () => {
...@@ -433,7 +435,7 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手' ...@@ -433,7 +435,7 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手'
}); });
test('⭐ 老批次没存温度 → 那一段**不出现**(⛔ 不许默认填一个)', async () => { test('⭐ 老批次没存温度 → 那一段**不出现**(⛔ 不许默认填一个)', async () => {
// 填了会让主管以为那批是按"窗口内"圈的,而他可能圈的是"黄金期" // 填了会让主管以为那批是按"三个月到半年"圈的,而他可能圈的是"三个月内"
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [], plans: [],
ledger: { planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, ledger: { planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 },
...@@ -442,7 +444,9 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手' ...@@ -442,7 +444,9 @@ describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手'
const svc = await build(prisma); const svc = await build(prisma);
const label = (await svc.detail(SCOPE, BATCH)).label; const label = (await svc.detail(SCOPE, BATCH)).label;
expect(label).toContain('种植治疗'); expect(label).toContain('种植治疗');
expect(label).not.toMatch(/黄金期|窗口内|窗口外/); // ⚠️ 从 TEMPERATURE_META 现取六个档名 —— ⛔ 别手写(2026-08-11 改过一次名,
// 手写的那份当场就漏,而漏了这条测试会**假绿**)
for (const t of TEMPERATURE_ORDER) expect(label).not.toContain(TEMPERATURE_META[t].zh);
}); });
test('已撤销的批次要在名字里标出来 —— 主管扫列表时最先要排除的就是它', async () => { test('已撤销的批次要在名字里标出来 —— 主管扫列表时最先要排除的就是它', async () => {
......
import { Temperature } from '@pac/types'; import { HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter'; import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';
/** /**
...@@ -106,15 +106,20 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () = ...@@ -106,15 +106,20 @@ describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () =
expect(cold).not.toMatch(/365/); expect(cold).not.toMatch(/365/);
}); });
test('⭐ 窗口天数来自 DiagnosisTreatmentMap,不是手写常量', () => { test('⭐ 前两档天数来自 @pac/types 常量,不是手写字面量', () => {
const vals = cohortWhereSql( // 🔴 2026-08-11 换口径:原来这里锁的是"按码取 DiagnosisTreatmentMap"
// (K08 种植 黄金 120 / 窗 180);现在六档同一把尺子,固定 90/180,与码无关。
const sql = cohortWhereSql(
SCOPE, SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
NOW, NOW,
).values.flat(); );
// K08 种植:黄金 120 / 窗 180 —— 这两个数必须以**参数**形式出现(= 从配置注入的) expect(sql.values.flat()).toContain(HOT_BUCKET_DAYS);
expect(vals).toContain(120); expect(sql.values.flat()).toContain(WARM_BUCKET_DAYS);
expect(vals).toContain(180); // ⛔ 天数不许出现在 SQL 文本里(那就是第二份真理源)
expect(sql.strings.join('?')).not.toMatch(/\b(90|180)\b/);
// 按码的窗口配置**不该**再被注进来(120 = K08 的旧黄金期)
expect(sql.values.flat()).not.toContain(120);
}); });
test('⛔ 判档用 SQL 的 NOW(),不掺 JS 时刻 —— 两个时钟会让边界人群漂', () => { test('⛔ 判档用 SQL 的 NOW(),不掺 JS 时刻 —— 两个时钟会让边界人群漂', () => {
......
...@@ -580,7 +580,10 @@ describe('确认单话术 —— 让主管一眼看懂', () => { ...@@ -580,7 +580,10 @@ describe('确认单话术 —— 让主管一眼看懂', () => {
expect(SVC_ASSIST).toMatch(/不要再列"您可以这样调整"那几条/); expect(SVC_ASSIST).toMatch(/不要再列"您可以这样调整"那几条/);
expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/); expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/);
// ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导 // ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="border-t border-slate-100 bg-slate-50/); // ⚠️ 类名别写太死:`border-slate-100` 2026-08-11 随「全局 border 规则放进 @layer base」
// 那次清理去掉了(颜色本来就来自全局那条,写了也是同一个灰)。这里只锁**结构** ——
// 「readOnly 时不渲染这块提示」,⛔ 别再把一整串 class 抄进正则。
expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="border-t [^"]*bg-slate-50/);
}); });
test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => { test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => {
......
import { classifyCodeToLabel, EXTRACTION_NAME_KEYWORDS, POTENTIAL_LABEL_RULES } from '@pac/types'; import {
classifyCodeToLabel,
EXTRACTION_NAME_KEYWORDS,
HOT_BUCKET_DAYS,
POTENTIAL_LABEL_RULES,
WARM_BUCKET_DAYS,
} from '@pac/types';
import { classifyGapToLabel } from '../src/modules/persona/features/potential-treatment.feature'; import { classifyGapToLabel } from '../src/modules/persona/features/potential-treatment.feature';
import type { PotentialGap } from '../src/modules/clinical-gap/potential-treatment.selector'; import type { PotentialGap } from '../src/modules/clinical-gap/potential-treatment.selector';
import { labelCaseSql, windowValuesSql } from '../src/modules/plan/reason-temperature.sql'; import {
AGE_YEARS_SQL,
labelCaseSql,
labelTemperatureExistsSql,
} from '../src/modules/plan/reason-temperature.sql';
/** /**
* 🔴 码 → 业务标签的判定,现在有**两个执行现场**: * 🔴 码 → 业务标签的判定,现在有**两个执行现场**:
...@@ -72,18 +82,26 @@ describe('SQL 生成 —— 数字/关键词一个都不许手抄', () => { ...@@ -72,18 +82,26 @@ describe('SQL 生成 —— 数字/关键词一个都不许手抄', () => {
expect(flat).not.toMatch(/~\s*\?/); expect(flat).not.toMatch(/~\s*\?/);
}); });
test('⭐⭐ 窗口天数来自 DiagnosisTreatmentMap(参数注入),⛔ 不是 SQL 里的字面量', () => { /**
const w = windowValuesSql(); * 🔴 2026-08-11:原来这里锁的是 `windowValuesSql()`(按码的 `(code, urg, wnd)` VALUES 表)——
const wflat = w.strings.join('?'); * 前两档改成固定 90/180 天后那张表连同它的 JOIN 一起删了,这两条用例随之改写成:
// K08 种植 120/180、K02 龋 60/90 —— 必须是参数 * ① 天数仍走参数注入(常量在 @pac/types,SQL 文本里不许出现字面量);
for (const n of [120, 180, 60, 90]) expect(w.values.flat()).toContain(n); * ② 删掉那次 JOIN **没有放宽行集**(它当年顺带起的过滤作用由 labelCase 全额承担)。
expect(wflat).not.toMatch(/\b(120|180)\b/); */
test('⭐⭐ 前两档天数走参数注入,⛔ 不是 SQL 里的字面量', () => {
const s = labelTemperatureExistsSql('implant', ['hot']);
expect(s.values.flat()).toContain(HOT_BUCKET_DAYS);
expect(s.values.flat()).toContain(WARM_BUCKET_DAYS);
expect(s.strings.join('?')).not.toMatch(/\b(90|180)\b/);
}); });
test('⭐ 只生成 8 类标签用到的码 —— K00/K09 不该出现(它们没有矩阵行)', () => { test('⭐ 码白名单仍只认 8 类标签的码 —— K00/K09 落 NULL(它们没有矩阵行)', () => {
const codes = windowValuesSql().values.flat().filter((v) => typeof v === 'string'); // 删 JOIN 的等价性就落在这条:过滤现在**只**由 labelCase 做,
// 而它认的码集合与当年那张 VALUES 表逐字相同。
const codes = [...new Set(POTENTIAL_LABEL_RULES.map((r) => r.code))];
expect(codes).not.toContain('K00'); expect(codes).not.toContain('K00');
expect(codes).not.toContain('K09'); expect(codes).not.toContain('K09');
expect(codes).toContain('K08'); expect(codes).toContain('K08');
expect(labelCaseSql('f', AGE_YEARS_SQL).values.flat()).not.toContain('K00');
}); });
}); });
...@@ -8,15 +8,16 @@ import { ...@@ -8,15 +8,16 @@ import {
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import type { PoolMatrix as PoolMatrixData, PoolMatrixRow } from './plans-api'; import type { PoolMatrix as PoolMatrixData, PoolMatrixRow } from './plans-api';
/** /**
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 召回窗口)。排版按 claude design 的 **1b「连续色带」**。 * PoolMatrix — 初选矩阵(8 潜在治疗 × 3 召回窗口)。排版按 claude design 的 **1b「连续色带」**。
* *
* 生产线的**第一环**:主管在这里点一格,就完成了「初选」,人群随即交给助手出确认单。 * 生产线的**第一环**:主管在这里点一格,就完成了「初选」,人群随即交给助手出确认单。
* 矩阵是召回池的一个**视图模式**(tab 行「分配」浮层展开),⛔ 不新开路由 —— * ⚠️ 落点已经变过两次:最早是执行页左栏的「分配」浮层,后来是主管工作台的居中 modal,
* 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。 * 2026-08-11 起**常驻**在主管工作台「分一批新的」面板里(见 supervisor/new-batch.tsx)。
* T16 也已改写(主管工作台是独立路由)——⛔ 别再照旧注释说"不新开路由"。
* *
* ═══ 1b 的核心:矩阵区是**一张连续的渐变面**,不是 24 个色块 ═══════════ * ═══ 1b 的核心:矩阵区是**一张连续的渐变面**,不是 24 个色块 ═══════════
* 三列共用一条 `amber → emerald → sky` 的横向渐变,**只用色相区分窗口**; * 三列共用一条 `amber → emerald → sky` 的横向渐变,**只用色相区分窗口**;
...@@ -125,14 +126,12 @@ export function PoolMatrix({ ...@@ -125,14 +126,12 @@ export function PoolMatrix({
—— 主管一对数就觉得系统在骗他(实测 2,526 vs 池子 2,129)。 —— 主管一对数就觉得系统在骗他(实测 2,526 vs 池子 2,129)。
他真正要的是「点哪一格拿多少人」,那是格子本身的数,不是列合计。 他真正要的是「点哪一格拿多少人」,那是格子本身的数,不是列合计。
*/} */}
{/* ⚠️ 这里原来右边还有一句「点一格 = 把这批人交给助手」,2026-08-11 挪走了: {/* ⚠️ 整个 CardHeader 2026-08-11 撤掉(产品指着截图说这行去掉):
矩阵现在常驻在「分一批新的」面板里,那句话已经写在面板标题旁边, 原来左边是「潜在治疗」(行轴标题)、右边是「点一格 = 把这批人交给助手」。
两行挨着说同一件事是噪音。⛔ 别加回来 —— 要改措辞去改面板那一处。 */} 后者已经写在「分一批新的」面板标题旁边;前者在常驻面板里是**纯占高**——
<CardHeader className="flex-row items-baseline justify-between space-y-0 p-3 pb-2.5"> 行头本来就是治疗项名(种植/正畸/…),再加一行标题等于把矩阵往下推 30px。
<div className="text-[13px] font-semibold">潜在治疗</div> ⛔ 别为了"结构完整"加回来:这块现在唯一的消费方是那个面板,标题它出。 */}
</CardHeader> <CardContent className="p-3">
<CardContent className="p-3 pt-0">
{/* 列头:圆点 + 档位名,颜色与下方渐变面的对应段同族 */} {/* 列头:圆点 + 档位名,颜色与下方渐变面的对应段同族 */}
<div className="flex items-center pb-1.5"> <div className="flex items-center pb-1.5">
<div className={cn(LABEL_W, 'shrink-0')} /> <div className={cn(LABEL_W, 'shrink-0')} />
...@@ -193,15 +192,13 @@ export function PoolMatrix({ ...@@ -193,15 +192,13 @@ export function PoolMatrix({
</div> </div>
{/* {/*
⭐ 一句话讲清两件事,替代逐格 hover 的长解释: ⚠️ 2026-08-11 大幅缩短:原来这里要解释"前两档按该治疗自己的周期、后四档按年数"——
· 前两档 vs 后四档量的**不是同一把尺子**(前者按该治疗自己的临床周期,后者按年数) 六档改成同一把尺子(都按诊断距今多久)之后,那半句话没有了,⛔ 别再写回去。
· 一个人可能出现在多行 —— 否则主管把各行相加会发现比池子总数大 ⭐ 剩下这一句是**必须留的**:一个人有几个潜在治疗就占几行,
⛔ 别写成教学文案。主管扫一眼就要能懂,写长了没人看 否则主管把各行相加会发现比池子总数大,当场以为系统在骗他(实测 2,526 vs 2,129)
*/} */}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground"> <p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
前两档按<span className="font-medium">该治疗自己的周期</span>算, <span className="font-medium">诊断距今多久</span>分档。一个人有几个潜在治疗就出现在几行。
后四档按<span className="font-medium">诊断距今多久</span>算。
一个人有几个潜在治疗就出现在几行。
</p> </p>
{data.note && ( {data.note && (
......
...@@ -326,18 +326,26 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -326,18 +326,26 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 两轴天然互补:X 轴(潜在治疗)已去时间门,Y 轴(窗口温度)正好把时间维度补回来。 > 两轴天然互补:X 轴(潜在治疗)已去时间门,Y 轴(窗口温度)正好把时间维度补回来。
### T6 · 温度 = 该治疗项目自身的临床时间周期 ### T6 · 档位 = 诊断距今多久(一条绝对时间轴)
不是客户价值、不是意愿、不是末诊天数,而是: 不是客户价值、不是意愿、不是末诊天数,而是:
| 档 | 定义 | | 档 | 定义(距锚点 = 该标签最新的未治疗诊断) |
|---|---| |---|---|
| 🔥 热 | 黄金期内(`daysSince ≤ urgencyDayThreshold`) | | 三个月内 | `≤ 90 天` |
| 🌡 温 | 周期内(`urgencyDayThreshold < daysSince ≤ windowDays`) | | 三个月到半年 | `90–180 天` |
| ❄️ 冷 | 超周期(`daysSince > windowDays`) | | 1 年内 / 1–2 年 / 2–3 年 / 3 年以上 | 按自然年数分 |
每个治疗项目**用自己的周期尺度**,归一化后**横向可比** > 🔴 **2026-08-11 改口径**。原来前两档是「该治疗项目**自身**的临床周期」
阈值复用 `DiagnosisTreatmentMap` 现成配置,不新增口径。 > (`hot = daysSince ≤ urgencyDayThreshold`、`warm ≤ windowDays`,每个 K 码一套天数)。
> 改成固定 90/180 天的理由是主管那一侧的可读性:同一列里「种植·黄金期」是 120 天、
> 「根管·黄金期」是 45 天,列头写的却是同一个词 —— 他按列比人数时,比的其实不是同一段时间。
>
> ⛔ 别为了「临床更精确」改回去:临床紧迫仍然由 `priority-scorer` 按各码自己的窗口衰减,
> 那是**排序**的活;矩阵列头是**切分**的活,切分必须一把尺子量到底。
> ⚠️ `DiagnosisTreatmentMap` 的 `urgencyDayThreshold` / `windowDays` 仍然管着召回入池下界
> (cooldown)与打分衰减,**只是不再管矩阵档位** —— 两者已脱钩,⛔ 别顺手统一。
> 天数常量的单一真理源:`HOT_BUCKET_DAYS` / `WARM_BUCKET_DAYS`(`packages/types/src/temperature.ts`)。
#### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`) #### T6′ · 落地口径(2026-08-02 定稿,实现见 `packages/types/src/temperature.ts`)
...@@ -832,15 +840,15 @@ release_note 退回文字说明 ...@@ -832,15 +840,15 @@ release_note 退回文字说明
> 服务端的 `potentialTreatment` / `temperature` 两个列表参数**保留**(`buildListWhere` 未动), > 服务端的 `potentialTreatment` / `temperature` 两个列表参数**保留**(`buildListWhere` 未动),
> 只是前端不再自动带上;将来若要「按初选看列表」,是加回一个显式入口的事。 > 只是前端不再自动带上;将来若要「按初选看列表」,是加回一个显式入口的事。
### 矩阵三档的叫法 ### 矩阵档位的叫法
**列头用临床说法,不用温度比喻**(2026-08-02 产品定): **列头直接写时间,不用温度比喻,也不用临床黑话**(2026-08-11 产品定,此前是「黄金期 / 窗口内」):
| code(不改) | 显示名 | 判据 | | code(不改) | 显示名 | 判据 |
|---|---|---| |---|---|---|
| `hot` | **黄金期** | `daysSince ≤ urgencyDayThreshold` | | `hot` | **三个月内** | `daysSince ≤ 90` |
| `warm` | **窗口内** | `urgencyDayThreshold < daysSince ≤ windowDays` | | `warm` | **三个月到半年** | `90 < daysSince ≤ 180` |
| `cold` | **窗口外** | `daysSince > windowDays` | | `cold_1y``cold_over` | **1 年内 / 1–2 年 / 2–3 年 / 3 年以上** | 按自然年数 |
⚠️ **代码名与显示名刻意不一致**:枚举值、API 参数 `temperature=` ⚠️ **代码名与显示名刻意不一致**:枚举值、API 参数 `temperature=`
`persona_features.data.temperature` 的 JSON 路径都仍是 `hot/warm/cold` `persona_features.data.temperature` 的 JSON 路径都仍是 `hot/warm/cold`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment