Commit da9c80c8 by luoqi

feat(recall): 宿主跟进闸 —— 宿主正在跟进的患者不进召回池

宿主自己已建复杂病例 / 工单、客服或咨询师在推进的患者,PAC 不再发起召回:
两边同时联系同一个人,患者体验差,也显得两个系统各说各话。

落点 patient_profiles.host_follow_up_active(canonical hostFollowUpActive),
与 do_not_contact / deceased 并列在召回②合规硬过滤,但性质不同:
  合规闸 = 法务/风险,永久,需人工解除
  跟进闸 = 协作分工,临时,宿主停手下次推主档即回池

【本次最关键的决定:三态而非两态】
  null  = 宿主未提供该信号(jvs-dw 未接入)→ 不启用该闸
  false = 宿主明确说"没在跟"
  true  = 正在跟 → 排除
由此三条纪律,全部有测试锁住:
  1. 列可空且**无 @default** —— 给默认值等于把存量 38 万患者一次性断言成某种状态,
     且将来分不出"没接入"和"接了但没跟"
  2. SQL 一律 `IS NOT TRUE`,**绝不能** `= false` —— 后者遇 NULL 恒 UNKNOWN,
     会把未接入宿主的患者全部静默挡在池外(不报错、不留痕,只表现为池子空了)
  3. upsert full 分支不能 `?? false`(那是替宿主表态);partial 分支未提供则不进 update 集合

改动:
  - canonical: hostFollowUpActive(可空无默认)+ 挂进 patient.booleanFields,自动吃 0/1
  - prisma: 副表加列 + 索引;migration 加可空列不重写表,存量行为不变
  - scenario SQL: 入池闸 + 注释框图同步
  - recall-debug: compliance 增补该项,否则排查时会显示"合规通过"却查不到人
  - 测试 host-follow-up-gate.spec.ts(18 项)

