Commit ab9ee7d5 by luoqi

feat(friday): refund_item 身份 inline(宿主反填真 patient_id)+ drift 误报修复

push 自洽收尾(判据:PAC 已有主体的不 inline,值不可信的才 inline):
- refund_item:spec.patient_id 部分品牌是诊所本地垃圾 id、不可信 → 宿主导出时 JOIN 结算头
  把真 patient_id 反填进 spec(inline);删 S.3.1 跨表 lookup,refund_item 单表 push 自洽。
  (patient_relation 不动:referee 本身是 PAC 患者实体,referee_sex 可从自有实体解析,无需 inline)
- drift 误报修复:refund_full/refund_item 共用 subjectType='refund' 但源表列集不同,
  detectRawColumnDrift 按「与本批列签名 Jaccard≥0.5」自选同源样本,排除跨源混列(消除 suspectFields 虚警)

验证:元和王永 dry-run refund_item=308(样本 patientExternalId=659325 真 6 位 id,无 lookup)、
refund_full=190、failed=0;299 测试绿、typecheck 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent c4636e36
Pipeline #3427 failed in 0 seconds
......@@ -196,8 +196,8 @@ icon: FileJson
|---|---|---|---|
| `id` | string | ✅ | 明细行主键 |
| `tenant_id` / `organization_id` | string | ✅ | 品牌 / 诊所 |
| `settlement_id` | string | ✅ | 所属结算单 uuid(退费挂回原消费;**也是 PAC 认患者的依据**) |
| `patient_id` | string | | ⚠️ **部分品牌此列为诊所本地 id、不可信**(实测元和王永品牌全列与结算头不符);PAC 不用它认患者,而是按 `settlement_id` 从 `patient_settlement` 头单继承真实 `patient_id` |
| `settlement_id` | string | ✅ | 所属结算单 uuid(退费挂回原消费) |
| `patient_id` | string | ✅ | **宿主已反填真 patient_id(inline)**——原生 spec.patient_id 部分品牌是诊所本地 id、不可信(实测元和王永全列与结算头不符),导出时按 `settlement_id` 从结算头取真值反填;PAC 直接用 |
| `cure_name` / `service_project_name` | string | | 项目名(退费行=被退项目) |
| `receivable_this` / `net_receipts_this` | number(元) | | 金额(`is_refund=1` 时为被退金额,正值) |
| `is_refund` | string/number | ✅ | `1`=退费行(PAC 只取此值切退费明细) `0`=正常明细 `2`=其他 |
......
......@@ -19,8 +19,8 @@ field_mapping:
externalId: id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
# spec.patient_id 部分品牌是诊所本地 id(不可信)→ manifest S.3.1 已从父结算头 lookup 纠正
patientExternalId: owner_patient_id
# spec.patient_id 部分品牌原是诊所本地 id → 宿主导出时已从结算头反填真 patient_id(inline);PAC 直接用
patientExternalId: patient_id
clinicId: organization_id
refundedAt: created_gmt_at
amount: net_receipts_this
......
......@@ -142,8 +142,9 @@ mysql_csv "SELECT mc.id,mc.tenant_id,mc.organization_id,mc.organization_name,mc.
mysql_csv "SELECT it.id,it.tenant_id,it.customer_id,it.treat_plan_id,it.tooth_position,it.mode_name,it.charge_min,it.charge_max,it.created_gmt_at,it.updated_gmt_at,h.organization_id,h.plan_name FROM customer.customer_treat_plan_item it LEFT JOIN customer.customer_treat_plan h ON h.id=it.treat_plan_id $(pf it.customer_id)" customer_treat_plan_item.csv
# 结算头单原表(全 status,导出侧不做业务 WHERE;消费/退费切分全在 PAC)
mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,receivable_this,net_receipts_this,billing_date,registration_id,ref_settlement_id,settlement_serial_num,reason,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement $(pf patient_id)" patient_settlement.csv
# 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。按 settlement_id 挂结算头(spec.patient_id 部分品牌是本地 id,不可信)
mysql_csv "SELECT id,tenant_id,organization_id,patient_id,settlement_id,cure_name,service_project_name,receivable_this,net_receipts_this,is_refund,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec $(pfs)" patient_settlement_spec.csv
# 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。JOIN 结算头:① 挂靠限定 cohort
# ② 反填真 patient_id(spec.patient_id 部分品牌是诊所本地 id,不可信 → 取结算头 ps.patient_id inline)
mysql_csv "SELECT sp.id,sp.tenant_id,sp.organization_id,ps.patient_id,sp.settlement_id,sp.cure_name,sp.service_project_name,sp.receivable_this,sp.net_receipts_this,sp.is_refund,sp.created_gmt_at,sp.updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec sp JOIN \`arrail-settlement-server\`.patient_settlement ps ON ps.uuid=sp.settlement_id $(pf ps.patient_id)" patient_settlement_spec.csv
# 转介绍圈(患者-患者关系边,双向成对存;referee_relationship 为宿主字典 id,PAC 侧统计推断解码)
mysql_csv "SELECT id,tenant_id,organization_id,customer_id,referee_patient_id,referee_relationship,type,created_gmt_at,updated_gmt_at FROM customer.customer_referee_circle $(pf customer_id)" customer_referee_circle.csv
# 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实)
......
......@@ -323,24 +323,15 @@ transforms:
output: refund_full_rows
# ── S.3 退费明细②:行级退费(patient_settlement_spec is_refund=1)——PAC 侧 filter ──
# ⚠️ spec.patient_id 部分品牌是「诊所本地 id」而非全局 customer id(元和王永 691828a5 实测
# 全 26421 行不符,spec=2/头=666716)。退费明细是结算头子行 → 宿主导出时已按 settlement_id
# 从结算头把真 patient_id **反填进 spec.patient_id**(within-host join,inline),PAC 直接信;
# 故此处仅 filter,不再跨表 lookup 结算头(单表 push 自洽)。
- kind: filter
input: patient_settlement_spec
output: _refund_item_raw
output: refund_item_rows
where:
is_refund: { equals: '1' }
# S.3.1 身份纠正:spec.patient_id 部分品牌是「诊所本地 id」而非全局 customer id
# (元和王永 691828a5 实测:全 26421 行 spec.patient_id ≠ 结算头 patient_id,如 spec=2/头=666716;
# 全库 2.64 万不符行几乎全出自该诊所)。退费明细本就是结算头的子行 → 患者身份从父头单继承:
# settlement_id → patient_settlement.patient_id(权威,与 payment/refund_full 同源)。
# 命中即覆盖(lookup 语义);未命中保留原值 → 失败行可见,不会静默挂错人。
- kind: lookup
input: _refund_item_raw
output: refund_item_rows
from: patient_settlement
left_key: settlement_id
right_key: uuid
select:
owner_patient_id: patient_id
# ═══════════ 患者关系链(customer_referee_circle → patient_relations 独立表)═══════════
# 宿主 referee_relationship 是字典 id(DB 无字典表,疑似代码硬编码)。解码 = 统计推断
......
......@@ -627,10 +627,25 @@ export class ColdImportService {
orderBy: { createdAt: 'desc' },
});
if (sample.length < MIN_BASELINE) return [];
const baseline = new Set<string>();
for (const s of sample) {
// 同 subjectType 可能混多源(如 refund = 结算头 patient_settlement ∪ 结算明细
// patient_settlement_spec,列集不同)。按「与本批列签名的 Jaccard 相似度」自选**同源**样本,
// 否则另一源的专属列会被误报成"本源删列"(suspectFields 虚警)。跨源如 settlement/spec 交并比
// 仅 ~0.35,≥0.5 阈值可干净分离;同源 ≈1.0。
const pushed = args.pushedCols;
const sameSource = sample.filter((s) => {
const rp = s.rawPayload as Record<string, unknown> | null;
if (rp) for (const k of Object.keys(rp)) if (!k.startsWith('_')) baseline.add(k);
if (!rp) return false;
const cols = Object.keys(rp).filter((k) => !k.startsWith('_'));
if (cols.length === 0) return false;
const inter = cols.filter((c) => pushed.has(c)).length;
const union = new Set([...cols, ...pushed]).size;
return union > 0 && inter / union >= 0.5;
});
if (sameSource.length < MIN_BASELINE) return []; // 同源基线不足 → 不可靠,宁可不报
const baseline = new Set<string>();
for (const s of sameSource) {
const rp = s.rawPayload as Record<string, unknown>;
for (const k of Object.keys(rp)) if (!k.startsWith('_')) baseline.add(k);
}
const out: SuspectField[] = [];
......
......@@ -24,10 +24,10 @@ const patient_settlement = [
{ uuid: 's3neg', status: '3', receivable_this: '-20', patient_id: '666716' }, // 退费(status3 负额表达)
{ uuid: 's2', status: '2', receivable_this: '10', patient_id: '666716' }, // 草稿/其他 → 都不收
];
// spec.patient_id 是「诊所本地垃圾 id」(元和王永品牌实证)→ 退费明细身份须从父结算头 lookup 纠正
// spec.patient_id 部分品牌原是「诊所本地垃圾 id」→ 宿主导出时已从结算头反填真 patient_id(inline);PAC 直用
const patient_settlement_spec = [
{ id: 'sp1', is_refund: '1', settlement_id: 's3', patient_id: '2' }, // 退费明细(本地 id 2,应被纠成 666716)
{ id: 'sp2', is_refund: '0', settlement_id: 's1', patient_id: '2' }, // 非退费
{ id: 'sp1', is_refund: '1', settlement_id: 's3', patient_id: '666716' }, // 退费明细(宿主已反填真 patient_id)
{ id: 'sp2', is_refund: '0', settlement_id: 's1', patient_id: '666716' }, // 非退费
];
const med_emr_info = [
{ emr_sub_id: 'e3', status: 3 }, // 正式
......@@ -40,8 +40,7 @@ const transforms = [
{ kind: 'filter', input: 'patient_settlement', output: '_refund_status4', where: { status: { equals: '4' } } },
{ kind: 'filter', input: 'patient_settlement', output: '_refund_neg3', where: { status: { equals: '3' }, receivable_this: { lt: 0 } } },
{ kind: 'union', inputs: ['_refund_status4', '_refund_neg3'], output: 'refund_full_rows' },
{ kind: 'filter', input: 'patient_settlement_spec', output: '_refund_item_raw', where: { is_refund: { equals: '1' } } },
{ kind: 'lookup', input: '_refund_item_raw', output: 'refund_item_rows', from: 'patient_settlement', left_key: 'settlement_id', right_key: 'uuid', select: { owner_patient_id: 'patient_id' } },
{ kind: 'filter', input: 'patient_settlement_spec', output: 'refund_item_rows', where: { is_refund: { equals: '1' } } },
{ kind: 'filter', input: 'med_emr_info', output: '_emr_official', where: { status: { in: ['3', '4'] } } },
] as unknown as TransformsConfig;
......@@ -65,9 +64,9 @@ describe('FRIDAY 结算/病历 WHERE 迁移(导原表 + PAC 切分)', () => {
expect(pick(out['refund_item_rows'] ?? [], 'id')).toEqual(['sp1']);
});
it('③.1 退费明细身份纠正:spec 本地 id 2 → 从父结算头继承真 patient_id 666716', () => {
it('③.1 退费明细患者 = 宿主反填的真 patient_id(spec.patient_id 直用,不再跨表 lookup)', () => {
const rows = (out['refund_item_rows'] ?? []) as Array<Record<string, unknown>>;
expect(rows.map((r) => r['owner_patient_id'])).toEqual(['666716']);
expect(rows.map((r) => r['patient_id'])).toEqual(['666716']);
});
it('④ 病历:status∈{3,4} 排草稿 e2', () => {
......
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