Commit 6693bc11 by luoqi

feat(recall): 接上 FRIDAY 跟进闸映射 + 脏值不拖垮患者主档

按「文档即契约」推进,不再等宿主口头确认:契约已写明 has_active_complex_case,
PAC 侧按此接好映射;宿主未推该列时 canonical 无此键 → 副表不写 → 闸保持未启用,
行为与接入前完全一致,所以提前接是零风险的。

【is_del 悬案已查源库坐实,不必再问宿主】
complex_case_info.is_del 注释「未删除1/已删除0」——**反直觉但是对的**,is_del=1 才是未删除:
  - 同注释的 complex_potential_demand 7 行全为 1(全存活,且被 30 行明细引用),
    若 1=已删则全表皆删,不合理
  - complex_case_info 47:8 ≈ 85% 存活是正常比例,反过来不是
之前之所以看着可疑,是因为**该库同时存在两种相反惯例**(customer_gift 是「未删除0/已删除1」)
→ 取数必须逐表看注释,不能套惯例。结论已写进 yaml 注释与契约文档。

【防护:脏值降级,不牵连主数据】
canonical hostFollowUpActive 加 .catch(null)。该字段是**可选的召回闸信号**,
而 patient 是**主数据**:若宿主推来无法识别的值(如 "Y2"/"待定"),不加 catch 会让
整条患者主档被 zod 拒收 —— 姓名/电话/生日全丢,为一个 nice-to-have 信号赔上主数据,
代价完全不成比例。脏值降级成 null(= 信号未提供 → 不启用闸),失败方向朝「照常召回」,
而不是「静默把人挡在池外」。0/1/true/false/y/n 等常见写法仍由 booleanFields 先行 coerce。

