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');
}); });
}); });
import { import {
HOT_BUCKET_DAYS,
Temperature, Temperature,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
WARM_BUCKET_DAYS,
classifyTemperature, classifyTemperature,
gapTemperatureBounds, gapTemperatureBounds,
hottestBounds, hottestBounds,
lookupDxTreatment,
POTENTIAL_TREATMENT_SOURCE_CODES, POTENTIAL_TREATMENT_SOURCE_CODES,
temperatureWindowHint, temperatureWindowHint,
} from '@pac/types'; } from '@pac/types';
import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff'; import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff';
/** /**
* 窗口温度口径回归(教条 T6 / 开发规划 Q-6 的落地)。 * 窗口档位口径回归(教条 T6)。
* *
* 这里锁的是三件**做错了不会报错、只会让主管看到一个假分布**的事: * 这里锁的是三件**做错了不会报错、只会让主管看到一个假分布**的事:
* ① 逐条 gap 按自己 K 码的窗判档 —— 而不是先 max(daysSince) 再判 * ① 逐条 gap 判档再取最热 —— 而不是先 max(daysSince) 再判
* ② 边界时刻是事实不是时钟 —— 换个"现在"重算,值必须一模一样 * ② 边界时刻是事实不是时钟 —— 换个"现在"重算,值必须一模一样
* ③ 老数据没有边界时 → 「未知」,⛔ 不许当成「冷」 * ③ 老数据没有边界时 → 「未知」,⛔ 不许当成「冷」
*
* 🔴 2026-08-11:前两档从「该 K 码自己的 urgencyDayThreshold / windowDays」改成
* **固定 90 / 180 天**。本文件里原有三条锁"各码各判"的用例(K01 vs K03、K05 vs K06、
* K02 的 60/90 压线)随之改写 —— ⚠️ 它们不是失效了,是**口径换了**:
* 现在要锁的恰恰相反 —— 同一天数的两条 gap,不管什么码,必须落同一档。
*/ */
const DAY = 86_400_000; const DAY = 86_400_000;
const NOW = new Date('2026-08-02T00:00:00.000Z'); const NOW = new Date('2026-08-02T00:00:00.000Z');
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY); const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY);
describe('温度口径 —— 逐条 gap 用自己的窗', () => { describe('档位口径 —— 六档同一把尺子(绝对天数)', () => {
test('⭐ 各码的边界严格来自 DiagnosisTreatmentMap,不是自己拍的常量', () => { test('⭐ 边界 = 锚点 + 固定 90/180 天,**与诊断码无关**', () => {
for (const code of ['K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08']) { for (const code of ['K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08']) {
const rule = lookupDxTreatment(code)!;
const b = gapTemperatureBounds(code, daysAgo(0))!; const b = gapTemperatureBounds(code, daysAgo(0))!;
expect(new Date(b.hotUntil).getTime() - NOW.getTime()).toBe(rule.urgencyDayThreshold * DAY); expect(new Date(b.hotUntil).getTime() - NOW.getTime()).toBe(HOT_BUCKET_DAYS * DAY);
expect(new Date(b.warmUntil).getTime() - NOW.getTime()).toBe(rule.windowDays * DAY); expect(new Date(b.warmUntil).getTime() - NOW.getTime()).toBe(WARM_BUCKET_DAYS * DAY);
} }
}); });
test('⭐⭐ Q-6「一对多无解」其实有解:extraction ← K01(180/90) + K03(90/60) 各归各的', () => { test('⭐⭐ 同一天数的两条 gap,码不同也必须同档(旧口径下 K01 热 / K03 温)', () => {
// 同一个「潜在拔牙」标签下的两条 gap,天数一样(70 天),但两码的黄金期不同: // 这是改口径的**全部意义**:列头写「三个月内」,那一列里就不能混着
// K01 urgency=90 → 70 天仍在黄金期 = 热 // 「K01 的 90 天」和「K03 的 60 天」两种"三个月内"。
// K03 urgency=60 → 70 天已过黄金期 = 温
// 取最热 → 热。⛔ 不需要为「潜在拔牙」另定一套标签级窗口(那才是新增口径)。
const k01 = gapTemperatureBounds('K01', daysAgo(70)); const k01 = gapTemperatureBounds('K01', daysAgo(70));
const k03 = gapTemperatureBounds('K03', daysAgo(70)); const k03 = gapTemperatureBounds('K03', daysAgo(70));
expect(classifyTemperature(k01, NOW)).toBe(Temperature.HOT); expect(classifyTemperature(k01, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(k03, NOW)).toBe(Temperature.WARM); expect(classifyTemperature(k03, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(hottestBounds([k01, k03]), NOW)).toBe(Temperature.HOT); expect(classifyTemperature(hottestBounds([k01, k03]), NOW)).toBe(Temperature.HOT);
}); });
test('⭐ perio ← K05(120/90) + K06(120/60) 同理', () => { test('⭐ perio ← K05 + K06 同理(旧口径 75 天时一热一温)', () => {
const k05 = gapTemperatureBounds('K05', daysAgo(75)); const k05 = gapTemperatureBounds('K05', daysAgo(75));
const k06 = gapTemperatureBounds('K06', daysAgo(75)); const k06 = gapTemperatureBounds('K06', daysAgo(75));
expect(classifyTemperature(k05, NOW)).toBe(Temperature.HOT); // 75 ≤ 90 expect(classifyTemperature(k05, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(k06, NOW)).toBe(Temperature.WARM); // 75 > 60 expect(classifyTemperature(k06, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(hottestBounds([k05, k06]), NOW)).toBe(Temperature.HOT);
}); });
test('三档边界是闭区间左闭右闭:恰好落在窗上的那天仍算窗内', () => { test('档位边界左闭右闭:恰好落在边界上的那天算前一档', () => {
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM); expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.WARM);
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(180)), NOW)).toBe(Temperature.WARM);
// 91 天前 = 出窗但不到 1 年 → 冷端第一档 // 181 天 = 过半年但不到 1 年 → 冷端第一档
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD_1Y); expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(181)), NOW)).toBe(Temperature.COLD_1Y);
}); });
test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => { test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => {
...@@ -88,17 +90,17 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)', ...@@ -88,17 +90,17 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)',
// 六档由热到冷的名次 —— ⛔ 别再手写,从 TEMPERATURE_ORDER 派生(加档位时自动跟上) // 六档由热到冷的名次 —— ⛔ 别再手写,从 TEMPERATURE_ORDER 派生(加档位时自动跟上)
const rank = Object.fromEntries(TEMPERATURE_ORDER.map((t, i) => [t, i])) as Record<string, number>; const rank = Object.fromEntries(TEMPERATURE_ORDER.map((t, i) => [t, i])) as Record<string, number>;
const before = rank[classifyTemperature(hottestBounds(base), NOW)!]!; const before = rank[classifyTemperature(hottestBounds(base), NOW)!]!;
for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) { for (const d of [1, 30, 89, 90, 91, 179, 180, 181, 365, 3000]) {
const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!; const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!;
expect(after).toBeLessThanOrEqual(before); expect(after).toBeLessThanOrEqual(before);
} }
}); });
test('⭐ 不变式 hotUntil < warmUntil 在跨取 max 之后仍成立', () => { test('⭐ 不变式 hotUntil < warmUntil 在跨 gap 取 max 之后仍成立', () => {
// 两个 max 可以来自不同 gap,这里证明那不会把区间弄反(弄反 = 档凭空消失) // 两个 max 可以来自不同 gap,这里证明那不会把区间弄反(弄反 = 第二档凭空消失)
const mixed = hottestBounds([ const mixed = hottestBounds([
gapTemperatureBounds('K01', daysAgo(10)), // hot 到 +80d, warm 到 +170d gapTemperatureBounds('K01', daysAgo(10)),
gapTemperatureBounds('K04', daysAgo(1)), // hot 到 +44d, warm 到 +59d gapTemperatureBounds('K04', daysAgo(1)),
])!; ])!;
expect(mixed.hotUntil < mixed.warmUntil).toBe(true); expect(mixed.hotUntil < mixed.warmUntil).toBe(true);
}); });
...@@ -122,10 +124,10 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => { ...@@ -122,10 +124,10 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => {
}); });
test('⭐ 时间推移会自然翻档,不需要任何重算', () => { test('⭐ 时间推移会自然翻档,不需要任何重算', () => {
const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45 const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!;
expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d expect(classifyTemperature(b, new Date('2026-10-01T00:00:00.000Z'))).toBe(Temperature.HOT); // +60d
expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d expect(classifyTemperature(b, new Date('2026-12-01T00:00:00.000Z'))).toBe(Temperature.WARM); // +121d
expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +91d expect(classifyTemperature(b, new Date('2027-03-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +211d
}); });
test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => { test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => {
...@@ -180,25 +182,23 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', () ...@@ -180,25 +182,23 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
}); });
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => { test('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天'); expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('诊断距今 ≤ 90 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天'); expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('诊断距今 90–180 天');
// 冷端四档是**绝对年数**,与治疗项无关 —— 不再是"> 窗口天数" expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('诊断距今 180 天–1 年');
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('出周期后 1 年内');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年'); expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年');
}); });
test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => { test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => {
// 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90) // 旧口径下这里是 '≤ 90 天' / '60–180 天'(两码窗口的并集),主管在同一列
expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天'); // 看到两种天数。现在同一档只有一句话。
expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天'); for (const t of TEMPERATURE_ORDER) {
// 冷端与码无关,多码标签也是同一套年数 expect(temperatureWindowHint('extraction', t)).toBe(temperatureWindowHint('implant', t));
expect(temperatureWindowHint('extraction', Temperature.COLD_2Y)).toBe('诊断距今 1–2 年'); }
}); });
test('⭐ 前两档区间必须**首尾相接**,不留缝也不写反', () => { test('⭐ 前两档区间必须**首尾相接**,不留缝也不写反', () => {
// 缝 = 有人哪一档都不属于;写反 = 温的上界跑到热的上界左边,主管一看就知道在乱说。 // 缝 = 有人哪一档都不属于;写反 = 第二档的上界跑到第一档左边,主管一看就知道在乱说。
// ⚠️ 冷端四档已改成绝对年数(与治疗项无关),不参与这条"按天数接龙"的校验。
for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) { for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) {
const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]); const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]);
const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number); const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number);
...@@ -209,9 +209,9 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -209,9 +209,9 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐⭐ 六档必须两两互斥且覆盖整条时间轴 —— 任何一天都恰好属于一档', () => { test('⭐⭐ 六档必须两两互斥且覆盖整条时间轴 —— 任何一天都恰好属于一档', () => {
// 这条比"文案接龙"硬:直接拿判定函数在时间轴上扫,漏一天或重一天都会红。 // 这条比"文案接龙"硬:直接拿判定函数在时间轴上扫,漏一天或重一天都会红。
const b = gapTemperatureBounds('K02', new Date('2020-01-01T00:00:00.000Z'))!; // 60/90 const b = gapTemperatureBounds('K02', new Date('2020-01-01T00:00:00.000Z'))!;
const seen: string[] = []; const seen: string[] = [];
for (const d of [0, 60, 61, 90, 91, 364, 365, 366, 730, 731, 1095, 1096, 5000]) { for (const d of [0, 90, 91, 180, 181, 364, 365, 366, 730, 731, 1095, 1096, 5000]) {
const t = classifyTemperature(b, new Date(Date.parse(b.anchorAt) + d * 86_400_000)); const t = classifyTemperature(b, new Date(Date.parse(b.anchorAt) + d * 86_400_000));
expect(t).not.toBeNull(); // ⛔ 任何一天都不许落进"未知" expect(t).not.toBeNull(); // ⛔ 任何一天都不许落进"未知"
if (!seen.length || seen[seen.length - 1] !== t) seen.push(t!); if (!seen.length || seen[seen.length - 1] !== t) seen.push(t!);
...@@ -222,8 +222,10 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -222,8 +222,10 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
expect(new Set(idx).size).toBe(idx.length); expect(new Set(idx).size).toBe(idx.length);
}); });
test('未知标签 → 空串(不编)', () => { test('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe(''); // 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
// 空串反而是错的(格子会失去 hover 提示)。
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('诊断距今 ≤ 90 天');
}); });
}); });
......
...@@ -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`
......
...@@ -3,16 +3,24 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -3,16 +3,24 @@ import { lookupDxTreatment } from './canonical-codes';
/** /**
* 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。 * 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。
* *
* ═══ 口径(教条 T6,2026-08-02 定稿)═════════════════════════════════ * ═══ 口径(2026-08-11 产品改口:六档统一成一条**绝对时间轴**)═══════════
* 温度 = **该治疗项目自身的临床时间周期**走到哪一段了,不是客户价值、不是意愿、不是末诊天数。 * 档位 = **诊断距今多久**,不是客户价值、不是意愿、不是末诊天数。
* *
* ``` * ```
* [0, urgencyDayThreshold] 🔥 热 黄金期内 * [0, 90d] 三个月内
* (urgencyDayThreshold, windowDays] 🌡 温 周期内 * (90d, 180d] 三个月到半年
* (windowDays, ∞) ❄️ 冷 超周期 * (180d, 1y] 1 年内
* (1y, 2y] (2y, 3y] (3y, ∞)
* ``` * ```
* 每个治疗项目**用自己的周期尺度**(`DiagnosisTreatmentMap`),归一化后横向可比 —— *
* 「潜在根管·热」(45 天内)与「潜在正畸·热」(180 天内)天数差 4 倍,但业务含义同级。 * 🔴 **前两档不再按治疗项自己的临床周期算**(2026-08-11 前的旧口径:
* `[0, urgencyDayThreshold]` = 黄金期、`(urgencyDayThreshold, windowDays]` = 窗口内,
* 每个 K 码一套天数)。改成固定 90/180 天,理由是主管那一侧的可读性:
* 同一列里「种植·黄金期」是 90 天、「根管·黄金期」是 45 天,列头写的却是同一个词,
* 主管按列比较人数时比的其实不是同一段时间。⛔ 别为了"临床更精确"改回去 ——
* 要表达临床紧迫,那是 priority-scorer 的活(它仍按各码自己的窗口衰减),不是矩阵列头的活。
* ⚠️ 六档现在**共用一个锚点、一把尺子**,所以列头可以直接写时间(见 TEMPERATURE_META)。
* ⚠️ 枚举值仍是 hot/warm/cold_* —— ⛔ 别跟着改(API 参数、SQL 谓词、已落库 JSON 路径都用它)。
* *
* ═══ 两条关键设计,都是被数据打出来的 ═══════════════════════════════ * ═══ 两条关键设计,都是被数据打出来的 ═══════════════════════════════
* *
...@@ -23,7 +31,8 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -23,7 +31,8 @@ import { lookupDxTreatment } from './canonical-codes';
* 那是**反单调**的:上周刚查出的龋齿,因为身上还有一颗两年前的旧龋,整个人被判成「冷」。 * 那是**反单调**的:上周刚查出的龋齿,因为身上还有一颗两年前的旧龋,整个人被判成「冷」。
* **多一条未满足需求,反而更冷** —— 这在召回池里是说不通的。 * **多一条未满足需求,反而更冷** —— 这在召回池里是说不通的。
* *
* ✅ 正解:**逐条 gap 用自己 K 码的窗口判档,再取最热**。 * ✅ 正解:**逐条 gap 判档,再取最热**(2026-08-11 前是"按自己 K 码的窗口"判,
* 现在六档统一按绝对天数 —— 聚合口径没变,变的只是每条 gap 的判据)。
* *
* ⚠️ **实测量级要说准**(2026-08-02 本地 5,825 患者):在**召回池**上(矩阵真正展示的人群, * ⚠️ **实测量级要说准**(2026-08-02 本地 5,825 患者):在**召回池**上(矩阵真正展示的人群,
* 3,880 个「患者×标签」格位)只有 **1.31% 翻档,43 条变热 / 8 条变冷** —— 不是什么大数目。 * 3,880 个「患者×标签」格位)只有 **1.31% 翻档,43 条变热 / 8 条变冷** —— 不是什么大数目。
...@@ -32,10 +41,9 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -32,10 +41,9 @@ import { lookupDxTreatment } from './canonical-codes';
* 量级小是因为池子已经过了 gap 闸;拿**未过闸的原始诊断事实**去量会得到 40-70% 的 * 量级小是因为池子已经过了 gap 闸;拿**未过闸的原始诊断事实**去量会得到 40-70% 的
* 夸张差值 —— 那是错的人群,别拿它当收益。 * 夸张差值 —— 那是错的人群,别拿它当收益。
* 这条改动真正的价值在 ② 的解冻和 Q-6 的收口,不在今天这 1.31%。 * 这条改动真正的价值在 ② 的解冻和 Q-6 的收口,不在今天这 1.31%。
* 顺带把开发规划 Q-6 说的「一对多无解」也解掉了 —— `extraction ← K01(180/90) + K03(90/60)` * ⚠️ 上面这段实测是**旧口径**(按 K 码窗口)下量的,2026-08-11 改成绝对天数后
* 根本不需要为标签另定一套窗口:K01 那条按 K01 判、K03 那条按 K03 判,各归各的。 * 这些数字只有考古价值 —— 别拿它当现在的分布。
* 于是 T6「阈值复用 DiagnosisTreatmentMap,不新增口径」真的做到了, * (Q-6 说的「一对多无解」现在自然消失:拔牙 ← K01+K03 两条 gap 用的是同一把尺子。)
* `canonical-codes.ts` 那句「不允许任一处再硬编码窗口/临界」也没被违反。
* *
* ② **存边界时刻,不存天数/不存档位** —— 与 `visitRecencyRange`(存日期读时分档)、 * ② **存边界时刻,不存天数/不存档位** —— 与 `visitRecencyRange`(存日期读时分档)、
* `applyLiveDays`(存锚点读时算天数)同一套路,本仓已两次为同一个坑付过学费。 * `applyLiveDays`(存锚点读时算天数)同一套路,本仓已两次为同一个坑付过学费。
...@@ -91,6 +99,18 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [ ...@@ -91,6 +99,18 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
Temperature.COLD_OVER, Temperature.COLD_OVER,
]; ];
/**
* 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。
*
* 🔴 这两个数是**唯一真理源**:TS 判档(`gapTemperatureBounds`)、SQL 判档
* (`reason-temperature.sql.ts`)、列头 hover 的天数区间全从这里取。
* ⛔ 任何一处再手写 90 / 180 都是第二份真理源。
* ⚠️ 与 `DiagnosisTreatmentMap` 的 `urgencyDayThreshold` / `windowDays` **已经脱钩** ——
* 那两个仍然管着召回入池下界(cooldown)与 priority-scorer 的衰减,⛔ 别顺手统一。
*/
export const HOT_BUCKET_DAYS = 90;
export const WARM_BUCKET_DAYS = 180;
/** 冷档的**年数上界**(距锚点);COLD_OVER 无上界 = null。SQL 与 TS 判档共用这一份。 */ /** 冷档的**年数上界**(距锚点);COLD_OVER 无上界 = null。SQL 与 TS 判档共用这一份。 */
export const COLD_BUCKET_YEARS: Readonly<Record<string, number | null>> = { export const COLD_BUCKET_YEARS: Readonly<Record<string, number | null>> = {
[Temperature.COLD_1Y]: 1, [Temperature.COLD_1Y]: 1,
...@@ -112,39 +132,40 @@ export function expandTemperatureFilter(v: string): readonly TemperatureValue[] ...@@ -112,39 +132,40 @@ export function expandTemperatureFilter(v: string): readonly TemperatureValue[]
* 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。 * 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。
* *
* ⚠️ **代码名与显示名刻意不一致**,不是遗漏: * ⚠️ **代码名与显示名刻意不一致**,不是遗漏:
* 代码里是 `hot` / `warm` / `cold`(枚举值、API 参数 `temperature=`、 * 代码里是 `hot` / `warm` / `cold_*`(枚举值、API 参数 `temperature=`、
* `persona_features.data.temperature` 的路径都是它),显示名是「黄金期 / 窗口内 / 窗口外」 * 已落库 JSON 路径都是它),显示名是六段中文时间
* ⛔ **不要顺手把枚举也改成 golden/inWindow/outWindow** —— 那要同时改 * ⛔ **不要顺手把枚举也改成 d90/d180** —— 那要同时改 API 契约、SQL 谓词、
* API 契约、SQL 谓词、已落库的 JSON 路径和前端 query,收益只是"看着顺眼"。 * 已落库的 JSON 路径和前端 query,收益只是"看着顺眼"。
* *
* ⭐ 显示名从「热/温/冷」改成临床说法(2026-08 产品定): * ⭐ 2026-08-11:前两档显示名从「黄金期 / 窗口内」改成「三个月内 / 三个月到半年」。
* 「热」是**比喻**,而这三档本来就有精确的临床语义(黄金期 / 临床周期内 / 超周期)。 * ⚠️ 这**不只是改文案** —— 判据同时从"该治疗自己的临床周期"改成了固定 90/180 天
* 名字精确之后,温度渐变那套颜色比喻就成了第二套更弱的编码 —— 一并撤掉,见 pool-matrix。 * (见文件头)。⛔ 别只改回文案而留着新判据,或反过来:两者必须同进同退,
* 否则列头写着「黄金期」而里面装的是"三个月内",主管拿它当临床窗口用就会做错决定。
*/ */
export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = { export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = {
[Temperature.HOT]: { [Temperature.HOT]: {
zh: '黄金期', zh: '三个月内',
hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得', hint: '诊断距今不到三个月 —— 医生刚说过,患者还记得',
}, },
[Temperature.WARM]: { [Temperature.WARM]: {
zh: '窗口内', zh: '三个月到半年',
hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由', hint: '诊断距今三到六个月 —— 还来得及,话术要给个理由',
}, },
[Temperature.COLD_1Y]: { [Temperature.COLD_1Y]: {
zh: '1 年内', zh: '1 年内',
hint: '刚出临床周期 —— 情况变化不大,先问近况再谈方案', hint: '诊断距今半年到 1 年 —— 情况变化不大,先问近况再谈方案',
}, },
[Temperature.COLD_2Y]: { [Temperature.COLD_2Y]: {
zh: '1–2 年', zh: '1–2 年',
hint: '出周期 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲', hint: '诊断距今 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲',
}, },
[Temperature.COLD_3Y]: { [Temperature.COLD_3Y]: {
zh: '2–3 年', zh: '2–3 年',
hint: '出周期 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在', hint: '诊断距今 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在',
}, },
[Temperature.COLD_OVER]: { [Temperature.COLD_OVER]: {
zh: '3 年以上', zh: '3 年以上',
hint: '出周期 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术', hint: '诊断距今 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术',
}, },
}; };
...@@ -178,11 +199,11 @@ export const TEMPERATURE_TOOL_DESC: string = ...@@ -178,11 +199,11 @@ export const TEMPERATURE_TOOL_DESC: string =
'矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' + '矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' +
'按此表把主管的话翻成取值:' + '按此表把主管的话翻成取值:' +
TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') + TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') +
'。⚠️ `cold` = 后四档**合计**(窗口外全体),' + '。⚠️ `cold` = 后四档**合计**(诊断距今半年以上全体),' +
'⛔ 只有主管明确说「窗口外 / 超周期」整体时才用 —— ' + '⛔ 只有主管明确说「半年以上 / 老单子」整体时才用 —— ' +
'他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' + '他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' +
'\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' + '\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' +
'回话时一律用中文档名:「2–3 年」「1–2 年」「窗口内」「黄金期」。' + '回话时一律用中文档名(就是上面那张表左边那些)。' +
'⛔ 也不要说「温度」这个词 —— 界面上没有,那就是一档一档的时间;' + '⛔ 也不要说「温度」这个词 —— 界面上没有,那就是一档一档的时间;' +
'要说「往后放一档」而不是「放宽温度」。'; '要说「往后放一档」而不是「放宽温度」。';
...@@ -194,9 +215,9 @@ export const TEMPERATURE_TOOL_DESC: string = ...@@ -194,9 +215,9 @@ export const TEMPERATURE_TOOL_DESC: string =
* 带偏移量的写法(`+08:00`)会破坏这个性质。 * 带偏移量的写法(`+08:00`)会破坏这个性质。
*/ */
export interface TemperatureBounds { export interface TemperatureBounds {
/** 黄金期终点 = 锚点 + urgencyDayThreshold */ /** 第一档终点 = 锚点 + HOT_BUCKET_DAYS(90 天) */
hotUntil: string; hotUntil: string;
/** 临床周期终点 = 锚点 + windowDays */ /** 第二档终点 = 锚点 + WARM_BUCKET_DAYS(180 天) */
warmUntil: string; warmUntil: string;
/** /**
* 锚点本身(该标签**最新的未治疗**诊断)。冷端四档按距它的绝对年数分。 * 锚点本身(该标签**最新的未治疗**诊断)。冷端四档按距它的绝对年数分。
...@@ -208,11 +229,16 @@ export interface TemperatureBounds { ...@@ -208,11 +229,16 @@ export interface TemperatureBounds {
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
/** /**
* 单条 gap 的温度边界 = 锚点日期 + **该诊断码自己**的窗口配置。 * 单条 gap 的档位边界 = 锚点日期 + 固定 90 / 180 天。
*
* ⚠️ 2026-08-11 起**与诊断码无关**(旧口径是 `rule.urgencyDayThreshold / windowDays`)。
* `primaryCode` 仍然要传、也仍然要查表 —— 它现在只当**有效性校验**:
* 码不在 `DiagnosisTreatmentMap` 里 = 这条 gap 根本不该参与矩阵,返回 null 让调用方丢掉。
* ⛔ 别因为"不用它算天数了"就把参数删掉,那会让未知码静默混进某一档。
* *
* @param primaryCode gap 的主码(K01/K02/…),与挖 gap 时用的 `lookupDxTreatment(primaryCode)` 同一条规则 * @param primaryCode gap 的主码(K01/K02/…),与挖 gap 时用的 `lookupDxTreatment(primaryCode)` 同一条规则
* @param anchorAt 信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点** * @param anchorAt 信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点**
* @returns 码不在表里 / 锚点非法 → null(调用方丢弃该条,别猜一个默认窗口出来) * @returns 码不在表里 / 锚点非法 → null
*/ */
export function gapTemperatureBounds( export function gapTemperatureBounds(
primaryCode: string, primaryCode: string,
...@@ -221,11 +247,10 @@ export function gapTemperatureBounds( ...@@ -221,11 +247,10 @@ export function gapTemperatureBounds(
if (!anchorAt) return null; if (!anchorAt) return null;
const t = anchorAt instanceof Date ? anchorAt.getTime() : new Date(anchorAt).getTime(); const t = anchorAt instanceof Date ? anchorAt.getTime() : new Date(anchorAt).getTime();
if (Number.isNaN(t)) return null; if (Number.isNaN(t)) return null;
const rule = lookupDxTreatment(primaryCode); if (!lookupDxTreatment(primaryCode)) return null;
if (!rule) return null;
return { return {
hotUntil: new Date(t + rule.urgencyDayThreshold * DAY_MS).toISOString(), hotUntil: new Date(t + HOT_BUCKET_DAYS * DAY_MS).toISOString(),
warmUntil: new Date(t + rule.windowDays * DAY_MS).toISOString(), warmUntil: new Date(t + WARM_BUCKET_DAYS * DAY_MS).toISOString(),
anchorAt: new Date(t).toISOString(), anchorAt: new Date(t).toISOString(),
}; };
} }
...@@ -288,26 +313,19 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> ...@@ -288,26 +313,19 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
/** /**
* 这一档对应的**天数区间** —— 矩阵格子 hover 用,只给区间,不给解释。 * 这一档对应的**天数区间** —— 矩阵格子 hover 用,只给区间,不给解释。
* *
* ⚠️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是各码区间的**并集**: * ⚠️ 2026-08-11 起六档都是绝对时间,**与治疗项无关** —— `label` 因此不再参与计算。
* 每条 gap 按**自己那个码**的窗口判档(见文件头 ①),不存在"标签级的那一个天数"。 * ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。
* 并集是对该档人群天数范围的**如实**描述 —— 比挑一个码的数字诚实,也比塞一堆解释短。 * 旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。
* 例:拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
* 热 [0,90]∪[0,60] = ≤90 天 · 温 (90,180]∪(60,90] = 60–180 天 · 冷 >180∪>90 = >90 天
*/ */
export function temperatureWindowHint(label: string, temp: TemperatureValue): string { export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
void label;
const years = COLD_BUCKET_YEARS[temp]; const years = COLD_BUCKET_YEARS[temp];
// 冷四档是**绝对年数**,与治疗项无关 —— 不查窗口配置
if (years !== undefined) { if (years !== undefined) {
if (years === null) return '诊断距今 > 3 年'; if (years === null) return '诊断距今 > 3 年';
return years === 1 ? '出周期后 1 年内' : `诊断距今 ${years - 1}${years} 年`; return years === 1 ? `诊断距今 ${WARM_BUCKET_DAYS} 天–1 年` : `诊断距今 ${years - 1}${years} 年`;
} }
const codes = POTENTIAL_TREATMENT_SOURCE_CODES[label]; if (temp === Temperature.HOT) return `诊断距今 ≤ ${HOT_BUCKET_DAYS} 天`;
const rules = (codes ?? []).map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r); return `诊断距今 ${HOT_BUCKET_DAYS}${WARM_BUCKET_DAYS} 天`;
if (!rules.length) return '';
const urg = rules.map((r) => r.urgencyDayThreshold);
const win = rules.map((r) => r.windowDays);
if (temp === Temperature.HOT) return `≤ ${Math.max(...urg)} 天`;
return `${Math.min(...urg)}${Math.max(...win)} 天`;
} }
/** /**
......
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