Commit 4b2dec7e by luoqi

fix(名册/矩阵): 在岗按「在这儿干活」判,不是「碰过一次」;算不出档位的人数去重

测试服上海世纪公园显示「193 位在岗」,而同规模的其他诊所都是 32–43。产品指认
康慧捧是别家诊所的人 —— 查下来正是如此,而且是普遍现象。

① **名册闸**(agent-roster.service.ts)
   拆开看这 193 人:19 个做了 15,476 条(95.8%),**98 个只有 1 条**、63 个 2–5 条;
   161 人(83%)全年合计 296 条,占 1.8%。按主场分:**160 人主场在别家诊所**,
   他们在这儿总共只有 330 条(康慧捧:主场 3,799 条在另一家,这儿 3 条)。
   医生/护士/前台偶尔被记成一次回访负责人,就进了名册。
   ️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
     (在岗人数 × 每天几通 × 时效)—— 193 × 15 = 2,895,比真实规模大一个数量级。
   ⇒ 判据改成「本诊所回访量占个人总量 ≥20%,**或**本诊所 ≥20 条」。
   ️ 两条取或,缺一不可:只用占比会挡掉 13 个在某诊所做了 50+ 条但个人总量更大的
     真客服;只用绝对量对小诊所和新人不公平(那正是"名册不是白名单"要护的人)。
   ️ 分母是**跨诊所**总量, 不是本诊所的 —— 否则占比恒为 100%,整道闸失效(已锁测试)。
    实测:世纪公园 193 → 34(25 个靠量进、9 个靠占比进),康慧捧被挡掉;
     其余诊所各减 1–6 人,每家留下的名册仍覆盖本诊所 **98% 以上**的回访量。
   ️ 闸只改"建议给谁", 没改"能分给谁":被挡掉的人走 extraUserIds 照样能被点名,
     姓名由 namesAnywhere 兜底、inRoster=false。rosterNote 的措辞跟着判据一起改。

② **「另有 N 人算不出档位」数错了**(cohort-attributes.service.ts)
   界面写 50,去重只有 42 —— 50 是把 8 个治疗行的 unknown **竖着相加**得来的,
   一个患者有几个治疗项就被数几次,而那句话写的是「人」。
   本方法开头那条口径(「矩阵按患者去重」「 别让主管一对数就觉得系统在骗他」)
   讲的正是这件事,偏偏这行汇总自己踩了。
   ⇒ 改成 GROUPING SETS 多取一组跨标签去重行。️ 必须与各格**同一次查询**算:
     分两次就是两个 NOW(),边界人群会在两个时刻落进不同档,差额又对不上。
   ️ 话里补一句「那一列竖着加会大于这个数」—— 不写清楚,主管一相加还是对不上,
     那只是换了种方式让他不信任这个数。

📌 顺带定案:**咨询不算到诊**(产品定)。那 42 人名下只有 diagnosis_record +
   consultation_record、**一条 encounter/病历都没有**,末诊为空是对的,不是漏算。
    不改末诊口径(schema 上写死的 encounter + 治疗 + 挂号 + 病历 并集)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent be90e662
