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
2972c5f5
Commit
2972c5f5
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge explicit null customer service cancellation into test
parents
ca1e5d71
6a4e0d99
Pipeline
#3786
failed in 0 seconds
Changes
6
Pipelines
1
Show 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 @
2972c5f5
...
@@ -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` 不代表取消。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 补推:接入完成后,按当前关系分批补推历史患者主档。
- 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。
- 验收:首次分配、换人、取消、重复推送、跨品牌及多门店归属。
...
...
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
2972c5f5
...
@@ -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
{
...
...
apps/pac-service/src/modules/sync/cold-import/patient-upsert.util.ts
View file @
2972c5f5
...
@@ -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
>
,
...
...
apps/pac-service/src/modules/sync/pipeline/patient-preferences.util.ts
View file @
2972c5f5
...
@@ -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
};
}
}
...
...
apps/pac-service/tests/friday-dedicated-cs.spec.ts
View file @
2972c5f5
...
@@ -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
();
});
});
apps/pac-service/tests/patient-preferences-postgres.spec.ts
View file @
2972c5f5
...
@@ -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
}
});
});
});
});
});
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