Commit d4cb98d2 by luoqi

merge: feat/return-visit-task-director → main(回访客服名册 + 宿主时间 + 纯日期时区修复)

parents 108558dd 45498176
...@@ -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
...@@ -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)
......
-- 回访执行客服(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);
...@@ -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")
} }
......
...@@ -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);
......
...@@ -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 {
......
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('');
});
});
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(/\btask_director_id\b/);
expect(sql).toMatch(/\btask_director\b/);
expect(sql).toMatch(/\bcreated_date\b/);
expect(sql).toMatch(/\bupdated_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/);
});
});
...@@ -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: {
......
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