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
d4cb98d2
Commit
d4cb98d2
authored
Aug 02, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/return-visit-task-director → main(回访客服名册 + 宿主时间 + 纯日期时区修复)
parents
108558dd
45498176
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
293 additions
and
5 deletions
+293
-5
apps/pac-service/data/jvs-dw/assemblers/patient_return_visit.yaml
+9
-0
apps/pac-service/data/jvs-dw/manifest.yaml
+6
-1
apps/pac-service/prisma/migrations/20260801150000_return_visit_task_director/migration.sql
+32
-0
apps/pac-service/prisma/schema.prisma
+21
-0
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
+19
-3
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
+17
-0
apps/pac-service/tests/normalize-datetime-timezone.spec.ts
+64
-0
apps/pac-service/tests/return-visit-task-director.spec.ts
+112
-0
packages/types/src/schemas/canonical.ts
+13
-1
No files found.
apps/pac-service/data/jvs-dw/assemblers/patient_return_visit.yaml
View file @
d4cb98d2
...
@@ -20,3 +20,12 @@ field_mapping:
...
@@ -20,3 +20,12 @@ field_mapping:
treatmentItems
:
treatment_items_full
# 大类·子项 合并(transforms H);单字段展示,不镜像 host 两列
treatmentItems
:
treatment_items_full
# 大类·子项 合并(transforms H);单字段展示,不镜像 host 两列
followContent
:
follow_content
followContent
:
follow_content
result
:
return_visit_result
result
:
return_visit_result
# 执行本次回访的客服(≠ 患者主档的 dedicatedCs「专属客服」,可以是不同人)。
# 给"按诊所取客服名册 → 主管指派"用:host 无「客服归属诊所」字段,只能按回访行为反推。
# ⚠️ 客服与诊所是多对多(实测 24% 跨诊所),名册里同一人会出现在多个诊所下,这是正常的。
taskDirectorId
:
task_director_id
taskDirectorName
:
task_director
# 宿主侧时间(≠ PAC 入库时间 created_at/updated_at,后者重摄会被刷新)。
# task_date 含未来排程(生产实测最远 2033),名册按"近 N 月在岗"筛人时用创建时间更准。
sourceCreatedAt
:
created_date
sourceUpdatedAt
:
updated_date
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
d4cb98d2
...
@@ -288,10 +288,15 @@ sql_source:
...
@@ -288,10 +288,15 @@ sql_source:
# customer_id AS patient_id(让 cohort 过滤生效);WHERE org ∈ EMR 表出现的所有诊所
# customer_id AS patient_id(让 cohort 过滤生效);WHERE org ∈ EMR 表出现的所有诊所
# (随 DW 全量,当前 64 家,非 5 家)—— 与核心数据同范围。
# (随 DW 全量,当前 64 家,非 5 家)—— 与核心数据同范围。
# 非召回信号,详情页"回访记录"块展示(常规/术后/咨询回访)。
# 非召回信号,详情页"回访记录"块展示(常规/术后/咨询回访)。
# task_director_id/task_director:**执行本次回访的客服**(≠ 患者主档的"专属客服")。
# 用途:按诊所反推客服名册供主管指派 —— host 不提供"客服归属诊所"字段,只能按回访行为反推。
# 实测口径:本表 5,110 个 distinct 客服,患者表 current_task_director 只有 1,865 个 —— 回访是
# 操作留痕,覆盖更全(97.2% 的专属客服在此出现过)。
fact_returnvisit_out
:
|
fact_returnvisit_out
:
|
SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
return_visit_type_name, return_visit_status_name, task_status_name,
return_visit_type_name, return_visit_status_name, task_status_name,
treatment_items, treatment_items_two, follow_content, return_visit_result
treatment_items, treatment_items_two, follow_content, return_visit_result,
task_director_id, task_director, created_date, updated_date
FROM dw_group.fact_returnvisit_out
FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
...
...
apps/pac-service/prisma/migrations/20260801150000_return_visit_task_director/migration.sql
0 → 100644
View file @
d4cb98d2
-- 回访执行客服(patient_return_visits.task_director_id / _name)
--
-- 【要解决什么】主管指派工单时要选人,但 host **不提供"客服归属诊所"字段** —— 只能按行为反推:
-- 「该诊所近 N 月有过回访记录的客服」。DW fact_returnvisit_out 早就带 task_director_id/name,
-- PAC 侧此前没映射,于是花名册只能靠离线快照 data/jvs-dw/users.json(已陈旧,无刷新机制)。
--
-- 【为什么不是"专属客服"】患者主档的 current_task_director 落在 patients.preferences.dedicatedCs,
-- 语义是"这个患者当前挂在谁名下";本字段是"**这次回访是谁做的**",两者可以是不同人。
-- 实测口径差异也很大:回访表 5,110 个 distinct 客服 vs 患者表 1,865 个(2.7 倍)——
-- 回访是操作留痕,覆盖更全。故各存各的,不复用同一列。
--
-- 【索引】(host, tenant, clinic, task_director, task_date desc) 正是名册查询的形状:
-- 按诊所圈人 + 时间窗过滤 + 去重客服。单列索引不够(clinic 基数只有 64,选择性差)。
--
-- 【存量】加可空列不重写表(PG11+ 元数据操作),存量行为 NULL —— 名册查询自动跳过它们。
-- 166.7 万存量行走一次性 DW 回填(按 external_id 批量 UPDATE),不走 cold-import:
-- 回访是 upsert 资源、**不进 transaction**,所以 reparse 无效;而整表重摄会连带
-- 重摄这批患者的病历/结算/预约(2026-08-01 实测:那条路把测试服磁盘写满了)。
-- 【顺带补宿主侧时间】host created_date / updated_date 一直没摄。
-- 注意**不能**复用本表已有的 created_at / updated_at —— 那两个是 PAC 入库时间
-- (@default(now()) / @updatedAt),回填和重摄都会刷成"现在",反映不了业务时间。
-- 命名沿用 patient_facts.source_updated_at 的既有口径(source* = 宿主侧时间)。
-- 用处:名册的"近 N 月在岗"若改用任务创建时间而非 task_date 会更准 —— task_date 含未来排程
-- (生产实测最远到 2033-11-12),拿它卡时间窗会把排了远期任务的人误算成在岗。
ALTER
TABLE
"patient_return_visits"
ADD
COLUMN
IF
NOT
EXISTS
"task_director_id"
TEXT
,
ADD
COLUMN
IF
NOT
EXISTS
"task_director_name"
TEXT
,
ADD
COLUMN
IF
NOT
EXISTS
"source_created_at"
TIMESTAMPTZ
(
3
),
ADD
COLUMN
IF
NOT
EXISTS
"source_updated_at"
TIMESTAMPTZ
(
3
);
CREATE
INDEX
IF
NOT
EXISTS
"patient_return_visits_roster_idx"
ON
"patient_return_visits"
(
"host_id"
,
"tenant_id"
,
"clinic_id"
,
"task_director_id"
,
"task_date"
DESC
);
apps/pac-service/prisma/schema.prisma
View file @
d4cb98d2
...
@@ -417,6 +417,25 @@ model PatientReturnVisit {
...
@@ -417,6 +417,25 @@ model PatientReturnVisit {
///
回访结果
///
回访结果
result
String
?
result
String
?
///
负责本次回访任务的客服
(
host
列
task_director
,
其
comment
写作
"专属客服"
)
。
///
⚠️
**
与患者主档的
"当前专属客服"
(
patients
.
preferences
.
dedicatedCs
←
current_task_director
)
///
不是一回事
**:
2026
-
08
-
01
实测近
12
月
367
万对回访
,
两者仅
**
23.1
%**
相同
——
///
本字段是
"该任务当时派给谁"
,
患者主档那个是
"此刻挂在谁名下"
。合并成一列会同时丢两边语义。
///
用途
:
按诊所反推客服名册
(
该诊所近
N
月有回访行为的人
)
供主管指派
——
host
不提供
///
"客服归属诊所"
字段
,
只能按行为反推。⚠️
客服与诊所是
**
多对多
**(
实测
24
%
跨诊所
,
///
最多跨
13
家
),
同一人会出现在多个诊所名册里
,
不要当成一对一属性建模。
taskDirectorId
String
?
@
map
(
"task_director_id"
)
taskDirectorName
String
?
@
map
(
"task_director_name"
)
///
宿主侧记录的创建
/
末次修改时间
(
host
created_date
/
updated_date
,
DW
侧非空
)
。
///
⚠️
与上面的
createdAt
/
updatedAt
**
不是一回事
**:
那两个是
PAC
自己的入库时间
///
(@
default
(
now
())
/
@
updatedAt
),
回填与重摄都会把它们刷成
"现在"
,
无法反映业务时间。
///
命名沿用
PatientFact
.
sourceUpdatedAt
的既有口径
(
source
*
=
宿主侧时间
)
。
///
用途
:
名册
"近 N 月在岗"
若改用
"任务创建时间"
而非
task_date
时可用
(
task_date
含未来排程
,
///
实测最远到
2033
年
,
直接拿它卡时间窗会把排了远期任务的人算成
"在岗"
)
。
sourceCreatedAt
DateTime
?
@
map
(
"source_created_at"
)
@
db
.
Timestamptz
(
3
)
sourceUpdatedAt
DateTime
?
@
map
(
"source_updated_at"
)
@
db
.
Timestamptz
(
3
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
@
db
.
Timestamptz
(
3
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
@
db
.
Timestamptz
(
3
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
@
db
.
Timestamptz
(
3
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
@
db
.
Timestamptz
(
3
)
...
@@ -424,6 +443,8 @@ model PatientReturnVisit {
...
@@ -424,6 +443,8 @@ model PatientReturnVisit {
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
@@
index
([
patientId
,
taskDate
(
sort
:
Desc
)])
@@
index
([
patientId
,
taskDate
(
sort
:
Desc
)])
///
客服名册查询
:
按
(
诊所
,
客服
)
聚合
+
taskDate
卡时间窗
@@
index
([
hostId
,
tenantId
,
clinicId
,
taskDirectorId
,
taskDate
(
sort
:
Desc
)])
@@
map
(
"patient_return_visits"
)
@@
map
(
"patient_return_visits"
)
}
}
...
...
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
View file @
d4cb98d2
...
@@ -262,8 +262,22 @@ function coerceArray(v: unknown): unknown[] | null {
...
@@ -262,8 +262,22 @@ function coerceArray(v: unknown): unknown[] | null {
/**
/**
* 把 datetime 字符串规范成 ISO 8601 带 offset 形态:
* 把 datetime 字符串规范成 ISO 8601 带 offset 形态:
* - 已带 tz('Z' / '+08:00' / '-05:00')→ 直通
* - 已带 tz('Z' / '+08:00' / '-05:00')→ 直通
* - 仅日期 'YYYY-MM-DD' →
直通(给 birthDate 用
)
* - 仅日期 'YYYY-MM-DD' →
**补 offset 到当地零点**('2026-05-10T00:00:00+08:00'
)
* - 无 tz('2026-05-10 14:00:00' / '2026-05-10T14:00:00')→ 按 timezone 配置补 offset
* - 无 tz('2026-05-10 14:00:00' / '2026-05-10T14:00:00')→ 按 timezone 配置补 offset
*
* ⚠️ 【为什么仅日期也要补 offset】2026-08-01 实测发现的既有 bug:
* 旧版对纯日期直通,下游 `new Date('2026-05-10')` 按 **ISO 规范解析成 UTC 零点** ——
* 而宿主给的日期是**当地日期**(DW 是 Asia/Shanghai),于是整整偏 8 小时:
* DW created_date = 2022-09-30(北京)→ 存成 2022-09-30T00:00:00Z = 北京 09-30 **08:00**
* 正确应为 2022-09-29T16:00:00Z = 北京 09-30 **00:00**
* 测试服实测:diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(6%),
* 正是这条路径来的;落在 UTC 16:00(正确形态)的只有 5 条。
*
* ⚠️ 【为什么不怕"补 offset 导致跨日"】只有目标是 **@db.Date**(纯日期列)的字段才有这风险
* (北京零点 → UTC 前一天 16:00 → PG 取 UTC 日期会退一天)。而本函数只作用于
* `CanonicalResourceMeta.datetimeFields` 声明的字段,那些目标**全是 timestamptz**;
* 真正的纯日期列(patient_return_visit.taskDate / patient.birthDate)刻意不在该清单里,
* 走各自的 `new Date(...)` 直解,行为不变。新增字段时按此规则归类。
*/
*/
export
function
normalizeDatetime
(
s
:
string
,
timezone
:
string
):
string
{
export
function
normalizeDatetime
(
s
:
string
,
timezone
:
string
):
string
{
const
trimmed
=
s
.
trim
();
const
trimmed
=
s
.
trim
();
...
@@ -271,8 +285,10 @@ export function normalizeDatetime(s: string, timezone: string): string {
...
@@ -271,8 +285,10 @@ export function normalizeDatetime(s: string, timezone: string): string {
if
(
/
[
Zz
]
$/
.
test
(
trimmed
)
||
/
[
+
\-]\d{2}
:
?\d{2}
$/
.
test
(
trimmed
))
{
if
(
/
[
Zz
]
$/
.
test
(
trimmed
)
||
/
[
+
\-]\d{2}
:
?\d{2}
$/
.
test
(
trimmed
))
{
return
trimmed
.
replace
(
' '
,
'T'
);
return
trimmed
.
replace
(
' '
,
'T'
);
}
}
// 仅日期
// 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
trimmed
))
return
trimmed
;
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
trimmed
))
{
return
`
${
trimmed
}
T00:00:00
${
timezoneToOffsetSuffix
(
timezone
)}
`
;
}
// 看起来像 datetime 无 tz
// 看起来像 datetime 无 tz
if
(
/^
\d{4}
-
\d{2}
-
\d{2}[
T
]\d{2}
:
\d{2}
/
.
test
(
trimmed
))
{
if
(
/^
\d{4}
-
\d{2}
-
\d{2}[
T
]\d{2}
:
\d{2}
/
.
test
(
trimmed
))
{
const
offset
=
timezoneToOffsetSuffix
(
timezone
);
const
offset
=
timezoneToOffsetSuffix
(
timezone
);
...
...
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
d4cb98d2
...
@@ -1795,6 +1795,14 @@ export class ColdImportService {
...
@@ -1795,6 +1795,14 @@ export class ColdImportService {
treatmentItems
:
normalizeMergedItems
(
c
.
treatmentItems
as
string
|
undefined
),
treatmentItems
:
normalizeMergedItems
(
c
.
treatmentItems
as
string
|
undefined
),
followContent
:
(
c
.
followContent
as
string
|
undefined
)
??
null
,
followContent
:
(
c
.
followContent
as
string
|
undefined
)
??
null
,
result
:
(
c
.
result
as
string
|
undefined
)
??
null
,
result
:
(
c
.
result
as
string
|
undefined
)
??
null
,
// 执行本次回访的客服(≠ 患者的"专属客服")。给"按诊所取客服名册 → 主管指派"用。
// host 侧 id 是数字,canonical 已 coerce 成 string(同 externalId 口径:external 标识一律字符串)。
// 空串归一为 null —— 名册查询按 IS NOT NULL 圈人,留空串会混进一个"无名客服"分组。
taskDirectorId
:
emptyToNull
(
c
.
taskDirectorId
),
taskDirectorName
:
emptyToNull
(
c
.
taskDirectorName
),
// 宿主侧时间(≠ 本表的 createdAt/updatedAt,那两个是 PAC 入库时间、重摄会被刷新)
sourceCreatedAt
:
c
.
sourceCreatedAt
?
new
Date
(
c
.
sourceCreatedAt
as
string
)
:
null
,
sourceUpdatedAt
:
c
.
sourceUpdatedAt
?
new
Date
(
c
.
sourceUpdatedAt
as
string
)
:
null
,
};
};
try
{
try
{
await
this
.
withDbRetry
(
await
this
.
withDbRetry
(
...
@@ -2618,6 +2626,15 @@ function shiftIsoHours(value: string, deltaHours: number): string {
...
@@ -2618,6 +2626,15 @@ function shiftIsoHours(value: string, deltaHours: number): string {
return
new
Date
(
d
.
getTime
()
+
deltaHours
*
3600
_000
).
toISOString
();
return
new
Date
(
d
.
getTime
()
+
deltaHours
*
3600
_000
).
toISOString
();
}
}
/// 空值归一:undefined / null / 空白串 / host 的数字 0 → null;其余 trim 后返回字符串。
/// 用于 external 标识类字段(如回访客服 id)—— 名册查询按 IS NOT NULL 圈人,
/// 留下空串或 "0" 会各自成为一个假的"客服"分组。
function
emptyToNull
(
v
:
unknown
):
string
|
null
{
if
(
v
===
undefined
||
v
===
null
)
return
null
;
const
s
=
String
(
v
).
trim
();
return
s
===
''
||
s
===
'0'
?
null
:
s
;
}
/// 回访治疗项「大类 · 子项」合并值归一(transforms H 的 concat)。
/// 回访治疗项「大类 · 子项」合并值归一(transforms H 的 concat)。
/// 两列皆空 → 合并出来只剩 " · "(纯分隔符/空白)→ 视为无治疗项,归 null;有真实内容则 trim 返回。
/// 两列皆空 → 合并出来只剩 " · "(纯分隔符/空白)→ 视为无治疗项,归 null;有真实内容则 trim 返回。
function
normalizeMergedItems
(
v
:
string
|
undefined
):
string
|
null
{
function
normalizeMergedItems
(
v
:
string
|
undefined
):
string
|
null
{
...
...
apps/pac-service/tests/normalize-datetime-timezone.spec.ts
0 → 100644
View file @
d4cb98d2
import
{
normalizeDatetime
}
from
'../src/modules/sync/assembler/field-mapper'
;
/**
* 宿主时间归一的时区语义。
*
* 【2026-08-01 实测发现的既有 bug】旧版对**纯日期**('2026-05-10')直通不补 offset,
* 下游 `new Date('2026-05-10')` 按 ISO 规范解析成 **UTC 零点** —— 而宿主给的是**当地日期**
* (jvs-dw 的 DW 是 Asia/Shanghai),于是整整偏 8 小时:
* DW created_date = 2022-09-30(北京)
* 旧:2022-09-30T00:00:00Z = 北京 09-30 **08:00** ❌
* 新:2022-09-29T16:00:00Z = 北京 09-30 **00:00** ✅
* 测试服实测规模:diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(占 6%),
* 正是这条路径来的;落在 UTC 16:00(正确形态)的只有 5 条。
*
* 【边界纪律】本函数只作用于 CanonicalResourceMeta.datetimeFields 声明的字段,
* 那些目标列**全是 timestamptz**。真正的纯日期列(patient_return_visit.taskDate /
* patient.birthDate)刻意不进该清单 —— 对它们补 offset 会让 PG 取 UTC 日期时**退一天**。
* 新增 canonical 字段时按"目标列类型"归类,别凭字段名像不像时间。
*/
describe
(
'normalizeDatetime — 时区语义'
,
()
=>
{
const
SH
=
'Asia/Shanghai'
;
test
(
'⭐ 纯日期 → 当地零点(不是 UTC 零点)'
,
()
=>
{
const
out
=
normalizeDatetime
(
'2022-09-30'
,
SH
);
expect
(
out
).
toBe
(
'2022-09-30T00:00:00+08:00'
);
// 落到实际时刻:北京零点 = UTC 前一天 16:00
expect
(
new
Date
(
out
).
toISOString
()).
toBe
(
'2022-09-29T16:00:00.000Z'
);
});
test
(
'⭐ 回归:旧行为会偏 8 小时 —— 直通字符串被 JS 当成 UTC 零点'
,
()
=>
{
// 这一行是"如果有人把补 offset 改回直通"会发生什么的固化证据
expect
(
new
Date
(
'2022-09-30'
).
toISOString
()).
toBe
(
'2022-09-30T00:00:00.000Z'
);
// 与正确值差整 8 小时
const
correct
=
new
Date
(
normalizeDatetime
(
'2022-09-30'
,
SH
)).
getTime
();
expect
(
new
Date
(
'2022-09-30'
).
getTime
()
-
correct
).
toBe
(
8
*
3600
*
1000
);
});
test
(
'UTC 宿主不受影响(offset 是 Z,纯日期仍是 UTC 零点)'
,
()
=>
{
expect
(
new
Date
(
normalizeDatetime
(
'2022-09-30'
,
'UTC'
)).
toISOString
()).
toBe
(
'2022-09-30T00:00:00.000Z'
,
);
});
test
(
'东京宿主按 +09:00 解释(时区取自 manifest,不写死东八区)'
,
()
=>
{
expect
(
new
Date
(
normalizeDatetime
(
'2022-09-30'
,
'Asia/Tokyo'
)).
toISOString
()).
toBe
(
'2022-09-29T15:00:00.000Z'
,
);
});
test
(
'无 tz 的 datetime 照旧补 offset(既有行为不变)'
,
()
=>
{
expect
(
normalizeDatetime
(
'2026-05-10 14:00:00'
,
SH
)).
toBe
(
'2026-05-10T14:00:00+08:00'
);
expect
(
normalizeDatetime
(
'2026-05-10T14:00:00'
,
SH
)).
toBe
(
'2026-05-10T14:00:00+08:00'
);
});
test
(
'已带 tz 的直通(只把空格换成 T),不重复补 offset'
,
()
=>
{
expect
(
normalizeDatetime
(
'2026-05-10 14:00:00+08:00'
,
SH
)).
toBe
(
'2026-05-10T14:00:00+08:00'
);
expect
(
normalizeDatetime
(
'2026-05-10T06:00:00Z'
,
SH
)).
toBe
(
'2026-05-10T06:00:00Z'
);
});
test
(
'识别不了的形态原样返回(交给 zod 报错,不猜)'
,
()
=>
{
expect
(
normalizeDatetime
(
'not-a-date'
,
SH
)).
toBe
(
'not-a-date'
);
expect
(
normalizeDatetime
(
''
,
SH
)).
toBe
(
''
);
});
});
apps/pac-service/tests/return-visit-task-director.spec.ts
0 → 100644
View file @
d4cb98d2
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
CanonicalResourceMeta
,
PatientReturnVisitCanonicalSchema
}
from
'@pac/types'
;
/**
* 回访执行客服(patient_return_visits.task_director_id / _name)契约回归。
*
* 【为什么要这个字段】主管指派工单要选人,而 host **不提供「客服归属诊所」字段** ——
* 只能按行为反推「该诊所近 N 月有过回访记录的客服」。DW fact_returnvisit_out 一直带
* task_director_id/name,PAC 侧此前没映射,花名册只能靠离线快照 data/jvs-dw/users.json
* (已陈旧且无刷新机制)。
*
* 【最容易搞错的一点】它**不是**患者的"专属客服":
* - patients.preferences.dedicatedCs ← fact_client_out.current_task_director(患者挂在谁名下)
* - patient_return_visits.task_director_* ← 本次回访是谁做的
* 两者可以是不同人,口径也差 2.7 倍(回访表 5,110 个 distinct vs 患者表 1,865 个)。
* 谁要是把两处合并成一列,名册就会少掉一大半人 —— 故这组测试显式锁住"各存各的"。
*/
const
RV_YAML
=
join
(
__dirname
,
'../data/jvs-dw/assemblers/patient_return_visit.yaml'
);
const
MANIFEST
=
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
);
describe
(
'canonical schema'
,
()
=>
{
test
(
'⭐ host 侧数字 id → 字符串(PAC 的 external 标识一律字符串,同 externalId 口径)'
,
()
=>
{
const
out
=
PatientReturnVisitCanonicalSchema
.
parse
({
externalId
:
'rv1'
,
patientExternalId
:
'p1'
,
taskDirectorId
:
5679
,
// DW 是 Int64
taskDirectorName
:
'李欣'
,
});
expect
(
out
.
taskDirectorId
).
toBe
(
'5679'
);
expect
(
out
.
taskDirectorName
).
toBe
(
'李欣'
);
});
test
(
'宿主侧时间被识别为 datetime 字段(要走无时区补 offset 的归一)'
,
()
=>
{
const
meta
=
CanonicalResourceMeta
.
patient_return_visit
;
expect
(
meta
.
datetimeFields
).
toContain
(
'sourceCreatedAt'
);
expect
(
meta
.
datetimeFields
).
toContain
(
'sourceUpdatedAt'
);
// taskDate 是 @db.Date(纯日期),补 offset 会跨日 —— 刻意不进这个列表
expect
(
meta
.
datetimeFields
).
not
.
toContain
(
'taskDate'
);
});
test
(
'未提供 → undefined(不是空串);不因缺这两列而拒收整条回访'
,
()
=>
{
const
out
=
PatientReturnVisitCanonicalSchema
.
parse
({
externalId
:
'rv2'
,
patientExternalId
:
'p2'
,
});
expect
(
out
.
taskDirectorId
).
toBeUndefined
();
expect
(
out
.
externalId
).
toBe
(
'rv2'
);
// 主数据完好
});
});
describe
(
'yaml 契约'
,
()
=>
{
const
rv
=
yaml
.
load
(
readFileSync
(
RV_YAML
,
'utf-8'
))
as
{
canonical
:
string
;
field_mapping
:
Record
<
string
,
string
>
;
};
test
(
'assembler 映射到 host 的 task_director_id / task_director'
,
()
=>
{
expect
(
rv
.
canonical
).
toBe
(
'patient_return_visit'
);
expect
(
rv
.
field_mapping
.
taskDirectorId
).
toBe
(
'task_director_id'
);
expect
(
rv
.
field_mapping
.
taskDirectorName
).
toBe
(
'task_director'
);
expect
(
rv
.
field_mapping
.
sourceCreatedAt
).
toBe
(
'created_date'
);
expect
(
rv
.
field_mapping
.
sourceUpdatedAt
).
toBe
(
'updated_date'
);
});
test
(
'⭐ 源 query 必须 SELECT 这两列 —— 漏了则映射静默失效(canonical 无此键 → 落 null)'
,
()
=>
{
const
manifest
=
yaml
.
load
(
readFileSync
(
MANIFEST
,
'utf-8'
))
as
{
sql_source
?:
{
queries
?:
Record
<
string
,
string
>
};
};
const
sql
=
manifest
.
sql_source
?.
queries
?.[
'fact_returnvisit_out'
]
??
''
;
expect
(
sql
).
toMatch
(
/
\b
task_director_id
\b
/
);
expect
(
sql
).
toMatch
(
/
\b
task_director
\b
/
);
expect
(
sql
).
toMatch
(
/
\b
created_date
\b
/
);
expect
(
sql
).
toMatch
(
/
\b
updated_date
\b
/
);
});
test
(
'⭐ 患者主档的「专属客服」仍走自己的字段,没被本次改动挪用'
,
()
=>
{
const
p
=
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/assemblers/patient.yaml'
),
'utf-8'
),
)
as
{
field_mapping
:
Record
<
string
,
string
>
};
expect
(
p
.
field_mapping
.
dedicatedCsName
).
toBe
(
'current_task_director'
);
expect
(
p
.
field_mapping
.
dedicatedCsId
).
toBe
(
'current_task_director_id'
);
// 两处映射的 host 列不同 —— 合并会让名册少掉 2/3 的人
expect
(
p
.
field_mapping
.
dedicatedCsId
).
not
.
toBe
(
rv
.
field_mapping
.
taskDirectorId
);
});
});
describe
(
'schema.prisma'
,
()
=>
{
const
schema
=
readFileSync
(
join
(
__dirname
,
'../prisma/schema.prisma'
),
'utf-8'
);
const
model
=
schema
.
slice
(
schema
.
indexOf
(
'model PatientReturnVisit'
),
schema
.
indexOf
(
'@@map("patient_return_visits")'
),
);
test
(
'两列都是可空(存量 166 万行加列不重写表,回填前保持 NULL)'
,
()
=>
{
expect
(
model
).
toMatch
(
/taskDirectorId
\s
+String
\?\s
+@map
\(
"task_director_id"
\)
/
);
expect
(
model
).
toMatch
(
/taskDirectorName
\s
+String
\?\s
+@map
\(
"task_director_name"
\)
/
);
expect
(
model
).
toMatch
(
/sourceCreatedAt
\s
+DateTime
\?\s
+@map
\(
"source_created_at"
\)
/
);
expect
(
model
).
toMatch
(
/sourceUpdatedAt
\s
+DateTime
\?\s
+@map
\(
"source_updated_at"
\)
/
);
});
test
(
'⭐ 宿主时间没有占用 PAC 自己的入库时间列(那两个重摄会被刷新)'
,
()
=>
{
expect
(
model
).
toMatch
(
/createdAt
\s
+DateTime
\s
+@default
\(
now
\(\)\)
/
);
expect
(
model
).
toMatch
(
/updatedAt
\s
+DateTime
\s
+@updatedAt/
);
});
test
(
'⭐ 名册查询的复合索引存在 —— 单列索引不够(clinic 基数仅 64,选择性差)'
,
()
=>
{
expect
(
model
).
toMatch
(
/@@index
\(\[
hostId, tenantId, clinicId, taskDirectorId, taskDate/
);
});
});
packages/types/src/schemas/canonical.ts
View file @
d4cb98d2
...
@@ -114,6 +114,16 @@ export const PatientReturnVisitCanonicalSchema = z
...
@@ -114,6 +114,16 @@ export const PatientReturnVisitCanonicalSchema = z
treatmentItems
:
z
.
string
().
optional
().
nullable
(),
treatmentItems
:
z
.
string
().
optional
().
nullable
(),
followContent
:
z
.
string
().
optional
().
nullable
(),
followContent
:
z
.
string
().
optional
().
nullable
(),
result
:
z
.
string
().
optional
().
nullable
(),
result
:
z
.
string
().
optional
().
nullable
(),
/// 负责本次回访任务的客服 —— **不是**患者主档的"当前专属客服"(patient.dedicatedCs*)。
/// 实测近 12 月 367 万对回访,两者仅 23.1% 相同:本字段是"该任务当时派给谁",
/// 患者主档那个是"此刻挂在谁名下"。用途:按诊所反推客服名册供主管指派
/// (host 无"客服归属诊所"字段,只能按回访行为反推)。
/// id 用 string:host 侧是数字,但 PAC 一律按字符串存 external 标识(同 externalId 口径)。
taskDirectorId
:
z
.
coerce
.
string
().
optional
().
nullable
(),
taskDirectorName
:
z
.
string
().
optional
().
nullable
(),
/// 宿主侧的创建 / 末次修改时间(≠ PAC 入库时间,后者由 DB 自己维护、重摄会被刷新)。
sourceCreatedAt
:
optionalIsoDateTime
,
sourceUpdatedAt
:
optionalIsoDateTime
,
})
})
.
passthrough
();
.
passthrough
();
export
type
PatientReturnVisitCanonical
=
z
.
infer
<
typeof
PatientReturnVisitCanonicalSchema
>
;
export
type
PatientReturnVisitCanonical
=
z
.
infer
<
typeof
PatientReturnVisitCanonicalSchema
>
;
...
@@ -534,7 +544,9 @@ export const CanonicalResourceMeta: Record<
...
@@ -534,7 +544,9 @@ export const CanonicalResourceMeta: Record<
patient_return_visit
:
{
patient_return_visit
:
{
moneyFields
:
[],
moneyFields
:
[],
moneyArrayFields
:
[],
moneyArrayFields
:
[],
datetimeFields
:
[],
/// 宿主侧时间要走无时区补 offset 的归一(DW 给的是裸 Date,按 manifest timezone 解释)。
/// taskDate 不在此列:它是 @db.Date(纯日期,无时刻),补 offset 反而会跨日。
datetimeFields
:
[
'sourceCreatedAt'
,
'sourceUpdatedAt'
],
booleanFields
:
[],
booleanFields
:
[],
},
},
consultation
:
{
consultation
:
{
...
...
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