测试增至 21 项:新增映射存在断言 + 脏值不拖垮主档(校验 name 仍完好)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent da9c80c8
......@@ -67,7 +67,7 @@ icon: FileJson
| `has_active_complex_case` | boolean | | **是否有进行中的复杂病例(宿主 inline,新增)**——`1`/`true`=宿主正在跟进该患者。PAC 用作召回排除闸,见下方「跟进中患者不进召回池」 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 建档/更新时间 |
#### 跟进中患者不进召回池 —— `has_active_complex_case`(待宿主确认)
#### 跟进中患者不进召回池 —— `has_active_complex_case`(PAC 侧已就绪,待宿主推)
**要解决的问题**:宿主自己正在跟进(已建复杂病例、客服/咨询师在推进)的患者,PAC 不该再发起召回 ——
否则两边同时联系同一个人,患者体验差,也显得两个系统各说各话。
......@@ -97,12 +97,19 @@ icon: FileJson
3. 与既有 inline 纪律一致 —— 私有字典码 / 头-行聚合由宿主 within-host join 后 inline
(同 `contacts_tel`、`std_code`、`class_name`)。
> ⚠️ **两处待宿主确认,确认前 PAC 不启用该闸**
> ① **列名**:`has_active_complex_case` 是 PAC 侧的建议名(取自宿主表名 `complex_case_info`)。
> 该列宿主源表尚不存在,属新定义 —— 若宿主内部另有习惯叫法(如模块在日志里也称「潜在治疗」),
> 以宿主的为准,PAC 改映射即可,**契约纪律是列名随宿主**。
> ② **`is_del` 语义**:`complex_case_info.is_del` 的注释写「未删除1/已删除0」,与常规惯例相反,
> 而实际数据里 `0` 和 `1` 都有。取数前必须确认哪个值代表"未删除",否则判定会整体反掉。
> **`is_del` 取值方向(PAC 已查源库坐实,按此实现即可)**
> `complex_case_info.is_del` 的注释是「未删除1/已删除0」——**反直觉,但确实如此**,
> 即 **`is_del = 1` 才是未删除**。旁证:同注释的 `complex_potential_demand` 7 行全为 `1`
> (全存活,且被 30 行明细引用);`complex_case_info` 47:8 ≈ 85% 存活,是正常比例,反过来不是。
>
> ⚠️ 该库**同时存在两种相反惯例**(`customer_gift` 是「未删除0/已删除1」)——
> 取数请**逐表看注释**,不要套用惯例,也不要照抄别的表。
>
> **列名**:`has_active_complex_case`(PAC 已按此名接好映射)。若贵方内部另有习惯叫法,
> 告知即可,PAC 改一行映射 —— 契约纪律是列名随宿主。
>
> **PAC 侧已做的防护**:该列推来无法识别的值(如 `"Y2"`、`"待定"`)时,PAC 将其降级为
> "信号未提供"(不启用该闸),**不会**因此拒收整条患者主档 —— 姓名/电话等主数据不受牵连。
### `customer_referee_circle` — 转介绍圈(患者-患者关系)
......
......@@ -19,6 +19,16 @@ field_mapping:
gender: sex # tinyint 1/2 → enum_mapping 归一;0/空 → 空
birthDate: birthday # date;空值入库为 null
medicalRecordNumber: file_number # 档案号(病历号,客服沟通用)
# 宿主跟进闸(2026-07-30 新增)—— 宿主自己正在跟进的患者不进召回池。
# 宿主 inline:该患者名下**未删除**(complex_case_info.is_del = 1)且 case_stage ∈
# {待跟进, 已咨询, 已预约, 诊疗中} 的复杂病例是否存在(布尔,不要条数)。
# 已成单(需求已闭环)/ 暂停跟进(宿主已停手)不计 —— 交还 PAC 召回。
# ⚠️ is_del 的取值方向查过源库坐实:该表注释「未删除1/已删除0」是**对的**(反直觉但确实如此),
# 旁证:同注释的 complex_potential_demand 7 行全为 1(全存活,且被 30 行明细引用);
# complex_case_info 47:8 ≈ 85% 存活是正常比例,反过来不是。
# ⚠️ 该库**同时存在两种相反惯例**(customer_gift 是「未删除0/已删除1」)→ 取数必须逐表看注释,不能套惯例。
# 宿主未推该列 → canonical 无此键 → 副表不写(保持 null = 信号未提供 → 不启用闸),行为与接入前一致。
hostFollowUpActive: has_active_complex_case
createdAt: created_gmt_at
updatedAt: updated_gmt_at
# source_unit(品牌)由 manifest.identity_namespace_field=brand_id 填,不在此声明。
......
......@@ -4,6 +4,7 @@ import {
buildProfileUpsertData,
} from '../src/modules/sync/cold-import/patient-upsert.util';
import { normalizeCanonical } from '../src/modules/sync/assembler/field-mapper';
import { PatientCanonicalSchema } from '@pac/types';
/**
* 宿主跟进闸(`patient_profiles.host_follow_up_active`)—— 宿主自己正在跟进的患者不进召回池。
......@@ -100,7 +101,31 @@ describe('宿主跟进闸 host_follow_up_active', () => {
});
});
describe('⑤ prisma schema:必须可空且无默认值', () => {
describe('⑤ FRIDAY 映射 + 脏值防护', () => {
const yamlPath = join(__dirname, '../data/friday/assemblers/patient.yaml');
const yamlText = readFileSync(yamlPath, 'utf8');
test('⭐ FRIDAY patient.yaml 映射到契约约定的宿主列名', () => {
expect(yamlText).toMatch(/hostFollowUpActive:\s*has_active_complex_case/);
});
test('⭐ 脏值降级成 null,**不能**拖垮整条患者主档', () => {
// 该字段是可选的召回闸信号,而 patient 是主数据。宿主推来无法识别的值时,
// 若让 zod 拒收整行,姓名/电话/生日全丢 —— 为一个 nice-to-have 信号赔上主数据。
for (const dirty of ['Y2', '待定', 'maybe', 42]) {
const out = PatientCanonicalSchema.parse({ externalId: 'P1', name: '张三', hostFollowUpActive: dirty });
expect(`${String(dirty)}${JSON.stringify(out.hostFollowUpActive)}`).toBe(`${String(dirty)} → null`);
expect(out.name).toBe('张三'); // 主数据完好
}
});
test('正常值不受 catch 影响', () => {
expect(PatientCanonicalSchema.parse({ externalId: 'P1', hostFollowUpActive: true }).hostFollowUpActive).toBe(true);
expect(PatientCanonicalSchema.parse({ externalId: 'P1', hostFollowUpActive: false }).hostFollowUpActive).toBe(false);
});
});
describe('⑥ prisma schema:必须可空且无默认值', () => {
const schema = readFileSync(join(__dirname, '../prisma/schema.prisma'), 'utf8');
const line = schema
.split('\n')
......
......@@ -64,7 +64,14 @@ export const PatientCanonicalSchema = z
/// 宿主侧正在跟进该患者(true → 召回排除闸)。**刻意可空且无默认值**:
/// null = 宿主未提供该信号(如 jvs-dw 未接入)→ 不启用该闸;给 false 会把未接入宿主
/// 的患者误判成"宿主确认没在跟",语义不同。判定一律用 IS NOT TRUE,不要 = false。
hostFollowUpActive: z.boolean().optional().nullable(),
///
/// ⚠️ `.catch(null)` 是**刻意的防护,不是偷懒**:本字段是可选的召回闸信号,
/// 而它所在的 patient 是**主数据**。若宿主推来无法识别的值(如 "Y2"、"待定"),
/// 不加 catch 会让整条患者主档被 zod 拒收 —— 姓名/电话/生日全部丢失,
/// 为一个 nice-to-have 的闸门信号赔上主数据,代价完全不成比例。
/// 脏值降级成 null(= 信号未提供 → 不启用闸),失败方向朝"照常召回"这一侧,
/// 而不是"静默把人挡在池外"。0/1/true/false/y/n 等常见写法由 booleanFields 先行 coerce。
hostFollowUpActive: z.boolean().optional().nullable().catch(null),
/// 产品收集(回访中心信息字段.docx):多源标签数组
tags: z.array(z.string()).optional().default([]),
/// 产品收集:host 备注 / 客户描述自由文本
......
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