FRIDAY 侧对应 has_active_complex_case,assembler 映射待宿主确认列名与 is_del 语义后再接,
故本次**未接 FRIDAY 映射** —— 该闸对所有宿主当前均为 NULL,行为与上线前完全一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 021414b9
-- 宿主跟进闸:宿主侧正在跟进的患者不进召回池(patient_profiles.host_follow_up_active)
--
-- 【要解决什么】宿主自己已建复杂病例 / 工单、客服或咨询师在推进的患者,PAC 不该再发起召回 ——
-- 两边同时联系同一个人,患者体验差,也显得两个系统各说各话。
--
-- 【为什么可空,且没有 DEFAULT false】这是本次最关键的一个决定:
-- NULL = **宿主未提供该信号**(如 jvs-dw 尚未接入),此时不启用该闸;
-- false = **宿主明确说"没在跟"**。两者语义不同,不能合并。
-- 若给 DEFAULT false,存量 38 万患者会被一次性断言成"宿主确认没在跟",
-- 等于替宿主表了态;将来真接入时也分不出"没接"和"接了但没跟"。
-- ⚠️ 由此,所有查询必须写 `IS NOT TRUE` 而**不是** `= false` ——
-- 后者因 NULL != false 恒为 UNKNOWN,会把未接入宿主的患者全部挡在召回池外(静默清空池子)。
--
-- 【为什么落副表而非 patients】patient_profiles 本就装派生 / 摄入属性(合规标记、获客渠道、
-- 到诊派生);patients 是主数据。且召回硬过滤本来就 join 副表,不多一次连接。
--
-- 【索引】召回入池 SQL 每次都过这个条件,与 do_not_contact / deceased 同规格建索引。
-- 加可空列不重写表(PG11+ 元数据操作),存量行自动为 NULL,行为不变 —— 该闸对未接入宿主静默无效。
ALTER TABLE "patient_profiles"
ADD COLUMN IF NOT EXISTS "host_follow_up_active" BOOLEAN;
CREATE INDEX IF NOT EXISTS "patient_profiles_host_follow_up_active_idx"
ON "patient_profiles" ("host_follow_up_active");
......@@ -273,6 +273,14 @@ model PatientProfile {
deceased Boolean @default(false)
deceasedAt DateTime? @map("deceased_at") @db.Timestamptz(3)
/// 宿主跟进闸 —— 宿主侧正在跟进该患者(自己已建工单/复杂病例、客服在推进) PAC 不再召回,
/// 避免两边同时联系同一个人。与 do_not_contact / deceased 并列在召回②合规硬过滤,**性质不同**:
/// 合规闸 = 法务/风险,永久,需人工解除;跟进闸 = 协作分工,临时,宿主停止跟进即回池。
/// ⚠️ **可空且无默认值**:null = 宿主未提供该信号( jvs-dw 未接入) 不启用该闸。
/// 查询一律用 `IS NOT TRUE` 而非 `= false` —— 后者会因 NULL != false 把未接入宿主的患者全挡在池外。
/// FRIDAY 侧口径见 docs/integration/friday-push-payload(has_active_complex_case)
hostFollowUpActive Boolean? @map("host_follow_up_active")
/// 产品收集(回访中心信息字段.docx):宿主侧给客户打的标签数组 多源标签 / 客户偏好。
/// :["VIP", "难沟通", "投诉过", "充值卡客户"]Persona engine 可作为输入信号。
tags String[] @default([]) @map("tags")
......@@ -334,6 +342,7 @@ model PatientProfile {
@@index([doNotContact])
@@index([deceased])
@@index([hostFollowUpActive])
@@index([acquisitionChannel])
@@map("patient_profiles")
}
......
......@@ -302,7 +302,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ │ p.active = true (患者主档活跃) │ ║
// ║ │ pp.do_not_contact = false (没勾"勿打扰") │ ║
// ║ │ pp.deceased = false (没标"已故") │ ║
// ║ │ pp.host_follow_up_active IS NOT TRUE (宿主没在自己跟进) │ ║
// ║ │ → 法务 / 投诉风险硬过滤,任一不满足直接踢 │ ║
// ║ │ 注:跟进闸性质不同于前两者 —— 合规是永久/法务,跟进是临时/协作, │ ║
// ║ │ 宿主停手下次推主档即回池。可空,NULL=未接入该信号,不启用。 │ ║
// ║ └────────────────────────────────────────────────────────────────────┘ ║
// ║ ║
// ║ ┌─ ③ 触发信号(sig 这条 fact 必须是"该召回理由") ───────────────┐ ║
......@@ -375,6 +378,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND p.active = true -- ② 合规闸
AND pp.do_not_contact = false -- ② 合规闸
AND pp.deceased = false -- ② 合规闸
-- ② 宿主跟进闸:宿主自己正在跟进的患者不召回(避免两边同时联系同一个人)。
-- ⚠️ 必须 IS NOT TRUE 而非 = false:该列可空,NULL = 宿主未提供该信号
-- (jvs-dw 未接入),= false 会因 NULL != false 把这些患者全挡在池外。
AND pp.host_follow_up_active IS NOT TRUE -- ② 宿主跟进闸
AND sig.status = 'active' -- ③ 触发信号 active 版本
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
......
......@@ -95,9 +95,14 @@ export class RecallDebugService {
active: patient.active,
doNotContact: patient.profile?.doNotContact ?? false,
deceased: patient.profile?.deceased ?? false,
/// 宿主跟进闸:只有显式 true 才算被挡(null = 宿主未提供该信号 → 不启用)
hostFollowUpActive: patient.profile?.hostFollowUpActive === true,
};
const compliancePass =
compliance.active && !compliance.doNotContact && !compliance.deceased;
compliance.active &&
!compliance.doNotContact &&
!compliance.deceased &&
!compliance.hostFollowUpActive;
const futureAppt = facts.find((f) => {
if (f.type !== 'appointment_record' || f.status !== 'active') return false;
......@@ -436,6 +441,7 @@ export interface RecallDebugReport {
active: boolean;
doNotContact: boolean;
deceased: boolean;
hostFollowUpActive: boolean;
};
signalCount: number;
signals: SignalFunnel[];
......
......@@ -57,6 +57,9 @@ export function buildProfileUpsertData(
return {
doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false,
// ⚠️ 不能 `?? false` —— 未提供该信号时必须留 null(= 不启用跟进闸);
// 落 false 等于替宿主断言"确认没在跟",语义不同,会让未接入宿主的行为静默改变。
hostFollowUpActive: (c.hostFollowUpActive as boolean | undefined) ?? null,
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
......@@ -70,6 +73,9 @@ export function buildProfileUpsertData(
return {
...(given('doNotContact') ? { doNotContact: c.doNotContact as boolean } : {}),
...(given('deceased') ? { deceased: c.deceased as boolean } : {}),
...(given('hostFollowUpActive')
? { hostFollowUpActive: c.hostFollowUpActive as boolean }
: {}),
...(Array.isArray(c.tags) && c.tags.length > 0 ? { tags: c.tags as string[] } : {}),
...(given('notes') ? { notes: c.notes as string } : {}),
...(given('acquisitionChannel')
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
buildProfileUpsertData,
} from '../src/modules/sync/cold-import/patient-upsert.util';
import { normalizeCanonical } from '../src/modules/sync/assembler/field-mapper';
/**
* 宿主跟进闸(`patient_profiles.host_follow_up_active`)—— 宿主自己正在跟进的患者不进召回池。
*
* 【本测最要紧的一条:三态,不是两态】
* null = 宿主**未提供**该信号(如 jvs-dw 未接入)→ 不启用该闸,患者照常可召回
* false = 宿主明确说"没在跟" → 可召回
* true = 宿主正在跟 → 排除
*
* 由此查询必须写 `IS NOT TRUE` 而**不是** `= false`:后者遇 NULL 恒为 UNKNOWN,
* 会把所有未接入该信号的宿主(现在就是 jvs-dw 全部 38 万患者)静默挡在召回池外 ——
* 而且不报错、不留痕,只表现为"池子空了",极难排查。同理 upsert 侧不能 `?? false`,
* 那等于替宿主断言"确认没在跟"。
*
* 与 do_not_contact / deceased 并列在②合规硬过滤,但性质不同:
* 合规闸 = 法务/风险,永久,需人工解除;跟进闸 = 协作分工,临时,宿主停手即回池。
*/
describe('宿主跟进闸 host_follow_up_active', () => {
describe('① canonical 归一:0/1 与 true/false 都吃', () => {
const ctx = { amountUnit: 'yuan' as const, timezone: 'Asia/Shanghai' };
const norm = (v: unknown) =>
normalizeCanonical({ externalId: 'P1', hostFollowUpActive: v }, 'patient', ctx)
.hostFollowUpActive;
test.each([
['1', true],
[1, true],
['true', true],
[true, true],
['0', false],
[0, false],
['false', false],
[false, false],
])('%s → %s', (input, expected) => {
expect(norm(input)).toBe(expected);
});
test('⭐ 未提供 → 保持 undefined,不被补成 false', () => {
const out = normalizeCanonical({ externalId: 'P1' }, 'patient', ctx);
expect(out.hostFollowUpActive).toBeUndefined();
});
});
describe('② 副表 upsert:full(cold-import 全量覆盖语义)', () => {
test('⭐ 宿主未提供 → 落 null,**不能**落 false', () => {
const d = buildProfileUpsertData({}, { partial: false });
expect(d.hostFollowUpActive).toBeNull();
// 对照:doNotContact / deceased 是有默认值的合规位,未提供落 false —— 语义不同,别照抄
expect(d.doNotContact).toBe(false);
expect(d.deceased).toBe(false);
});
test('宿主给 true / false → 如实落库', () => {
expect(buildProfileUpsertData({ hostFollowUpActive: true }, { partial: false })
.hostFollowUpActive).toBe(true);
expect(buildProfileUpsertData({ hostFollowUpActive: false }, { partial: false })
.hostFollowUpActive).toBe(false);
});
});
describe('③ 副表 upsert:partial(push 单表部分更新语义)', () => {
test('⭐ 未提供 → 该键不进 update 集合(不覆盖存量)', () => {
const d = buildProfileUpsertData({ name: 'X' }, { partial: true });
expect('hostFollowUpActive' in d).toBe(false);
});
test('提供 false → 进 update(宿主明确说没在跟,要能把 true 改回来)', () => {
const d = buildProfileUpsertData({ hostFollowUpActive: false }, { partial: true });
expect(d.hostFollowUpActive).toBe(false);
});
test('提供 true → 进 update', () => {
const d = buildProfileUpsertData({ hostFollowUpActive: true }, { partial: true });
expect(d.hostFollowUpActive).toBe(true);
});
});
describe('④ 召回 SQL 闸门(源码闸)', () => {
const sql = readFileSync(
join(__dirname, '../src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts'),
'utf8',
);
const code = sql
.split('\n')
.filter((l) => !/^\s*(\/\/|\/\*|\*)/.test(l))
.join('\n');
test('⭐ 入池 SQL 带宿主跟进闸', () => {
expect(code).toContain('pp.host_follow_up_active IS NOT TRUE');
});
test('⭐ 绝不能写成 `= false` —— 会因 NULL 把未接入宿主的患者全挡在池外', () => {
expect(code).not.toMatch(/host_follow_up_active\s*=\s*false/);
});
});
describe('⑤ prisma schema:必须可空且无默认值', () => {
const schema = readFileSync(join(__dirname, '../prisma/schema.prisma'), 'utf8');
const line = schema
.split('\n')
.find((l) => l.includes('hostFollowUpActive'))!;
test('列声明为可空 Boolean?', () => {
expect(line).toMatch(/hostFollowUpActive\s+Boolean\?/);
});
test('⭐ 不得有 @default —— 给了默认值就把存量患者一次性断言成某种状态', () => {
expect(line).not.toContain('@default');
});
});
});
......@@ -61,6 +61,10 @@ export const PatientCanonicalSchema = z
doNotContact: z.boolean().optional().default(false),
doNotContactReason: z.string().optional().nullable(),
deceased: z.boolean().optional().default(false),
/// 宿主侧正在跟进该患者(true → 召回排除闸)。**刻意可空且无默认值**:
/// null = 宿主未提供该信号(如 jvs-dw 未接入)→ 不启用该闸;给 false 会把未接入宿主
/// 的患者误判成"宿主确认没在跟",语义不同。判定一律用 IS NOT TRUE,不要 = false。
hostFollowUpActive: z.boolean().optional().nullable(),
/// 产品收集(回访中心信息字段.docx):多源标签数组
tags: z.array(z.string()).optional().default([]),
/// 产品收集:host 备注 / 客户描述自由文本
......@@ -512,7 +516,7 @@ export const CanonicalResourceMeta: Record<
moneyFields: [],
moneyArrayFields: [],
datetimeFields: ['createdAt', 'updatedAt'],
booleanFields: ['doNotContact', 'deceased'],
booleanFields: ['doNotContact', 'deceased', 'hostFollowUpActive'],
},
patient_relation: {
moneyFields: [],
......
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