Commit 6a4e0d99 by luoqi

fix(sync): clear dedicated customer service on explicit null pair

parent 46fda545
Pipeline #3788 failed in 0 seconds
...@@ -47,7 +47,7 @@ icon: FileJson ...@@ -47,7 +47,7 @@ icon: FileJson
### `customer_basic_info` — 患者主档 ### `customer_basic_info` — 患者主档
> **部分更新语义**:只更新本次提供的字段,未提供的不覆盖存量;push 无"清空字段"语义,需清空走全量通道。 > **部分更新语义**:只更新本次提供的字段,未提供的不覆盖存量;除下述专属客服成对显式 `null` 外,其他字段不通过 `null` 清空。
> >
> **联系电话已 inline(不再单独推 customer_contacts)**:宿主按 `is_default↓ → contacts_type↑(1=本人优先) → id↑` > **联系电话已 inline(不再单独推 customer_contacts)**:宿主按 `is_default↓ → contacts_type↑(1=本人优先) → id↑`
> **挑出默认号**,把号码与归属 inline 进本表的 `contacts_tel` / `relationship` 两列(沿用 customer_contacts 原列名)。PAC 现只消费这一个有效号 + 归属 > **挑出默认号**,把号码与归属 inline 进本表的 `contacts_tel` / `relationship` 两列(沿用 customer_contacts 原列名)。PAC 现只消费这一个有效号 + 归属
...@@ -74,8 +74,8 @@ icon: FileJson ...@@ -74,8 +74,8 @@ icon: FileJson
- 来源:`customer.customer_follow_person`,`is_default = 1`。 - 来源:`customer.customer_follow_person`,`is_default = 1`。
- 关联:`tenant_id` + `customer_id`;多门店存在不同客服时,先确认有效归属,不任取一条。 - 关联:`tenant_id` + `customer_id`;多门店存在不同客服时,先确认有效归属,不任取一条。
- 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。 - 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。
- 变更:新增、换人、改名时重推患者主档;关系表单独变更也须触发,不能仅依赖主档更新时间。 - 变更:新增、换人、改名、取消时重推患者主档;关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:清空协议待联调;省略字段或传 `null` 不代表取消。 - 取消:`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段 `null` 不代表取消。
- 补推:接入完成后,按当前关系分批补推历史患者主档。 - 补推:接入完成后,按当前关系分批补推历史患者主档。
- 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。 - 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。
......
...@@ -1706,7 +1706,7 @@ export class ColdImportService { ...@@ -1706,7 +1706,7 @@ export class ColdImportService {
} }
// 主/副表数据 — 构造逻辑抽 patient-upsert.util(full=历史语义;partial=push 单表不覆盖缺失字段) // 主/副表数据 — 构造逻辑抽 patient-upsert.util(full=历史语义;partial=push 单表不覆盖缺失字段)
const patientData = buildPatientUpsertData(c, externalId, { partial }); const patientData = buildPatientUpsertData(c, externalId, { partial, rawSource, fieldMapping: config.field_mapping });
const profileData = buildProfileUpsertData(c, { partial }); const profileData = buildProfileUpsertData(c, { partial });
try { try {
......
...@@ -13,12 +13,12 @@ import { mergePatientPreferences } from '../pipeline/patient-preferences.util'; ...@@ -13,12 +13,12 @@ import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
* 存量真号覆盖成合成假号;partial 语义下 phone 缺失 → 不动存量。 * 存量真号覆盖成合成假号;partial 语义下 phone 缺失 → 不动存量。
* phone 在 partial 下**永不合成假号**(显式提供才更新)。 * phone 在 partial 下**永不合成假号**(显式提供才更新)。
* 注意:显式 null 与 undefined 同义(canonical zod nullable 归一),都视为"未提供"—— * 注意:显式 null 与 undefined 同义(canonical zod nullable 归一),都视为"未提供"——
* push 通道没有"显式清空字段"语义(如需清空走全量通道)。 * 专属客服例外:原始推送的 ID/姓名成对显式 null 时清空;其他字段维持原语义。
*/ */
export function buildPatientUpsertData( export function buildPatientUpsertData(
c: Record<string, unknown>, c: Record<string, unknown>,
externalId: string, externalId: string,
opts: { partial: boolean }, opts: { partial: boolean; rawSource?: Record<string, unknown>; fieldMapping?: Record<string, string> },
): Record<string, unknown> { ): Record<string, unknown> {
if (!opts.partial) { if (!opts.partial) {
return { return {
...@@ -43,11 +43,25 @@ export function buildPatientUpsertData( ...@@ -43,11 +43,25 @@ export function buildPatientUpsertData(
? { medicalRecordNumber: c.medicalRecordNumber as string } ? { medicalRecordNumber: c.medicalRecordNumber as string }
: {}), : {}),
// preferences 本就是"有可写才返回,否则 undefined 不动列"语义,天然 partial-safe // preferences 本就是"有可写才返回,否则 undefined 不动列"语义,天然 partial-safe
preferences: mergePatientPreferences(c), preferences: mergePatientPreferences(c, {
clearDedicatedCs: isExplicitDedicatedCsClear(opts.rawSource, opts.fieldMapping),
}),
...(given('status') ? { active: (c.status as string) === 'active' } : {}), ...(given('status') ? { active: (c.status as string) === 'active' } : {}),
}; };
} }
/** 只认原始推送中成对的 JSON null,避免归一化产生的空值被误认成取消。 */
export function isExplicitDedicatedCsClear(
raw: Record<string, unknown> | undefined,
mapping: Record<string, string> | undefined,
): boolean {
const id = mapping?.dedicatedCsId;
const name = mapping?.dedicatedCsName;
return !!raw && !!id && !!name &&
Object.prototype.hasOwnProperty.call(raw, id) && raw[id] === null &&
Object.prototype.hasOwnProperty.call(raw, name) && raw[name] === null;
}
/** 副表(profile)同款双语义。 */ /** 副表(profile)同款双语义。 */
export function buildProfileUpsertData( export function buildProfileUpsertData(
c: Record<string, unknown>, c: Record<string, unknown>,
......
...@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client'; ...@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
*/ */
export function mergePatientPreferences( export function mergePatientPreferences(
c: Record<string, unknown>, c: Record<string, unknown>,
opts: { clearDedicatedCs?: boolean } = {},
): Prisma.InputJsonValue | undefined { ): Prisma.InputJsonValue | undefined {
const base = const base =
c.preferences && typeof c.preferences === 'object' c.preferences && typeof c.preferences === 'object'
...@@ -24,7 +25,9 @@ export function mergePatientPreferences( ...@@ -24,7 +25,9 @@ export function mergePatientPreferences(
const csName = ((c.dedicatedCsName as string | undefined) ?? '').trim() || null; const csName = ((c.dedicatedCsName as string | undefined) ?? '').trim() || null;
const rawId = c.dedicatedCsId == null ? '' : String(c.dedicatedCsId).trim(); const rawId = c.dedicatedCsId == null ? '' : String(c.dedicatedCsId).trim();
const csId = rawId && rawId !== '0' ? rawId : null; const csId = rawId && rawId !== '0' ? rawId : null;
if (csName || csId) { if (opts.clearDedicatedCs) {
base.dedicatedCs = null;
} else if (csName || csId) {
base.dedicatedCs = { id: csId, name: csName }; base.dedicatedCs = { id: csId, name: csName };
} }
......
...@@ -26,3 +26,34 @@ test('reparse 拒绝将 Friday 患者套用 5i5ya 配置,查询数据库前失 ...@@ -26,3 +26,34 @@ test('reparse 拒绝将 Friday 患者套用 5i5ya 配置,查询数据库前失
await expect(svc.reparseFromTransactions({ dir: join(__dirname, '../data/jvs-dw'), hostName: 'friday', dryRun: true })).rejects.toThrow('不一致'); await expect(svc.reparseFromTransactions({ dir: join(__dirname, '../data/jvs-dw'), hostName: 'friday', dryRun: true })).rejects.toThrow('不一致');
expect(hostQuery).not.toHaveBeenCalled(); expect(hostQuery).not.toHaveBeenCalled();
}); });
describe('Friday 专属客服显式取消', () => {
test.each([
['省略', {}, true, undefined],
['成对 null', { follow_people_id: null, follow_people: null }, true, { dedicatedCs: null }],
['仅 ID null', { follow_people_id: null }, true, undefined],
['仅姓名 null', { follow_people: null }, true, undefined],
['空字符串', { follow_people_id: '', follow_people: '' }, true, undefined],
['非 push 全量路径', { follow_people_id: null, follow_people: null }, false, undefined],
])('%s', async (_name, fields, partial, expected) => {
const config = AssemblerConfigSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/friday/assemblers/patient.yaml'), 'utf8')));
const raw = { id: 'P1', tenant_id: 'brand-a', ...fields };
const patch = jest.fn().mockResolvedValue(1);
const upsert = jest.fn().mockResolvedValue({ id: '00000000-0000-4000-8000-000000000001' });
const prisma = { patient: { upsert }, patientProfile: { upsert: jest.fn() }, $executeRaw: patch };
const svc = new ColdImportService(prisma as never, null as never, null as never,
new AssemblerEngine(), null as never, null as never, null as never);
const invoke = svc as unknown as { processPatients(...args: unknown[]): Promise<{failed: number; patientsUpserted: number}> };
const result = await invoke.processPatients({ customer_basic_info: [raw] }, config, 'host-friday',
{ resolve: () => 'friday-market' }, { resolve: () => 'brand-a' }, new Set(),
{ amountUnit: 'yuan', timezone: 'Asia/Shanghai' }, false, partial);
expect(result).toMatchObject({ failed: 0, patientsUpserted: 1 });
expect(upsert.mock.calls[0][0].where.hostId_tenantId_sourceUnit_externalId).toEqual({
hostId: 'host-friday', tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'P1',
});
if (expected) {
expect(patch).toHaveBeenCalledTimes(1);
expect(JSON.parse(patch.mock.calls[0][0].values[0])).toEqual(expected);
} else expect(patch).not.toHaveBeenCalled();
});
});
...@@ -5,7 +5,7 @@ import { mergePatientPreferences, mergeStoredPatientPreferences } from '../src/m ...@@ -5,7 +5,7 @@ import { mergePatientPreferences, mergeStoredPatientPreferences } from '../src/m
// 显式指定本地容器;临时表遮蔽真实表,事务回滚,不读写患者业务数据。 // 显式指定本地容器;临时表遮蔽真实表,事务回滚,不读写患者业务数据。
const container = process.env.PAC_TEST_POSTGRES_CONTAINER; const container = process.env.PAC_TEST_POSTGRES_CONTAINER;
(container ? describe : describe.skip)('preferences PostgreSQL 合并', () => { (container ? describe : describe.skip)('preferences PostgreSQL 合并', () => {
it('客服与联系号分别更新仍保留对方;空推送不覆盖,换客服替换旧值', async () => { it.each([false, true])('客服换人/取消幂等且保留其他偏好,取消=%s', async (clear) => {
const statements: string[] = []; const statements: string[] = [];
const db = { $executeRaw: async (q: Prisma.Sql) => { const db = { $executeRaw: async (q: Prisma.Sql) => {
let i = 0; let i = 0;
...@@ -17,10 +17,15 @@ const container = process.env.PAC_TEST_POSTGRES_CONTAINER; ...@@ -17,10 +17,15 @@ const container = process.env.PAC_TEST_POSTGRES_CONTAINER;
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({ phoneRelationshipCode: '2', phoneRelationship: 'father' })); await mergeStoredPatientPreferences(db, id, mergePatientPreferences({ phoneRelationshipCode: '2', phoneRelationship: 'father' }));
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({})); await mergeStoredPatientPreferences(db, id, mergePatientPreferences({}));
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({ dedicatedCsId: 72, dedicatedCsName: '客服乙' })); await mergeStoredPatientPreferences(db, id, mergePatientPreferences({ dedicatedCsId: 72, dedicatedCsName: '客服乙' }));
if (clear) {
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({}, { clearDedicatedCs: true }));
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({}, { clearDedicatedCs: true }));
await mergeStoredPatientPreferences(db, id, mergePatientPreferences({}));
}
const result = execFileSync('docker', ['exec', '-i', container!, 'psql', '-X', '-qAt', '-v', 'ON_ERROR_STOP=1', '-U', 'pac', '-d', 'pac'], { const result = execFileSync('docker', ['exec', '-i', container!, 'psql', '-X', '-qAt', '-v', 'ON_ERROR_STOP=1', '-U', 'pac', '-d', 'pac'], {
input: `BEGIN; CREATE TEMP TABLE patients(id uuid, preferences jsonb); INSERT INTO patients VALUES ('${id}', '{"other":true}');\n${statements.join('\n')}\nSELECT preferences FROM patients; ROLLBACK;`, encoding: 'utf8', timeout: 15000, input: `BEGIN; CREATE TEMP TABLE patients(id uuid, preferences jsonb); INSERT INTO patients VALUES ('${id}', '{"other":true}');\n${statements.join('\n')}\nSELECT preferences FROM patients; ROLLBACK;`, encoding: 'utf8', timeout: 15000,
}); });
expect(JSON.parse(result.trim())).toEqual({ other: true, dedicatedCs: { id: '72', name: '客服乙' }, expect(JSON.parse(result.trim())).toEqual({ other: true, dedicatedCs: clear ? null : { id: '72', name: '客服乙' },
contactPhone: { relationshipCode: '2', relationship: 'father', isSelf: false } }); contactPhone: { relationshipCode: '2', relationship: 'father', isSelf: false } });
}); });
}); });
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