Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
6a4e0d99
Commit
6a4e0d99
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(sync): clear dedicated customer service on explicit null pair
parent
46fda545
Pipeline
#3788
failed in 0 seconds
Changes
6
Pipelines
2
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
63 additions
and
10 deletions
+63
-10
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
+3
-3
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
+1
-1
apps/pac-service/src/modules/sync/cold-import/patient-upsert.util.ts
+17
-3
apps/pac-service/src/modules/sync/pipeline/patient-preferences.util.ts
+4
-1
apps/pac-service/tests/friday-dedicated-cs.spec.ts
+31
-0
apps/pac-service/tests/patient-preferences-postgres.spec.ts
+7
-2
No files found.
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
View file @
6a4e0d99
...
...
@@ -47,7 +47,7 @@ icon: FileJson
### `customer_basic_info` — 患者主档
> **部分更新语义**:只更新本次提供的字段,未提供的不覆盖存量;
push 无"清空字段"语义,需清空走全量通道
。
> **部分更新语义**:只更新本次提供的字段,未提供的不覆盖存量;
除下述专属客服成对显式 `null` 外,其他字段不通过 `null` 清空
。
>
> **联系电话已 inline(不再单独推 customer_contacts)**:宿主按 `is_default↓ → contacts_type↑(1=本人优先) → id↑`
> **挑出默认号**,把号码与归属 inline 进本表的 `contacts_tel` / `relationship` 两列(沿用 customer_contacts 原列名)。PAC 现只消费这一个有效号 + 归属
...
...
@@ -74,8 +74,8 @@ icon: FileJson
- 来源:`customer.customer_follow_person`,`is_default = 1`。
- 关联:`tenant_id` + `customer_id`;多门店存在不同客服时,先确认有效归属,不任取一条。
- 字段:`follow_people_id`(字符串用户 ID)、`follow_people`(姓名),来自同一条记录,inline 到 `customer_basic_info`。
- 变更:新增、换人、改名时重推患者主档;关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:
清空协议待联调;省略字段或传
`null` 不代表取消。
- 变更:新增、换人、改名
、取消
时重推患者主档;关系表单独变更也须触发,不能仅依赖主档更新时间。
- 取消:
`follow_people_id` 和 `follow_people` 同时显式传 `null`,清除原客服 ID 和姓名;不传字段保持原值。单字段
`null` 不代表取消。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。
...
...
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
6a4e0d99
...
...
@@ -1706,7 +1706,7 @@ export class ColdImportService {
}
// 主/副表数据 — 构造逻辑抽 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
});
try
{
...
...
apps/pac-service/src/modules/sync/cold-import/patient-upsert.util.ts
View file @
6a4e0d99
...
...
@@ -13,12 +13,12 @@ import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
* 存量真号覆盖成合成假号;partial 语义下 phone 缺失 → 不动存量。
* phone 在 partial 下**永不合成假号**(显式提供才更新)。
* 注意:显式 null 与 undefined 同义(canonical zod nullable 归一),都视为"未提供"——
*
push 通道没有"显式清空字段"语义(如需清空走全量通道)
。
*
专属客服例外:原始推送的 ID/姓名成对显式 null 时清空;其他字段维持原语义
。
*/
export
function
buildPatientUpsertData
(
c
:
Record
<
string
,
unknown
>
,
externalId
:
string
,
opts
:
{
partial
:
boolean
},
opts
:
{
partial
:
boolean
;
rawSource
?:
Record
<
string
,
unknown
>
;
fieldMapping
?:
Record
<
string
,
string
>
},
):
Record
<
string
,
unknown
>
{
if
(
!
opts
.
partial
)
{
return
{
...
...
@@ -43,11 +43,25 @@ export function buildPatientUpsertData(
?
{
medicalRecordNumber
:
c
.
medicalRecordNumber
as
string
}
:
{}),
// preferences 本就是"有可写才返回,否则 undefined 不动列"语义,天然 partial-safe
preferences
:
mergePatientPreferences
(
c
),
preferences
:
mergePatientPreferences
(
c
,
{
clearDedicatedCs
:
isExplicitDedicatedCsClear
(
opts
.
rawSource
,
opts
.
fieldMapping
),
}),
...(
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)同款双语义。 */
export
function
buildProfileUpsertData
(
c
:
Record
<
string
,
unknown
>
,
...
...
apps/pac-service/src/modules/sync/pipeline/patient-preferences.util.ts
View file @
6a4e0d99
...
...
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
*/
export
function
mergePatientPreferences
(
c
:
Record
<
string
,
unknown
>
,
opts
:
{
clearDedicatedCs
?:
boolean
}
=
{},
):
Prisma
.
InputJsonValue
|
undefined
{
const
base
=
c
.
preferences
&&
typeof
c
.
preferences
===
'object'
...
...
@@ -24,7 +25,9 @@ export function mergePatientPreferences(
const
csName
=
((
c
.
dedicatedCsName
as
string
|
undefined
)
??
''
).
trim
()
||
null
;
const
rawId
=
c
.
dedicatedCsId
==
null
?
''
:
String
(
c
.
dedicatedCsId
).
trim
();
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
};
}
...
...
apps/pac-service/tests/friday-dedicated-cs.spec.ts
View file @
6a4e0d99
...
...
@@ -26,3 +26,34 @@ test('reparse 拒绝将 Friday 患者套用 5i5ya 配置,查询数据库前失
await
expect
(
svc
.
reparseFromTransactions
({
dir
:
join
(
__dirname
,
'../data/jvs-dw'
),
hostName
:
'friday'
,
dryRun
:
true
})).
rejects
.
toThrow
(
'不一致'
);
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
();
});
});
apps/pac-service/tests/patient-preferences-postgres.spec.ts
View file @
6a4e0d99
...
...
@@ -5,7 +5,7 @@ import { mergePatientPreferences, mergeStoredPatientPreferences } from '../src/m
// 显式指定本地容器;临时表遮蔽真实表,事务回滚,不读写患者业务数据。
const
container
=
process
.
env
.
PAC_TEST_POSTGRES_CONTAINER
;
(
container
?
describe
:
describe
.
skip
)(
'preferences PostgreSQL 合并'
,
()
=>
{
it
(
'客服与联系号分别更新仍保留对方;空推送不覆盖,换客服替换旧值'
,
async
(
)
=>
{
it
.
each
([
false
,
true
])(
'客服换人/取消幂等且保留其他偏好,取消=%s'
,
async
(
clear
)
=>
{
const
statements
:
string
[]
=
[];
const
db
=
{
$executeRaw
:
async
(
q
:
Prisma
.
Sql
)
=>
{
let
i
=
0
;
...
...
@@ -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
({}));
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'
],
{
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
}
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment