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
dca86634
Commit
dca86634
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(sync): scope return visit and relation patient lookups to batch
parent
584bd3dd
Pipeline
#3809
failed in 0 seconds
Changes
2
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
98 additions
and
27 deletions
+98
-27
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
+50
-27
apps/pac-service/tests/push-patient-index.spec.ts
+48
-0
No files found.
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
dca86634
...
@@ -1806,16 +1806,10 @@ export class ColdImportService {
...
@@ -1806,16 +1806,10 @@ export class ColdImportService {
stats
.
failed
=
assembled
.
stats
.
failed
;
stats
.
failed
=
assembled
.
stats
.
failed
;
if
(
assembled
.
rows
.
length
>
0
)
if
(
assembled
.
rows
.
length
>
0
)
stats
.
sampleCanonical
.
push
(
assembled
.
rows
[
0
]
!
.
canonical
);
stats
.
sampleCanonical
.
push
(
assembled
.
rows
[
0
]
!
.
canonical
);
// 本租户 externalId → PAC patient id 索引(本人 + 关系人解析共用)
const
getIndex
=
this
.
scopedPatientIndexLoader
(
const
indexByTenant
=
new
Map
<
string
,
Map
<
string
,
string
>>
();
assembled
.
rows
,
hostId
,
tenantResolver
,
sourceUnitResolver
,
const
getIndex
=
async
(
tenantId
:
string
)
=>
{
[
'patientExternalId'
,
'relatedExternalId'
],
let
idx
=
indexByTenant
.
get
(
tenantId
);
);
if
(
!
idx
)
{
idx
=
await
this
.
buildPatientIndex
(
hostId
,
tenantId
);
indexByTenant
.
set
(
tenantId
,
idx
);
}
return
idx
;
};
for
(
const
{
canonical
,
rawSource
}
of
assembled
.
rows
)
{
for
(
const
{
canonical
,
rawSource
}
of
assembled
.
rows
)
{
const
c
=
canonical
as
Record
<
string
,
unknown
>
;
const
c
=
canonical
as
Record
<
string
,
unknown
>
;
...
@@ -1838,7 +1832,7 @@ export class ColdImportService {
...
@@ -1838,7 +1832,7 @@ export class ColdImportService {
continue
;
continue
;
}
}
const
sourceUnit
=
sourceUnitResolver
.
resolve
(
rawSource
);
const
sourceUnit
=
sourceUnitResolver
.
resolve
(
rawSource
);
const
idx
=
await
getIndex
(
tenantId
);
const
idx
=
await
getIndex
(
tenantId
,
sourceUnit
);
// 本人不在索引 → 跟 processSubject 同处理:建空壳主档,边照常落。
// 本人不在索引 → 跟 processSubject 同处理:建空壳主档,边照常落。
// 2026-07-29 前这里是 `if (!patientId) continue`(注释说「非 active client,无处挂靠」),
// 2026-07-29 前这里是 `if (!patientId) continue`(注释说「非 active client,无处挂靠」),
// 但 cohort 路径每张表都按患者 id 过滤、patients 又先跑,那句几乎永不触发;
// 但 cohort 路径每张表都按患者 id 过滤、patients 又先跑,那句几乎永不触发;
...
@@ -1908,15 +1902,9 @@ export class ColdImportService {
...
@@ -1908,15 +1902,9 @@ export class ColdImportService {
stats
.
failed
=
assembled
.
stats
.
failed
;
stats
.
failed
=
assembled
.
stats
.
failed
;
if
(
assembled
.
rows
.
length
>
0
)
if
(
assembled
.
rows
.
length
>
0
)
stats
.
sampleCanonical
.
push
(
assembled
.
rows
[
0
]
!
.
canonical
);
stats
.
sampleCanonical
.
push
(
assembled
.
rows
[
0
]
!
.
canonical
);
const
indexByTenant
=
new
Map
<
string
,
Map
<
string
,
string
>>
();
const
getIndex
=
this
.
scopedPatientIndexLoader
(
const
getIndex
=
async
(
tenantId
:
string
)
=>
{
assembled
.
rows
,
hostId
,
tenantResolver
,
sourceUnitResolver
,
[
'patientExternalId'
],
let
idx
=
indexByTenant
.
get
(
tenantId
);
);
if
(
!
idx
)
{
idx
=
await
this
.
buildPatientIndex
(
hostId
,
tenantId
);
indexByTenant
.
set
(
tenantId
,
idx
);
}
return
idx
;
};
for
(
const
{
canonical
,
rawSource
}
of
assembled
.
rows
)
{
for
(
const
{
canonical
,
rawSource
}
of
assembled
.
rows
)
{
const
c
=
canonical
as
Record
<
string
,
unknown
>
;
const
c
=
canonical
as
Record
<
string
,
unknown
>
;
...
@@ -1938,7 +1926,7 @@ export class ColdImportService {
...
@@ -1938,7 +1926,7 @@ export class ColdImportService {
continue
;
continue
;
}
}
const
sourceUnit
=
sourceUnitResolver
.
resolve
(
rawSource
);
const
sourceUnit
=
sourceUnitResolver
.
resolve
(
rawSource
);
const
idx
=
await
getIndex
(
tenantId
);
const
idx
=
await
getIndex
(
tenantId
,
sourceUnit
);
// 同 processPatientRelations:本人不在索引就建空壳,不再静默丢(见那边注释)
// 同 processPatientRelations:本人不在索引就建空壳,不再静默丢(见那边注释)
let
patientId
=
idx
.
get
(
patientIndexKey
(
sourceUnit
,
patientExternalId
));
let
patientId
=
idx
.
get
(
patientIndexKey
(
sourceUnit
,
patientExternalId
));
if
(
!
patientId
)
{
if
(
!
patientId
)
{
...
@@ -2414,20 +2402,55 @@ export class ColdImportService {
...
@@ -2414,20 +2402,55 @@ export class ColdImportService {
}
}
}
}
/** 只加载本批引用的患者;关系人也按同一品牌隔离,dry-run 不触发查询。 */
private
scopedPatientIndexLoader
(
rows
:
Array
<
{
canonical
:
unknown
;
rawSource
:
Record
<
string
,
unknown
>
}
>
,
hostId
:
string
,
tenantResolver
:
TenantResolver
,
sourceUnitResolver
:
SourceUnitResolver
,
fields
:
string
[],
)
{
const
refs
=
new
Map
<
string
,
Set
<
string
>>
();
const
indexes
=
new
Map
<
string
,
Map
<
string
,
string
>>
();
const
scopeKey
=
(
tenant
:
string
,
unit
:
string
)
=>
JSON
.
stringify
([
tenant
,
unit
]);
for
(
const
row
of
rows
)
{
const
tenant
=
tenantResolver
.
resolve
(
row
.
rawSource
);
if
(
!
tenant
)
continue
;
const
key
=
scopeKey
(
tenant
,
sourceUnitResolver
.
resolve
(
row
.
rawSource
));
const
ids
=
refs
.
get
(
key
)
??
new
Set
<
string
>
();
const
canonical
=
row
.
canonical
as
Record
<
string
,
unknown
>
;
for
(
const
field
of
fields
)
{
const
id
=
String
(
canonical
[
field
]
??
''
).
trim
();
if
(
id
)
ids
.
add
(
id
);
}
refs
.
set
(
key
,
ids
);
}
return
async
(
tenant
:
string
,
unit
:
string
)
=>
{
const
key
=
scopeKey
(
tenant
,
unit
);
let
index
=
indexes
.
get
(
key
);
if
(
!
index
)
{
index
=
await
this
.
buildPatientIndex
(
hostId
,
tenant
,
[...(
refs
.
get
(
key
)
??
[])],
unit
);
indexes
.
set
(
key
,
index
);
}
return
index
;
};
}
private
async
buildPatientIndex
(
private
async
buildPatientIndex
(
hostId
:
string
,
hostId
:
string
,
tenantId
:
string
,
tenantId
:
string
,
externalIds
?
:
string
[],
externalIds
:
string
[],
sourceUnit
?
:
string
,
sourceUnit
:
string
,
):
Promise
<
Map
<
string
,
string
>>
{
):
Promise
<
Map
<
string
,
string
>>
{
const
rows
:
Array
<
{
id
:
string
;
externalId
:
string
;
sourceUnit
:
string
}
>
=
[];
const
rows
:
Array
<
{
id
:
string
;
externalId
:
string
;
sourceUnit
:
string
}
>
=
[];
const
chunks
=
externalIds
===
undefined
?
[
undefined
]
:
const
chunks
=
Array
.
from
({
length
:
Math
.
ceil
(
externalIds
.
length
/
1000
)
},
(
_
,
i
)
=>
externalIds
.
slice
(
i
*
1000
,
(
i
+
1
)
*
1000
));
Array
.
from
({
length
:
Math
.
ceil
(
externalIds
.
length
/
1000
)
},
(
_
,
i
)
=>
externalIds
.
slice
(
i
*
1000
,
(
i
+
1
)
*
1000
));
for
(
const
refs
of
chunks
)
{
for
(
const
refs
of
chunks
)
{
rows
.
push
(...
await
this
.
withDbRetry
(()
=>
this
.
prisma
.
patient
.
findMany
({
const
batch
=
await
this
.
withDbRetry
(()
=>
this
.
prisma
.
patient
.
findMany
({
where
:
{
hostId
,
tenantId
,
...(
sourceUnit
!==
undefined
?
{
sourceUnit
}
:
{}),
...(
refs
?
{
externalId
:
{
in
:
refs
}
}
:
{})
},
where
:
{
hostId
,
tenantId
,
sourceUnit
,
externalId
:
{
in
:
refs
}
},
select
:
{
id
:
true
,
externalId
:
true
,
sourceUnit
:
true
},
select
:
{
id
:
true
,
externalId
:
true
,
sourceUnit
:
true
},
}),
'buildPatientIndex'
));
}),
'buildPatientIndex'
);
for
(
const
row
of
batch
)
rows
.
push
(
row
);
}
}
// 集团内 external_id 可跨 source_unit 撞号 → 索引按 (source_unit, external_id) 建键
// 集团内 external_id 可跨 source_unit 撞号 → 索引按 (source_unit, external_id) 建键
return
new
Map
(
rows
.
map
((
p
)
=>
[
patientIndexKey
(
p
.
sourceUnit
,
p
.
externalId
),
p
.
id
]));
return
new
Map
(
rows
.
map
((
p
)
=>
[
patientIndexKey
(
p
.
sourceUnit
,
p
.
externalId
),
p
.
id
]));
...
...
apps/pac-service/tests/push-patient-index.spec.ts
View file @
dca86634
...
@@ -28,3 +28,51 @@ test('小批事件只读取引用患者,保留跨品牌同号隔离', async ()
...
@@ -28,3 +28,51 @@ test('小批事件只读取引用患者,保留跨品牌同号隔离', async ()
expect
(
resolutions
).
toEqual
([
'pac-brand-a'
,
'pac-brand-b'
]);
expect
(
resolutions
).
toEqual
([
'pac-brand-a'
,
'pac-brand-b'
]);
expect
(
patient
.
upsert
).
not
.
toHaveBeenCalled
();
expect
(
patient
.
upsert
).
not
.
toHaveBeenCalled
();
});
});
describe
.
each
([
'processPatientReturnVisits'
,
'processPatientRelations'
])(
'%s'
,
(
method
)
=>
{
function
fixture
()
{
const
rows
=
[
'brand-a'
,
'brand-b'
].
map
(
unit
=>
({
canonical
:
{
externalId
:
`visit-
${
unit
}
`
,
patientExternalId
:
'same-id'
,
relatedExternalId
:
'relative'
,
relationship
:
'friend'
,
taskDirectorId
:
'5992'
,
taskDirectorName
:
'客服'
},
rawSource
:
{
unit
},
}));
const
patient
=
{
findMany
:
jest
.
fn
(
async
({
where
}:
any
)
=>
where
.
externalId
.
in
.
map
((
id
:
string
)
=>
({
id
:
`
${
where
.
sourceUnit
}
-
${
id
}
`
,
externalId
:
id
,
sourceUnit
:
where
.
sourceUnit
,
}))),
upsert
:
jest
.
fn
()
};
const
prisma
=
{
patient
,
patientReturnVisit
:
{
upsert
:
jest
.
fn
()
},
patientRelation
:
{
upsert
:
jest
.
fn
()
}
};
const
assembler
=
{
assemble
:
()
=>
({
rows
,
stats
:
{
fetched
:
2
,
failed
:
0
,
mappingMisses
:
[],
suspectFields
:
[]
},
rejections
:
[]
})
};
const
cold
=
new
ColdImportService
(
prisma
as
never
,
{}
as
never
,
{}
as
never
,
assembler
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
);
const
run
=
(
dry
=
false
)
=>
(
cold
as
any
)[
method
]({},
{
canonical
:
'test'
},
'host'
,
{
resolve
:
()
=>
'tenant'
},
{
resolve
:
(
r
:
any
)
=>
r
.
unit
},
{},
dry
);
return
{
run
,
prisma
};
}
test
(
'仅查询本批引用且品牌隔离,正确挂到患者'
,
async
()
=>
{
const
{
run
,
prisma
}
=
fixture
();
const
stats
=
await
run
();
expect
(
stats
.
failed
).
toBe
(
0
);
expect
(
stats
.
patientsUpserted
).
toBe
(
2
);
expect
(
prisma
.
patient
.
findMany
).
toHaveBeenCalledTimes
(
2
);
for
(
const
unit
of
[
'brand-a'
,
'brand-b'
])
{
expect
(
prisma
.
patient
.
findMany
).
toHaveBeenCalledWith
({
where
:
{
hostId
:
'host'
,
tenantId
:
'tenant'
,
sourceUnit
:
unit
,
externalId
:
{
in
:
method
===
'processPatientRelations'
?
[
'same-id'
,
'relative'
]
:
[
'same-id'
]
},
},
select
:
{
id
:
true
,
externalId
:
true
,
sourceUnit
:
true
}
});
}
const
writes
=
method
===
'processPatientRelations'
?
prisma
.
patientRelation
.
upsert
:
prisma
.
patientReturnVisit
.
upsert
;
expect
(
writes
.
mock
.
calls
.
map
((
c
:
any
)
=>
c
[
0
].
create
.
patientId
)).
toEqual
([
'brand-a-same-id'
,
'brand-b-same-id'
]);
});
test
(
'预检不读写患者与回访关系表'
,
async
()
=>
{
const
{
run
,
prisma
}
=
fixture
();
await
run
(
true
);
expect
(
prisma
.
patient
.
findMany
).
not
.
toHaveBeenCalled
();
expect
(
prisma
.
patientReturnVisit
.
upsert
).
not
.
toHaveBeenCalled
();
expect
(
prisma
.
patientRelation
.
upsert
).
not
.
toHaveBeenCalled
();
});
});
test
(
'患者索引按1000个ID分块,大查询结果不通过函数参数展开'
,
async
()
=>
{
const
rows
=
Array
.
from
({
length
:
138772
},
(
_
,
i
)
=>
({
id
:
`p-
${
i
}
`
,
externalId
:
`
${
i
}
`
,
sourceUnit
:
'brand'
}));
const
findMany
=
jest
.
fn
().
mockResolvedValueOnce
(
rows
).
mockResolvedValue
([]);
const
cold
=
new
ColdImportService
({
patient
:
{
findMany
}
}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
,
{}
as
never
);
const
result
=
await
(
cold
as
any
).
buildPatientIndex
(
'host'
,
'tenant'
,
Array
.
from
({
length
:
1001
},
(
_
,
i
)
=>
`
${
i
}
`
),
'brand'
);
expect
(
result
.
size
).
toBe
(
138772
);
expect
(
findMany
.
mock
.
calls
.
map
(
c
=>
c
[
0
].
where
.
externalId
.
in
.
length
)).
toEqual
([
1000
,
1
]);
});
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