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
fff72b62
Commit
fff72b62
authored
Aug 31, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: main → test(反向合)—— 预约医生姓名
parents
8470c2f5
e19135b4
Pipeline
#3650
failed in 0 seconds
Changes
6
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
231 additions
and
0 deletions
+231
-0
apps/pac-service/data/jvs-dw/assemblers/appointment.yaml
+16
-0
apps/pac-service/src/modules/sync/adapters/mock-pull.adapter.ts
+1
-0
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
+8
-0
apps/pac-service/src/modules/sync/pipeline/parsers/appointment.parser.ts
+44
-0
apps/pac-service/tests/appointment-doctor-name.spec.ts
+158
-0
packages/types/src/schemas/canonical.ts
+4
-0
No files found.
apps/pac-service/data/jvs-dw/assemblers/appointment.yaml
View file @
fff72b62
...
...
@@ -24,6 +24,22 @@ field_mapping:
# 不是 PAC appointment_type 通用语义"预约目的"(咨询/治疗/检查/拔牙等);
# 且"初诊/复诊"是衍生信号,PAC 自己从 encounter_record 聚合算更可信(单一源 = PAC)。
doctorId
:
appo_doc_id
# ⭐ 预约医生姓名 —— DW 一直在同一行里给,只是历来没映射(预约事实因此只有 id 没有名字)。
# 列名叫 resource_name(排班资源名)容易让人以为是诊室/椅位,**不是**。行为核实(2026-08-31):
# ① 椅位另有列 appo_chair(仅 3 个取值),诊室/资源另有 resource_id;
# ② resource_name 与 appo_doc_id 的绑定比与 resource_id 更紧
# (本地:(doc_id,name) 140 组 < (resource_id,name) 151 组)——资源名不会有这个方向;
# ③ 拿「已到诊」预约按 (患者,日期) 对当天病历的 doctor_name:
# 生产近 90 天 99,170/101,192 = 98.0% 命中;144,148 条明细里
# 「id 对上而名字不对」和「名字对上而 id 不对」**各 0 条**(本地全量各 1 条,均为资源改派期历史行)。
# → resource_name 就是 appo_doc_id 这个人的姓名,不是恰好像人名的资源名。
# ⚠️ 语义是**约号时约的那位医生**,不是实际接诊医生(剩下 2% 是改派,业务事实非数据问题);
# 要实际接诊医生走病历 doctor_name,两个口径别混。
# ⚠️ 少数行排的**不是人**而是房间/服务/台席("预约"/"学前街手术室"/"正畸咨询"…):
# 生产近 180 天 9,838/1,047,901 = 0.94%。canonical 层原样透传,
# 由 AppointmentParser.isPersonResource 决定写不写 content.doctor_name
# (原值另存 content.resource_name,不丢)。词表与误伤核验记在那个函数上。
doctorName
:
resource_name
status
:
appo_status
complaintCategory
:
appo_complaint_category
# 预约科目 / 就诊意向(种植/正畸/…)
complaintText
:
appo_complaint
# 预约主诉自由文本(跟 category 配对)
...
...
apps/pac-service/src/modules/sync/adapters/mock-pull.adapter.ts
View file @
fff72b62
...
...
@@ -117,6 +117,7 @@ export class MockPullStrategy implements PullStrategy {
occurredAt
:
baseTime
.
toISOString
(),
status
:
'scheduled'
,
doctorId
:
`mock-d
${(
i
%
3
)
+
1
}
`
,
doctorName
:
`模拟医生
${(
i
%
3
)
+
1
}
`
,
treatmentCategory
:
'复查'
,
});
}
...
...
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
View file @
fff72b62
...
...
@@ -262,6 +262,14 @@ const AppointmentRecordContent = z
arrived_at
:
isoDateString
.
nullable
().
optional
().
default
(
null
),
appointment_type
:
nullableString
(),
doctor_id
:
nullableString
(),
// 约号时约的医生姓名(源自 jvs-dw resource_name;核实记录见 appointment.yaml)。
// ⚠️ 口径 = **约的**医生,不是实际接诊医生(改派时不同,实际接诊看 emr_record.doctor_name)。
// ⚠️ 已过「资源是不是人」这道闸(AppointmentParser.isPersonResource):排房间/服务/台席的
// 那 0.94% 在这里是 null,原值仍在 resource_name。所以本字段可以直接拼「X医生」。
doctor_name
:
nullableString
(),
// 排班资源原名(未过滤)。多数等于 doctor_name;不是人时("预约"/"学前街手术室"/"正畸咨询")
// doctor_name 为 null 而本字段保留原值 —— 既不丢 host 快照,也便于审计上面那道闸。
resource_name
:
nullableString
(),
// 预约科目/就诊意向(常规/正畸/种植/修复/拔牙/牙周…),host appo_complaint_category
complaint_category
:
nullableString
(),
// 预约主诉自由文本(跟 complaint_category 配对:分类 + 原文)— Layer C 源
...
...
apps/pac-service/src/modules/sync/pipeline/parsers/appointment.parser.ts
View file @
fff72b62
...
...
@@ -44,6 +44,12 @@ export class AppointmentParser implements Parser {
const
arrivedAt
=
c
.
arrivedAt
?
new
Date
(
c
.
arrivedAt
as
string
)
:
null
;
const
appointmentType
=
(
c
.
appointmentType
as
string
|
undefined
)
??
null
;
const
doctorId
=
(
c
.
doctorId
as
string
|
undefined
)
??
null
;
// 排班资源名(host 行内快照,DW resource_name)。约 99% 是医生本人,详见下方 isPersonResource。
const
resourceName
=
(
c
.
doctorName
as
string
|
undefined
)?.
trim
()
||
null
;
// 约号时约的医生姓名。见 appointment.yaml 里的核实记录。
// ⚠️ 不是实际接诊医生 —— 改派时两者不同,实际接诊以病历 doctor_name 为准。
const
doctorName
=
resourceName
&&
AppointmentParser
.
isPersonResource
(
resourceName
)
?
resourceName
:
null
;
return
[
{
...
...
@@ -77,6 +83,8 @@ export class AppointmentParser implements Parser {
arrived_at
:
arrivedAt
?
arrivedAt
.
toISOString
()
:
null
,
appointment_type
:
appointmentType
,
doctor_id
:
doctorId
,
doctor_name
:
doctorName
,
resource_name
:
resourceName
,
complaint_category
:
(
c
.
complaintCategory
as
string
|
undefined
)
??
null
,
complaint_text
:
(
c
.
complaintText
as
string
|
undefined
)
??
null
,
duration_minutes
:
...
...
@@ -93,6 +101,42 @@ export class AppointmentParser implements Parser {
];
}
/**
* 排班资源是不是**一个人**。
*
* DW 的 `resource_name`(→ canonical doctorName)是「排班资源」的名字,绝大多数资源就是医生本人,
* 但少数排的是房间 / 服务 / 台席。生产近 180 天 1,047,901 条预约里实测 9,838 条(**0.94%**)不是人:
* 预约(5,696)· 学前街手术室(1,218)· 种植手术(天使)(396)· 会诊室(351)· 显微镜管理(328)
* 舒适治疗(罗院/徐院/梁博)(738)· 种植室(欢乐)(260)· 方寸诊所客服(243)· 正畸咨询(205)
* 种植手术(金融街)(158)· 华侨城/三星手术室(141)· 公共诊室(49+19)· 种植手术(30)
* 华贸诊所B诊区客服(4)· 主诉初诊(2)
* 不拦的话,时间轴上每 106 条预约就有 1 条写着「预约医生」「学前街手术室医生」,
* 更糟的是详情页「主治医生」的最高频兜底可能解析成「学前街手术室」。
*
* 词表是**从上面这份实测清单反推**出来的(九个词覆盖全部 19 个取值),不是凭感觉列的。
* 误伤核验(生产 2026-08-31):临床事实里 1,314 个 distinct doctor_name 过一遍本规则,
* 命中 **1** 个 —— 「公共诊室」,它本身就是漏进病历的房间名,不是医生。**真人零误伤**。
*
* ⚠️ 被拦下的原值不丢:仍完整写在 content.resource_name(以及 raw_payload)里,
* `WHERE resource_name IS NOT NULL AND doctor_name IS NULL` 就能审计本规则拦了什么。
* ⚠️ 新诊所接入 / host 改排班命名后要复查这份词表 —— 用上面那条审计 SQL 看有没有新形态漏网。
*/
private
static
readonly
NON_PERSON_TOKENS
=
[
'预约'
,
'手术'
,
'室'
,
'治疗'
,
'咨询'
,
'客服'
,
'管理'
,
'诊区'
,
'初诊'
,
];
static
isPersonResource
(
name
:
string
):
boolean
{
return
!
AppointmentParser
.
NON_PERSON_TOKENS
.
some
((
t
)
=>
name
.
includes
(
t
));
}
// 已发生事实(actual)的 host 状态:到诊 / 接诊 / 结算 + walk-in 变更(已到店)
private
static
readonly
ACTUAL_STATUSES
=
new
Set
([
'arrived'
,
...
...
apps/pac-service/tests/appointment-doctor-name.spec.ts
0 → 100644
View file @
fff72b62
/**
* 预约医生姓名(jvs-dw resource_name → content.doctor_name)。
*
* 背景:预约事实历来只有 doctor_id 没有姓名,前端/话术拿到的是裸 ID。
* DW 其实一直在同一行里给了姓名,列名叫 `resource_name`(排班资源名)——名字有误导性,
* 核实过程与判据记在 data/jvs-dw/assemblers/appointment.yaml 的注释里。
*
* 本文件锁三件事:
* ① yaml 确实把 resource_name 映到 canonical doctorName(改名/删映射会红);
* ② parser 把它写进 content.doctor_name,空白归一成 null;
* ③ 口径不漂移 —— doctor_name 与 doctor_id 是**同一个人**的两面,别一个来自预约、
* 另一个来自别处。
*/
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
Action
}
from
'@pac/types'
;
import
{
AppointmentParser
}
from
'../src/modules/sync/pipeline/parsers/appointment.parser'
;
import
type
{
ParserContext
}
from
'../src/modules/sync/pipeline/parsers/parser.interface'
;
const
YAML_PATH
=
join
(
__dirname
,
'../data/jvs-dw/assemblers/appointment.yaml'
,
);
describe
(
'appointment.yaml | resource_name → doctorName'
,
()
=>
{
const
cfg
=
yaml
.
load
(
readFileSync
(
YAML_PATH
,
'utf-8'
))
as
{
field_mapping
:
Record
<
string
,
string
>
;
};
test
(
'doctorName 映射到 host 列 resource_name'
,
()
=>
{
expect
(
cfg
.
field_mapping
.
doctorName
).
toBe
(
'resource_name'
);
});
// ⚠️ 这两列是**不同的人**,历史上极易混:
// appo_doc_id = 约的医生(id) ←→ resource_name 是它的姓名
// director_id = 另一个角色(仅 34% 有值,与 appo_doc_id 几乎从不相同)
// create_name / appo_handler = 呼叫中心约号人(值形如 "400-叶玉娇")
test
(
'doctorId 仍取 appo_doc_id,没有被 director/handler 之类顶替'
,
()
=>
{
expect
(
cfg
.
field_mapping
.
doctorId
).
toBe
(
'appo_doc_id'
);
});
});
describe
(
'AppointmentParser | content.doctor_name'
,
()
=>
{
const
parser
=
new
AppointmentParser
();
const
ctx
=
(
row
:
Record
<
string
,
unknown
>
):
ParserContext
=>
({
transaction
:
{
id
:
'tx-1'
,
hostId
:
'host-1'
,
tenantId
:
'tenant-1'
,
patientId
:
'p-1'
,
action
:
Action
.
APPOINTMENT_CREATED
,
subjectType
:
'appointment'
,
subjectId
:
'appt-1'
,
occurredAt
:
new
Date
(
'2026-08-01T02:00:00Z'
),
clinicId
:
'c-1'
,
},
canonicalRow
:
{
externalId
:
'appt-1'
,
patientExternalId
:
'p-1'
,
clinicId
:
'c-1'
,
scheduledAt
:
'2026-08-01T02:00:00Z'
,
status
:
'scheduled'
,
doctorId
:
'5624'
,
...
row
,
},
});
const
contentOf
=
(
row
:
Record
<
string
,
unknown
>
)
=>
parser
.
parse
(
ctx
(
row
))[
0
]
!
.
content
as
Record
<
string
,
unknown
>
;
test
(
'有姓名 → 写进 content.doctor_name,与 doctor_id 成对'
,
()
=>
{
const
c
=
contentOf
({
doctorName
:
'赵茜'
});
expect
(
c
.
doctor_name
).
toBe
(
'赵茜'
);
expect
(
c
.
doctor_id
).
toBe
(
'5624'
);
});
test
(
'两侧空白被裁掉(host 常见尾随空格)'
,
()
=>
{
expect
(
contentOf
({
doctorName
:
' 李闻 '
}).
doctor_name
).
toBe
(
'李闻'
);
});
test
.
each
([
[
'缺字段'
,
{}],
[
'空串'
,
{
doctorName
:
''
}],
[
'纯空白'
,
{
doctorName
:
' '
}],
])(
'%s → null(不写空串,免得下游把 "" 当姓名渲染成「医生」)'
,
(
_label
,
row
)
=>
{
expect
(
contentOf
(
row
).
doctor_name
).
toBeNull
();
});
test
(
'resource_name 始终保留原值(host 快照不丢)'
,
()
=>
{
expect
(
contentOf
({
doctorName
:
'赵茜'
}).
resource_name
).
toBe
(
'赵茜'
);
expect
(
contentOf
({
doctorName
:
'学前街手术室'
}).
resource_name
).
toBe
(
'学前街手术室'
);
});
});
/**
* 「排班资源是不是人」这道闸。
*
* 生产近 180 天 0.94% 的预约排的是房间/服务/台席而非医生 —— 不拦的话时间轴上每 106 条
* 就有一条写着「预约医生」「学前街手术室医生」。词表是从那份实测清单反推的,不是凭感觉列的,
* 所以下面逐个锁住:清单变了(新诊所换了命名)这里就该红。
*/
describe
(
'AppointmentParser.isPersonResource | 非人资源闸'
,
()
=>
{
const
parser
=
new
AppointmentParser
();
const
doctorNameOf
=
(
nm
:
string
)
=>
(
parser
.
parse
({
transaction
:
{
id
:
'tx-1'
,
hostId
:
'h'
,
tenantId
:
't'
,
patientId
:
'p'
,
action
:
Action
.
APPOINTMENT_CREATED
,
subjectType
:
'appointment'
,
subjectId
:
'a'
,
occurredAt
:
new
Date
(
'2026-08-01T02:00:00Z'
),
clinicId
:
'c'
,
},
canonicalRow
:
{
externalId
:
'a'
,
patientExternalId
:
'p'
,
clinicId
:
'c'
,
scheduledAt
:
'2026-08-01T02:00:00Z'
,
status
:
'scheduled'
,
doctorName
:
nm
,
},
})[
0
]
!
.
content
as
Record
<
string
,
unknown
>
).
doctor_name
;
// 生产实测出现过的 19 个非人取值,逐个锁
test
.
each
([
'预约'
,
'学前街手术室'
,
'种植手术(天使)'
,
'会诊室'
,
'显微镜管理'
,
'舒适治疗(罗院)'
,
'种植室(欢乐)'
,
'方寸诊所客服'
,
'正畸咨询'
,
'种植手术(金融街)'
,
'华侨城手术室'
,
'公共诊室'
,
'三星手术室'
,
'顺义瑞捷公共诊室'
,
'华贸诊所B诊区客服'
,
'主诉初诊'
,
])(
'%s → 不是人,doctor_name 留空'
,
(
nm
)
=>
{
expect
(
AppointmentParser
.
isPersonResource
(
nm
)).
toBe
(
false
);
expect
(
doctorNameOf
(
nm
)).
toBeNull
();
});
// 真人零误伤 —— 含生产里那些容易被规则误伤的形态:
// 带消歧后缀的(吕晓玉(Y))、四字名(孙汪心悦)、外籍全名(SABELLI MARIA LUZ)
test
.
each
([
'赵茜'
,
'李闻'
,
'刘柳'
,
'吕晓玉(Y)'
,
'姚志远(Z)'
,
'孙汪心悦'
,
'SABELLI MARIA LUZ'
,
'TAN CHYI YANN'
,
'薛玫(L)'
,
])(
'%s → 是人,doctor_name 照写'
,
(
nm
)
=>
{
expect
(
AppointmentParser
.
isPersonResource
(
nm
)).
toBe
(
true
);
expect
(
doctorNameOf
(
nm
)).
toBe
(
nm
);
});
});
packages/types/src/schemas/canonical.ts
View file @
fff72b62
...
...
@@ -151,6 +151,10 @@ export const AppointmentCanonicalSchema = z
]),
appointmentType
:
z
.
string
().
optional
().
nullable
(),
doctorId
:
z
.
string
().
optional
().
nullable
(),
/// 排班资源名 = 约号时约的那位医生的姓名(host 行内快照,jvs-dw resource_name)。
/// ⚠️ 不等于实际接诊医生 —— 改派时以病历为准。
/// ⚠️ 约 1% 排的不是人而是房间/服务,canonical 层原样透传,由 parser 判定后再落 doctor_name。
doctorName
:
z
.
string
().
optional
().
nullable
(),
complaintCategory
:
z
.
string
().
optional
().
nullable
(),
durationMinutes
:
z
.
coerce
.
number
().
optional
().
nullable
(),
arrivedAt
:
optionalIsoDateTime
,
...
...
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