Commit e19135b4 by luoqi

merge: 预约医生姓名(DW resource_name)→ main

parents 6dc62917 b9564716
Pipeline #3649 failed in 0 seconds
......@@ -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 配对)
......
......@@ -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: '复查',
});
}
......
......@@ -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 源
......
......@@ -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',
......
/**
* 预约医生姓名(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);
});
});
......@@ -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,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment