Commit 1793c1bc by luoqi

Merge 5i5ya complaint ingestion into main

parents 76e7d5bc 3d29ada9
Pipeline #3826 failed in 0 seconds
# 标准反馈快照;有效投诉由标准性质+状态决定,不能整表/按评分猜投诉。
canonical: complaint
emits:
action: complaint_filed
subjectType: complaint
occurredAtField: filedAt
contentVersion: true
primary:
table: complaint_rows
key: id
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
filedAt: complain_date
createdAt: created_gmt_at
updatedAt: snapshot_changed_at
sourceUpdatedAt: updated_gmt_at
number: complain_number
content: complain_content
feedbackKind: complain_concerned
classificationKind: complain_classification
category: complain_concerned
channel: complain_channel
status: complain_type
sourceStatus: complain_type_name
sourceCategory: complain_classification_name
sourceConcern: complain_concerned_name
sourceChannel: complain_channel_name
relatedOrderExternalId: settlement_number
followUps: follow_ups
# 未识别枚举保持未知;旧的“其他投诉”默认分类不强行盖过无不满/表扬。
# 仍保留其原文供复核;明确服务/医疗投诉与表扬冲突时由通用 parser 标记未知。
enum_mapping:
feedbackKind:
'1': complaint
'3': complaint
'4': complaint
'5': complaint
'6': complaint
'8': praise
'9': neutral
_default: unknown
classificationKind:
'1': complaint
'2': complaint
'4': praise
_default: unknown
category:
'1': service
'3': clinical
'4': billing
'5': waiting
'6': environment
_default: unknown
channel:
'1': hotline
'3': third_party
'4': sms_survey
'5': mini_program
'6': sales
_default: unknown
status:
'1': in_progress
'2': open
'4': resolved
'5': draft
_default: unknown
...@@ -100,6 +100,8 @@ sql_source: ...@@ -100,6 +100,8 @@ sql_source:
incremental: incremental:
per_query: per_query:
fact_client_out: { cursor_column: last_visit_time } fact_client_out: { cursor_column: last_visit_time }
# 仅发现变化;明细查询不加游标,保证整单跟进完整。新表首轮补发现历史患者。
complaint_changes: { cursor_column: changed_at, bootstrap_full: true }
# 复杂病例:用 SELECT 里的 changed_at 别名(coalesce(updated,created));CH 支持 WHERE 引用别名。 # 复杂病例:用 SELECT 里的 changed_at 别名(coalesce(updated,created));CH 支持 WHERE 引用别名。
fact_complex_cases_out: { cursor_column: changed_at } fact_complex_cases_out: { cursor_column: changed_at }
fact_appointment_out: { cursor_column: updated_date } fact_appointment_out: { cursor_column: updated_date }
...@@ -133,12 +135,25 @@ sql_source: ...@@ -133,12 +135,25 @@ sql_source:
- fact_settlement_out - fact_settlement_out
- fact_settlement_mode_out - fact_settlement_mode_out
- fact_complex_cases_out - fact_complex_cases_out
- complaint_changes
# SQL 最朴素化 — host(DW)给的数据范围就是 PAC 该消化的范围。 # SQL 最朴素化 — host(DW)给的数据范围就是 PAC 该消化的范围。
# PAC 这边不预设诊所 / brand / 时间过滤,数据来什么就是什么。 # PAC 这边不预设诊所 / brand / 时间过滤,数据来什么就是什么。
# dev 期的 cohort LIMIT 已去除 → 全量;只保留必要业务过滤(退费状态)。 # dev 期的 cohort LIMIT 已去除 → 全量;只保留必要业务过滤(退费状态)。
# cohort 用 (patient_id, brand) 复合键 — patient_id 跨 brand 撞车,需带 brand 才精确。 # cohort 用 (patient_id, brand) 复合键 — patient_id 跨 brand 撞车,需带 brand 才精确。
queries: queries:
# 投诉表混合满意度/表扬/投诉,完整读取变化后的状态,不在 SQL 过滤成仅未完成/负面。
# 字符串源时间均为北京时间;沿用 manifest.timezone 格式化游标,canonical 再归一到 UTC。
# 保留正常查询里的 changed_at 别名,供 cohort UNION 和空转探针共同复用。
complaint_changes: |
SELECT patient_id, brand,
greatest(ifNull(created_gmt_at, ''), ifNull(updated_gmt_at, ''), ifNull(follow_date, '')) AS changed_at
FROM dw_group.fact_complain_out
WHERE patient_id IS NOT NULL AND patient_id > 0 AND notEmpty(ifNull(brand, ''))
# 不配 per_query:被变化带进 cohort 的患者,拉全部投诉与跟进,而不是只有窗口内子行。
fact_complain_out: |
SELECT * FROM dw_group.fact_complain_out
# ── 患者主档 ── # ── 患者主档 ──
# has_active_complex_case:宿主跟进闸(→ canonical hostFollowUpActive → patient_profiles)。 # has_active_complex_case:宿主跟进闸(→ canonical hostFollowUpActive → patient_profiles)。
# DW「潜在治疗池」= FRIDAY 侧「复杂病例」:宿主自己在跟的患者,PAC 不再电话召回。 # DW「潜在治疗池」= FRIDAY 侧「复杂病例」:宿主自己在跟的患者,PAC 不再电话召回。
...@@ -352,6 +367,36 @@ sql_source: ...@@ -352,6 +367,36 @@ sql_source:
# Layer A.5 transforms(SQL 干过的事搬这里,yaml 声明式) # Layer A.5 transforms(SQL 干过的事搬这里,yaml 声明式)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
transforms: transforms:
# 投诉主单+跟进(源主字段冲突走拒绝举证,不随机匹配;空品牌不回落单命名空间)。
- kind: derive
input: fact_complain_out
output: fact_complain_out
fields:
follow_action:
op: recode
from: follow_type
default: unknown
map: { '转交': transferred, '完成': completed, '编辑': edited, '跟进': followed_up, '暂时搁置': paused }
- kind: group_rows
input: fact_complain_out
output: complaint_rows
by: [brand, id]
consistent_fields: [patient_id, organization_id, complain_number, complain_date, created_gmt_at, updated_gmt_at,
complain_content, complain_type, complain_type_name, complain_concerned, complain_concerned_name,
complain_classification, complain_classification_name, complain_channel, complain_channel_name, settlement_number]
max_fields:
snapshot_changed_at: [created_gmt_at, updated_gmt_at, follow_date]
collect:
follow_ups:
present_when: [follow_date, follow_type, follow_content, follow_user_id, follow_user_name]
fields:
occurredAt: follow_date
action: follow_action
sourceAction: follow_type
content: follow_content
actorId: follow_user_id
actorName: follow_user_name
# ── A. EMR 主表 → 只留自由文本(剥离 JSON 字段) ── # ── A. EMR 主表 → 只留自由文本(剥离 JSON 字段) ──
- kind: project - kind: project
input: fact_emr_treatment_out input: fact_emr_treatment_out
...@@ -1004,6 +1049,7 @@ transforms: ...@@ -1004,6 +1049,7 @@ transforms:
# PAC 写的 assembler 配置(每个 canonical resource 一份) # PAC 写的 assembler 配置(每个 canonical resource 一份)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
assemblers: assemblers:
- { file: assemblers/complaint.yaml }
- { file: assemblers/patient.yaml } - { file: assemblers/patient.yaml }
- { file: assemblers/patient_relation.yaml } # 联系人/亲属边(referee → PatientRelation upsert) - { file: assemblers/patient_relation.yaml } # 联系人/亲属边(referee → PatientRelation upsert)
- { file: assemblers/patient_return_visit.yaml } # 诊所回访(展示用,全瑞尔 → PatientReturnVisit upsert) - { file: assemblers/patient_return_visit.yaml } # 诊所回访(展示用,全瑞尔 → PatientReturnVisit upsert)
......
...@@ -188,6 +188,16 @@ export function normalizeCanonical( ...@@ -188,6 +188,16 @@ export function normalizeCanonical(
} }
} }
// 嵌套业务时间也按宿主时区解释,禁止跟进裸时间依赖 Node 进程时区。
for (const { array, field } of meta.datetimeArrayFields ?? []) {
const items = coerceArray(out[array]);
if (items) out[array] = items.map(item => {
if (!item || typeof item !== 'object') return item;
const row = item as Record<string, unknown>;
return typeof row[field] === 'string'
? { ...row, [field]: normalizeDatetime(row[field] as string, ctx.timezone) } : item;
});
}
return out; return out;
} }
......
...@@ -76,7 +76,7 @@ export function resolveOnlyPatientKeys(): OnlyPatientKey[] { ...@@ -76,7 +76,7 @@ export function resolveOnlyPatientKeys(): OnlyPatientKey[] {
/// W4 末:DW 增量配置(per table cursor) /// W4 末:DW 增量配置(per table cursor)
export interface IncrementalConfig { export interface IncrementalConfig {
/// per query 配置:cursor 列名(host 表那边的字段)+ 上次 cursor 值(从 sync_logs 读) /// per query 配置:cursor 列名(host 表那边的字段)+ 上次 cursor 值(从 sync_logs 读)
perQuery: Record<string, { cursorColumn: string; cursorValue: string | null }>; perQuery: Record<string, { cursorColumn: string; cursorValue: string | null; bootstrapFull?: boolean }>;
/// 跑完后返回新的 cursor 值(每张表的 max) /// 跑完后返回新的 cursor 值(每张表的 max)
cursorAdvances?: Record<string, string>; cursorAdvances?: Record<string, string>;
} }
...@@ -223,11 +223,8 @@ export class ClickHouseSourceService { ...@@ -223,11 +223,8 @@ export class ClickHouseSourceService {
// 反向结算 is_refund=1、无就诊记录患者 last_visit_time NULL)→ 真实拉取正确地不拉, // 反向结算 is_refund=1、无就诊记录患者 last_visit_time NULL)→ 真实拉取正确地不拉,
// 探针却数到 → 误报"增量空转"CRITICAL(2026-07-25 实测:settlement 正/退表各 5788 // 探针却数到 → 误报"增量空转"CRITICAL(2026-07-25 实测:settlement 正/退表各 5788
// 完全相等,正是这批全为退费单的铁证)。 // 完全相等,正是这批全为退费单的铁证)。
const clauses = [ // 包裹真实增量查询,保留 changed_at 等 SELECT 派生别名,避免探针直查物理表报未知列。
`${cfg.cursorColumn} > '${cfg.cursorValue.replace(/'/g, "''")}'`, const q = `SELECT count(*) AS c FROM (${this.injectIncrementalCursor(sql, cfg.cursorColumn, cfg.cursorValue)})`;
...this.extractBusinessFilters(sql),
];
const q = `SELECT count(*) AS c FROM ${m[1]} WHERE ${clauses.join(' AND ')}`;
const rs = await client.query({ query: q, format: 'JSONEachRow' }); const rs = await client.query({ query: q, format: 'JSONEachRow' });
const rows = (await rs.json()) as Array<{ c: string | number }>; const rows = (await rs.json()) as Array<{ c: string | number }>;
out[tableName] = Number(rows[0]?.c ?? 0); out[tableName] = Number(rows[0]?.c ?? 0);
...@@ -483,7 +480,7 @@ export class ClickHouseSourceService { ...@@ -483,7 +480,7 @@ export class ClickHouseSourceService {
const incBranches: string[] = []; const incBranches: string[] = [];
if (incremental) { if (incremental) {
for (const [tbl, cfg] of Object.entries(incremental.perQuery)) { for (const [tbl, cfg] of Object.entries(incremental.perQuery)) {
if (!cfg.cursorColumn || !cfg.cursorValue) continue; if (!cfg.cursorColumn || (!cfg.cursorValue && !cfg.bootstrapFull)) continue;
const q = source.queries[tbl]; const q = source.queries[tbl];
const keyCols = tenant_key_column const keyCols = tenant_key_column
? `${patient_key_column}, ${tenant_key_column}` ? `${patient_key_column}, ${tenant_key_column}`
...@@ -505,7 +502,8 @@ export class ClickHouseSourceService { ...@@ -505,7 +502,8 @@ export class ClickHouseSourceService {
} }
if (!branch) { if (!branch) {
const fqn = (q && this.splitSelectFrom(q)?.fromTable) ?? tbl; const fqn = (q && this.splitSelectFrom(q)?.fromTable) ?? tbl;
branch = `SELECT ${keyCols} FROM ${fqn} WHERE ${cfg.cursorColumn} > '${cfg.cursorValue.replace(/'/g, "''")}'`; branch = `SELECT ${keyCols} FROM ${fqn}` + (cfg.cursorValue
? ` WHERE ${cfg.cursorColumn} > '${cfg.cursorValue.replace(/'/g, "''")}'` : '');
} }
incBranches.push(branch); incBranches.push(branch);
} }
......
...@@ -971,6 +971,7 @@ export class ColdImportService { ...@@ -971,6 +971,7 @@ export class ColdImportService {
table, table,
{ {
cursorColumn: cfg.cursor_column, cursorColumn: cfg.cursor_column,
bootstrapFull: cfg.bootstrap_full,
// ⚠️ 必须规范化:DW 的 cursor 列是 String(如 updated_date: Nullable(String), // ⚠️ 必须规范化:DW 的 cursor 列是 String(如 updated_date: Nullable(String),
// 值形如 'YYYY-MM-DD HH:mm:ss')→ WHERE 比较是字典序。游标存的是 run_start ISO // 值形如 'YYYY-MM-DD HH:mm:ss')→ WHERE 比较是字典序。游标存的是 run_start ISO
// ('2026-06-09T03:30:00.012Z'),第 11 字符 'T'(0x54) > ' '(0x20) → 所有真实行 // ('2026-06-09T03:30:00.012Z'),第 11 字符 'T'(0x54) > ' '(0x20) → 所有真实行
......
...@@ -66,6 +66,8 @@ export const IncrementalDeclSchema = z.object({ ...@@ -66,6 +66,8 @@ export const IncrementalDeclSchema = z.object({
z.string(), z.string(),
z.object({ z.object({
cursor_column: z.string().min(1), cursor_column: z.string().min(1),
// 新资源无水位时主动发现历史患者;默认保持旧资源行为。
bootstrap_full: z.boolean().optional(),
}), }),
), ),
}); });
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { import {
ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema,
ClinicalSignalsSchema, ClinicalSignalsSchema,
FactType, FactType,
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
...@@ -445,7 +446,11 @@ const ComplaintRecordContent = z ...@@ -445,7 +446,11 @@ const ComplaintRecordContent = z
.object({ .object({
complaint_external_id: z.string().min(1), complaint_external_id: z.string().min(1),
severity: z.enum(['low', 'medium', 'high']).nullable().optional().default(null), severity: z.enum(['low', 'medium', 'high']).nullable().optional().default(null),
status: z.enum(['filed', 'in_progress', 'resolved']).nullable().optional().default(null), status: ComplaintStatusSchema.nullable().optional().default(null),
feedback_kind: ComplaintFeedbackKindSchema.optional(),
category: ComplaintCategorySchema.optional(),
channel: ComplaintChannelSchema.optional(),
signal_eligible: z.boolean().optional(),
content: nullableString(), content: nullableString(),
}) })
.passthrough(); .passthrough();
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType } from '@pac/types'; import { Action, FactKind, FactStatus, FactType, ComplaintCanonicalSchema } from '@pac/types';
import type { FactDraft, Parser, ParserContext } from './parser.interface'; import type { FactDraft, FactSnapshotScope, Parser, ParserContext } from './parser.interface';
/** /** 投诉标准快照;只负责事实,不设置免打扰、不改变召回规则。 */
* ComplaintParser — `complaint_filed` / `complaint_resolved` 解析器
*
* 产 1 个 `complaint_record`(actual)— 投诉事实。
* 同 subject_id 在 filed → resolved 两次 transaction 间走版本流(supersede)。
*
* 合规重要性:Persona.do_not_contact_status 应消费 active complaint_record 触发 DNC。
*/
@Injectable() @Injectable()
export class ComplaintParser implements Parser { export class ComplaintParser implements Parser {
readonly name = Action.COMPLAINT_FILED; readonly name = Action.COMPLAINT_FILED;
private readonly logger = new Logger(ComplaintParser.name);
parse(ctx: ParserContext): FactDraft[] { snapshotScope(ctx: ParserContext): FactSnapshotScope {
const c = ctx.canonicalRow; const c = ComplaintCanonicalSchema.parse(ctx.canonicalRow);
const externalId = String(c.externalId ?? ctx.transaction.subjectId).trim(); return { anchorSubjectId: `${FactType.COMPLAINT_RECORD}:${c.externalId}`,
if (!externalId) { memberType: FactType.COMPLAINT_RECORD, contentKey: 'complaint_external_id',
this.logger.warn(`complaint parser: missing externalId; tx=${ctx.transaction.id};skip`); externalId: c.externalId, replace: false };
return [];
} }
const filedAt = c.filedAt ? new Date(c.filedAt as string) : null; parse(ctx: ParserContext): FactDraft[] {
const resolved = ctx.transaction.action === Action.COMPLAINT_RESOLVED; const c = ComplaintCanonicalSchema.parse(ctx.canonicalRow);
const resolvedAt = c.resolvedAt ? new Date(c.resolvedAt as string) : null; const classificationConflict = c.classificationKind !== 'unknown' && c.feedbackKind !== 'unknown'
const hostStatus = String(c.status ?? (resolved ? 'resolved' : 'open')).toLowerCase(); && c.classificationKind !== c.feedbackKind;
const factStatus = hostStatus === 'resolved' ? FactStatus.FULFILLED : FactStatus.ACTIVE; const kind = classificationConflict ? 'unknown' : c.feedbackKind;
const eligible = kind === 'complaint' && ['open', 'in_progress', 'resolved'].includes(c.status);
return [ const followUps = c.followUps.map(f => ({ ...f,
{ occurredAt: f.occurredAt ? new Date(f.occurredAt).toISOString() : null,
subjectId: `${FactType.COMPLAINT_RECORD}:${externalId}`, })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
kind: FactKind.ACTUAL, const completionTimes = followUps.filter(f => f.action === 'completed' && f.occurredAt)
type: FactType.COMPLAINT_RECORD, .map(f => f.occurredAt!);
status: factStatus, if (c.resolvedAt) completionTimes.push(new Date(c.resolvedAt).toISOString());
clinicId: ctx.transaction.clinicId, const resolvedAt = c.status === 'resolved' ? completionTimes.sort().at(-1) ?? null : null;
occurredAt: filedAt, // 非投诉/未知/草稿保留无效快照:新分类能替换旧有效投诉,旧报文重试不能复活它。
title: `投诉 ${externalId}${resolved ? '(已结案)' : ''}`, // ⛔ 不把这些快照当有效投诉统计;消费时检查 signal_eligible 与最新版本。
summary: (c.content as string | undefined) ?? null, return [{
subjectId: `${FactType.COMPLAINT_RECORD}:${c.externalId}`,
kind: FactKind.ACTUAL, type: FactType.COMPLAINT_RECORD,
status: !eligible ? FactStatus.INVALIDATED
: c.status === 'resolved' ? FactStatus.FULFILLED : FactStatus.ACTIVE,
clinicId: c.clinicId, occurredAt: new Date(c.filedAt),
title: eligible ? `投诉${c.status === 'resolved' ? '(已完成)' : ''}` : '患者反馈(非有效投诉)',
summary: c.content ?? null,
content: { content: {
complaint_external_id: externalId, complaint_external_id: c.externalId, number: c.number ?? null,
host_status: hostStatus, feedback_kind: kind, status: c.status, category: c.category, channel: c.channel,
category: c.category ?? null, signal_eligible: eligible,
content: c.content ?? null, review_reason: classificationConflict ? 'classification_conflict'
: kind === 'unknown' ? 'unknown_feedback_kind' : c.status === 'unknown' ? 'unknown_status' : null,
content: c.content ?? null, severity: null,
related_treatment_subject_id: c.relatedTreatmentSubjectId ?? null, related_treatment_subject_id: c.relatedTreatmentSubjectId ?? null,
resolved_at: resolvedAt?.toISOString() ?? null, related_order_external_id: c.relatedOrderExternalId ?? null,
}, resolved_at: resolvedAt, follow_ups: followUps,
source_updated_at: c.sourceUpdatedAt ? new Date(c.sourceUpdatedAt).toISOString() : null,
host_status: c.sourceStatus ?? c.status,
source_category: c.sourceCategory ?? null, source_concern: c.sourceConcern ?? null,
source_channel: c.sourceChannel ?? null,
}, },
]; }];
} }
} }
...@@ -122,8 +122,8 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown { ...@@ -122,8 +122,8 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
} }
case 'recode': { case 'recode': {
const v = row[expr.from]; const v = row[expr.from];
if (typeof v !== 'string') return v ?? null; if (typeof v !== 'string') return expr.default ?? v ?? null;
return Object.prototype.hasOwnProperty.call(expr.map, v) ? expr.map[v] : v; return Object.prototype.hasOwnProperty.call(expr.map, v) ? expr.map[v] : (expr.default ?? v);
} }
} }
} }
......
import { createHash } from 'node:crypto';
import type { GroupRowsOp } from '../transforms.schema';
import { SOURCE_ROW_KEY, isEmptyText, type Row } from '../row';
export const GROUP_ERROR = '__group_error';
const ENVELOPE = '__pac_group_rows';
const SNAPSHOT = '__group_snapshot';
/** 原文是多行快照时,离线重放恢复原输入表,不把一条跟进冒充整单。 */
export function unpackGroupRows(table: string, row: Row): Row[] {
const envelope = row[ENVELOPE] as { table?: string; rows?: Row[] } | undefined;
if (envelope?.table !== table || !Array.isArray(envelope.rows)) return [row];
// 每个封套只算一次短指纹,避免把完整原文重复拼进每条跟进的分组键。
const snapshot = createHash('sha256').update(stable(envelope)).digest('hex');
return envelope.rows.map(raw => ({ ...raw, [SOURCE_ROW_KEY]: raw, [SNAPSHOT]: snapshot }));
}
function stable(value: unknown): string {
return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v);
}
function uniqueSorted(rows: Row[]): Row[] {
return [...new Map(rows.map(r => [stable(r), r])).entries()]
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, r]) => r);
}
/** 仅聚合配置指定的字段;身份缺失/主体矛盾送 assembler 的拒绝举证。 */
export function runGroupRows(op: GroupRowsOp, input: Row[]): Row[] {
const groups = new Map<string, Row[]>();
input.forEach((row, i) => {
const keys = op.by.map(k => row[k]);
const key = keys.some(isEmptyText) ? `invalid:${i}` : stable([row[SNAPSHOT] ?? null, ...keys.map(String)]);
const rows = groups.get(key) ?? [];
rows.push(row);
groups.set(key, rows);
});
return [...groups.values()].map(rows => {
const raw = uniqueSorted(rows.map(row => (row[SOURCE_ROW_KEY] ?? row) as Row));
const first = rows[0]!;
const out: Row = Object.fromEntries([...new Set([...op.by, ...op.consistent_fields])]
.map(k => [k, first[k] ?? null]));
const missing = op.by.filter(k => isEmptyText(first[k]));
const conflicts = op.consistent_fields.filter(k => new Set(rows.map(row => stable(row[k] ?? null))).size > 1);
if (missing.length || conflicts.length) {
out[GROUP_ERROR] = `group_rows: missing keys [${missing.join(',')}]; conflicting fields [${conflicts.join(',')}]`;
}
for (const [target, fields] of Object.entries(op.max_fields)) {
const values = rows.flatMap(row => fields.map(f => row[f])).filter(v => !isEmptyText(v));
// 此算子只比较同格式源时间字符串,不猜测时区/混合格式。
if (values.some(v => typeof v !== 'string' || !/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d(?:\.\d+)?$/.test(v))) {
out[GROUP_ERROR] = `group_rows: invalid comparable datetime [${target}]`;
}
out[target] = values.length ? values.map(String).sort().at(-1)! : null;
}
for (const [target, collect] of Object.entries(op.collect)) {
out[target] = uniqueSorted(rows.filter(row => collect.present_when.some(f => !isEmptyText(row[f])))
.map(row => Object.fromEntries(Object.entries(collect.fields).map(([k, f]) => [k, row[f] ?? null]))));
}
// 顶层保留原始主体字段供租户/命名空间解析,完整原行放有标识的证据封套。
out[SOURCE_ROW_KEY] = { ...raw[0], [ENVELOPE]: { table: op.input, rows: raw } };
return out;
});
}
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { runGroupRows, unpackGroupRows } from './operators/group-rows.op';
import { runProject } from './operators/project.op'; import { runProject } from './operators/project.op';
import { runSplitJsonArray } from './operators/split-json-array.op'; import { runSplitJsonArray } from './operators/split-json-array.op';
import { runDerive } from './operators/derive.op'; import { runDerive } from './operators/derive.op';
...@@ -36,7 +37,7 @@ export class TransformEngine { ...@@ -36,7 +37,7 @@ export class TransformEngine {
const tables: TableMap = {}; const tables: TableMap = {};
// 入口标记:每个源行带上自身的 __source_row 引用(深拷贝避免后续 operator 修改污染) // 入口标记:每个源行带上自身的 __source_row 引用(深拷贝避免后续 operator 修改污染)
for (const [name, rows] of Object.entries(opts.tables)) { for (const [name, rows] of Object.entries(opts.tables)) {
tables[name] = rows.map((row) => { tables[name] = rows.flatMap(row => unpackGroupRows(name, row)).map((row) => {
// 已有 __source_row 的不覆盖(允许 caller 提前注入,比如 Push 路径) // 已有 __source_row 的不覆盖(允许 caller 提前注入,比如 Push 路径)
if (SOURCE_ROW_KEY in row) return row; if (SOURCE_ROW_KEY in row) return row;
return { ...row, [SOURCE_ROW_KEY]: row }; return { ...row, [SOURCE_ROW_KEY]: row };
...@@ -63,6 +64,12 @@ export class TransformEngine { ...@@ -63,6 +64,12 @@ export class TransformEngine {
} }
switch (op.kind) { switch (op.kind) {
case 'group_rows': {
const out = runGroupRows(op, input);
tables[op.output] = out;
this.logger.log(`[transform/group_rows] "${inputName}"(${input.length}) → "${op.output}"(${out.length})`);
break;
}
case 'project': { case 'project': {
const out = runProject(op, input); const out = runProject(op, input);
tables[op.output] = out; tables[op.output] = out;
......
...@@ -190,6 +190,7 @@ export const DeriveCoalesceSchema = z.object({ ...@@ -190,6 +190,7 @@ export const DeriveCoalesceSchema = z.object({
/// { from: std_code, map: { 'K07.303': 'K03' } } → K07.303 改 K03,再交 substring 截短;其余码不变。 /// { from: std_code, map: { 'K07.303': 'K03' } } → K07.303 改 K03,再交 substring 截短;其余码不变。
export const DeriveRecodeSchema = z.object({ export const DeriveRecodeSchema = z.object({
op: z.literal('recode'), op: z.literal('recode'),
default: z.string().optional(),
from: z.string().min(1), from: z.string().min(1),
map: z.record(z.string(), z.string()).describe('原值 → 目标值;未命中则透传原值'), map: z.record(z.string(), z.string()).describe('原值 → 目标值;未命中则透传原值'),
}); });
...@@ -422,6 +423,22 @@ export type LookupOp = z.infer<typeof LookupOpSchema>; ...@@ -422,6 +423,22 @@ export type LookupOp = z.infer<typeof LookupOpSchema>;
// operator union + transforms[] 顶层 // operator union + transforms[] 顶层
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
/** 一对多展开表还原稳定主体快照;字段/身份均由宿主配置声明。 */
export const GroupRowsOpSchema = z.object({
kind: z.literal('group_rows'),
input: tableNameSchema,
output: tableNameSchema,
by: z.array(z.string().min(1)).min(1),
consistent_fields: z.array(z.string().min(1)).min(1),
// 同格式、同时区的源时间字符串求最大;时区归一在 canonical 入口完成。
max_fields: z.record(z.string(), z.array(z.string().min(1)).min(1)).default({}),
collect: z.record(z.string(), z.object({
fields: z.record(z.string(), z.string()),
present_when: z.array(z.string().min(1)).min(1),
})).default({}),
});
export type GroupRowsOp = z.infer<typeof GroupRowsOpSchema>;
export const TransformOpSchema = z.discriminatedUnion('kind', [ export const TransformOpSchema = z.discriminatedUnion('kind', [
ProjectOpSchema, ProjectOpSchema,
SplitJsonArrayOpSchema, SplitJsonArrayOpSchema,
...@@ -431,6 +448,7 @@ export const TransformOpSchema = z.discriminatedUnion('kind', [ ...@@ -431,6 +448,7 @@ export const TransformOpSchema = z.discriminatedUnion('kind', [
FilterOpSchema, FilterOpSchema,
UnionOpSchema, UnionOpSchema,
LookupOpSchema, LookupOpSchema,
GroupRowsOpSchema,
]); ]);
export type TransformOp = z.infer<typeof TransformOpSchema>; export type TransformOp = z.infer<typeof TransformOpSchema>;
......
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
PACImageModalities, PACImageModalities,
FDI_TOOTH_POSITIONS, FDI_TOOTH_POSITIONS,
ImageAnalysisCanonicalSchema, ImageAnalysisCanonicalSchema,
ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema,
} from '@pac/types'; } from '@pac/types';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas'; import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import { import {
...@@ -194,6 +195,19 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co ...@@ -194,6 +195,19 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
}); });
continue; continue;
} }
// 同名字段按资源契约校验:投诉的 category/status 不是治疗类别/治疗状态。
if (config.canonical === 'complaint') {
const complaintEnums: Record<string, readonly string[]> = {
status: ComplaintStatusSchema.options, category: ComplaintCategorySchema.options,
channel: ComplaintChannelSchema.options, feedbackKind: ComplaintFeedbackKindSchema.options,
classificationKind: ComplaintFeedbackKindSchema.options,
};
const options = complaintEnums[canonicalField];
if (options) test(`${host}/${file}:complaint.${canonicalField} 及兜底值符合标准枚举`, () => {
for (const target of Object.values(valueMap)) expect(options).toContain(target);
});
continue;
}
const allowed = canonicalField === 'status' && config.canonical === 'image_analysis' const allowed = canonicalField === 'status' && config.canonical === 'image_analysis'
? new Set<string>(ImageAnalysisCanonicalSchema.shape.status.options) ? new Set<string>(ImageAnalysisCanonicalSchema.shape.status.options)
: closedSets[canonicalField]; : closedSets[canonicalField];
......
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { Logger } from '@nestjs/common';
import { createClient } from '@clickhouse/client';
import { ColdImportManifestSchema } from '../src/modules/sync/cold-import/manifest.schema';
import { ClickHouseSourceService, type IncrementalConfig } from '../src/modules/sync/cold-import/clickhouse-source.service';
jest.mock('@clickhouse/client', () => ({ createClient: jest.fn() }));
const manifest = ColdImportManifestSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/jvs-dw/manifest.yaml'), 'utf8')));
const source = { ...manifest.sql_source!, connection: { ...manifest.sql_source!.connection, password_env: 'PAC_TEST_COMPLAINT_CH_PASSWORD' } };
beforeAll(() => { process.env.PAC_TEST_COMPLAINT_CH_PASSWORD = 'mock-only'; });
afterAll(() => { delete process.env.PAC_TEST_COMPLAINT_CH_PASSWORD; });
const svc = new ClickHouseSourceService();
let queries: string[] = [];
const cursor = '2026-10-16 22:15:00'; // 对应 UTC 2026-10-16T14:15:00Z,调用方已按源时区回转
beforeEach(() => {
Logger.overrideLogger(false); queries = [];
(createClient as jest.Mock).mockReturnValue({
query: jest.fn(async ({ query }: { query: string }) => {
queries.push(query);
return { json: async () => query.startsWith('SELECT count(*)') ? [{ c: '3' }] : [] };
}), close: jest.fn(async () => undefined),
});
});
function increment(value: string | null): IncrementalConfig {
return { perQuery: { complaint_changes: { cursorColumn: 'changed_at', cursorValue: value, bootstrapFull: true } } };
}
test('已有宿主新增投诉资源:无游标时扫描历史变化患者,其他资源无游标不受影响', async () => {
await svc.listPatientPairs(source, increment(null));
expect(queries[0]).toContain('fact_complain_out');
expect(queries[0]).toContain('patient_id, brand');
expect(queries[0]).not.toContain('changed_at >');
expect(queries[0]).toContain('patient_id > 0');
const noBootstrap = increment(null); noBootstrap.perQuery.complaint_changes!.bootstrapFull = false;
queries = []; await svc.listPatientPairs(source, noBootstrap);
expect(queries[0]).not.toContain('fact_complain_out');
});
test('日常增量同时捕捉创建/主单更新/跟进更新,保留源时区游标', async () => {
await svc.listPatientPairs(source, increment(cursor));
expect(queries[0]).toContain("greatest(ifNull(created_gmt_at, ''), ifNull(updated_gmt_at, ''), ifNull(follow_date, '')) AS changed_at");
expect(queries[0]).toContain(`changed_at > '${cursor}'`);
expect(queries[0]).toContain('notEmpty');
});
test('发现变化与完整读取分开:跟进快照不被增量窗口切断;按配置品牌+患者圈定', async () => {
const subset = { ...source, queries: { complaint_changes: source.queries.complaint_changes!, fact_complain_out: source.queries.fact_complain_out! } };
await svc.loadTablesForCohort(subset, [{ key: '123', tenant: '瑞尔' }], increment(cursor));
const full = queries.find(q => q.includes('SELECT *'))!;
expect(full).toContain("(patient_id, brand) IN (('123', '瑞尔'))");
expect(full).not.toContain('changed_at >');
expect(full).not.toContain('follow_date >');
expect(queries.find(q => q.includes('AS changed_at'))).toContain(`changed_at > '${cursor}'`);
});
test('空转探针复用派生游标查询,不把 changed_at 当物理列', async () => {
expect(await svc.probeNewRowCounts(source, increment(cursor).perQuery)).toEqual({ complaint_changes: 3 });
expect(queries[0]).toMatch(/^SELECT count\(\*\) AS c FROM \(SELECT/);
expect(queries[0]).toContain('AS changed_at');
expect(queries[0]).toContain('patient_id > 0');
});
(process.env.PAC_COMPLAINT_SQL_EVIDENCE && process.env.PAC_COMPLAINT_REAL_FIXTURE ? test : test.skip)(
'导出实际生成SQL供独立只读数仓验证(仅本地私有路径)', async () => {
const raw = JSON.parse(readFileSync(process.env.PAC_COMPLAINT_REAL_FIXTURE!, 'utf8')) as Record<string, unknown>[];
const valid = raw.filter(r => r.patient_id && r.follow_date && String(r.follow_date) > String(r.updated_gmt_at ?? ''));
const chosen = valid[0]!;
const subset = { ...source, queries: { complaint_changes: source.queries.complaint_changes!, fact_complain_out: source.queries.fact_complain_out! } };
const mark = String(chosen.updated_gmt_at ?? chosen.created_gmt_at);
await svc.listPatientPairs(subset, increment(null));
const bootstrap = queries[0]; queries = [];
await svc.loadTablesForCohort(subset, [{ key: String(chosen.patient_id), tenant: String(chosen.brand) }], increment(mark));
const cohort = queries.slice(); queries = [];
await svc.probeNewRowCounts(subset, increment(mark).perQuery);
writeFileSync(process.env.PAC_COMPLAINT_SQL_EVIDENCE!, JSON.stringify({ bootstrap, cohort, probe: queries[0],
chosen: { id: chosen.id, brand: chosen.brand, patient_id: chosen.patient_id }, cursor: mark }), { mode: 0o600 });
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { Logger } from '@nestjs/common';
import { ComplaintCanonicalSchema, FactType } from '@pac/types';
import { TransformEngine } from '../src/modules/sync/transforms/transform-engine';
import { ColdImportManifestSchema } from '../src/modules/sync/cold-import/manifest.schema';
import { AssemblerEngine } from '../src/modules/sync/assembler/assembler-engine';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
import { buildSourceUnitResolver, buildTenantResolver } from '../src/modules/sync/cold-import/tenant-resolver';
import { ComplaintParser } from '../src/modules/sync/pipeline/parsers/complaint.parser';
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import type { Row } from '../src/modules/sync/transforms/row';
export const manifest = ColdImportManifestSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/jvs-dw/manifest.yaml'), 'utf8')));
export const config = AssemblerConfigSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/jvs-dw/assemblers/complaint.yaml'), 'utf8')));
const transforms = manifest.transforms!.filter(t => 'input' in t && t.input === 'fact_complain_out');
const namespace = buildSourceUnitResolver(manifest);
export function assemble(raw: Row[]) {
const tables = new TransformEngine().run({ tables: { fact_complain_out: raw }, transforms });
return new AssemblerEngine().assemble({ tables, config, normalize: { timezone: manifest.timezone, amountUnit: 'yuan' }, hostId: 'test-host' });
}
export function parse(row: ReturnType<typeof assemble>['rows'][number]) {
const canonicalRow = row.canonical as Record<string, unknown>;
const tx = new TransactionSynthesizer().synthesize({ rawRow: row.rawSource, canonicalRow, emits: config.emits,
resource: 'complaint', hostId: 'test-host', tenantId: buildTenantResolver(manifest).resolve(row.rawSource)!,
sourceUnit: namespace.resolve(row.rawSource), sourceContext: 'cold-import', patientIdResolver: id => `patient:${id}` })!;
const context = { canonicalRow, transaction: { id: 'tx-test', hostId: tx.hostId, tenantId: tx.tenantId,
patientId: tx.patientId!, action: 'complaint_filed' as const, subjectType: 'complaint',
subjectId: tx.subjectId, clinicId: tx.clinicId, occurredAt: new Date(tx.occurredAt!) } };
const draft = new ComplaintParser().parse(context)[0]!;
expect(FactContentSchemas[FactType.COMPLAINT_RECORD].safeParse(draft.content).success).toBe(true);
return { tx, draft, canonicalRow, context };
}
export const rawRow = (extra: Row = {}): Row => ({ id: 19, brand: '瑞尔', patient_id: 77, organization_id: 'original-clinic',
complain_number: 'C19', complain_date: '2026-10-17 00:15:00.000', created_gmt_at: '2026-10-17 00:16:00.000',
updated_gmt_at: null, complain_type: 1, complain_type_name: '处理中', complain_concerned: 1,
complain_concerned_name: '服务流程', complain_classification: null, complain_classification_name: null,
complain_channel: 4, complain_channel_name: '满意度短信', complain_content: '验证用反馈', settlement_number: null,
follow_date: '2026-10-17 10:30:00.000', follow_type: '跟进', follow_content: '已联系', follow_user_id: 'u1', follow_user_name: '测试操作人', ...extra });
beforeAll(() => Logger.overrideLogger(false));
test('源时区归一:北京跨日转UTC,主单/版本/跟进同口径', () => {
const result = assemble([rawRow()]); expect(result.errors).toEqual([]);
const { tx, draft, canonicalRow } = parse(result.rows[0]!);
expect(tx.occurredAt).toEqual(new Date('2026-10-16T16:15:00.000Z'));
expect(new Date(canonicalRow.updatedAt as string).toISOString()).toBe('2026-10-17T02:30:00.000Z');
expect((draft.content as any).follow_ups[0].occurredAt).toBe('2026-10-17T02:30:00.000Z');
expect(draft.clinicId).toBe('original-clinic');
expect(draft.status).toBe('active');
});
test('品牌由既有命名空间解析;同投诉ID/患者ID不跨品牌合并', () => {
const result = assemble([rawRow(), rawRow({ brand: '瑞泰' })]);
expect(result.rows).toHaveLength(2);
const parsed = result.rows.map(parse);
expect(new Set(parsed.map(x => x.tx.sourceEventId)).size).toBe(2);
expect(result.rows.map(r => namespace.resolve(r.rawSource)).sort()).toEqual(['瑞尔', '瑞泰']);
expect(buildSourceUnitResolver({ ...manifest, identity_namespace_field: 'issuer' }).resolve({ issuer: 'scope' })).toBe('scope');
});
test('完整跟进稳定聚合:乱序/重复输入不改变幂等键;可离线重放', () => {
const a = rawRow(), b = rawRow({ follow_date: '2026-10-18 09:00:00.000', follow_type: '完成' });
const original = [a, b]; const r = assemble(original); expect(r.rows).toHaveLength(1);
const first = parse(r.rows[0]!);
expect((first.canonicalRow.followUps as unknown[])).toHaveLength(2);
expect(parse(assemble([b, a, a]).rows[0]!).tx.sourceEventId).toBe(first.tx.sourceEventId);
expect(parse(assemble([r.rows[0]!.rawSource]).rows[0]!).tx.sourceEventId).toBe(first.tx.sourceEventId);
expect(original[0]).not.toHaveProperty('follow_action');
});
test('离线多个历史快照不混拼成一个冲突主体', () => {
const before = assemble([rawRow()]).rows[0]!;
const after = assemble([rawRow({ complain_type: 4, updated_gmt_at: '2026-10-18 09:00:00.000' })]).rows[0]!;
const replay = assemble([before.rawSource, after.rawSource]);
expect(replay.errors).toEqual([]); expect(replay.rows).toHaveLength(2);
});
test.each([{ patient_id: null }, { brand: '' }, { organization_id: '' }, { complain_date: 'bad-date' }])('缺身份/非法业务时间拒绝:%j', change => {
const r = assemble([rawRow(change)]); expect(r.rows).toHaveLength(0); expect(r.stats.failed).toBe(1);
});
test('主体冲突拒绝整单且不污染其他单', () => {
const r = assemble([rawRow(), rawRow({ patient_id: 88 }), rawRow({ id: 20 })]);
expect(r.stats.failed).toBe(1); expect(r.rows).toHaveLength(1);
expect(r.errors[0]!.reason).toContain('patient_id');
});
test.each([8,9,null,100])('非负面/未分类不形成有效投诉 %s', concern => {
const { draft } = parse(assemble([rawRow({ complain_concerned: concern })]).rows[0]!);
expect(draft.status).toBe('invalidated'); expect((draft.content as any).signal_eligible).toBe(false);
});
test.each([5,999,null])('草稿/未知状态不默认open %s', state => {
expect(parse(assemble([rawRow({ complain_type: state })]).rows[0]!).draft.status).toBe('invalidated');
});
test('明确相互矛盾的分类进入待确认;默认其他分类不覆盖实际关注点', () => {
const conflict = parse(assemble([rawRow({ complain_classification: 4 })]).rows[0]!).draft;
expect((conflict.content as any).review_reason).toBe('classification_conflict');
expect(conflict.status).toBe('invalidated');
expect(parse(assemble([rawRow({ complain_classification: 3, complain_concerned: 9 })]).rows[0]!).draft.status).toBe('invalidated');
});
test('结案用完成跟进的UTC时间,无记录不伪造;重新处理清除当前结案时间', () => {
const closed = rawRow({ complain_type: 4, follow_type: '完成' });
expect((parse(assemble([closed]).rows[0]!).draft.content as any).resolved_at).toBe('2026-10-17T02:30:00.000Z');
expect((parse(assemble([rawRow({ complain_type: 4 })]).rows[0]!).draft.content as any).resolved_at).toBeNull();
expect((parse(assemble([rawRow({ follow_type: '完成' })]).rows[0]!).draft.content as any).resolved_at).toBeNull();
});
test('跟进未知动作有原文;时间不合法不能入库', () => {
const { draft } = parse(assemble([rawRow({ follow_type: '新动作' })]).rows[0]!);
expect((draft.content as any).follow_ups[0]).toMatchObject({ action: 'unknown', sourceAction: '新动作' });
expect(assemble([rawRow({ follow_date: '2026-99-99 00:00:00.000' })]).stats.failed).toBe(1);
});
test('标准契约缺状态/性质时保守未知', () => {
const c = ComplaintCanonicalSchema.parse({ externalId: '1', patientExternalId: '1', clinicId: 'c', filedAt: '2026-10-18T00:00:00Z' });
expect(c.status).toBe('unknown'); expect(c.feedbackKind).toBe('unknown');
});
(process.env.PAC_COMPLAINT_REAL_FIXTURE ? test : test.skip)('只读源真实样本:全链路标准化与重复重放', () => {
const raw = JSON.parse(readFileSync(process.env.PAC_COMPLAINT_REAL_FIXTURE!, 'utf8')) as Row[];
const r = assemble(raw);
const groups = new Map<string, Row[]>();
raw.forEach(row => { const k = `${row.brand}:${row.id}`; groups.set(k, [...(groups.get(k) ?? []), row]); });
const bad = [...groups.values()].filter(rows => !rows[0]!.patient_id).length;
expect(r.rows.length + r.stats.failed).toBe(groups.size);
expect(r.stats.failed).toBe(bad);
const repeated = assemble(raw.slice().reverse());
const keys = r.rows.map(row => parse(row).tx.sourceEventId).sort();
expect(repeated.rows.map(row => parse(row).tx.sourceEventId).sort()).toEqual(keys);
const statuses: Record<string, number> = {};
for (const row of r.rows) {
const { draft } = parse(row); statuses[draft.status!] = (statuses[draft.status!] ?? 0) + 1;
}
console.info(JSON.stringify({ realRows: raw.length, subjects: groups.size, accepted: r.rows.length, rejected: bad, statuses }));
});
// 显式启用的真实 PostgreSQL 验证:仅准许独立本地库,绝不连接服务使用的数据库。
(process.env.PAC_COMPLAINT_LOCAL_DB_ENV && process.env.PAC_COMPLAINT_REAL_FIXTURE ? test : test.skip)(
'真实源样本落本地库:幂等、品牌隔离、同时间修订、旧快照和身份冲突保护', async () => {
const { PrismaClient } = await import('@prisma/client');
const { FactWriter } = await import('../src/modules/sync/pipeline/fact-writer.service');
const { ParserPipeline } = await import('../src/modules/sync/pipeline/parser-pipeline.service');
const { ClinicalSignalService } = await import('../src/modules/clinical-signals/clinical-signal.service');
const { Action } = await import('@pac/types');
const env = JSON.parse(readFileSync(process.env.PAC_COMPLAINT_LOCAL_DB_ENV!, 'utf8'));
const url = new URL(env.DATABASE_URL);
if (url.hostname !== '127.0.0.1' || url.port !== '5532' || url.pathname !== '/pac_complaint_verify_20261018') {
throw new Error('拒绝连接非独立本地验收库');
}
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
const host = await db.host.create({ data: { name: `complaint-verification-${Date.now()}`,
appId: `local-complaint-${Date.now()}`, appSecretHash: 'local-test-only' } });
try {
const raw = JSON.parse(readFileSync(process.env.PAC_COMPLAINT_REAL_FIXTURE!, 'utf8')) as Row[];
const rows = assemble(raw).rows.map(row => ({ ...row, canonical: ComplaintCanonicalSchema.parse(row.canonical) }));
const patients = new Map<string, string>();
for (const row of rows) {
const c = row.canonical as Record<string, unknown>;
const sourceUnit = namespace.resolve(row.rawSource);
const key = `${sourceUnit}:${c.patientExternalId}`;
if (!patients.has(key)) {
const patient = await db.patient.create({ data: { hostId: host.id, tenantId: manifest.tenant_id!,
sourceUnit, externalId: String(c.patientExternalId) } });
patients.set(key, patient.id);
}
}
const signals = new ClinicalSignalService(); signals.onModuleInit();
const parser = new ComplaintParser();
const prisma = db as unknown as import('../src/prisma/prisma.service').PrismaService;
const registry = { get: (action: string) => action === Action.COMPLAINT_FILED ? parser : undefined } as unknown as import('../src/modules/sync/pipeline/parsers/parser.registry').ParserRegistry;
const pipeline = new ParserPipeline(registry, new FactWriter(prisma), prisma, signals);
const ingest = async (row: ReturnType<typeof assemble>['rows'][number]) => {
const c = row.canonical as Record<string, unknown>, sourceUnit = namespace.resolve(row.rawSource);
const data = new TransactionSynthesizer().synthesize({ rawRow: row.rawSource, canonicalRow: c,
emits: config.emits, resource: 'complaint', hostId: host.id, tenantId: manifest.tenant_id!, sourceUnit,
sourceContext: 'cold-import', patientIdResolver: id => patients.get(`${sourceUnit}:${id}`) })!;
let transaction = await db.patientTransaction.findFirst({ where: { hostId: host.id, sourceEventId: data.sourceEventId } });
if (!transaction) transaction = await db.patientTransaction.create({ data });
const metrics = await pipeline.runForTransaction({ transaction: { ...transaction, action: Action.COMPLAINT_FILED }, canonicalRow: c });
return { transaction, metrics };
};
for (const row of rows) expect((await ingest(row)).metrics.factsFailed).toBe(0);
const initialFacts = await db.patientFact.findMany({ where: { hostId: host.id } });
expect(initialFacts).toHaveLength(65);
expect(initialFacts.filter(f => f.status === 'active')).toHaveLength(5);
expect(initialFacts.filter(f => f.status === 'fulfilled')).toHaveLength(16);
expect(initialFacts.filter(f => f.status === 'invalidated')).toHaveLength(44);
for (const row of rows.slice().reverse()) {
const result = await ingest(row);
expect(result.metrics.factsCreated + result.metrics.factsSuperseded + result.metrics.factsFailed).toBe(0);
}
expect(await db.patientFact.count({ where: { hostId: host.id } })).toBe(65);
expect(await db.patientTransaction.count({ where: { hostId: host.id } })).toBe(65);
for (const fact of initialFacts) {
const row = rows.find(r => namespace.resolve(r.rawSource) === fact.sourceUnit
&& `complaint_record:${r.canonical.externalId}` === fact.subjectId)!;
expect(fact.occurredAt!.toISOString()).toBe(new Date(String(row.canonical.filedAt)).toISOString());
expect(fact.sourceUpdatedAt!.toISOString()).toBe(new Date(String(row.canonical.updatedAt)).toISOString());
}
// 同一编号跨品牌各保留自己的版本,不能互相去重。
expect(new Set(initialFacts.map(f => f.subjectId)).size).toBeLessThan(initialFacts.length);
const active = rows.find(r => parse(r).draft.status === 'active')!;
const originalRaw = raw.filter(r => String(r.id) === active.canonical.externalId && r.brand === active.rawSource.brand);
// 模拟源端修改分类却不改更新时间;内容版本键+留账序号仍能撤销,旧报文不能复活。
const neutral = assemble(originalRaw.map(r => ({ ...r, complain_concerned: 9, complain_concerned_name: '无不满',
complain_classification: null, complain_classification_name: null }))).rows[0]!;
expect((await ingest(neutral)).metrics.factsSuperseded).toBe(1);
expect((await ingest(active)).metrics.factsStaleSkipped).toBe(1);
const latest = await db.patientFact.findFirst({ where: { hostId: host.id,
sourceUnit: namespace.resolve(active.rawSource), subjectId: `complaint_record:${active.canonical.externalId}` },
orderBy: { version: 'desc' } });
expect(latest?.status).toBe('invalidated');
expect((latest?.content as Record<string, unknown>).signal_eligible).toBe(false);
// 实际冷导入使用批量管线;从已留原文重建多历史版本,旧快照即使排后面也不能覆盖新快照。
const stored = await db.patientTransaction.findMany({ where: { hostId: host.id }, orderBy: { eventSeq: 'desc' } });
const replayRows = assemble(stored.map(t => t.rawPayload as Row)).rows;
const replayItems = replayRows.map(row => {
const c = row.canonical as Record<string, unknown>, sourceUnit = namespace.resolve(row.rawSource);
const data = new TransactionSynthesizer().synthesize({ rawRow: row.rawSource, canonicalRow: c,
emits: config.emits, resource: 'complaint', hostId: host.id, tenantId: manifest.tenant_id!, sourceUnit,
sourceContext: 'cold-import', patientIdResolver: id => patients.get(`${sourceUnit}:${id}`) })!;
const tx = stored.find(t => t.sourceEventId === data.sourceEventId)!;
expect(tx).toBeDefined();
return { transaction: { ...tx, action: Action.COMPLAINT_FILED }, canonicalRow: c };
});
const batch = await pipeline.runForBatch(replayItems);
expect(batch.factsFailed + batch.factsCreated + batch.factsSuperseded).toBe(0);
expect(await db.patientFact.count({ where: { hostId: host.id } })).toBe(66);
// 归属变化不能因为同号自动搬给另一患者;保留拒绝证据供源端核对。
const another = rows.find(r => r.canonical.patientExternalId !== active.canonical.patientExternalId
&& namespace.resolve(r.rawSource) === namespace.resolve(active.rawSource))!;
const conflict = assemble(originalRaw.map(r => ({ ...r, patient_id: another.canonical.patientExternalId }))).rows[0]!;
const blocked = await ingest(conflict);
expect(blocked.metrics.factsFailed).toBe(1);
expect(blocked.metrics.factRejects[0]?.issues.join(' ')).toContain('identity changed');
console.info(JSON.stringify({ localDatabase: 'pac_complaint_verify_20261018', sourceSubjects: rows.length,
firstFacts: initialFacts.length, duplicateNewFacts: 0, utcVerified: true, revisionVerified: true, identityConflictBlocked: true }));
} finally {
await db.patientFact.deleteMany({ where: { hostId: host.id } });
await db.patientTransaction.deleteMany({ where: { hostId: host.id } });
await db.patient.deleteMany({ where: { hostId: host.id } });
await db.host.delete({ where: { id: host.id } });
await db.$disconnect();
}
}, 60000);
品牌,源诊所ID,源诊所名称,PAC字典名称,反馈单总数,负面关注点候选,候选未完成,表扬,无不满,关注点未分类,全类缺患者ID,候选草稿,候选非草稿且患者可关联源主档,候选可关联当前生产PAC患者,候选未完成可关联当前生产PAC患者,最近反馈时间
瑞尔,bcda42d627dc4fe09eb248e21f9fd2f7,瑞尔齿科深圳诺德诊所,瑞尔齿科深圳诺德诊所,399,92,1,33,274,0,7,2,84,37,0,2026-09-03 16:41:41.000
瑞尔,ceed7716d1cc40c8a6392fcb8928944a,瑞尔齿科厦门国际银行大厦诊所,瑞尔齿科厦门国际银行大厦诊所,139,49,0,5,77,8,0,0,49,27,0,2026-10-16 17:28:57.000
瑞尔,ff90434c695f409d8f0fdfb35a5a6b76,瑞尔齿科深圳华侨城诊所,瑞尔齿科深圳华侨城诊所,171,43,0,27,100,1,1,0,42,23,0,2026-09-13 12:38:25.000
瑞尔,031f22411ab94afea8355ef42264030f,瑞尔齿科深圳欢乐海岸诊所,瑞尔齿科深圳欢乐海岸诊所,376,42,0,52,282,0,2,1,40,20,0,2026-10-16 09:01:40.000
瑞尔,2523178e193346c883118718672eeed8,瑞尔齿科广州甲子花园诊所,瑞尔齿科广州甲子花园诊所,112,38,3,1,73,0,4,0,34,12,0,2026-01-27 09:04:00.000
瑞尔,ada5fcfe2a234eab9242d6d5d8dc0166,瑞尔齿科广州高德诊所,瑞尔齿科广州高德诊所,308,38,3,23,196,51,1,0,37,16,2,2026-10-16 12:57:05.000
瑞尔,d53e735b28f944aa8770f24ed7ff8803,地王诊所,瑞尔齿科深圳地王诊所,184,37,0,10,137,0,6,0,32,16,0,2024-11-16 20:03:01.000
瑞尔,d063b969850f461192f443a7f6a58ad0,卓越世纪诊所,卓越世纪诊所,147,26,0,12,109,0,0,0,26,13,0,2022-11-05 22:16:41.000
瑞尔,b6f0dc73b6c240d98ac8e00f66ac3688,瑞尔齿科深圳南山恒裕中心诊所,瑞尔齿科深圳南山恒裕中心诊所,171,23,0,26,121,1,0,0,23,7,0,2026-10-17 19:38:01.000
瑞尔,28cbf0a2e8484a75806c2246f896ad43,猎德诊所,瑞尔齿科广州猎德诊所,130,21,0,14,95,0,0,0,21,5,0,2022-12-12 16:00:21.000
瑞尔,8301dc0da8274b1ba0a389bb0e736224,中石化诊所,中石化诊所,129,20,0,4,105,0,1,0,19,6,0,2024-08-14 15:13:25.000
瑞泰,4c13b9c33330401fba469d871aaab6ec,成都瑞泰口腔医院武侯总院,,1495,276,0,232,983,4,41,0,244,45,0,2026-10-14 11:13:14.000
瑞泰,1e9a41cd8c644859a157f70a7105bf7b,长沙瑞泰口腔科尔雅定王台医院,,717,141,0,138,437,1,20,0,122,13,0,2026-04-18 12:30:41.000
瑞泰,484060c2ea1e4ead943cb731a746d20f,重庆瑞泰口腔医院,,1324,129,1,826,344,25,14,0,115,21,0,2026-10-12 15:16:43.000
瑞泰,0f18cf193b0147f487467ac152968dab,西安瑞泰口腔尔仓医院,,674,113,0,142,416,3,12,0,101,10,0,2026-10-17 17:00:34.000
瑞泰,38803ab6a9dc42f495390075b100cbe3,上海瑞泰口腔前滩门诊,,443,100,4,64,273,6,21,0,79,15,0,2026-09-27 15:07:26.000
瑞泰,95eb5789205843c682f97d154534e374,西安瑞泰口腔雁塔医院,,504,95,0,140,269,0,16,0,79,1,0,2026-05-31 16:54:44.000
瑞泰,13b0035726f742888274f3e30fffed2d,重庆瑞泰口腔江北门诊,,807,91,0,418,297,1,9,0,82,13,0,2026-10-17 16:15:13.000
瑞泰,39d283de8ad84c3eb6bf6b5cbf26318e,青岛瑞泰口腔市北门诊,,350,83,0,55,212,0,16,0,69,4,0,2026-09-05 18:22:43.000
瑞泰,9e256b519d1e4beea34400616387be89,长沙瑞泰科尔雅口腔医院中庭分院,,318,80,0,21,209,8,9,0,72,7,0,2026-08-22 09:27:39.000
瑞泰,491cb4ee54c84e5a97f1c711f258586f,重庆瑞泰久悦口腔万象城门诊,,661,76,0,405,179,1,5,0,72,21,0,2023-11-26 11:00:20.000
瑞泰,5d8b66cdd71c4806938e114007ac9f5b,重庆瑞泰口腔南方花园门诊,,596,74,0,336,186,0,2,0,72,11,0,2025-07-23 11:21:46.000
瑞泰,70dbe1e5728843129c970805fcfd9249,重庆瑞泰口腔南CBD门诊,,454,72,1,144,237,1,10,0,63,10,0,2026-10-16 12:05:25.000
瑞泰,4839f58bc8364da4b49ca1f3c51b4d7f,重庆瑞泰口腔爱琴海门诊,,339,69,0,135,135,0,11,0,59,12,0,2025-07-27 08:41:27.000
瑞泰,2f44ac9c1f04425796d0af892e361733,北京瑞泰口腔医院工体分院,,278,66,0,99,112,1,0,0,66,6,0,2026-08-26 09:32:03.000
瑞泰,235d58fd98ce4bcbb85cf91ad9055d37,成都瑞泰口腔医院贝森分院,,503,64,0,139,300,0,12,0,52,6,0,2024-03-29 09:05:08.000
瑞泰,f5180b97b6de4705a6e5b1fb619dbfff,大连瑞泰口腔葆嘉门诊,,212,62,1,68,80,2,10,0,52,9,0,2026-10-12 11:00:53.000
瑞泰,f464495631cf4c4ea3136a38480a017c,北京瑞泰口腔医院望京分院,,257,62,0,68,127,0,2,0,61,6,0,2026-09-28 11:28:32.000
瑞泰,ad4a4eb7e36d4cadb5a2b0fb82250767,重庆瑞泰口腔龙湖门诊,,757,61,0,289,407,0,6,0,56,6,0,2026-04-24 09:29:17.000
瑞泰,ef4254c21e6547e092c033780c758916,西安瑞泰口腔医院太白分院,,242,61,0,46,133,2,9,0,52,5,0,2026-10-05 11:34:05.000
瑞泰,5b4a90f4f19f484691e2aec68e88b417,重庆瑞泰口腔汽博门诊,,489,60,0,134,294,1,12,0,49,10,0,2026-10-12 15:00:43.000
瑞泰,c1021ae5c2334777ac7920a8518562f6,成都瑞泰口腔医院银泰分院,,324,58,0,78,188,0,11,0,47,12,0,2026-10-15 17:46:46.000
瑞泰,e1ce24a05f7a48029233821cf231b88c,江苏瑞泰口腔江阴美嘉欣门诊,,372,58,0,141,173,0,8,0,51,7,0,2026-09-11 10:08:00.000
瑞泰,70525b35ab1e408a9a0da5e7e451b005,长沙瑞泰科尔雅口腔医院西奥分院,,210,57,0,65,87,1,7,0,50,2,0,2025-10-07 00:15:18.000
瑞泰,859e5c7401a5483697a5a24e6d1cf103,北京瑞泰口腔医院陶然亭分院,,371,57,0,50,264,0,3,0,56,4,0,2026-09-14 14:00:39.000
瑞泰,dcbfb4173cbd41bc94e964cabef92567,济南瑞泰济东口腔医院,,221,56,0,26,127,12,6,0,50,1,0,2023-08-29 10:30:29.000
瑞泰,99a60449410d4f05af232da8560bda93,成都瑞泰口腔医院建设分院,,250,55,1,47,148,0,11,0,44,4,1,2026-01-23 11:13:50.000
瑞泰,23a773fa1f464d05aa0aa00e87e4cddb,重庆瑞泰口腔巴南门诊,,311,53,0,108,142,8,12,0,41,3,0,2026-10-13 10:01:19.000
瑞泰,d673c106bb614ef19130c24fc5450581,长沙瑞泰科尔雅口腔医院齐美分院,,195,52,0,38,102,3,10,0,43,1,0,2026-10-14 11:01:38.000
瑞泰,69165bd994ec49429dcc66cf8c3c54d6,重庆瑞泰口腔大坪门诊,,383,49,0,242,92,0,11,0,38,5,0,2025-08-16 16:00:32.000
瑞泰,1baf80fbdd7842a19e63b19e46d90d9b,西安瑞泰口腔医院高科分院,,268,49,1,108,110,1,5,0,44,5,0,2026-10-17 16:42:34.000
瑞泰,6cd7d4408e7e46ad9c9fad0b764e6d66,重庆瑞泰口腔大学城门诊,,791,49,3,329,361,52,14,0,35,13,2,2026-10-12 12:18:38.000
瑞泰,46daed76087f4aa9a36709413c21d9e7,西安瑞泰口腔医院龙首分院,,337,47,1,77,212,1,11,0,36,2,0,2026-10-01 16:07:19.000
瑞泰,ce6db69026b44347b527b0158a05bb8f,北京瑞泰口腔医院亦庄分院,,250,47,1,62,140,1,1,0,46,7,0,2026-10-16 19:04:53.000
瑞泰,e83d432a38bb4f6284713b36db4e7497,上海瑞泰口腔世纪公园门诊,上海瑞泰口腔世纪公园门诊,245,44,3,31,156,14,10,0,34,34,1,2026-09-01 16:10:45.000
瑞泰,830e216eed28413cb57c00beb71a1854,北京瑞泰口腔医院上地分院,,274,44,0,18,212,0,0,0,44,0,0,2025-08-12 17:41:47.000
瑞泰,358c9c024e254d3586856aab770eb217,重庆瑞泰华西口腔巴国城门诊,,428,41,0,223,163,1,7,0,35,4,0,2025-06-21 11:46:22.000
瑞泰,83f93882fa884b96bb3bb9fdddb75541,重庆瑞泰久悦口腔沙坪坝门诊,,697,41,0,228,428,0,9,0,32,8,0,2025-06-29 17:01:22.000
瑞泰,c50b5e509354470889020d5674cb888f,成都瑞泰口腔医院世纪城分院,,219,40,0,48,126,5,19,0,25,5,0,2026-10-17 15:20:47.000
瑞泰,81d67cc568f441b78a0da3ee9dffe96b,青岛瑞泰口腔江西路门诊,,345,39,0,77,228,1,2,0,37,1,0,2026-10-18 08:37:34.000
瑞泰,f338d89191d64056964966cc9c5d23a2,长沙瑞泰科尔雅口腔医院北辰分院,,242,39,0,86,116,1,3,0,36,1,0,2025-09-03 16:03:21.000
瑞泰,d0d5976bf8924544a59fd6cb0bed3b96,北京瑞泰双井分院,,186,36,1,36,114,0,1,0,35,1,0,2025-01-10 14:33:58.000
瑞泰,ce07474115f5463586605381049cb45d,重庆瑞泰久悦口腔源著门诊,,328,36,0,138,154,0,6,0,30,3,0,2024-07-12 17:50:53.000
瑞泰,3a9ee869eb4e4121bcafe6fef490f9f6,重庆瑞泰口腔英利门诊,,233,35,1,86,111,1,10,0,26,1,0,2026-10-16 12:00:59.000
瑞泰,b44d5e738fb842278e69a29bd76929ad,靖江瑞泰口腔门诊部,,124,35,0,27,62,0,5,0,31,3,0,2023-05-05 09:57:59.000
瑞泰,384741cdca30437e81ff4477ddc30979,重庆瑞泰口腔长寿门诊,,355,35,0,117,178,25,10,0,25,4,0,2026-10-12 16:52:39.000
瑞泰,e147b68f65a14c33a80299d34040f92d,青岛瑞泰东和齿科同安路门诊,,142,33,0,27,82,0,14,0,21,3,0,2024-09-14 18:01:01.000
瑞泰,30f4f6702e22441a9ac40e1141fe739a,长沙瑞泰科尔雅口腔医院麓南分院,,136,31,0,22,81,2,10,0,22,1,0,2025-06-05 15:33:19.000
瑞泰,f07ae73a506d4aaa99c76091fb2199f8,北京瑞泰广外分院,,290,29,0,83,59,119,1,0,29,3,0,2026-09-11 13:30:34.000
瑞泰,829fe22dc2c044fc962b4cd981b88af8,重庆瑞泰口腔礼嘉门诊,,261,29,0,111,121,0,8,0,22,3,0,2024-09-10 15:28:07.000
瑞泰,924a6a4489f74f43886e8e07c679fa5f,西安瑞泰口腔医院曲江分院,,305,26,0,95,184,0,1,0,25,4,0,2026-07-22 11:39:46.000
瑞泰,9100447bec654e689e4f7f42b202d361,成都瑞泰口腔医院悦街分院,,277,22,2,84,171,0,10,0,12,2,0,2026-06-27 16:23:54.000
瑞泰,aca95973f9ac43d0b5cee4cb25f7330e,青岛瑞泰口腔五四广场门诊,,124,22,0,16,86,0,7,0,15,0,0,2024-09-02 09:07:38.000
瑞泰,c78931a84e244bf99bcba68e4dc02a66,成都瑞泰口腔医院西宸分院,,160,19,0,57,84,0,10,0,9,2,0,2024-08-12 14:54:01.000
瑞泰,4e86b5a2a23e4f2baa541643d541d152,重庆瑞泰口腔U城天街门诊,,479,18,3,295,147,19,10,0,8,0,0,2026-07-03 15:02:30.000
瑞泰,d2b64687bc4a41418f0cbc5f03b0a44f,长沙瑞泰科尔雅口腔医院昊天分院,,196,13,0,46,137,0,3,0,10,2,0,2025-07-24 17:26:22.000
瑞泰,b905a2bc04ef4abfa3bcc47f3d695e36,大连瑞泰口腔和平广场门诊,,77,12,1,20,38,7,4,0,8,1,0,2026-09-16 16:14:56.000
瑞泰,f3d67e38ffd847128590566bded9bd1e,大连瑞泰口腔三八广场门诊,,93,11,1,39,43,0,5,0,6,0,0,2025-08-19 14:19:32.000
瑞泰,c686add3b0254eadbcb27d07e9702b90,上海瑞泰静安昌平路门诊,,228,7,1,44,154,23,4,0,3,0,0,2024-11-27 21:22:51.000
瑞泰,48cd2a167556491c8bfe65b19c86cc26,北京瑞泰云岗分院,,75,6,0,4,65,0,0,0,6,0,0,2025-06-13 14:45:17.000
{
"scope": "Read-only DW and production PAC; no data modifications. 2026-10-18 Asia/Shanghai",
"source_table": "dw_group.fact_complain_out",
"base_main": "76e7d5bc69e98d9f61d4a967c131c4a58d3d0207",
"note": "Counts use (brand,id), negative means source concern in 1,3,4,5,6; not clinically or manually verified complaints.",
"source_aggregates": {
"quality": [
{
"complaints": "24488",
"null_patient": "538",
"zero_patient": "0",
"negative_patient": "0",
"no_filed": "0",
"invalid_filed": "0",
"future_filed": "0",
"invalid_updated": "199",
"no_category": "23195",
"category_unknown": "8",
"praise": "4",
"draft": "3",
"completed_no_resolution": "31",
"uncompleted_with_resolution": "0",
"follow_after_update": "58",
"extension_present": "23375"
}
],
"join": [
{
"brand": "瑞泰",
"complaints": "22222",
"valid_patient": "21706",
"found_master": "21706",
"found_current_cohort": "21706",
"missing_master": "0"
},
{
"brand": "瑞尔",
"complaints": "2266",
"valid_patient": "2244",
"found_master": "2244",
"found_current_cohort": "2244",
"missing_master": "0"
}
],
"status": [
{
"brand": "瑞尔",
"state": 1,
"state_name": "处理中",
"complaints": "21",
"bad_patient": "1"
},
{
"brand": "瑞尔",
"state": 2,
"state_name": "未处理",
"complaints": "73",
"bad_patient": "4"
},
{
"brand": "瑞尔",
"state": 4,
"state_name": "已完成",
"complaints": "2169",
"bad_patient": "15"
},
{
"brand": "瑞尔",
"state": 5,
"state_name": "草稿",
"complaints": "3",
"bad_patient": "2"
},
{
"brand": "瑞泰",
"state": 1,
"state_name": "处理中",
"complaints": "167",
"bad_patient": "10"
},
{
"brand": "瑞泰",
"state": 2,
"state_name": "未处理",
"complaints": "373",
"bad_patient": "8"
},
{
"brand": "瑞泰",
"state": 4,
"state_name": "已完成",
"complaints": "21682",
"bad_patient": "498"
}
],
"consistency": [
{
"conflicting_status": "0",
"conflicting_category": "0",
"conflicting_number": "0",
"cross_brand": "0"
}
],
"number_duplicates": [
{
"duplicate_numbers": "3",
"affected_complaints": "6"
}
],
"years": [
{
"brand": "瑞尔",
"year": "2020",
"complaints": "83",
"unresolved": "27",
"no_category": "0"
},
{
"brand": "瑞尔",
"year": "2021",
"complaints": "976",
"unresolved": "1",
"no_category": "938"
},
{
"brand": "瑞尔",
"year": "2022",
"complaints": "641",
"unresolved": "0",
"no_category": "641"
},
{
"brand": "瑞尔",
"year": "2023",
"complaints": "183",
"unresolved": "3",
"no_category": "183"
},
{
"brand": "瑞尔",
"year": "2024",
"complaints": "125",
"unresolved": "0",
"no_category": "125"
},
{
"brand": "瑞尔",
"year": "2025",
"complaints": "146",
"unresolved": "47",
"no_category": "102"
},
{
"brand": "瑞尔",
"year": "2026",
"complaints": "112",
"unresolved": "16",
"no_category": "96"
},
{
"brand": "瑞泰",
"year": "2020",
"complaints": "441",
"unresolved": "173",
"no_category": "4"
},
{
"brand": "瑞泰",
"year": "2021",
"complaints": "9434",
"unresolved": "24",
"no_category": "9082"
},
{
"brand": "瑞泰",
"year": "2022",
"complaints": "9819",
"unresolved": "24",
"no_category": "9795"
},
{
"brand": "瑞泰",
"year": "2023",
"complaints": "993",
"unresolved": "58",
"no_category": "934"
},
{
"brand": "瑞泰",
"year": "2024",
"complaints": "658",
"unresolved": "56",
"no_category": "610"
},
{
"brand": "瑞泰",
"year": "2025",
"complaints": "445",
"unresolved": "97",
"no_category": "359"
},
{
"brand": "瑞泰",
"year": "2026",
"complaints": "432",
"unresolved": "108",
"no_category": "326"
}
],
"fields": [
{
"complain_concerned": 9,
"complain_concerned_name": "无不满",
"complaints": "12845"
},
{
"complain_concerned": 8,
"complain_concerned_name": "表扬",
"complaints": "7535"
},
{
"complain_concerned": 1,
"complain_concerned_name": "服务流程",
"complaints": "1883"
},
{
"complain_concerned": 3,
"complain_concerned_name": "医疗质量",
"complaints": "990"
},
{
"complain_concerned": 4,
"complain_concerned_name": "价格",
"complaints": "617"
},
{
"complain_concerned": null,
"complain_concerned_name": null,
"complaints": "414"
},
{
"complain_concerned": 6,
"complain_concerned_name": "就诊环境",
"complaints": "145"
},
{
"complain_concerned": 5,
"complain_concerned_name": "等候时间",
"complaints": "59"
}
],
"channels": [
{
"complain_channel": 4,
"complain_channel_name": "满意度短信",
"complaints": "23375"
},
{
"complain_channel": 1,
"complain_channel_name": "400热线",
"complaints": "590"
},
{
"complain_channel": 3,
"complain_channel_name": "第三方平台",
"complaints": "515"
},
{
"complain_channel": 6,
"complain_channel_name": "销售渠道",
"complaints": "5"
},
{
"complain_channel": null,
"complain_channel_name": null,
"complaints": "2"
},
{
"complain_channel": 5,
"complain_channel_name": "小程序自助",
"complaints": "1"
}
],
"clock": [
{
"queried_at": "2026-10-18 15:56:51",
"server_timezone": "Asia/Shanghai"
}
]
},
"details": {
"semantic_summary": [
{
"brand": "瑞泰",
"total": "22222",
"negative": "3265",
"praise": "7328",
"neutral": "11276",
"unknown": "353",
"negative_submitted": "3265",
"negative_unresolved": "27",
"negative_linkable": "2784",
"negative_unresolved_linkable": "10"
},
{
"brand": "瑞尔",
"total": "2266",
"negative": "429",
"praise": "207",
"neutral": "1569",
"unknown": "61",
"negative_submitted": "426",
"negative_unresolved": "7",
"negative_linkable": "407",
"negative_unresolved_linkable": "2"
}
],
"quality_times": [
{
"bad_created": "0",
"updated_before_created": "0",
"updated_before_filed": "0",
"follow_after_updated": "58",
"eligible_bad_updated": "5",
"negative_completed_no_resolution": "14"
}
],
"conflicts": [
{
"concerns": "0",
"channels": "0",
"dates": "0",
"createds": "0",
"clinicnames": "0"
}
],
"repeated_ids": [
{
"shared_ids": "2208"
}
],
"neg_topics": [
{
"complain_concerned_name": "服务流程",
"count": "1883"
},
{
"complain_concerned_name": "医疗质量",
"count": "990"
},
{
"complain_concerned_name": "价格",
"count": "617"
},
{
"complain_concerned_name": "就诊环境",
"count": "145"
},
{
"complain_concerned_name": "等候时间",
"count": "59"
}
],
"unknown_states": [
{
"state": 1,
"n": "169",
"linkable": "169"
},
{
"state": 2,
"n": "224",
"linkable": "223"
},
{
"state": 4,
"n": "21",
"linkable": "16"
}
],
"negative_years": [
{
"year": "2020",
"n": "7",
"unresolved": "0"
},
{
"year": "2021",
"n": "1984",
"unresolved": "2"
},
{
"year": "2022",
"n": "1037",
"unresolved": "1"
},
{
"year": "2023",
"n": "320",
"unresolved": "4"
},
{
"year": "2024",
"n": "209",
"unresolved": "6"
},
{
"year": "2025",
"n": "71",
"unresolved": "17"
},
{
"year": "2026",
"n": "66",
"unresolved": "4"
}
]
},
"checks": {
"null_versions": [
{
"rows": "81756",
"nulls": "199",
"empty": "0",
"invalid": "0",
"invalid_follow": "0"
}
],
"conflicts": [
{
"negative_class_positive_concern": "864",
"positive_class_negative_concern": "0"
}
],
"content_quality": [
{
"cases": "24488",
"blank": "0",
"very_short": "5"
}
]
},
"pac_patient_coverage": [
{
"brand": "瑞泰",
"total": 22222,
"found_patient": 3207,
"eligible": 2784,
"eligible_found": 383
},
{
"brand": "瑞尔",
"total": 2266,
"found_patient": 1059,
"eligible": 407,
"eligible_found": 182
}
],
"pac_complaint_fact_count": 0,
"clinics": [
{
"brand": "瑞尔",
"organization_id": "bcda42d627dc4fe09eb248e21f9fd2f7",
"clinic_name": "瑞尔齿科深圳诺德诊所",
"total": "399",
"negative": "92",
"negative_unresolved": "1",
"praise": "33",
"neutral": "274",
"unknown": "0",
"unlinked": "7",
"negative_draft": "2",
"eligible": "84",
"last_filed": "2026-09-03 16:41:41.000",
"eligible_in_pac": 37,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科深圳诺德诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "ceed7716d1cc40c8a6392fcb8928944a",
"clinic_name": "瑞尔齿科厦门国际银行大厦诊所",
"total": "139",
"negative": "49",
"negative_unresolved": "0",
"praise": "5",
"neutral": "77",
"unknown": "8",
"unlinked": "0",
"negative_draft": "0",
"eligible": "49",
"last_filed": "2026-10-16 17:28:57.000",
"eligible_in_pac": 27,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科厦门国际银行大厦诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "ff90434c695f409d8f0fdfb35a5a6b76",
"clinic_name": "瑞尔齿科深圳华侨城诊所",
"total": "171",
"negative": "43",
"negative_unresolved": "0",
"praise": "27",
"neutral": "100",
"unknown": "1",
"unlinked": "1",
"negative_draft": "0",
"eligible": "42",
"last_filed": "2026-09-13 12:38:25.000",
"eligible_in_pac": 23,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科深圳华侨城诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "031f22411ab94afea8355ef42264030f",
"clinic_name": "瑞尔齿科深圳欢乐海岸诊所",
"total": "376",
"negative": "42",
"negative_unresolved": "0",
"praise": "52",
"neutral": "282",
"unknown": "0",
"unlinked": "2",
"negative_draft": "1",
"eligible": "40",
"last_filed": "2026-10-16 09:01:40.000",
"eligible_in_pac": 20,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科深圳欢乐海岸诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "2523178e193346c883118718672eeed8",
"clinic_name": "瑞尔齿科广州甲子花园诊所",
"total": "112",
"negative": "38",
"negative_unresolved": "3",
"praise": "1",
"neutral": "73",
"unknown": "0",
"unlinked": "4",
"negative_draft": "0",
"eligible": "34",
"last_filed": "2026-01-27 09:04:00.000",
"eligible_in_pac": 12,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科广州甲子花园诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "ada5fcfe2a234eab9242d6d5d8dc0166",
"clinic_name": "瑞尔齿科广州高德诊所",
"total": "308",
"negative": "38",
"negative_unresolved": "3",
"praise": "23",
"neutral": "196",
"unknown": "51",
"unlinked": "1",
"negative_draft": "0",
"eligible": "37",
"last_filed": "2026-10-16 12:57:05.000",
"eligible_in_pac": 16,
"unresolved_in_pac": 2,
"pac_clinic_name": "瑞尔齿科广州高德诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "d53e735b28f944aa8770f24ed7ff8803",
"clinic_name": "地王诊所",
"total": "184",
"negative": "37",
"negative_unresolved": "0",
"praise": "10",
"neutral": "137",
"unknown": "0",
"unlinked": "6",
"negative_draft": "0",
"eligible": "32",
"last_filed": "2024-11-16 20:03:01.000",
"eligible_in_pac": 16,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科深圳地王诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "d063b969850f461192f443a7f6a58ad0",
"clinic_name": "卓越世纪诊所",
"total": "147",
"negative": "26",
"negative_unresolved": "0",
"praise": "12",
"neutral": "109",
"unknown": "0",
"unlinked": "0",
"negative_draft": "0",
"eligible": "26",
"last_filed": "2022-11-05 22:16:41.000",
"eligible_in_pac": 13,
"unresolved_in_pac": 0,
"pac_clinic_name": "卓越世纪诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "b6f0dc73b6c240d98ac8e00f66ac3688",
"clinic_name": "瑞尔齿科深圳南山恒裕中心诊所",
"total": "171",
"negative": "23",
"negative_unresolved": "0",
"praise": "26",
"neutral": "121",
"unknown": "1",
"unlinked": "0",
"negative_draft": "0",
"eligible": "23",
"last_filed": "2026-10-17 19:38:01.000",
"eligible_in_pac": 7,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科深圳南山恒裕中心诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "28cbf0a2e8484a75806c2246f896ad43",
"clinic_name": "猎德诊所",
"total": "130",
"negative": "21",
"negative_unresolved": "0",
"praise": "14",
"neutral": "95",
"unknown": "0",
"unlinked": "0",
"negative_draft": "0",
"eligible": "21",
"last_filed": "2022-12-12 16:00:21.000",
"eligible_in_pac": 5,
"unresolved_in_pac": 0,
"pac_clinic_name": "瑞尔齿科广州猎德诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞尔",
"organization_id": "8301dc0da8274b1ba0a389bb0e736224",
"clinic_name": "中石化诊所",
"total": "129",
"negative": "20",
"negative_unresolved": "0",
"praise": "4",
"neutral": "105",
"unknown": "0",
"unlinked": "1",
"negative_draft": "0",
"eligible": "19",
"last_filed": "2024-08-14 15:13:25.000",
"eligible_in_pac": 6,
"unresolved_in_pac": 0,
"pac_clinic_name": "中石化诊所",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞泰",
"organization_id": "4c13b9c33330401fba469d871aaab6ec",
"clinic_name": "成都瑞泰口腔医院武侯总院",
"total": "1495",
"negative": "276",
"negative_unresolved": "0",
"praise": "232",
"neutral": "983",
"unknown": "4",
"unlinked": "41",
"negative_draft": "0",
"eligible": "244",
"last_filed": "2026-10-14 11:13:14.000",
"eligible_in_pac": 45,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "1e9a41cd8c644859a157f70a7105bf7b",
"clinic_name": "长沙瑞泰口腔科尔雅定王台医院",
"total": "717",
"negative": "141",
"negative_unresolved": "0",
"praise": "138",
"neutral": "437",
"unknown": "1",
"unlinked": "20",
"negative_draft": "0",
"eligible": "122",
"last_filed": "2026-04-18 12:30:41.000",
"eligible_in_pac": 13,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "484060c2ea1e4ead943cb731a746d20f",
"clinic_name": "重庆瑞泰口腔医院",
"total": "1324",
"negative": "129",
"negative_unresolved": "1",
"praise": "826",
"neutral": "344",
"unknown": "25",
"unlinked": "14",
"negative_draft": "0",
"eligible": "115",
"last_filed": "2026-10-12 15:16:43.000",
"eligible_in_pac": 21,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "0f18cf193b0147f487467ac152968dab",
"clinic_name": "西安瑞泰口腔尔仓医院",
"total": "674",
"negative": "113",
"negative_unresolved": "0",
"praise": "142",
"neutral": "416",
"unknown": "3",
"unlinked": "12",
"negative_draft": "0",
"eligible": "101",
"last_filed": "2026-10-17 17:00:34.000",
"eligible_in_pac": 10,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "38803ab6a9dc42f495390075b100cbe3",
"clinic_name": "上海瑞泰口腔前滩门诊",
"total": "443",
"negative": "100",
"negative_unresolved": "4",
"praise": "64",
"neutral": "273",
"unknown": "6",
"unlinked": "21",
"negative_draft": "0",
"eligible": "79",
"last_filed": "2026-09-27 15:07:26.000",
"eligible_in_pac": 15,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "95eb5789205843c682f97d154534e374",
"clinic_name": "西安瑞泰口腔雁塔医院",
"total": "504",
"negative": "95",
"negative_unresolved": "0",
"praise": "140",
"neutral": "269",
"unknown": "0",
"unlinked": "16",
"negative_draft": "0",
"eligible": "79",
"last_filed": "2026-05-31 16:54:44.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "13b0035726f742888274f3e30fffed2d",
"clinic_name": "重庆瑞泰口腔江北门诊",
"total": "807",
"negative": "91",
"negative_unresolved": "0",
"praise": "418",
"neutral": "297",
"unknown": "1",
"unlinked": "9",
"negative_draft": "0",
"eligible": "82",
"last_filed": "2026-10-17 16:15:13.000",
"eligible_in_pac": 13,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "39d283de8ad84c3eb6bf6b5cbf26318e",
"clinic_name": "青岛瑞泰口腔市北门诊",
"total": "350",
"negative": "83",
"negative_unresolved": "0",
"praise": "55",
"neutral": "212",
"unknown": "0",
"unlinked": "16",
"negative_draft": "0",
"eligible": "69",
"last_filed": "2026-09-05 18:22:43.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "9e256b519d1e4beea34400616387be89",
"clinic_name": "长沙瑞泰科尔雅口腔医院中庭分院",
"total": "318",
"negative": "80",
"negative_unresolved": "0",
"praise": "21",
"neutral": "209",
"unknown": "8",
"unlinked": "9",
"negative_draft": "0",
"eligible": "72",
"last_filed": "2026-08-22 09:27:39.000",
"eligible_in_pac": 7,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "491cb4ee54c84e5a97f1c711f258586f",
"clinic_name": "重庆瑞泰久悦口腔万象城门诊",
"total": "661",
"negative": "76",
"negative_unresolved": "0",
"praise": "405",
"neutral": "179",
"unknown": "1",
"unlinked": "5",
"negative_draft": "0",
"eligible": "72",
"last_filed": "2023-11-26 11:00:20.000",
"eligible_in_pac": 21,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "5d8b66cdd71c4806938e114007ac9f5b",
"clinic_name": "重庆瑞泰口腔南方花园门诊",
"total": "596",
"negative": "74",
"negative_unresolved": "0",
"praise": "336",
"neutral": "186",
"unknown": "0",
"unlinked": "2",
"negative_draft": "0",
"eligible": "72",
"last_filed": "2025-07-23 11:21:46.000",
"eligible_in_pac": 11,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "70dbe1e5728843129c970805fcfd9249",
"clinic_name": "重庆瑞泰口腔南CBD门诊",
"total": "454",
"negative": "72",
"negative_unresolved": "1",
"praise": "144",
"neutral": "237",
"unknown": "1",
"unlinked": "10",
"negative_draft": "0",
"eligible": "63",
"last_filed": "2026-10-16 12:05:25.000",
"eligible_in_pac": 10,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "4839f58bc8364da4b49ca1f3c51b4d7f",
"clinic_name": "重庆瑞泰口腔爱琴海门诊",
"total": "339",
"negative": "69",
"negative_unresolved": "0",
"praise": "135",
"neutral": "135",
"unknown": "0",
"unlinked": "11",
"negative_draft": "0",
"eligible": "59",
"last_filed": "2025-07-27 08:41:27.000",
"eligible_in_pac": 12,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "2f44ac9c1f04425796d0af892e361733",
"clinic_name": "北京瑞泰口腔医院工体分院",
"total": "278",
"negative": "66",
"negative_unresolved": "0",
"praise": "99",
"neutral": "112",
"unknown": "1",
"unlinked": "0",
"negative_draft": "0",
"eligible": "66",
"last_filed": "2026-08-26 09:32:03.000",
"eligible_in_pac": 6,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "235d58fd98ce4bcbb85cf91ad9055d37",
"clinic_name": "成都瑞泰口腔医院贝森分院",
"total": "503",
"negative": "64",
"negative_unresolved": "0",
"praise": "139",
"neutral": "300",
"unknown": "0",
"unlinked": "12",
"negative_draft": "0",
"eligible": "52",
"last_filed": "2024-03-29 09:05:08.000",
"eligible_in_pac": 6,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "f5180b97b6de4705a6e5b1fb619dbfff",
"clinic_name": "大连瑞泰口腔葆嘉门诊",
"total": "212",
"negative": "62",
"negative_unresolved": "1",
"praise": "68",
"neutral": "80",
"unknown": "2",
"unlinked": "10",
"negative_draft": "0",
"eligible": "52",
"last_filed": "2026-10-12 11:00:53.000",
"eligible_in_pac": 9,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "f464495631cf4c4ea3136a38480a017c",
"clinic_name": "北京瑞泰口腔医院望京分院",
"total": "257",
"negative": "62",
"negative_unresolved": "0",
"praise": "68",
"neutral": "127",
"unknown": "0",
"unlinked": "2",
"negative_draft": "0",
"eligible": "61",
"last_filed": "2026-09-28 11:28:32.000",
"eligible_in_pac": 6,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "ad4a4eb7e36d4cadb5a2b0fb82250767",
"clinic_name": "重庆瑞泰口腔龙湖门诊",
"total": "757",
"negative": "61",
"negative_unresolved": "0",
"praise": "289",
"neutral": "407",
"unknown": "0",
"unlinked": "6",
"negative_draft": "0",
"eligible": "56",
"last_filed": "2026-04-24 09:29:17.000",
"eligible_in_pac": 6,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "ef4254c21e6547e092c033780c758916",
"clinic_name": "西安瑞泰口腔医院太白分院",
"total": "242",
"negative": "61",
"negative_unresolved": "0",
"praise": "46",
"neutral": "133",
"unknown": "2",
"unlinked": "9",
"negative_draft": "0",
"eligible": "52",
"last_filed": "2026-10-05 11:34:05.000",
"eligible_in_pac": 5,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "5b4a90f4f19f484691e2aec68e88b417",
"clinic_name": "重庆瑞泰口腔汽博门诊",
"total": "489",
"negative": "60",
"negative_unresolved": "0",
"praise": "134",
"neutral": "294",
"unknown": "1",
"unlinked": "12",
"negative_draft": "0",
"eligible": "49",
"last_filed": "2026-10-12 15:00:43.000",
"eligible_in_pac": 10,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "c1021ae5c2334777ac7920a8518562f6",
"clinic_name": "成都瑞泰口腔医院银泰分院",
"total": "324",
"negative": "58",
"negative_unresolved": "0",
"praise": "78",
"neutral": "188",
"unknown": "0",
"unlinked": "11",
"negative_draft": "0",
"eligible": "47",
"last_filed": "2026-10-15 17:46:46.000",
"eligible_in_pac": 12,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "e1ce24a05f7a48029233821cf231b88c",
"clinic_name": "江苏瑞泰口腔江阴美嘉欣门诊",
"total": "372",
"negative": "58",
"negative_unresolved": "0",
"praise": "141",
"neutral": "173",
"unknown": "0",
"unlinked": "8",
"negative_draft": "0",
"eligible": "51",
"last_filed": "2026-09-11 10:08:00.000",
"eligible_in_pac": 7,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "70525b35ab1e408a9a0da5e7e451b005",
"clinic_name": "长沙瑞泰科尔雅口腔医院西奥分院",
"total": "210",
"negative": "57",
"negative_unresolved": "0",
"praise": "65",
"neutral": "87",
"unknown": "1",
"unlinked": "7",
"negative_draft": "0",
"eligible": "50",
"last_filed": "2025-10-07 00:15:18.000",
"eligible_in_pac": 2,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "859e5c7401a5483697a5a24e6d1cf103",
"clinic_name": "北京瑞泰口腔医院陶然亭分院",
"total": "371",
"negative": "57",
"negative_unresolved": "0",
"praise": "50",
"neutral": "264",
"unknown": "0",
"unlinked": "3",
"negative_draft": "0",
"eligible": "56",
"last_filed": "2026-09-14 14:00:39.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "dcbfb4173cbd41bc94e964cabef92567",
"clinic_name": "济南瑞泰济东口腔医院",
"total": "221",
"negative": "56",
"negative_unresolved": "0",
"praise": "26",
"neutral": "127",
"unknown": "12",
"unlinked": "6",
"negative_draft": "0",
"eligible": "50",
"last_filed": "2023-08-29 10:30:29.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "99a60449410d4f05af232da8560bda93",
"clinic_name": "成都瑞泰口腔医院建设分院",
"total": "250",
"negative": "55",
"negative_unresolved": "1",
"praise": "47",
"neutral": "148",
"unknown": "0",
"unlinked": "11",
"negative_draft": "0",
"eligible": "44",
"last_filed": "2026-01-23 11:13:50.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 1,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "23a773fa1f464d05aa0aa00e87e4cddb",
"clinic_name": "重庆瑞泰口腔巴南门诊",
"total": "311",
"negative": "53",
"negative_unresolved": "0",
"praise": "108",
"neutral": "142",
"unknown": "8",
"unlinked": "12",
"negative_draft": "0",
"eligible": "41",
"last_filed": "2026-10-13 10:01:19.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "d673c106bb614ef19130c24fc5450581",
"clinic_name": "长沙瑞泰科尔雅口腔医院齐美分院",
"total": "195",
"negative": "52",
"negative_unresolved": "0",
"praise": "38",
"neutral": "102",
"unknown": "3",
"unlinked": "10",
"negative_draft": "0",
"eligible": "43",
"last_filed": "2026-10-14 11:01:38.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "69165bd994ec49429dcc66cf8c3c54d6",
"clinic_name": "重庆瑞泰口腔大坪门诊",
"total": "383",
"negative": "49",
"negative_unresolved": "0",
"praise": "242",
"neutral": "92",
"unknown": "0",
"unlinked": "11",
"negative_draft": "0",
"eligible": "38",
"last_filed": "2025-08-16 16:00:32.000",
"eligible_in_pac": 5,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "1baf80fbdd7842a19e63b19e46d90d9b",
"clinic_name": "西安瑞泰口腔医院高科分院",
"total": "268",
"negative": "49",
"negative_unresolved": "1",
"praise": "108",
"neutral": "110",
"unknown": "1",
"unlinked": "5",
"negative_draft": "0",
"eligible": "44",
"last_filed": "2026-10-17 16:42:34.000",
"eligible_in_pac": 5,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "6cd7d4408e7e46ad9c9fad0b764e6d66",
"clinic_name": "重庆瑞泰口腔大学城门诊",
"total": "791",
"negative": "49",
"negative_unresolved": "3",
"praise": "329",
"neutral": "361",
"unknown": "52",
"unlinked": "14",
"negative_draft": "0",
"eligible": "35",
"last_filed": "2026-10-12 12:18:38.000",
"eligible_in_pac": 13,
"unresolved_in_pac": 2,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "46daed76087f4aa9a36709413c21d9e7",
"clinic_name": "西安瑞泰口腔医院龙首分院",
"total": "337",
"negative": "47",
"negative_unresolved": "1",
"praise": "77",
"neutral": "212",
"unknown": "1",
"unlinked": "11",
"negative_draft": "0",
"eligible": "36",
"last_filed": "2026-10-01 16:07:19.000",
"eligible_in_pac": 2,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "ce6db69026b44347b527b0158a05bb8f",
"clinic_name": "北京瑞泰口腔医院亦庄分院",
"total": "250",
"negative": "47",
"negative_unresolved": "1",
"praise": "62",
"neutral": "140",
"unknown": "1",
"unlinked": "1",
"negative_draft": "0",
"eligible": "46",
"last_filed": "2026-10-16 19:04:53.000",
"eligible_in_pac": 7,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "e83d432a38bb4f6284713b36db4e7497",
"clinic_name": "上海瑞泰口腔世纪公园门诊",
"total": "245",
"negative": "44",
"negative_unresolved": "3",
"praise": "31",
"neutral": "156",
"unknown": "14",
"unlinked": "10",
"negative_draft": "0",
"eligible": "34",
"last_filed": "2026-09-01 16:10:45.000",
"eligible_in_pac": 34,
"unresolved_in_pac": 1,
"pac_clinic_name": "上海瑞泰口腔世纪公园门诊",
"in_pac_clinic_dictionary": true
},
{
"brand": "瑞泰",
"organization_id": "830e216eed28413cb57c00beb71a1854",
"clinic_name": "北京瑞泰口腔医院上地分院",
"total": "274",
"negative": "44",
"negative_unresolved": "0",
"praise": "18",
"neutral": "212",
"unknown": "0",
"unlinked": "0",
"negative_draft": "0",
"eligible": "44",
"last_filed": "2025-08-12 17:41:47.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "358c9c024e254d3586856aab770eb217",
"clinic_name": "重庆瑞泰华西口腔巴国城门诊",
"total": "428",
"negative": "41",
"negative_unresolved": "0",
"praise": "223",
"neutral": "163",
"unknown": "1",
"unlinked": "7",
"negative_draft": "0",
"eligible": "35",
"last_filed": "2025-06-21 11:46:22.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "83f93882fa884b96bb3bb9fdddb75541",
"clinic_name": "重庆瑞泰久悦口腔沙坪坝门诊",
"total": "697",
"negative": "41",
"negative_unresolved": "0",
"praise": "228",
"neutral": "428",
"unknown": "0",
"unlinked": "9",
"negative_draft": "0",
"eligible": "32",
"last_filed": "2025-06-29 17:01:22.000",
"eligible_in_pac": 8,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "c50b5e509354470889020d5674cb888f",
"clinic_name": "成都瑞泰口腔医院世纪城分院",
"total": "219",
"negative": "40",
"negative_unresolved": "0",
"praise": "48",
"neutral": "126",
"unknown": "5",
"unlinked": "19",
"negative_draft": "0",
"eligible": "25",
"last_filed": "2026-10-17 15:20:47.000",
"eligible_in_pac": 5,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "81d67cc568f441b78a0da3ee9dffe96b",
"clinic_name": "青岛瑞泰口腔江西路门诊",
"total": "345",
"negative": "39",
"negative_unresolved": "0",
"praise": "77",
"neutral": "228",
"unknown": "1",
"unlinked": "2",
"negative_draft": "0",
"eligible": "37",
"last_filed": "2026-10-18 08:37:34.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "f338d89191d64056964966cc9c5d23a2",
"clinic_name": "长沙瑞泰科尔雅口腔医院北辰分院",
"total": "242",
"negative": "39",
"negative_unresolved": "0",
"praise": "86",
"neutral": "116",
"unknown": "1",
"unlinked": "3",
"negative_draft": "0",
"eligible": "36",
"last_filed": "2025-09-03 16:03:21.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "d0d5976bf8924544a59fd6cb0bed3b96",
"clinic_name": "北京瑞泰双井分院",
"total": "186",
"negative": "36",
"negative_unresolved": "1",
"praise": "36",
"neutral": "114",
"unknown": "0",
"unlinked": "1",
"negative_draft": "0",
"eligible": "35",
"last_filed": "2025-01-10 14:33:58.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "ce07474115f5463586605381049cb45d",
"clinic_name": "重庆瑞泰久悦口腔源著门诊",
"total": "328",
"negative": "36",
"negative_unresolved": "0",
"praise": "138",
"neutral": "154",
"unknown": "0",
"unlinked": "6",
"negative_draft": "0",
"eligible": "30",
"last_filed": "2024-07-12 17:50:53.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "3a9ee869eb4e4121bcafe6fef490f9f6",
"clinic_name": "重庆瑞泰口腔英利门诊",
"total": "233",
"negative": "35",
"negative_unresolved": "1",
"praise": "86",
"neutral": "111",
"unknown": "1",
"unlinked": "10",
"negative_draft": "0",
"eligible": "26",
"last_filed": "2026-10-16 12:00:59.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "b44d5e738fb842278e69a29bd76929ad",
"clinic_name": "靖江瑞泰口腔门诊部",
"total": "124",
"negative": "35",
"negative_unresolved": "0",
"praise": "27",
"neutral": "62",
"unknown": "0",
"unlinked": "5",
"negative_draft": "0",
"eligible": "31",
"last_filed": "2023-05-05 09:57:59.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "384741cdca30437e81ff4477ddc30979",
"clinic_name": "重庆瑞泰口腔长寿门诊",
"total": "355",
"negative": "35",
"negative_unresolved": "0",
"praise": "117",
"neutral": "178",
"unknown": "25",
"unlinked": "10",
"negative_draft": "0",
"eligible": "25",
"last_filed": "2026-10-12 16:52:39.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "e147b68f65a14c33a80299d34040f92d",
"clinic_name": "青岛瑞泰东和齿科同安路门诊",
"total": "142",
"negative": "33",
"negative_unresolved": "0",
"praise": "27",
"neutral": "82",
"unknown": "0",
"unlinked": "14",
"negative_draft": "0",
"eligible": "21",
"last_filed": "2024-09-14 18:01:01.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "30f4f6702e22441a9ac40e1141fe739a",
"clinic_name": "长沙瑞泰科尔雅口腔医院麓南分院",
"total": "136",
"negative": "31",
"negative_unresolved": "0",
"praise": "22",
"neutral": "81",
"unknown": "2",
"unlinked": "10",
"negative_draft": "0",
"eligible": "22",
"last_filed": "2025-06-05 15:33:19.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "f07ae73a506d4aaa99c76091fb2199f8",
"clinic_name": "北京瑞泰广外分院",
"total": "290",
"negative": "29",
"negative_unresolved": "0",
"praise": "83",
"neutral": "59",
"unknown": "119",
"unlinked": "1",
"negative_draft": "0",
"eligible": "29",
"last_filed": "2026-09-11 13:30:34.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "829fe22dc2c044fc962b4cd981b88af8",
"clinic_name": "重庆瑞泰口腔礼嘉门诊",
"total": "261",
"negative": "29",
"negative_unresolved": "0",
"praise": "111",
"neutral": "121",
"unknown": "0",
"unlinked": "8",
"negative_draft": "0",
"eligible": "22",
"last_filed": "2024-09-10 15:28:07.000",
"eligible_in_pac": 3,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "924a6a4489f74f43886e8e07c679fa5f",
"clinic_name": "西安瑞泰口腔医院曲江分院",
"total": "305",
"negative": "26",
"negative_unresolved": "0",
"praise": "95",
"neutral": "184",
"unknown": "0",
"unlinked": "1",
"negative_draft": "0",
"eligible": "25",
"last_filed": "2026-07-22 11:39:46.000",
"eligible_in_pac": 4,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "9100447bec654e689e4f7f42b202d361",
"clinic_name": "成都瑞泰口腔医院悦街分院",
"total": "277",
"negative": "22",
"negative_unresolved": "2",
"praise": "84",
"neutral": "171",
"unknown": "0",
"unlinked": "10",
"negative_draft": "0",
"eligible": "12",
"last_filed": "2026-06-27 16:23:54.000",
"eligible_in_pac": 2,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "aca95973f9ac43d0b5cee4cb25f7330e",
"clinic_name": "青岛瑞泰口腔五四广场门诊",
"total": "124",
"negative": "22",
"negative_unresolved": "0",
"praise": "16",
"neutral": "86",
"unknown": "0",
"unlinked": "7",
"negative_draft": "0",
"eligible": "15",
"last_filed": "2024-09-02 09:07:38.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "c78931a84e244bf99bcba68e4dc02a66",
"clinic_name": "成都瑞泰口腔医院西宸分院",
"total": "160",
"negative": "19",
"negative_unresolved": "0",
"praise": "57",
"neutral": "84",
"unknown": "0",
"unlinked": "10",
"negative_draft": "0",
"eligible": "9",
"last_filed": "2024-08-12 14:54:01.000",
"eligible_in_pac": 2,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "4e86b5a2a23e4f2baa541643d541d152",
"clinic_name": "重庆瑞泰口腔U城天街门诊",
"total": "479",
"negative": "18",
"negative_unresolved": "3",
"praise": "295",
"neutral": "147",
"unknown": "19",
"unlinked": "10",
"negative_draft": "0",
"eligible": "8",
"last_filed": "2026-07-03 15:02:30.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "d2b64687bc4a41418f0cbc5f03b0a44f",
"clinic_name": "长沙瑞泰科尔雅口腔医院昊天分院",
"total": "196",
"negative": "13",
"negative_unresolved": "0",
"praise": "46",
"neutral": "137",
"unknown": "0",
"unlinked": "3",
"negative_draft": "0",
"eligible": "10",
"last_filed": "2025-07-24 17:26:22.000",
"eligible_in_pac": 2,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "b905a2bc04ef4abfa3bcc47f3d695e36",
"clinic_name": "大连瑞泰口腔和平广场门诊",
"total": "77",
"negative": "12",
"negative_unresolved": "1",
"praise": "20",
"neutral": "38",
"unknown": "7",
"unlinked": "4",
"negative_draft": "0",
"eligible": "8",
"last_filed": "2026-09-16 16:14:56.000",
"eligible_in_pac": 1,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "f3d67e38ffd847128590566bded9bd1e",
"clinic_name": "大连瑞泰口腔三八广场门诊",
"total": "93",
"negative": "11",
"negative_unresolved": "1",
"praise": "39",
"neutral": "43",
"unknown": "0",
"unlinked": "5",
"negative_draft": "0",
"eligible": "6",
"last_filed": "2025-08-19 14:19:32.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "c686add3b0254eadbcb27d07e9702b90",
"clinic_name": "上海瑞泰静安昌平路门诊",
"total": "228",
"negative": "7",
"negative_unresolved": "1",
"praise": "44",
"neutral": "154",
"unknown": "23",
"unlinked": "4",
"negative_draft": "0",
"eligible": "3",
"last_filed": "2024-11-27 21:22:51.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
},
{
"brand": "瑞泰",
"organization_id": "48cd2a167556491c8bfe65b19c86cc26",
"clinic_name": "北京瑞泰云岗分院",
"total": "75",
"negative": "6",
"negative_unresolved": "0",
"praise": "4",
"neutral": "65",
"unknown": "0",
"unlinked": "0",
"negative_draft": "0",
"eligible": "6",
"last_filed": "2025-06-13 14:45:17.000",
"eligible_in_pac": 0,
"unresolved_in_pac": 0,
"pac_clinic_name": "",
"in_pac_clinic_dictionary": false
}
]
}
# 5i5ya 投诉数据核查与 PAC 接入建议
> 本文为修改前的只读评估。后续本地实现及验收见 [本地验收](./2026-10-18-jvs-complaint-ingestion-local-validation.md)。未部署。
> 2026-10-18|设计与数据评估,尚未实施摄入。以 `origin/main` 的 `76e7d5b` 为基线,独立分支 `codex/jvs-complaint-ingestion`。
> 只读核查生产使用的 5i5ya / jvs-dw 数仓及生产 PAC;下列数据是本次查询快照,不代表全部源业务系统的完整覆盖。
## 1. 结论
**可以接,但不能把 `fact_complain_out` 整表当成投诉。** 这张表同时包含满意度短信、表扬、无不满反馈和负面反馈,且一张单因多次跟进重复多行。
PAC 已有 `complaint` 标准结构、事件枚举和解析器;5i5ya 没有配置该资源,生产 `jvs-dw` 的 `complaint_record` 为 **0**。标准字段、状态和下游消费也尚未完全接齐,不能只补一条 SQL 就认为完成。
建议:**先按品牌和投诉 ID 还原一张业务单,区分反馈性质;明确的投诉/负面反馈才形成投诉事实。身份缺失、分类不明或矛盾的记录留待核查,不猜患者、不默认投诉。** 投诉事实与召回限制分开:历史投诉不等于永久免打扰。
## 2. 实际有什么数据
统计单位为 `(brand, id)`,不是原始行数,也不是投诉编号。查询于 2026-10-18 15:56 起进行(上海时区)。
| 品牌 | 原始行 | 反馈单 | 有记录诊所 | 负面关注点候选 | 表扬 | 无不满 | 关注点未分类 |
|---|---:|---:|---:|---:|---:|---:|---:|
| 瑞尔 | 9,001 | 2,266 | 11 | 429 | 207 | 1,569 | 61 |
| 瑞泰 | 72,755 | 22,222 | 59 | 3,265 | 7,328 | 11,276 | 353 |
| 合计 | **81,756** | **24,488** | **70** | **3,694** | **7,535** | **12,845** | **414** |
这里的“负面候选”仅指源端关注点为服务流程、医疗质量、价格、等候时间、就诊环境,不是人工确认的投诉数。原始内容抽样中既有具体文字,也有单独的“1”“3”“4”“3.5”等评分回复。**不能自行把某个分值解释为投诉或严重程度。**
- **23,375 单来自满意度短信**;“无不满+表扬”合计 20,380 单,占全部反馈约 83.2%。
- 负面候选按关注点分布:服务流程 1,883、医疗质量 990、价格 617、环境 145、等候 59。
- `complain_type` 实际是处理状态:`1=处理中、2=未处理、4=已完成、5=草稿`,不是投诉类别。
- `complain_classification` 约 94.7% 为空。另有 **864 单**分类标成“投诉”但关注点为“无不满/表扬”,不能仅靠 classification 判投诉;需源端确认默认分类和人工归类机制。
- 负面候选中的 3,021 单发生于 2021—2022 年;2026 年有 66 单。不能把多年累计数当成当前诊所投诉率或服务质量排名。
### 2.1 能否可靠关联和更新
| 核查项 | 实测 | 接入含义 |
|---|---|---|
| 一单多行 | 23,478 单多行,最多 145 行;重复行主要是跟进不同 | 一单形成一个有版本的投诉事实,跟进单独聚合 |
| 主体一致性 | 同品牌同 ID 的患者、诊所、内容、状态、关注点、类别、创建/更新时间未发现多值 | 当前快照具备聚合基础;未来冲突仍需检测,不能任意取首行 |
| 跨品牌撞 ID | 2,208 个 ID 同时出现在两个品牌 | 投诉和患者身份都必须携带 `sourceUnit=brand` |
| 投诉编号 | 瑞泰有 3 个重复编号,对应 6 张不同 ID 的单 | `complain_number` 只作展示,不作幂等主键 |
| 患者关联 | 538 单无患者 ID;其余 23,950 单全部能关联 `(brand, patient_id)` 源主档,且满足现有有就诊记录 cohort 条件 | 缺 ID 的隔离;不通过姓名、手机或投诉正文猜人 |
| 业务时间 | filed/created 均可解析,未发现未来投诉时间;199 单 updated 为空 | 区分发生时间和版本时间;不能因缺 updated 永久漏摄 |
| 跟进时间 | 58 单最新跟进晚于主单 updated | 只以 updated 为游标可能漏跟进变化 |
| 结案时间 | 已完成反馈中 31 单找不到“完成”跟进时间;负面候选中 14 单 | 结案状态可以保留,`resolvedAt` 允许空,不拿更新/摄入时间冒充 |
| 删除与 ETL 时间 | 本表未提供删除标记、ETL 入仓时间、跟进记录 ID | 当前快照不能证明可捕获所有删除、迟到更新及跟进事件身份 |
`customize_extend` 抽样是编号样式的字符串,并非可直接展开的标准 JSON;暂作不透明来源字段留底,不猜测业务含义。
### 2.2 真正具备首批关联条件的规模
从 3,694 个负面候选排除 3 个草稿,再排除缺患者 ID 的记录:
| 品牌 | 非草稿且有源患者 | 可关联当前生产 PAC 患者 | 候选未完成且有源患者 | 其中已在 PAC |
|---|---:|---:|---:|---:|
| 瑞尔 | 407 | 182 | 2 | 2 |
| 瑞泰 | 2,784 | 383 | 10 | 4 |
| 合计 | **3,191** | **565** | **12** | **6** |
因此 **3,191 是可关联的候选上限,不是已经确认可上线的投诉数**。其中 2,626 单的患者尚不在当前生产 PAC;源主档存在,接入需通过现有身份/主档摄入机制补拉,不能静默丢掉,也不能创建错品牌患者。本次没有补拉或写入。
另有 34 单负面候选处于未处理/处理中,其中 22 单缺患者 ID;414 单未分类反馈里有 393 单未完成。不能把后者全部当成“正在投诉”,也不能悄悄丢弃,需单列待确认。
## 3. 各诊所分布与覆盖
诊所按投诉单自己的 `organization_id` 归属。**投诉发生在哪家诊所,就保留在哪家;不能替换成患者最近就诊诊所。** 跨店患者风险是否在品牌内共享,是后续业务规则,不改变原事实归属。
①“可关联源患者”=负面候选、非草稿、有患者 ID;②“已在 PAC”=①中已有对应生产 PAC 患者。两列都未做人工投诉语义确认。
### 瑞尔:源表实际出现的 11 家(全部)
| 诊所 | 反馈单 | 负面候选 | 未完成候选 | 可关联源患者① | 已在 PAC② |
|---|---:|---:|---:|---:|---:|
| 瑞尔齿科深圳诺德诊所 | 399 | 92 | 1 | 84 | 37 |
| 瑞尔齿科厦门国际银行大厦诊所 | 139 | 49 | 0 | 49 | 27 |
| 瑞尔齿科深圳华侨城诊所 | 171 | 43 | 0 | 42 | 23 |
| 瑞尔齿科深圳欢乐海岸诊所 | 376 | 42 | 0 | 40 | 20 |
| 瑞尔齿科广州甲子花园诊所 | 112 | 38 | 3 | 34 | 12 |
| 瑞尔齿科广州高德诊所 | 308 | 38 | 3 | 37 | 16 |
| 地王诊所 | 184 | 37 | 0 | 32 | 16 |
| 卓越世纪诊所 | 147 | 26 | 0 | 26 | 13 |
| 瑞尔齿科深圳南山恒裕中心诊所 | 171 | 23 | 0 | 23 | 7 |
| 猎德诊所 | 130 | 21 | 0 | 21 | 5 |
| 中石化诊所 | 129 | 20 | 0 | 19 | 6 |
**当前瑞尔记录只覆盖广州、深圳、厦门这 11 家。** 杭州大厦、杭州高德、北京朝阳公园等在 PAC 存在的诊所,没有出现在这次全表投诉结果里。只能说“此数仓出口未查到”,不能据此认定源端从未有投诉;上线前须确认出口范围。
### 瑞泰:负面候选数前 10 家
| 诊所 | 反馈单 | 负面候选 | 未完成候选 | 可关联源患者① | 已在 PAC② |
|---|---:|---:|---:|---:|---:|
| 成都瑞泰口腔医院武侯总院 | 1495 | 276 | 0 | 244 | 45 |
| 长沙瑞泰口腔科尔雅定王台医院 | 717 | 141 | 0 | 122 | 13 |
| 重庆瑞泰口腔医院 | 1324 | 129 | 1 | 115 | 21 |
| 西安瑞泰口腔尔仓医院 | 674 | 113 | 0 | 101 | 10 |
| 上海瑞泰口腔前滩门诊 | 443 | 100 | 4 | 79 | 15 |
| 西安瑞泰口腔雁塔医院 | 504 | 95 | 0 | 79 | 1 |
| 重庆瑞泰口腔江北门诊 | 807 | 91 | 0 | 82 | 13 |
| 青岛瑞泰口腔市北门诊 | 350 | 83 | 0 | 69 | 4 |
| 长沙瑞泰科尔雅口腔医院中庭分院 | 318 | 80 | 0 | 72 | 7 |
| 重庆瑞泰久悦口腔万象城门诊 | 661 | 76 | 0 | 72 | 21 |
完整 **70 家**的 ID、反馈性质、患者关联、未完成情况和最近反馈时间见 [诊所明细 CSV](./2026-10-18-jvs-complaint-clinics.csv)。其中“全类缺患者 ID”覆盖全部反馈,不能直接从负面候选数中相减。
当前 PAC 宿主名字典有 66 家,与投诉表仅重合 12 家(瑞尔 11、瑞泰 1)。其余 58 个源诊所 ID 不在该字典,**这不等于诊所无效或没有授权**;需按现有组织机制核对和补全,不能按名称猜 ID,也不能用字典缺失直接过滤掉投诉。CSV 同时保留源名称和 PAC 字典名称。
## 4. PAC 现状:已有骨架,尚未形成业务闭环
| 层次 | 已有 | 缺口 |
|---|---|---|
| 标准契约 | `ComplaintCanonicalSchema`:ID、患者、诊所、时间、内容、类别、状态、结案时间 | 状态仍是任意字符串并默认 open;缺反馈性质、渠道、完整来源值和跟进结构 |
| 5i5ya 适配 | 现有 manifest / transform / assembler 框架 | 没有投诉查询、变化发现、投诉 assembler |
| 事实 | complaint_filed / complaint_resolved → ComplaintParser → complaint_record | parser 写 `host_status`,事实 schema 的标准 `status` 可能仍为 null;默认非 resolved 全当 active,无法稳妥表达草稿/未知/纠正 |
| 画像和召回 | 有 complaint_history 枚举;免打扰读取 patient_profiles | 未注册投诉历史提取器;召回现有闸门读 do_not_contact,不会因写入投诉事实自动生效 |
| 生产数据 | 已核查 jvs-dw | complaint_record = 0 |
代码中的“投诉应触发 DNC”是注释意图,不代表实现。文档里“不能给投诉过的人推销”也不能直接转成永久封禁所有历史反馈的规则。
## 5. 建议的 PAC 标准字段
沿用现有 complaint 资源和事实版本链,补齐标准语义;宿主字段和编码留在适配层。**以下是建议契约,不是已经提交的代码。**
| PAC 字段 | 语义 / 建议取值 | 5i5ya 来源与约束 |
|---|---|---|
| externalId | 业务单稳定 ID | id 转字符串;唯一身份含 host/tenant/sourceUnit |
| patientExternalId | 源患者 ID | patient_id;不可缺失或猜测 |
| clinicId | 本次反馈归属诊所 | organization_id;保留原业务归属 |
| filedAt | 反馈发生时间 | complain_date,按 Asia/Shanghai 规范化 |
| createdAt / updatedAt | 源创建 / 主体版本时间 | created_gmt_at / updated_gmt_at;保留真实口径,缺值明确处理 |
| feedbackKind | complaint / praise / neutral / unknown | 先做性质分流;源关注点和分类矛盾时留证据、标待确认,禁止默认 complaint |
| status | open / in_progress / resolved / draft / unknown | 主单 2→open、1→in_progress、4→resolved、5→draft;未知不默认 open |
| category | service / clinical / billing / environment / waiting / other / unknown | 关注点 1/3/4/6/5 对应前五项;不直接把宿主数字传给下游 |
| content | 原始反馈正文 | complain_content;评分原文保留,不自行推断量表含义 |
| channel | sms_survey / hotline / third_party / sales / mini_program / unknown | 渠道字段显式映射,保留原编码和名称 |
| resolvedAt | 已完成时间,可空 | 当前已完成时取可确认的“完成”跟进时间;缺失不伪造 |
| followUps[] | 时间、动作、内容、操作人来源 ID/名称 | 聚合 follow_*;不把“编辑/转交/跟进”误作主单状态 |
| relatedOrderExternalId | 可选关联结算/单据 | settlement_number;属于单据关联,不冒充治疗事实 ID |
| sourceMetadata | 原编号、原状态、原分类/关注点/渠道、关联单号、扩展值 | 用于解释映射与复核,不让业务层依赖美化后的文本 |
`sourceUnit` 仍由现有摄入上下文从 brand 传递,不另造品牌患者体系。`severity` 没有可靠源字段,保持未知;“医疗质量”不是“严重投诉”的同义词。
`feedbackKind` 用于分流和纠正追踪:投诉事实只表示可确认的投诉/负面反馈;表扬、无不满不生成负面事实,未分类进入待核查。若已摄入的投诉后来被改成表扬或无不满,应撤销原负面事实效力并保留版本,不能简单过滤新行后让旧投诉永远有效。具体原始留存/隔离载体实施时沿用项目机制;当前不能仅靠 YAML 过滤保证这件事。
## 6. 怎么接,改动放在哪里
```mermaid
flowchart LR
A[5i5ya 反馈及跟进原始行] --> B[按品牌和单ID聚合
身份校验与性质分流]
B --> C[PAC complaint 标准资源]
B --> Q[表扬/中性/待核查
留来源与处理原因]
C --> D[现有 transaction → complaint_record 版本链]
D --> E[投诉历史与当前状态]
E --> F[按已确认规则决定召回限制]
```
1. **先保真,再接业务。** 在 jvs-dw manifest 增加源查询和变化发现;适配层还原主体+跟进,检测主字段冲突。通用引擎不硬编码 5i5ya 字段。当前 `dedup_by` 只是取首行,不能单独解决跟进聚合、最新结案时间和冲突举证。
2. **主体快照与跟进分开。** 主体键 `(brand,id)`;跟进没有源 ID,可用规范化时间、动作、人员 ID、内容构造稳定指纹,明确它只能去除完全相同记录,不能保证区分所有同内容的真实跟进。不要把跟进行分别当一次投诉。
3. **变化发现覆盖主单及跟进。** 主单新增/更新、跟进新增都应触发整张单重拉,不能只拿窗口内跟进拼一个不完整快照。可按 created/updated/follow 的有效时间并集发现变化,再按单 ID 拉完整行;具体查询需避免大范围扫描,先测试耗时。
4. **幂等和迟到是两件事。** 稳定排序后的主体+跟进可用现有 contentVersion 扩展能力留内容版本,配合事实乱序保护;重复采集不重复造投诉。但内容哈希不能发现没有被拉到的迟到记录。源表没有 ETL 入仓时间,沿用回看窗口仍有窗口外漏数边界,需要源端变更承诺或后续周期核对。
5. **初次全量只补投诉资源。** 不触发全宿主历史事实重解析或全量召回重算;需要的患者经现有身份机制补拉。无患者 ID、未知状态、分类冲突都要有数量和原因,不能悄悄吞掉。
6. **消费单独验收。** 标准事实落库成功不等于召回已避让。后续确认投诉历史展示、未完成投诉风险提示及营销联系限制,按受影响患者定向更新。不要把多年已完成记录直接写成永久 doNotContact;明确拒绝联系仍沿用独立免打扰语义。
## 7. 开始实施前最关键的确认
- **性质口径**:关注点由谁填写,classification 是否有默认值,评分量表如何定义;尤其 864 单字段矛盾和 414 单未分类记录。初版可保守隔离,不能靠猜测补齐。
- **出口覆盖**:为何瑞尔仅 11 家、部分 PAC 现有诊所无记录;投诉表与主档/其他事实出口的覆盖范围是否一致。
- **变化保证**:主单状态变更是否总更新 updated、跟进新增是否独立更新、删除/撤回如何暴露;目前字段不足以承诺全量变更不漏。
- **业务效果**:投诉中是否暂停营销召回、处理完成是否自动解除、跨诊所是否共享限制。这些是独立于“源数据存在”的产品决定。
可以先完成标准字段、保守分流、聚合、隔离与本地验证;没有确认前不默认开启永久免打扰或全历史患者封禁。
本地验收至少覆盖:跨品牌同 ID、多跟进只产一单、表扬/无不满不误标、缺患者隔离、未知状态不默认 open、结案时间缺失、跟进晚于主单 updated、重复采集不重复、乱序不旧覆新、投诉改为非投诉能撤销旧效力、缺主档可补拉且不跨品牌。
## 8. 核查依据与交付
- 数据:只读 `dw_group.fact_complain_out`,关联 `fact_client_out`;生产 PAC 只读 hosts / patients / patient_facts。ClickHouse 限单线程及查询时限,PostgreSQL 使用只读事务;未写入、未重算、未部署。
- [完整诊所明细](./2026-10-18-jvs-complaint-clinics.csv);[不含患者姓名/手机号/正文的汇总证据](./2026-10-18-jvs-complaint-evidence.json)。
- 核查先确认同键主字段无冲突,再按 `(brand,id)` 聚合;跟进用最大时间核查,计数用源枚举,不对正文做自动情绪判断。70 家逐行分类之和、品牌汇总、患者覆盖数已经本地交叉校验。
- 代码:[canonical](../../packages/types/src/schemas/canonical.ts)、[投诉 parser](../../apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts)、[事实 content schema](../../apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts)、[jvs-dw manifest](../../apps/pac-service/data/jvs-dw/manifest.yaml)、[画像注册](../../apps/pac-service/src/modules/persona/features/feature.registry.ts)、[召回闸门](../../apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts)。
本次只新增这份评估及汇总附件,不修改运行代码、不提交;主目录已有改动、本地服务和美亚工作区保持原状。
# 5i5ya 投诉标准化与增量采集:本地验收
日期:2026-10-18(Asia/Shanghai)
分支:`codex/jvs-complaint-ingestion`
基线:`main` / `76e7d5bc69e98d9f61d4a967c131c4a58d3d0207`
**投诉摄入已实现并完成本地验证。未提交、未合并、未部署;没有修改召回规则、画像提取器或免打扰字段。**
## 1. 接入方式
沿用现有链路:
`5i5ya 数仓 → jvs-dw manifest → transform / assembler → transaction → parser → patient_facts`
- 源表为 `dw_group.fact_complain_out`,没有绕过现有摄入另建定时任务。
- 患者沿用 `tenant_id=ruier-grp` 和 `identity_namespace_field=brand`,按宿主、租户、命名空间、患者外部 ID 关联。
- 反馈单按配置的 `(brand,id)` 聚合;投诉编号只用于展示。通用聚合算子不硬编码品牌或 5i5ya 字段。
- `clinicId` 保留投诉单的 `organization_id`,不改成患者最近就诊门店,不因名称字典缺失过滤诊所。
- 共用现有事务账本和事实版本表,无新数据库表、迁移或后台服务。
## 2. 标准化口径
标准字段包括:单 ID、患者 ID、诊所、发生时间、版本时间、反馈性质、投诉类别、渠道、处理状态、正文、结案时间及完整跟进数组。
| 信息 | 实现口径 |
|---|---|
| 性质 | 关注点明确为负面才标 `complaint`;表扬、中性、未知分别保留,不靠评分猜投诉 |
| 分类冲突 | 明确的服务/医疗投诉分类与关注点性质冲突,标未知并保留原因;源默认“其他投诉”不覆盖关注点 |
| 状态 | 未处理→`open`、处理中→`in_progress`、已完成→`resolved`、草稿→`draft`;未知不默认未处理 |
| 有效信号 | 性质为投诉,且状态为未处理、处理中或已完成;严重度没有可靠来源,留空 |
| 非投诉快照 | 共用版本链,以 `invalidated` 和 `signal_eligible=false` 保存;用于撤销先前投诉和阻止旧版本恢复,不计为有效投诉 |
| 跟进 | 一单多行还原为完整数组;相同跟进去重、稳定排序,保留动作原文和人员来源信息 |
| 结案时间 | 已完成时取“完成”跟进时间;没有则空,不用采集时间冒充 |
| 时间 | 主单、创建、源更新、跟进均按 `Asia/Shanghai` 解释;事实/事务落库为 UTC |
| 原文 | 保留完整原始多行封套,可从账本离线重新标准化,不依赖再次查询源端 |
`updatedAt` 是本次完整反馈快照的版本时间:`max(created_gmt_at, updated_gmt_at, follow_date)`;源主单原更新时间另存 `sourceUpdatedAt`。因此只有跟进更新,也能正确升级快照。
缺患者、缺诊所、非法时间、同键主体冲突不能强行归属;标准化阶段按单拒绝并接入现有拒绝举证。正常增量发现会先排除无患者编号的源行,这部分无法进入患者 cohort,需要保留源侧核查统计,不能声称它们已进入 PAC 拒绝账本。
## 3. 增量与幂等
**先找变化的患者,再读取该患者完整的投诉及跟进。**
- `complaint_changes` 查询用上述三个时间的最大值作游标;SQL 只过滤无法关联的患者身份,不过滤成“仅未完成”或“仅投诉”,以便捕获结案和改分类。
- `fact_complain_out` 完整查询只加现有患者 cohort 范围,不再加时间窗,避免老跟进丢失。
- 新资源没有历史游标时,通过显式 `bootstrap_full` 发现存量患者。该开关只启用于投诉变化查询,不改变其他资源的默认行为。
- 继续使用既有调度、游标、48 小时回看和患者分批机制。首轮可能带入较多患者;这些患者的其他资源仍按现有 cohort 管线处理,不是另开全宿主重解析或重算任务。
- 内容变化参与事务幂等键;同源快照乱序、重复输入保持同一键。事实使用已有快照水位/事件序号防护,同时间的分类修订能够替代旧状态,旧报文重试不能恢复旧投诉。
- 空转探针复用完整增量 SQL,支持 `changed_at` 派生别名,避免把它误当作物理列。
## 4. 实测结果
### 真实源样本 → 本地 PostgreSQL
只读抽取 **139 行、79 张反馈单**;样本含不同品牌、性质、状态、跨品牌同号、空更新时间、跟进晚于主单、缺患者等情况。原始样本仅保存在本机私有临时目录,不加入仓库。
| 检查 | 结果 |
|---|---|
| 可标准化并落库 | 65 张 |
| 缺患者编号拒绝 | 14 张 |
| 有效投诉 | 21 张:5 张未完成、16 张已完成 |
| 无效反馈快照 | 44 张:非投诉、未知、草稿或冲突,均无有效投诉信号 |
| 全样本再次摄入 | 事务、事实均无重复新增 |
| 投诉改为“无不满”,不修改源时间 | 旧投诉效力撤销;旧报文重放不复活 |
| 从已存原文批量重放历史版本 | 无新增或错误覆盖,当前版本保持正确 |
| 同号跨品牌 | 分别落库,不互相去重或覆盖 |
| 同号改挂另一个患者 | 拒绝,不搬移事实 |
| 北京跨日时间 | `2026-10-17 00:15` → `2026-10-16T16:15:00Z`;所有样本事实发生/版本时间与规范化结果逐一一致 |
验证使用独立本地数据库 `pac_complaint_verify_20261018`,应用现有迁移;测试完成后清除本次测试宿主及样本。未触碰本地应用数据库的数据,未启动/重启应用或容器。
### 生成 SQL → 数仓只读验证
直接执行新采集器生成的查询,限制单线程和查询时限:
- 首轮存量发现:**19,114 位患者**(品牌+患者 ID 去重;不是投诉人数)。
- 选取存在“跟进晚于主单更新时间”的真实患者:增量查询 **2 行**,完整查询 **4 行**。
- 确认新跟进被发现,窗口前的旧跟进仍保留。
- 派生游标探针成功执行,该验证窗口返回 **2,386 行**;不是本轮实际导入数。
### 自动验证
- 相关回归:**17 组、345 项通过**;另 1 项为可选 SQL 导出步骤,已单独启用运行并用于上述实测。
- 覆盖投诉标准化、身份隔离、时区、完整聚合、重复/乱序重放、事实批写,以及原有病历、影像、cohort、原文追溯和增量探针。
- 类型检查:后端源码及 tests 一起检查通过;共享类型构建通过。
- 没有运行召回全量计算。
## 5. 仍然存在的源端边界
1. 数仓没有 ETL 入仓时间和删除标记。48 小时回看能覆盖窗口内迟到,**不能保证发现窗口外的回填、原地修改但所有时间均未变化、物理删除**。这些需要源端补充变更保证或后续对账。
2. 跟进没有独立 ID,相同内容只能按规范化内容去重,无法区分两次内容完全一致的真实跟进。
3. 分类含义不清、患者身份缺失的记录不猜测;目前出口覆盖范围仍以源数据评估为准。
4. 本次接通数据,不表示已有投诉展示或召回避让效果;投诉如何影响召回另行设计。
源码入口:[manifest](../../apps/pac-service/data/jvs-dw/manifest.yaml)、[投诉 assembler](../../apps/pac-service/data/jvs-dw/assemblers/complaint.yaml)、[标准字段](../../packages/types/src/schemas/canonical.ts)、[parser](../../apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts)。
初始源数据调查见 [摄入评估](./2026-10-18-jvs-complaint-ingestion-assessment.md) 和 [70 家诊所汇总](./2026-10-18-jvs-complaint-clinics.csv)。初始评估描述的是修改前状态,以本验收说明为当前本地实现状态。
...@@ -496,25 +496,47 @@ export const RechargeCanonicalSchema = z ...@@ -496,25 +496,47 @@ export const RechargeCanonicalSchema = z
.passthrough(); .passthrough();
export type RechargeCanonical = z.infer<typeof RechargeCanonicalSchema>; export type RechargeCanonical = z.infer<typeof RechargeCanonicalSchema>;
/// 投诉记录(合规营销避雷的关键信号) /// 投诉/反馈快照:是否形成有效投诉信号与源记录留存分开。
/// 产品收集 export const ComplaintStatusSchema = z.enum(['open', 'in_progress', 'resolved', 'draft', 'unknown']);
export const ComplaintCanonicalSchema = z export const ComplaintFeedbackKindSchema = z.enum(['complaint', 'praise', 'neutral', 'unknown']);
.object({ export const ComplaintCategorySchema = z.enum(['service', 'clinical', 'billing', 'environment', 'waiting', 'other', 'unknown']);
externalId: z.string().min(1), export const ComplaintChannelSchema = z.enum(['sms_survey', 'hotline', 'third_party', 'sales', 'mini_program', 'unknown']);
patientExternalId: z.string().min(1), const complaintDateTime = z.iso.datetime({ offset: true });
clinicId: z.string().min(1), const optionalComplaintDateTime = z.preprocess(v => v === '' ? null : v, complaintDateTime.nullable().optional());
filedAt: isoDateTime, export const ComplaintFollowUpSchema = z.object({
/// 投诉内容自由文本 occurredAt: optionalComplaintDateTime,
action: z.enum(['transferred', 'completed', 'edited', 'followed_up', 'paused', 'unknown']),
sourceAction: z.string().nullable().optional(),
content: z.string().nullable().optional(),
actorId: z.coerce.string().nullable().optional(),
actorName: z.string().nullable().optional(),
});
const complaintId = z.union([z.string().trim().min(1), z.number().int().safe()]).transform(String);
export const ComplaintCanonicalSchema = z.object({
externalId: complaintId,
patientExternalId: complaintId,
clinicId: complaintId,
filedAt: complaintDateTime,
createdAt: optionalComplaintDateTime,
updatedAt: optionalComplaintDateTime,
sourceUpdatedAt: optionalComplaintDateTime,
content: z.string().optional().nullable(), content: z.string().optional().nullable(),
/// 关联治疗(如有)— 引用 treatment_record subject_id 或 host 治疗 id
relatedTreatmentSubjectId: z.string().optional().nullable(), relatedTreatmentSubjectId: z.string().optional().nullable(),
/// 投诉类别(host 自定义,如 service / clinical / billing) relatedOrderExternalId: z.string().optional().nullable(),
category: z.string().optional().nullable(), number: z.string().optional().nullable(),
/// 处理状态(open / resolved / pending)— host 编码,assembler.yaml enum_mapping 翻译 feedbackKind: z.preprocess(v => v == null ? undefined : v, ComplaintFeedbackKindSchema.default('unknown')),
status: z.string().optional().default('open'), // 宿主另一分类维度,仅用于识别互相矛盾的证据;没有则未知。
resolvedAt: optionalIsoDateTime, classificationKind: z.preprocess(v => v == null ? undefined : v, ComplaintFeedbackKindSchema.default('unknown')),
}) category: z.preprocess(v => v == null ? undefined : v, ComplaintCategorySchema.default('unknown')),
.passthrough(); channel: z.preprocess(v => v == null ? undefined : v, ComplaintChannelSchema.default('unknown')),
status: z.preprocess(v => v == null ? undefined : v, ComplaintStatusSchema.default('unknown')),
sourceStatus: z.string().optional().nullable(),
sourceCategory: z.string().optional().nullable(),
sourceConcern: z.string().optional().nullable(),
sourceChannel: z.string().optional().nullable(),
resolvedAt: optionalComplaintDateTime,
followUps: z.array(ComplaintFollowUpSchema).default([]),
}).passthrough();
export type ComplaintCanonical = z.infer<typeof ComplaintCanonicalSchema>; export type ComplaintCanonical = z.infer<typeof ComplaintCanonicalSchema>;
/// 转介绍记录(老客带新) /// 转介绍记录(老客带新)
...@@ -595,6 +617,7 @@ export const CanonicalResourceMeta: Record< ...@@ -595,6 +617,7 @@ export const CanonicalResourceMeta: Record<
moneyFields: readonly string[]; moneyFields: readonly string[];
moneyArrayFields: readonly { array: string; field: string }[]; // 数组里嵌套 money 字段 moneyArrayFields: readonly { array: string; field: string }[]; // 数组里嵌套 money 字段
datetimeFields: readonly string[]; datetimeFields: readonly string[];
datetimeArrayFields?: readonly { array: string; field: string }[];
booleanFields: readonly string[]; booleanFields: readonly string[];
} }
> = { > = {
...@@ -716,7 +739,8 @@ export const CanonicalResourceMeta: Record< ...@@ -716,7 +739,8 @@ export const CanonicalResourceMeta: Record<
complaint: { complaint: {
moneyFields: [], moneyFields: [],
moneyArrayFields: [], moneyArrayFields: [],
datetimeFields: ['filedAt', 'resolvedAt'], datetimeFields: ['filedAt', 'resolvedAt', 'sourceUpdatedAt'],
datetimeArrayFields: [{ array: 'followUps', field: 'occurredAt' }],
booleanFields: [], booleanFields: [],
}, },
referral: { referral: {
......
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