Commit 2516596f by luoqi

feat(sync): 回访再补宿主侧创建/更新时间 —— 名册时间窗不能用 task_date

DW fact_returnvisit_out 有 created_date / updated_date(均非空),之前没摄。

【为什么不复用本表已有的 created_at / updated_at】那两个是 **PAC 入库时间**
(@default(now()) / @updatedAt),存量回填和每次重摄都会把它们刷成"现在",
反映不了业务时间。命名沿用 patient_facts.source_updated_at 的既有口径(source* = 宿主侧)。

【为什么名册需要它】task_date 含**未来排程** —— 生产实测最远到 2033-11-12。
名册按"近 N 月在岗"筛人时若拿 task_date 卡窗口,会把"排了远期任务但早已不干活"的人
算成在岗。用 created_date(任务何时被创建)才是真实的行为时间。

datetimeFields 注册这两个字段(走无时区补 offset 归一);taskDate 刻意不进 ——
它是 @db.Date 纯日期,补 offset 会跨日。

顺带修正上一条 commit 的措辞:DW 给 task_director 的 comment 写的是"专属客服",
但实测近 12 月 367 万对回访,它与患者主档 current_task_director 仅 **23.1%** 相同 ——
本字段是"该任务当时派给谁",患者主档那个是"此刻挂在谁名下",确实是两回事。

测试 808 项(+2)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e3d4b3ef
Pipeline #3515 failed in 0 seconds
......@@ -25,3 +25,7 @@ field_mapping:
# ⚠️ 客服与诊所是多对多(实测 24% 跨诊所),名册里同一人会出现在多个诊所下,这是正常的。
taskDirectorId: task_director_id
taskDirectorName: task_director
# 宿主侧时间(≠ PAC 入库时间 created_at/updated_at,后者重摄会被刷新)。
# task_date 含未来排程(生产实测最远 2033),名册按"近 N 月在岗"筛人时用创建时间更准。
sourceCreatedAt: created_date
sourceUpdatedAt: updated_date
......@@ -296,7 +296,7 @@ sql_source:
SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
return_visit_type_name, return_visit_status_name, task_status_name,
treatment_items, treatment_items_two, follow_content, return_visit_result,
task_director_id, task_director
task_director_id, task_director, created_date, updated_date
FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
......
......@@ -16,9 +16,17 @@
-- 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 "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,14 +417,25 @@ model PatientReturnVisit {
/// 回访结果
result String?
/// 执行本次回访的客服(host task_director)—— **不是**患者的"专属客服"
/// (那个在 patients.preferences.dedicatedCs,可以是另一个人)
/// 负责本次回访任务的客服(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)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
......@@ -1800,6 +1800,9 @@ export class ColdImportService {
// 空串归一为 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 {
await this.withDbRetry(
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { PatientReturnVisitCanonicalSchema } from '@pac/types';
import { CanonicalResourceMeta, PatientReturnVisitCanonicalSchema } from '@pac/types';
/**
* 回访执行客服(patient_return_visits.task_director_id / _name)契约回归。
......@@ -33,6 +33,14 @@ describe('canonical schema', () => {
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',
......@@ -53,6 +61,8 @@ describe('yaml 契约', () => {
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)', () => {
......@@ -62,6 +72,8 @@ describe('yaml 契约', () => {
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('⭐ 患者主档的「专属客服」仍走自己的字段,没被本次改动挪用', () => {
......@@ -85,6 +97,13 @@ describe('schema.prisma', () => {
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,选择性差)', () => {
......
......@@ -114,11 +114,16 @@ export const PatientReturnVisitCanonicalSchema = z
treatmentItems: z.string().optional().nullable(),
followContent: z.string().optional().nullable(),
result: z.string().optional().nullable(),
/// 执行本次回访的客服 —— **不是**患者的"专属客服"(那个是 patient.dedicatedCs*,可以是另一个人)。
/// 用途:按诊所反推客服名册供主管指派(host 无"客服归属诊所"字段,只能按回访行为反推)。
/// 负责本次回访任务的客服 —— **不是**患者主档的"当前专属客服"(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();
export type PatientReturnVisitCanonical = z.infer<typeof PatientReturnVisitCanonicalSchema>;
......@@ -539,7 +544,9 @@ export const CanonicalResourceMeta: Record<
patient_return_visit: {
moneyFields: [],
moneyArrayFields: [],
datetimeFields: [],
/// 宿主侧时间要走无时区补 offset 的归一(DW 给的是裸 Date,按 manifest timezone 解释)。
/// taskDate 不在此列:它是 @db.Date(纯日期,无时刻),补 offset 反而会跨日。
datetimeFields: ['sourceCreatedAt', 'sourceUpdatedAt'],
booleanFields: [],
},
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