Pipeline #3570 failed in 0 seconds
...@@ -21,7 +21,32 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -21,7 +21,32 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
* ⚠️ 名册是**建议来源,不是白名单**:实测有 11 个人只在登录侧有行为、回访数为 0 * ⚠️ 名册是**建议来源,不是白名单**:实测有 11 个人只在登录侧有行为、回访数为 0
* (新入职 / 只做召回不做回访)。主管显式指定的人即使名册里查不到也必须能分, * (新入职 / 只做召回不做回访)。主管显式指定的人即使名册里查不到也必须能分,
* 只在旁边给一个中性提示。挡人是主管的权力,不是名册的。 * 只在旁边给一个中性提示。挡人是主管的权力,不是名册的。
*
* ── 归属:在这儿干活的人,不是「碰过这儿一次」的人 ──────────────
* 🔴 2026-08-16 加的量闸。此前只要「近 12 月在本诊所有 ≥1 条回访」就算在岗,
* 测试服上海世纪公园因此显示 **193 位在岗**,而同规模的其他诊所都是 32–43。
* 拆开看:19 个人做了 15,476 条(95.8%),另外 **98 人只有 1 条**、63 人 2–5 条 ——
* 161 人(83%)全年合计 296 条,占 1.8%。
* **160 人的主场在别的诊所**,他们在这儿总共只有 330 条(产品指认的康慧捧:
* 主场 3,799 条在另一家,这儿 3 条)。医生/护士/前台偶尔被记成一次回访负责人,
* 就进了名册。
* ⚠️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
* (在岗人数 × 每天几通 × 时效),193 会让默认批次比真实规模**大一个数量级**。
*
* ⇒ 判据 = **在本诊所的回访量占他个人总量 ≥20%,或在本诊所 ≥20 条**。
* ⚠️ 两条是**或**,缺一不可:
* · 只用占比 → 挡掉 13 个在某诊所做了 50+ 条、但个人总量更大的真客服;
* · 只用绝对量 → 对小诊所和新人不公平(那正是"名册不是白名单"要护的人)。
* ⚠️ 分母是**跨诊所总量**(同一时间窗),⛔ 不是本诊所的 —— 否则占比恒为 100%。
* ⭐ 实测(测试服,近 12 月):世纪公园 193 → 34,其余诊所各减 1–6 人;
* 每家诊所留下的名册仍覆盖本诊所 **98% 以上**的回访量。
* ⚠️ 被闸挡掉的人**照样能被主管点名**(走 `extraUserIds`,姓名由 `namesAnywhere` 兜底,
* `inRoster: false`)—— 这道闸只改"建议给谁",⛔ 没改"能分给谁"。
*/ */
/// 在本诊所的回访量占个人总量的下限(与 `ROSTER_MIN_VISITS` 取或)
const ROSTER_MIN_SHARE = 0.2;
/// 在本诊所的回访绝对条数下限(与 `ROSTER_MIN_SHARE` 取或)
const ROSTER_MIN_VISITS = 20;
@Injectable() @Injectable()
export class AgentRosterService { export class AgentRosterService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
...@@ -55,19 +80,37 @@ export class AgentRosterService { ...@@ -55,19 +80,37 @@ export class AgentRosterService {
const roster = await this.prisma.$queryRaw< const roster = await this.prisma.$queryRaw<
Array<{ id: string; name: string | null; visits: number; lastAt: Date }> Array<{ id: string; name: string | null; visits: number; lastAt: Date }>
>(Prisma.sql` >(Prisma.sql`
SELECT DISTINCT ON (rv.task_director_id) WITH here AS (
rv.task_director_id AS "id", SELECT DISTINCT ON (rv.task_director_id)
rv.task_director_name AS "name", rv.task_director_id AS "id",
COUNT(*) OVER (PARTITION BY rv.task_director_id)::int AS "visits", rv.task_director_name AS "name",
MAX(rv.source_created_at) OVER (PARTITION BY rv.task_director_id) AS "lastAt" COUNT(*) OVER (PARTITION BY rv.task_director_id)::int AS "visits",
FROM patient_return_visits rv MAX(rv.source_created_at) OVER (PARTITION BY rv.task_director_id) AS "lastAt"
WHERE rv.host_id = ${scope.hostId}::uuid FROM patient_return_visits rv
AND rv.tenant_id = ${scope.tenantId} WHERE rv.host_id = ${scope.hostId}::uuid
AND rv.clinic_id = ${clinicId} AND rv.tenant_id = ${scope.tenantId}
AND rv.task_director_id IS NOT NULL AND rv.clinic_id = ${clinicId}
-- ⛔ 不是 task_date,见类注释 AND rv.task_director_id IS NOT NULL
AND rv.source_created_at >= ${since} -- ⛔ 不是 task_date,见类注释
ORDER BY rv.task_director_id, rv.source_created_at DESC AND rv.source_created_at >= ${since}
ORDER BY rv.task_director_id, rv.source_created_at DESC
),
-- 归属判据的分母:同一时间窗、**跨诊所**的个人总量(⛔ 不限 clinic_id,见类注释)
everywhere AS (
SELECT rv.task_director_id AS "id", COUNT(*)::int AS "total"
FROM patient_return_visits rv
WHERE rv.host_id = ${scope.hostId}::uuid
AND rv.tenant_id = ${scope.tenantId}
AND rv.task_director_id IN (SELECT "id" FROM here)
AND rv.source_created_at >= ${since}
GROUP BY 1
)
SELECT h."id", h."name", h."visits", h."lastAt"
FROM here h
JOIN everywhere e ON e."id" = h."id"
-- 「在这儿干活的人」而不是「碰过这儿一次的人」——两条取**或**,理由见类注释
WHERE h."visits" >= ${ROSTER_MIN_VISITS}
OR h."visits"::numeric / e."total" >= ${ROSTER_MIN_SHARE}
`); `);
const ids = new Set<string>(); const ids = new Set<string>();
...@@ -155,9 +198,11 @@ export class AgentRosterService { ...@@ -155,9 +198,11 @@ export class AgentRosterService {
agents, agents,
// ⭐ 成品句子,给助手**照抄**用。T14 的落地方式是"提示词 + 工具返回值"双保险: // ⭐ 成品句子,给助手**照抄**用。T14 的落地方式是"提示词 + 工具返回值"双保险:
// LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。 // LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。
// ⚠️ 措辞跟着判据走:从「有回访记录」改成「常在这个诊所做回访」——
// ⛔ 别只改判据不改这句,主管照着旧话去对人数会对不上(名册收窄了)。
rosterNote: rosterNote:
`在岗名册按「近 ${months} 个月有回访记录」近似判定,不代表系统确认在职;` + `在岗名册按「近 ${months} 个月常在这个诊所做回访」近似判定,不代表系统确认在职;` +
`名册外的客服也可以指定。`, `偶尔来支援一次的不算在内,名册外的客服也可以指定。`,
}; };
} }
......
...@@ -113,27 +113,43 @@ export class CohortAttributesService { ...@@ -113,27 +113,43 @@ export class CohortAttributesService {
*/ */
async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) { async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) {
void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂) void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
/**
* 🔴 `GROUPING SETS` 多出的那一组 `(temp)` 是**跨标签去重**的那一行(`label` 为 NULL)——
* 「另有 N 人算不出档位」必须取它,⛔ 不能把各行的 unknown 相加。
* 2026-08-16 实测(测试服上海世纪公园):竖着加得 50,去重是 42 ——
* 一个患者有几个治疗项就被数几次,而那句话写的是「**人**」。
* 本方法开头那条口径(「矩阵按患者去重」「⛔ 别让主管一对数就觉得系统在骗他」)
* 讲的正是这件事,偏偏这行汇总自己踩了。
* ⚠️ 必须与各格**同一次查询**算出来:分两次查就是两个 `NOW()`,
* 边界上的人会在两个时刻落进不同档,差额又对不上(同上面 `void now` 那条)。
* ⚠️ `la` 里 `label` 恒非 NULL(WHERE 已滤),所以 `label IS NULL` 唯一地标出汇总行。
*/
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ label: string; temp: string | null; n: bigint }> Array<{ label: string | null; temp: string | null; n: bigint }>
>( >(
Prisma.sql` Prisma.sql`
WITH la AS ( WITH la AS (
${planLabelAnchorsSql(poolBaseSql(scope, clinicId))} ${planLabelAnchorsSql(poolBaseSql(scope, clinicId))}
),
b AS (
SELECT patient_id, label,
${temperatureBucketCaseSql(
Prisma.raw('hot_until'),
Prisma.raw('warm_until'),
Prisma.raw('anchor_at'),
)} AS temp
FROM la
) )
SELECT label, -- label 为 NULL 的那一行 = 跨标签去重的汇总(见方法内注释),⛔ 别改成各行相加
${temperatureBucketCaseSql( SELECT label, temp, count(DISTINCT patient_id) AS n
Prisma.raw('hot_until'), FROM b
Prisma.raw('warm_until'), GROUP BY GROUPING SETS ((label, temp), (temp))
Prisma.raw('anchor_at'),
)} AS temp,
count(DISTINCT patient_id) AS n
FROM la
GROUP BY 1, 2
`, `,
); );
const cells = new Map<string, Record<string, number>>(); const cells = new Map<string, Record<string, number>>();
for (const r of rows) { for (const r of rows) {
if (r.label == null) continue; // 汇总行(GROUPING SETS 那一组),下面单独取
const row = cells.get(r.label) ?? {}; const row = cells.get(r.label) ?? {};
row[r.temp ?? 'unknown'] = Number(r.n); row[r.temp ?? 'unknown'] = Number(r.n);
cells.set(r.label, row); cells.set(r.label, row);
...@@ -141,7 +157,8 @@ export class CohortAttributesService { ...@@ -141,7 +157,8 @@ export class CohortAttributesService {
// 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序), // 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序),
// ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。 // ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。
const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!; const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
const unknownTotal = rows.filter((r) => r.temp == null).reduce((a, r) => a + Number(r.n), 0); /// 跨标签**去重**的人数 —— ⛔ 别改回 `rows.filter(...).reduce(相加)`(见上方 SQL 里那段)
const unknownTotal = Number(rows.find((r) => r.label == null && r.temp == null)?.n ?? 0);
return { return {
clinicId, clinicId,
...@@ -167,7 +184,11 @@ export class CohortAttributesService { ...@@ -167,7 +184,11 @@ export class CohortAttributesService {
note: note:
unknownTotal > 0 unknownTotal > 0
? `另有 ${unknownTotal} 人**算不出档位**(没有末诊记录),已单列,` + ? `另有 ${unknownTotal} 人**算不出档位**(没有末诊记录),已单列,` +
'⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。' '⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。' +
// ⚠️ 这半句必须留着:这个数是**去重人数**,而「算不出」那一列是按治疗项分行的,
// 一个人有几个治疗项就在几行里各出现一次 ⇒ 竖着加必然大于它。
// 不写清楚,主管一相加就发现对不上,而那正是这行汇总上一版真的算错过的地方。
'(同一个人有几个治疗项就会在几行里各出现一次,所以那一列竖着加会大于这个数。)'
: '', : '',
}; };
} }
......
...@@ -206,3 +206,44 @@ describe('模型选择 —— 谁压过谁', () => { ...@@ -206,3 +206,44 @@ describe('模型选择 —— 谁压过谁', () => {
expect(schema).not.toMatch(/\.max\(\d+\)/); expect(schema).not.toMatch(/\.max\(\d+\)/);
}); });
}); });
/**
* 🔴 名册的**归属判据** —— 2026-08-16 测试服上海世纪公园显示「193 位在岗」引出的。
*
* 拆开看:19 人做了 95.8% 的回访,98 人只有 1 条;**160 人的主场在别家诊所**,
* 他们在这儿总共 330 条(产品指认的康慧捧:主场 3,799 条在另一家,这儿 3 条)。
* 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时效)——
* 名册虚高一个数量级,默认批次就跟着虚高一个数量级。
*
* ⚠️ 这里锁的是**判据在不在**,⛔ 不锁阈值数字(20% / 20 条是产品可调的)。
*/
describe('在岗名册 —— 「在这儿干活的人」不是「碰过这儿一次的人」', () => {
const ROSTER = svc('modules/plan/agent-roster.service.ts');
test('🔴 两条判据都在,且是****(只留一条都会误伤)', () => {
// 只用占比 → 挡掉在某诊所做了 50+ 条、但个人总量更大的真客服(实测 13 人);
// 只用绝对量 → 对小诊所和新人不公平,那正是「名册不是白名单」要护的人。
expect(ROSTER).toContain('ROSTER_MIN_SHARE');
expect(ROSTER).toContain('ROSTER_MIN_VISITS');
expect(/>=\s*\$\{ROSTER_MIN_VISITS\}[\s\S]{0,80}OR[\s\S]{0,120}ROSTER_MIN_SHARE/.test(ROSTER)).toBe(
true,
);
});
test('🔴 占比的分母是**跨诊所**总量 —— 按本诊所算的话它恒等于 100%,整道闸失效', () => {
const everywhere = /everywhere AS \(([\s\S]*?)\n \)/.exec(ROSTER)?.[1] ?? '';
expect(everywhere).not.toBe('');
expect(everywhere).toContain('task_director_id IN');
// ⛔ 这个 CTE 里不许出现 clinic_id:一加上,占比就成了「本诊所 ÷ 本诊所」
expect(everywhere).not.toContain('clinic_id');
// 时间窗必须与名册那半边同一个,否则分子分母量的不是同一段时间
expect(everywhere).toContain('source_created_at >=');
});
test('⚠️ 名册仍不是白名单 —— 被挡掉的人主管照样点得到', () => {
// 闸只改"建议给谁",⛔ 没改"能分给谁"。extraUserIds 那条路必须还在。
expect(ROSTER).toContain('extraUserIds');
expect(ROSTER).toContain('namesAnywhere');
expect(ROSTER).toContain('inRoster');
});
});
...@@ -150,7 +150,11 @@ describe('画像圈人 —— 维度点名', () => { ...@@ -150,7 +150,11 @@ describe('画像圈人 —— 维度点名', () => {
}); });
describe('初选矩阵', () => { describe('初选矩阵', () => {
const matrixPrisma = (rows: Array<{ label: string; temp: string | null; n: number }>) => /**
* ⚠️ `label: null` 的行 = SQL 里 `GROUPING SETS` 多出的**跨标签去重汇总**行。
* `unknownTotal` 只认这一行,⛔ 不再把各标签的 unknown 相加(见下面那条测试的由来)。
*/
const matrixPrisma = (rows: Array<{ label: string | null; temp: string | null; n: number }>) =>
({ ({
$queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))), $queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))),
}) as unknown as PrismaService; }) as unknown as PrismaService;
...@@ -163,6 +167,7 @@ describe('初选矩阵', () => { ...@@ -163,6 +167,7 @@ describe('初选矩阵', () => {
{ label: 'implant', temp: 'hot', n: 10 }, { label: 'implant', temp: 'hot', n: 10 },
{ label: 'implant', temp: 'cold_1y', n: 5 }, { label: 'implant', temp: 'cold_1y', n: 5 },
{ label: 'implant', temp: null, n: 3 }, { label: 'implant', temp: null, n: 3 },
{ label: null, temp: null, n: 3 }, // 汇总行
]), ]),
); );
const m = await svc.matrix(SCOPE, 'c1'); const m = await svc.matrix(SCOPE, 'c1');
...@@ -176,6 +181,30 @@ describe('初选矩阵', () => { ...@@ -176,6 +181,30 @@ describe('初选矩阵', () => {
expect(m.note).toContain('没有并进'); expect(m.note).toContain('没有并进');
}); });
/**
* 🔴 2026-08-16 实测(测试服上海世纪公园):界面写「另有 **50 人**算不出档位」,
* 而去重只有 42 —— 50 是把 8 个治疗行的 unknown 竖着加出来的,
* 一个患者有几个治疗项就被数几次。矩阵开头那条口径(「按患者去重」)自己被这行汇总踩了。
* ⇒ 汇总必须来自 SQL 的跨标签去重行,⛔ 不许在 TS 里相加。
*/
test('🔴 「另有 N 人算不出档位」取去重行,⛔ 不是各行相加(一个人有几个治疗项就被数几次)', async () => {
const svc = await build(
matrixPrisma([
{ label: 'implant', temp: null, n: 3 },
{ label: 'extraction', temp: null, n: 4 },
{ label: null, temp: null, n: 5 }, // 去重后只有 5 人(有 2 人同时占两行)
]),
);
const m = await svc.matrix(SCOPE, 'c1');
expect(m.unknownTotal).toBe(5); // ⛔ 不是 3 + 4 = 7
// 各行照旧是本行的去重人数 —— ⛔ 别为了"加起来等于 5"去改行内的数
expect(m.rows.find((r) => r.key === 'implant')!.unknown).toBe(3);
expect(m.rows.find((r) => r.key === 'extraction')!.unknown).toBe(4);
// ⚠️ 行合计(7)本来就会大于去重数(5),这一点必须写在话里,
// 否则主管一相加就发现对不上 —— 那是换了一种方式让他不信任这个数。
expect(m.note).toContain('竖着加会大于这个数');
});
test('⭐⭐ 六档都要在 counts 里出现(没人的给 0)—— ⛔ 缺键会让前端渲染成空白而不是 0', async () => { test('⭐⭐ 六档都要在 counts 里出现(没人的给 0)—— ⛔ 缺键会让前端渲染成空白而不是 0', async () => {
const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 1 }])); const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 1 }]));
const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!; const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
......
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