Commit 72a212a3 by luoqi

merge: feat/host-followup-recall-gate → test(宿主跟进闸 + 契约文档)

宿主自己正在跟进的患者不进召回池。patient_profiles.host_follow_up_active,
与 do_not_contact / deceased 并列在②合规硬过滤。

三态语义(有测试锁):null=宿主未提供该信号→不启用闸;false=明确没在跟;true=在跟→排除。
SQL 一律 IS NOT TRUE,写成 = false 会把未接入宿主的患者静默全挡在池外。

FRIDAY 映射(has_active_complex_case)待宿主确认列名与 is_del 语义后再接 ——
本次该闸对所有宿主均为 NULL,行为与上线前一致。
parents 78ee1289 cc36dc6c
Pipeline #3500 failed in 0 seconds
......@@ -52,6 +52,7 @@ icon: Filter
```
① 隔离 host_id + tenant_id
② 合规硬过滤 patients.active=true AND do_not_contact=false AND deceased=false
AND host_follow_up_active IS NOT TRUE ← 宿主正在跟进的不召回(见下「宿主跟进闸」)
③ 触发信号 fact.status='active' AND type ∈ {diagnosis_record, recommendation_record}
AND content->>'code' = ANY(allCodes)
④ 冷静期下界 COALESCE(occurred_at, planned_for) <= now - cooldownDays
......@@ -70,6 +71,30 @@ icon: Filter
---
## 宿主跟进闸(`host_follow_up_active`)
**要解决的问题**:宿主自己已经在跟进的患者,PAC 不该再发起召回 —— 否则两边同时联系同一个人,
患者体验差,也显得两个系统各说各话。
**位置**:`patient_profiles.host_follow_up_active`,与 `do_not_contact` / `deceased` 并列在②合规硬过滤,
但**性质不同**:
| | 合规闸(`do_not_contact`/`deceased`)| 宿主跟进闸(`host_follow_up_active`)|
|---|---|---|
| 性质 | 法务 / 风险,**永久** | 协作分工,**临时** |
| 解除 | 需人工介入 | 宿主停止跟进 → 下次推主档即回池 |
| 来源 | 患者意愿 / 事实 | 宿主业务系统的跟进状态 |
**判定用 `IS NOT TRUE` 而非 `= false`** —— 该列可空,`NULL` 表示**宿主未提供该信号**
(如 jvs-dw 暂未接入),此时不启用该闸,不能因为 `NULL != false` 把这些患者全挡在池外。
**信号由宿主给,PAC 不猜**:各宿主"正在跟进"的定义不同(FRIDAY 是复杂病例阶段,
其他宿主可能是工单/任务),且宿主的私有枚举 PAC 无从翻译。宿主在推患者主档时
inline 一列自己的跟进凭据,PAC 归一成本字段。FRIDAY 侧的具体口径见
[FRIDAY 推送载荷 · customer_basic_info](/docs/integration/friday-push-payload)。
---
## 「已启动 / 无需召」的牙位级判定(resolved)
排除不是简单"做过同类治疗就不召",而是**逐牙算"剩余未治牙位"**:`remaining = 信号牙位 − resolved`,`remaining` 非空才召(全口病退化到 patient/category 级)。判定逻辑在 `clinical-gap/potential-treatment-gap.sql`,**召回与「潜在治疗」画像标签共用同一份**,口径逐字节一致(圈到的人群 = 召回候选)。
......
......@@ -16,7 +16,7 @@ icon: Database
|---|---|---|---|
| `hosts` | 顶层 | upsert(admin) | 接入宿主注册:换票凭据 + 出站 `pull_config` + push 验签密钥 + deep-link 模板。子表 `host_id` FK 到此 |
| `patients` | L1 | upsert | 患者主档,**只装身份**(name/phone/dob/病历号);跨诊所共享一行 |
| `patient_profiles` | L1 | upsert(1:1) | 患者非事实属性副表:合规标记(`do_not_contact`/`deceased`)+ 标签/备注 + 获客渠道 + 转介绍聚合 |
| `patient_profiles` | L1 | upsert(1:1) | 患者非事实属性副表:合规标记(`do_not_contact`/`deceased`)+ 宿主跟进闸(`host_follow_up_active`)+ 标签/备注 + 获客渠道 + 转介绍聚合 |
| `patient_relations` | L1 | upsert(边) | 患者—患者关系边(联系人/亲属/转介绍人);关系人本身也是 patient |
| `patient_return_visits` | L1 | upsert | 诊所回访任务记录(展示用)。**非临床 fact,不进召回信号** |
| `patient_transactions` | L1 | append-only | 操作账本:谁在哪个诊所对哪个主体做了什么。原文留底 |
......@@ -102,9 +102,9 @@ patient_facts.transactionIds → patient_transactions.id → rawPayload
**`patients`** — 患者主档,**只装身份**。唯一键 `(host_id, tenant_id, source_unit, external_id)`。`active` 布尔(true=在召回池,false=宿主软删/归档);`name`/`phone` stub 期可空(push 首见只拿到 ref,后续 pull 补全;`phone IS NULL` 自动排除召回池);`medical_record_number` 可读病历号。**不支持物理删除**(PIPL 删除权走匿名化或 `active=false`),子表 `onDelete=Restrict` 兜底防误删。
> 召回池硬过滤:`active=true` AND `phone IS NOT NULL` AND(`patient_profiles`)`do_not_contact=false` AND `deceased=false`。
> 召回池硬过滤:`active=true` AND `phone IS NOT NULL` AND(`patient_profiles`)`do_not_contact=false` AND `deceased=false` AND `host_follow_up_active IS NOT TRUE`
**`patient_profiles`** — 患者非事实属性副表(1:1,PK=`patient_id`,Cascade)。合规标记 `do_not_contact`(+原因/时间)、`deceased`;产品标签 `tags[]` + 自由 `notes`;获客渠道 `acquisition_channel`(立柱标准枚举,host 值经 enum_mapping 归一)+ `acquisition_sub`;转介绍聚合 `referral_count` / `referral_amount_cents`。拆出副表让主表身份查询更轻,合规过滤走 join。
**`patient_profiles`** — 患者非事实属性副表(1:1,PK=`patient_id`,Cascade)。合规标记 `do_not_contact`(+原因/时间)、`deceased`;宿主跟进闸 `host_follow_up_active`(宿主侧正在跟进 → 不召回,与合规闸并列但语义不同:合规是**永久/法务**,跟进闸是**临时/协作**,宿主停止跟进即回池);产品标签 `tags[]` + 自由 `notes`;获客渠道 `acquisition_channel`(立柱标准枚举,host 值经 enum_mapping 归一)+ `acquisition_sub`;转介绍聚合 `referral_count` / `referral_amount_cents`。拆出副表让主表身份查询更轻,合规过滤走 join。
**`patient_relations`** — 患者—患者关系边(联系人/亲属/转介绍人)。自关联:关系人本身也是 patient,姓名/电话从对端现取、零冗余。唯一键 `(patient_id, related_external_id, relationship)`;`related_external_id` 始终保留(晚绑定),`related_patient_id` 仅当关系人也是 active patient 时填。
......
......@@ -39,6 +39,7 @@ icon: Table2
| `medicalRecordNumber` | 病历号(客服沟通用)|
| `status` | `active` / `archived`(默认 active)|
| `doNotContact` / `doNotContactReason` / `deceased` | 免打扰 / 离世标记(合规)|
| `hostFollowUpActive` | 宿主侧正在跟进该患者(true → 召回排除闸)。宿主推自己的「跟进中」凭据,PAC 不猜 |
| `tags` / `notes` | 标签数组 / 备注 |
| `acquisitionChannel` / `acquisitionSub` | 获客渠道 / 二级渠道 |
| `referralCount` / `referralAmount` | 转介绍人数 / 带来金额 |
......
......@@ -64,8 +64,46 @@ icon: FileJson
| `organization_id` | string | | 建档诊所 |
| `contacts_tel` | string | | **默认联系号(宿主 inline)**——按上述规则挑出的有效号码(= customer_contacts 原列名) |
| `relationship` | string/number | | **该号归属(宿主 inline,= customer_contacts 原列名)**——`PhoneRelationshipEnum`:`1`本人 `2`爸爸 `3`妈妈 `4`爷爷 `5`奶奶 `6`朋友 `7`配偶 `8`子女 `9`其他(PAC 存 `isSelf`=码=='1') |
| `has_active_complex_case` | boolean | | **是否有进行中的复杂病例(宿主 inline,新增)**——`1`/`true`=宿主正在跟进该患者。PAC 用作召回排除闸,见下方「跟进中患者不进召回池」 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 建档/更新时间 |
#### 跟进中患者不进召回池 —— `has_active_complex_case`(待宿主确认)
**要解决的问题**:宿主自己正在跟进(已建复杂病例、客服/咨询师在推进)的患者,PAC 不该再发起召回 ——
否则两边同时联系同一个人,患者体验差,也显得两个系统各说各话。
**取值口径**:该患者名下**是否存在未删除**且 `case_stage` 处于以下 4 档的 `complex_case_info`:
| 阶段 | 是否算"进行中" | 理由 |
|---|---|---|
| 待跟进 | ✅ 计入 | 已建档待联系,宿主即将接触 |
| 已咨询 | ✅ 计入 | 沟通已开始 |
| 已预约 | ✅ 计入 | 已约到店 |
| 诊疗中 | ✅ 计入 | 治疗未结束 |
| 已成单 | ❌ 不计 | 该需求已转化闭环,患者可被其他理由召回 |
| 暂停跟进 | ❌ 不计 | 宿主已停止跟进 → 交还给 PAC 召回 |
无进行中病例填 `0`/`false`(**不要留空** —— 留空 PAC 按"宿主未提供该信号"处理,不启用该闸)。
**只要布尔,不要条数** —— 闸门只判有无,条数 PAC 不消费。而且 `complex_case_info` 带 `organization_id`,
跨诊所累加的条数在多品牌 SaaS 里语义含糊,`EXISTS` 反而是干净且宿主易保证正确的口径。
取值接受 `1`/`0` 或 `true`/`false`,PAC 归一层统一 coerce 成布尔。
**为什么由宿主算、而不是把 `complex_case_info` 整表推给 PAC**
1. `case_stage` 的**码→中文映射不在库里**(`column_comment` 只写了「病例阶段:」,值为空),
PAC 无从翻译;宿主认识自己的枚举。
2. 一个患者可有多条复杂病例(不同阶段),PAC 需要的是**患者粒度的聚合结论**,不是逐条明细。
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` 都有。取数前必须确认哪个值代表"未删除",否则判定会整体反掉。
### `customer_referee_circle` — 转介绍圈(患者-患者关系)
| 字段 | 类型 | 必填 | 说明 |
......@@ -75,7 +113,7 @@ icon: FileJson
| `organization_id` | string | | 诊所 |
| `customer_id` | string | ✅ | 本人患者 id |
| `referee_patient_id` | string | ✅ | 关系人患者 id(也是在册患者) |
| `referee_relationship` | string/number | | 关系码(官方枚举 `RecommendRelationshipEnum`:`1`配偶 `2`爸爸 `3`子女 `4`兄弟 `5`爷爷 `6`孙子女 `7`朋友 `8`其他亲属 `9`姐妹 `10`妈妈 `11`奶奶 `12`姐弟 `13`兄妹 `14`其他 `15`外公 `16`外婆 `17`外孙子女;**语义=本行 customer 是 referee 的 X**) |
| `referee_relationship` | string/number | | 关系码(官方枚举 `RecommendRelationshipEnum`:`1`配偶 `2`爸爸 `3`子女 `4`兄弟 `5`爷爷 `6`孙子女 `7`朋友 `8`其他亲属 `9`姐妹 `10`妈妈 `11`奶奶 `12`姐弟 `13`兄妹 `14`其他 `15`外公 `16`外婆 `17`外孙子女;**语义=本行 referee 是 customer 的 X**,与 PAC 契约同向,PAC 直译不取逆) |
| `type` | string/number | | 关系类型标记 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | | |
......
-- 宿主跟进闸:宿主侧正在跟进的患者不进召回池(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