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
108558dd
Commit
108558dd
authored
Aug 01, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/jvs-dw-complex-case-gate → main(jvs-dw 潜在治疗池不进召回 + 增量链路四处修复)
parents
257ba23b
82d782dd
Pipeline
#3514
failed in 0 seconds
Changes
5
Pipelines
1
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
121 additions
and
5 deletions
+121
-5
apps/pac-service/data/jvs-dw/manifest.yaml
+14
-4
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
+0
-0
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
+5
-0
apps/pac-service/tests/cohort-only-patient.spec.ts
+42
-1
apps/pac-service/tests/complex-case-gate-sql.spec.ts
+60
-0
No files found.
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
108558dd
...
@@ -115,9 +115,12 @@ sql_source:
...
@@ -115,9 +115,12 @@ sql_source:
clinic_scope
:
clinic_scope
:
from_table
:
dw_group.fact_emr_treatment_out
from_table
:
dw_group.fact_emr_treatment_out
org_column
:
organization_id
org_column
:
organization_id
# 增量「反向拉主档」的来源表 —— 这些表有自己的 cursor,它们变了就要把对应患者的主档补拉回来
# 增量「反向拉主档」的来源表 —— 这些表变了就把对应患者的主档补拉回来
# (主档 cursor 是 last_visit_time,人不来诊拉不到)。前四张是历史默认(EMR 编辑等场景);
# (主档 cursor 是 last_visit_time,人不来诊拉不到)。
# fact_complex_cases_out 是本次新增:宿主开/关复杂病例时把人带进 cohort,跟进闸才跟得上。
# ⚠️ **仅 single-shot(非 cohort)模式走这条**。jvs-dw 日常增量是 cohort 模式,
# 靠 incremental.per_query 的 cursor 拼 UNION 分支列患者(见上面 fact_complex_cases_out
# 的注释),不经过反向拉。这里保持声明是为了两点:① 该列表原先四张表名硬编码在通用
# service 里,挪进 yaml 才符合"宿主差异只在 yaml";② single-shot 路径仍然会用到。
reverse_pull_from
:
reverse_pull_from
:
-
fact_appointment_out
-
fact_appointment_out
-
fact_emr_treatment_out
-
fact_emr_treatment_out
...
@@ -151,8 +154,15 @@ sql_source:
...
@@ -151,8 +154,15 @@ sql_source:
# ── 复杂病例(潜在治疗池)──
# ── 复杂病例(潜在治疗池)──
# ⚠️ 本表**不产 fact、无 assembler**,拉它只为一件事:增量时把"病例变了但人没来诊"的患者
# ⚠️ 本表**不产 fact、无 assembler**,拉它只为一件事:增量时把"病例变了但人没来诊"的患者
# 带进 cohort
(见 cohort.reverse_pull_from)
。真正的判定在上面主档 SQL 的标量子查询里。
# 带进 cohort。真正的判定在上面主档 SQL 的标量子查询里。
# 不拉它的话:患者不来诊 → 主档 cursor(last_visit_time)拉不到 → 病例开/关 PAC 永远不知道。
# 不拉它的话:患者不来诊 → 主档 cursor(last_visit_time)拉不到 → 病例开/关 PAC 永远不知道。
# ⭐ 生效的是下面 incremental.per_query 给它配的 cursor:listPatientPairs 会把每张
# **配了 cursor 且有水位**的表拼成 UNION 分支来列患者(cohort 模式走这条,不走反向拉)。
# 2026-08-01 测试服实测:接入后 UNION 从 6 张变 7 张,窗口内 19 个"末次就诊在几个月前、
# 但病例刚变"的患者被正确带进 cohort 并写上闸。
# ⚠️ **新表首轮不生效**:无历史水位 → cursorValue 为空 → 该分支被跳过(见
# clickhouse-source.service.ts 的 `if (!cfg.cursorValue) continue`)。第一轮只建水位,
# 第二轮起才开始感知变化。上线时别把首轮的"没反应"当 bug。
# changed_at:updated_gmt_at 有 44% 为 NULL(建后没改过,恰是刚入池的新病例),
# changed_at:updated_gmt_at 有 44% 为 NULL(建后没改过,恰是刚入池的新病例),
# 直接拿它当 cursor 会 `NULL > x` 恒 UNKNOWN 静默漏掉这批 → 必须 coalesce 到 created_gmt_at。
# 直接拿它当 cursor 会 `NULL > x` 恒 UNKNOWN 静默漏掉这批 → 必须 coalesce 到 created_gmt_at。
fact_complex_cases_out
:
|
fact_complex_cases_out
:
|
...
...
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
View file @
108558dd
This diff is collapsed.
Click to expand it.
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
View file @
108558dd
...
@@ -126,6 +126,11 @@ export const ClickHouseSourceSchema = z.object({
...
@@ -126,6 +126,11 @@ export const ClickHouseSourceSchema = z.object({
/// 患者不来诊则主档拉不到他 —— 但他的**事实**可能变了(EMR 被编辑、复杂病例被关闭)。
/// 患者不来诊则主档拉不到他 —— 但他的**事实**可能变了(EMR 被编辑、复杂病例被关闭)。
/// 增量跑完后从这些表里收集患者键,反向补拉一次主档,保证"事实变了主档也在场"。
/// 增量跑完后从这些表里收集患者键,反向补拉一次主档,保证"事实变了主档也在场"。
///
///
/// ⚠️ **只作用于 single-shot(非 cohort)模式** —— reversePullPatientMaster 仅在
/// loadAllTables 里调用。cohort 模式下同一诉求由 listPatientPairs 的 UNION 分支满足
/// (每张"配了 cursor 且有水位"的表各出一条分支来列患者),不经过反向拉;
/// 且主档在 cohort 模式下不再注入 cursor,被带进来的患者主档一定拉得到。
///
/// 【为什么要可配】此前是四张 jvs-dw 表名硬编码在 service 里,新增一张要改通用代码;
/// 【为什么要可配】此前是四张 jvs-dw 表名硬编码在 service 里,新增一张要改通用代码;
/// 而"哪些表的变化该带出主档"本就是**宿主口径**(取决于该宿主哪些表有独立 cursor)。
/// 而"哪些表的变化该带出主档"本就是**宿主口径**(取决于该宿主哪些表有独立 cursor)。
/// 不配 → 保持历史默认(见 DEFAULT_REVERSE_PULL_FROM),行为不变。
/// 不配 → 保持历史默认(见 DEFAULT_REVERSE_PULL_FROM),行为不变。
...
...
apps/pac-service/tests/cohort-only-patient.spec.ts
View file @
108558dd
import
*
as
fs
from
'node:fs'
;
import
*
as
fs
from
'node:fs'
;
import
*
as
os
from
'node:os'
;
import
*
as
os
from
'node:os'
;
import
*
as
path
from
'node:path'
;
import
*
as
path
from
'node:path'
;
import
{
resolveOnlyPatientIds
}
from
'../src/modules/sync/cold-import/clickhouse-source.service'
;
import
{
resolveOnlyPatientIds
,
resolveOnlyPatientKeys
,
}
from
'../src/modules/sync/cold-import/clickhouse-source.service'
;
/**
/**
* PAC_COHORT_ONLY_PATIENT(定向重摄名单)解析。
* PAC_COHORT_ONLY_PATIENT(定向重摄名单)解析。
...
@@ -64,4 +67,42 @@ describe('PAC_COHORT_ONLY_PATIENT 解析', () => {
...
@@ -64,4 +67,42 @@ describe('PAC_COHORT_ONLY_PATIENT 解析', () => {
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'@/no/such/pids.txt'
;
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'@/no/such/pids.txt'
;
expect
(()
=>
resolveOnlyPatientIds
()).
toThrow
(
/不存在/
);
expect
(()
=>
resolveOnlyPatientIds
()).
toThrow
(
/不存在/
);
});
});
/**
* 命名空间维(manifest cohort.tenant_key_column 声明的那一维)。
*
* 患者主键只在命名空间内唯一 —— jvs-dw 的 patient_id 261067 在两个品牌下是两个人。
* 2026-08-01 测试服实测:7 万个纯 id 的定向名单列出 **140,566** 个 cohort key(翻倍),
* 一半是另一命名空间下的同号患者,白摄一倍数据。
* ⚠️ 维名不写死("brand" 只是 jvs-dw 填进 tenant_key_column 的值),单命名空间宿主写纯 id。
*/
describe
(
'命名空间维'
,
()
=>
{
test
(
'纯 id → 无 tenant(单命名空间宿主,行为与改动前一致)'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'1855960,1855959'
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([{
key
:
'1855960'
},
{
key
:
'1855959'
}]);
});
test
(
'`id|命名空间` → 复合键'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'261067|瑞尔,261067|瑞泰'
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([
{
key
:
'261067'
,
tenant
:
'瑞尔'
},
{
key
:
'261067'
,
tenant
:
'瑞泰'
},
]);
});
test
(
'TAB 分隔 → 同义(SQL dump TSV 可直接喂)'
,
()
=>
{
tmpFile
=
path
.
join
(
os
.
tmpdir
(),
`pids-tsv-
${
Date
.
now
()}
.txt`
);
fs
.
writeFileSync
(
tmpFile
,
'261067
\
t瑞尔
\
n261068
\
t瑞泰
\
n'
);
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
`@
${
tmpFile
}
`
;
expect
(
resolveOnlyPatientKeys
()).
toEqual
([
{
key
:
'261067'
,
tenant
:
'瑞尔'
},
{
key
:
'261068'
,
tenant
:
'瑞泰'
},
]);
});
test
(
'resolveOnlyPatientIds 仍只返回 key —— 旧调用点(是否定向模式的判定)不受影响'
,
()
=>
{
process
.
env
.
PAC_COHORT_ONLY_PATIENT
=
'261067|瑞尔,261068|瑞泰'
;
expect
(
resolveOnlyPatientIds
()).
toEqual
([
'261067'
,
'261068'
]);
});
});
});
});
apps/pac-service/tests/complex-case-gate-sql.spec.ts
View file @
108558dd
...
@@ -127,6 +127,66 @@ describe('injectIncrementalCursor — 带派生列的主档 SQL', () => {
...
@@ -127,6 +127,66 @@ describe('injectIncrementalCursor — 带派生列的主档 SQL', () => {
});
});
});
});
describe
(
'增量列患者的 UNION 分支 — 别名列 + 顶层表名'
,
()
=>
{
/// 复刻 listPatientPairs 里 incBranches 的构造(见 clickhouse-source.service.ts)
const
branchOf
=
(
q
:
string
,
cursorCol
:
string
,
cursorVal
:
string
)
=>
`SELECT patient_id, brand FROM (
${
priv
.
injectIncrementalCursor
(
q
,
cursorCol
,
cursorVal
)}
)`
;
test
(
'⭐ 主档分支查的是主档表,不是 SELECT 列里子查询的那张'
,
()
=>
{
const
b
=
branchOf
(
MASTER_SQL
,
'last_visit_time'
,
'2026-07-30 08:15:00'
);
// 2026-08-01 测试服 dry-run 真实翻车过:分支被拼成
// SELECT patient_id, brand FROM dw_group.fact_complex_cases_out WHERE last_visit_time > …
// → 表名取自子查询、cursor 列取自主档,CH 报 Missing columns: 'last_visit_time' 'patient_id'
expect
(
b
).
not
.
toMatch
(
/FROM
\s
+dw_group
\.
fact_complex_cases_out
\s
+WHERE
\s
+last_visit_time/
);
expect
(
b
).
toContain
(
'FROM dw_group.fact_client_out'
);
});
test
(
'⭐ 复杂病例分支能读到别名列 —— 物理表只有 customer_id,没有 patient_id'
,
()
=>
{
const
q
=
`
SELECT customer_id AS patient_id, brand,
coalesce(updated_gmt_at, toDateTime(created_gmt_at)) AS changed_at
FROM dw_group.fact_complex_cases_out
WHERE (customer_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
)`
;
const
b
=
branchOf
(
q
,
'changed_at'
,
'2026-07-30 08:15:00'
);
// 外层 SELECT patient_id 必须落在**子查询之上**(别名在那里才存在)
expect
(
b
).
toMatch
(
/^SELECT patient_id, brand FROM
\(
/
);
expect
(
b
).
toContain
(
'customer_id AS patient_id'
);
});
});
describe
(
'定向名单分片 — ClickHouse max_query_size 256KiB'
,
()
=>
{
/// CH 服务端默认上限;超过即 `Syntax error: failed at position 262142`
const
MAX_QUERY_SIZE
=
256
*
1024
;
const
ID_CHUNK
=
10
_000
;
const
mkIds
=
(
n
:
number
)
=>
Array
.
from
({
length
:
n
},
(
_
,
i
)
=>
String
(
1
_000_000
+
i
));
test
(
'⭐ 7 万 id 单条 SQL 会超 256KiB —— 这正是 2026-08-01 定向补数 fatal 的原因'
,
()
=>
{
const
oneShot
=
`
${
mkIds
(
70
_283
).
map
((
id
)
=>
`'
${
id
}
'`
).
join
(
', '
)}
`
;
expect
(
oneShot
.
length
).
toBeGreaterThan
(
MAX_QUERY_SIZE
);
});
test
(
'⭐ 按 10k 分片后每片都远低于上限'
,
()
=>
{
const
ids
=
mkIds
(
70
_283
);
const
chunks
=
Array
.
from
({
length
:
Math
.
ceil
(
ids
.
length
/
ID_CHUNK
)
},
(
_
,
i
)
=>
ids
.
slice
(
i
*
ID_CHUNK
,
(
i
+
1
)
*
ID_CHUNK
),
);
expect
(
chunks
).
toHaveLength
(
8
);
for
(
const
c
of
chunks
)
{
const
clause
=
c
.
map
((
id
)
=>
`'
${
id
}
'`
).
join
(
', '
);
expect
(
clause
.
length
).
toBeLessThan
(
MAX_QUERY_SIZE
/
2
);
// 留一半余量给 SQL 其余部分
}
// 分片是**无损**切分:并集 = 原名单,不重不漏
expect
(
chunks
.
flat
()).
toEqual
(
ids
);
});
test
(
'名单不超过阈值时不分片(保持单条 SQL,行为不变)'
,
()
=>
{
const
ids
=
mkIds
(
500
);
expect
(
ids
.
length
>
ID_CHUNK
).
toBe
(
false
);
});
});
describe
(
'manifest 契约'
,
()
=>
{
describe
(
'manifest 契约'
,
()
=>
{
const
raw
=
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf-8'
);
const
raw
=
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf-8'
);
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
raw
));
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
raw
));
...
...
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