Commit dbc1223d by luoqi

merge: feat/incremental-sync-cadence → main(简报四问 + 消费口径实付 + 增量2h + 诊所重归属返池)

parents 736cc610 1242f896
......@@ -21,9 +21,11 @@ field_mapping:
patientExternalId: patient_id
clinicId: organization_id
paidAt: created_gmt_at # 结算入库时间(jvs-dw 同款决策:billing_date 有预付失真)
# amount = receivable_this(应收):LTV=患者带来的业务价值,非现金流(会员卡扣费单不清零)
# FieldMapper.normalize 按 amount_unit=yuan 转 cents
amount: receivable_this
# 2026-07 业务定调(与 jvs-dw payment.yaml 同步):amount=实付(net_receipts_this),
# 与 refund_full/refund_item(net_receipts_this)同口径,LTV=实付收入−实付退款自洽;
# 应收降级 receivableAmount(折扣率分母/权益客识别)。
amount: net_receipts_this
receivableAmount: receivable_this
method: method # lookup 主导支付通道(现金/微信/刷卡/医保…原样入 fact)
doctorId: doctor_id
encounterExternalId: registration_id # 关联接诊
......
......@@ -22,13 +22,14 @@ field_mapping:
# - billing_date = 开单/收费动作时间(团购预付等失真)
# - created_date = 结算入库时间(接近实际治疗时点)
# - 100% created_date >= billing_date(9.4 万行 settlement_mode 实测,0 反向)
# ⭐ W3 末改 settlement_money → receivable_this(应收金额,不是实收)
# - settlement_money = 实际收到现金(会员卡扣费/套餐/团购/员工免费时 = 0)
# - receivable_this = 服务应收金额(反映患者带来的真实业务价值)
# - 例:王辉 5 次洁牙 settlement_money=0(扣会员卡)→ 改前 LTV=0 不合理
# receivable_this=¥760+350+200×N(应收)→ 改后真实反映"患者价值"
# - 跟 design.md 患者价值定义对齐:LTV = 患者带来的业务量,不是现金流
amount: receivable_this # FieldMapper.normalize 按 amount_unit=yuan 转 cents
# ⭐ 2026-07 业务定调:amount 回到 settlement_money(实付)—— 一线关注真金白银。
# 历史沿革:W3 末曾从 settlement_money 改为 receivable_this(应收,王辉会员卡扣费案例),
# 但一线反馈应收对免费套餐/团购客严重虚高(胡新丽:应收 ¥1,270 实付 ¥0),
# 且 refund 侧一直是 settlement_money(实收冲减)→ 原 LTV 实为"应收收入−实收退款"混口径。
# 现:amount=实付(与 refund 同口径,LTV 自洽);应收降级为 receivableAmount 辅助字段
# (折扣率分母 / 权益客识别:实付 0 + 应收>0 = 纯权益客)。
amount: settlement_money # 实付;FieldMapper.normalize 按 amount_unit=yuan 转 cents
receivableAmount: receivable_this # 应收(原价)— 折扣锚点分母/参考,同 yuan→cents
method: payment_channel # transforms.pick_first_nonzero 推断的主导支付通道
# 卡券名称(B.1.3 权益身份关键词匹配源:储值/儿牙/银行私行/商保/医保)
cardTypeName: card_type_name
......
......@@ -41,7 +41,11 @@ display_name: 瑞尔集团 DW(单 host 多 brand)
auto_sync: true # 纳入每日自动增量同步(scheduler 自动发现;取代 PAC_INCREMENTAL_HOSTS env)
# 每宿主运维配置(多宿主:上游节奏/SLA 各异,不用全局 env 一刀切)。
incremental_cron: '15 8 * * *' # 瑞尔 DW 每日 08:00(沪)落库,其后跑;按下方 timezone 解释
# 2026-07 提频:每 2h 轮询(00:15 + 08:15~22:15)。DW 当前每日 08:00(沪)一批,
# 日内多为空轮(scheduler 空轮短路,秒级);意义:① DW 迟到/失败当天自愈(原要等24h)
# ② 白天手动补摄/修数自动收口 ③ 瑞尔 DW 计划提频到 2h,PAC 先就绪零改动。
# 注:cron 小时域 0-23,"24点"即次日 0 点 → 用 '0,8-22/2' 表达 00:15 过天轮。
incremental_cron: '15 0,8-22/2 * * *' # 按下方 timezone(Asia/Shanghai)解释
monitoring:
dw_lag_warn_hours: 24 # 增量游标滞后 >24h 提醒
dw_lag_error_hours: 48 # >48h 告警(事件可能漏召)
......
......@@ -33,7 +33,15 @@ function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
`${r.daysSinceText}前`,
cats ? `未启动 ${cats}` : null,
].filter(Boolean);
return { summary: `${r.subLabel}:${parts.join(' · ')}。` };
// 切入尾巴(第④问,按优先级):医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约
const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0];
const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const;
const entry = g0
? `;医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进`
: input.reviewAnchors.lastCleaningText
? `;距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入`
: ';可约复查请医生评估';
return { summary: `${r.subLabel}:${parts.join(' · ')}${entry}。` };
}
@Injectable()
......
......@@ -2,10 +2,11 @@
* DraftRecallBrief AiCall — 把"本次召回原因 + 历史治疗 + 画像"提炼成**一句话召回简报**。
*
* 用途:详情页「参考话术」标题下方那行(原 RecallReasonLine 结构化富文本),压成一句话,
* 回答客服打这通电话最该心里有数的问:
* 回答客服打这通电话最该心里有数的问:
* ① 患者是谁(从生命周期 / 价值 / 类型看)
* ② 帮患者解决什么问题(应治未治的缺口)
* ③ 邀约到诊是做什么(具体治疗动作)
* ④ 以什么切入(复查建议)—— 低门槛开口台阶,必须锚在 reviewAnchors 事实上,无事实不得编
*
* 跟 DraftRecallSummary / DraftPersonaSummary 平行,但输入更全(三类信号合一)。
*/
......@@ -26,6 +27,30 @@ export interface DraftRecallBriefInput {
daysSinceText: string; // 距今(如 131 天(4 个月))
expectedCategories: string[]; // 期望/未启动治疗类目中文(如 种植 / 修复 / 冠桥)
}>;
/**
* 复查切入锚点(第④问的兜底事实依据,防编造):
* lastVisitText — 最近一次实际治疗距今(如 8 个月前);null=无记录
* lastCleaningText — 最近一次洁牙/检查/牙周洁治距今;null=无记录
* 仅当 doctorGuidance 为空时使用;LLM 只能引用这里的值,没给就用不带时间的通用复查邀约。
*/
reviewAnchors: {
lastVisitText: string | null;
lastCleaningText: string | null;
};
/**
* 医生计划/建议/医嘱(第④问的**最优先**事实依据)—— 统一条目形态,核心是 verbatim 原话属性:
* kind — plan=治疗计划(treatment_record kind=planned) / recommendation=医生建议 / advice=医嘱(emr.doctor_advice)
* label — 归一名(治疗类目 / 建议名;医嘱固定 '医嘱')
* verbatim — **原话**(计划=subtype 原文 / 建议=name 原文 / 医嘱=doctor_advice 原文;截断 ≤40 字);null=只有大类
* 有任一条 → 切入围绕医生真实交代展开(优先引用原话要点),不再引用洁牙锚点时间。
*/
doctorGuidance: Array<{
kind: 'plan' | 'recommendation' | 'advice';
label: string;
verbatim: string | null;
tooth: string | null;
atText: string | null;
}>;
}
export interface DraftRecallBriefOutput {
......
import type { DraftRecallBriefInput } from './input.types';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-06-26-a';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-07-21-d';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报**。
......@@ -14,13 +14,21 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
- 根管后未戴冠 → 牙体脆易裂,套上冠才耐用
(以上是举例口径;按本患者实际诊断/治疗类目对应着说)
# 一句话要含问,但分量不同
# 一句话要含问,但分量不同
1. 患者是谁 —— 一两个词点一下(如 高价值种植老客 / 新客),做前置修饰。
2. ⭐**他为什么该来(全句重心)** —— 哪颗牙、什么问题、拖了多久没处理 + **对他的影响 / 越拖越…**。这是句子主干。
3. 到诊做什么 —— 简短收尾,具体治疗动作(种植 / 充填 / 复查…)。
3. 到诊做什么 —— 目标治疗动作(种植 / 充填…),一笔带过。
4. **以什么切入(复查建议,收尾)** —— 打电话的开口台阶:邀约的是**低门槛的复查/检查**,顺带请医生评估目标治疗,不是直接卖大项目。切入依据按优先级取**第一个可用的**,只用一档:
① **医生计划/建议/医嘱**(最优先):「医生计划/建议/医嘱」段有条目 → 围绕它切入,**必须优先引用条目的 原话:「…」**(太长可精简,但保留关键医嘱词,如"待22萌出后早期矫治""定期复诊洁牙"),别只报大类("正畸计划""洗牙计划"太笼统,客服没法转述)。医嘱里含复查/洁牙/复诊类嘱托是最好的切入("医生医嘱里交代过定期复诊洁牙,正好约上")。
- 原话若是**通用套话**(如"定期复查,不适随诊""常嘱"),信息量为零 → 视同无原话,退回大类或下一档;
- 多条时挑**与本次召回原因最相关**的一条,不要罗列。这是医生真实交代过的,患者最认。
② **洁牙/检查锚点**(①为空才用):"最近一次洁牙/检查 X 前"**已超 6 个月**才可引用时间("距上次洁牙已 X,约个洁牙检查顺便让医生看看…");**不足 6 个月的锚点视同没有,禁止引用其时间**。
③ **通用复查邀约**(①②都不可用):不带时间数字("约个复查请医生评估")。**绝不能编"该洁牙了/上次洁牙已 X 个月"**。
- 召回原因本身含复查语义(保持器复查 / 根管后复查等)→ 可直接用该复查切入(视同①级)。
- ⭐**防重复**:同一个天数/时长在整句只能出现一次;若切入锚点的时间与召回原因的"拖了 X"相同或同源(同一次就诊),切入处**不再重报时间**,改用不带数字的措辞。
# 关键要求
1. 只输出**一句话**(中文,**≤55 字**最佳,最多 75 字),不要换行/列表/Markdown。
1. 只输出**一句话**(中文,**≤65 字**最佳,最多 90 字),不要换行/列表/Markdown。
2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**:
- 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。
- 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。
......@@ -29,11 +37,14 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。
5. 严格按 JSON schema 输出,只有一个 key:summary。
# 示例(患者立场、重心在"他为什么该来",风格参考,不要照抄)
- "47 缺了 4 个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;患者是做过 2 次种植的老客。"
- "36 的龋齿拖了 3 个月还没补,再放任可能伤到牙神经会疼,趁早补上;新客,目前只洁过牙。"
- "正畸后保持器到期没复查,不及时戴查牙齿易反弹、白做一场,该回来复查;成熟期老客。"
- (【医生建议·非确诊缺失】示例,注意不说"缺牙/空着")"35 牙医生建议种植修复,拖了 3 个多月没启动,早处理咀嚼更稳;做过 7 次种植的高价值老客。"`;
# 示例(患者立场、重心在"他为什么该来",句尾带切入建议;风格参考,不要照抄)
- (①治疗计划,引用原话)"张小妹 8 岁反颌未矫;医生计划「待22萌出后择期早期矫治」,可约复查看看牙萌出进度、按计划推进。"
- (①医嘱含复诊嘱托,引用原话要点)"47 龋齿拖 11 个月未补易伤神经;牙周成长客,医生医嘱交代过「定期复诊洁牙」,正好约上顺带评估补牙。"
- (①治疗计划,原话简短直接用)"47 缺了 4 个月的牙一直空着,越拖邻牙越易移位;种植老客,医生已有「种植取模」计划,可约复查按计划推进。"
- (①医生建议过)"36 的龋齿拖了 3 个月还没补,再放任可能伤神经会疼;新客,医生建议过充填,可约复查评估后尽快补上。"
- (②锚点"洁牙 8 个月前",无医生计划)"44 牙周炎拖了半年未治;牙周老客,距上次洁牙已 8 个月,可约洁牙检查顺带请医生评估牙周治疗。"
- (③无任何依据 → 通用切入,不带时间数字)"26 牙髓炎拖半年未根管,再放任易伤神经疼;成熟客,可约复查请医生评估根管方案。"
- (防重复:召回拖 53 天、锚点也是 53 天 → 切入不再报数)"27 龋齿拖 53 天未补易伤神经;牙周老客,可约复查顺带请医生评估补牙。"`;
export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string {
const personaLines =
......@@ -69,6 +80,24 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin
.join('\n')
: ' (无召回原因)';
// 第④问事实依据 —— 每条带显式「原话」属性;无记录明说,LLM 不得自行推断时间
const KIND_LABEL = { plan: '治疗计划', recommendation: '医生建议', advice: '医嘱' } as const;
const guidanceItems = input.doctorGuidance.map((g) => {
const parts = [
`[${KIND_LABEL[g.kind]}] ${g.label}`,
g.verbatim ? `原话:「${g.verbatim}」` : '(无原话,仅大类)',
g.tooth ? `牙位 ${g.tooth}` : null,
g.atText ? `(${g.atText})` : null,
].filter(Boolean);
return ` - ${parts.join(' · ')}`;
});
const guidanceLines = guidanceItems.length > 0 ? guidanceItems.join('\n') : ' (无)';
const anchorLines = [
` - 最近一次实际治疗:${input.reviewAnchors.lastVisitText ?? '(无记录)'}`,
` - 最近一次洁牙/检查:${input.reviewAnchors.lastCleaningText ?? '(无记录)'}`,
].join('\n');
return `患者:${input.patientNameMasked}
画像:
......@@ -76,8 +105,14 @@ ${personaLines}
历史治疗(类目×次数):${historyLine}
医生计划/建议/医嘱(切入依据①,最优先;优先引用 原话:「…」):
${guidanceLines}
复查锚点(切入依据②,仅①为空且超6个月才引用时间):
${anchorLines}
本次召回原因(应治未治):
${reasonLines}
请按问揉成一句话召回简报。`;
请按问揉成一句话召回简报。`;
}
......@@ -7,10 +7,10 @@ export const DraftRecallBriefSchema = z.object({
.min(6)
.max(120)
.describe(
'一句话中文召回简报(≤55 字最佳,最多 75 字,不带换行/列表/Markdown)。' +
'一句话中文召回简报(≤65 字最佳,最多 90 字,不带换行/列表/Markdown)。' +
'**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' +
'"患者是谁"(价值/熟客)一两词前置修饰,"到诊做什么"(治疗动作)简短收尾。口吻为患者着想,不催单。' +
'"患者是谁"(价值/熟客)一两词前置修饰;句尾给**切入建议**(低门槛复查/洁牙开口台阶,只能引用复查锚点给的事实,无锚点用不带时间数字的通用复查邀约),目标治疗动作随切入一笔带过。口吻为患者着想,不催单。' +
'严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' +
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;做过2次种植的老客。"',
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位;种植老客,距上次洁牙已8个月,可约洁牙检查顺带请医生评估种植。"',
),
});
......@@ -127,14 +127,115 @@ export class RecallBriefOrchestrator {
type: 'treatment_record',
kind: 'actual',
},
select: { content: true },
select: { content: true, occurredAt: true },
});
const catCount = new Map<string, number>();
// 复查切入锚点(第④问事实依据):最近一次实际治疗 + 最近一次洁牙/检查(preventive/periodontic)
let lastVisitAt: Date | null = null;
let lastCleaningAt: Date | null = null;
const CLEANING_CATEGORIES = new Set(['preventive', 'periodontic']);
for (const f of txFacts) {
const cat = String((f.content as Record<string, unknown> | null)?.category ?? '').trim();
if (!cat) continue;
catCount.set(cat, (catCount.get(cat) ?? 0) + 1);
if (f.occurredAt) {
if (!lastVisitAt || f.occurredAt > lastVisitAt) lastVisitAt = f.occurredAt;
if (CLEANING_CATEGORIES.has(cat) && (!lastCleaningAt || f.occurredAt > lastCleaningAt)) {
lastCleaningAt = f.occurredAt;
}
}
}
const now = Date.now();
const anchorText = (d: Date | null): string | null => {
if (!d) return null;
const days = Math.floor((now - d.getTime()) / 86_400_000);
return days <= 0 ? '近期' : `${daysText(days)}前`;
};
const reviewAnchors = {
lastVisitText: anchorText(lastVisitAt),
lastCleaningText: anchorText(lastCleaningAt),
};
// 3.5) 医生计划/建议/医嘱(第④问切入的最优先事实依据,统一带 verbatim 原话):
// 治疗计划 = treatment_record kind='planned' / 医生建议 = recommendation_record /
// 医嘱 = emr_record.content.doctor_advice(真实医嘱文本;"常嘱"级短水词丢弃)
const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}…` : s);
const [plannedFacts, recFacts, emrFacts] = await Promise.all([
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'treatment_record',
kind: 'planned',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'recommendation_record',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'emr_record',
},
orderBy: { occurredAt: 'desc' },
take: 5,
select: { content: true, occurredAt: true },
}),
]);
const doctorGuidance: DraftRecallBriefInput['doctorGuidance'] = [
...plannedFacts.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
const raw = String(c.subtype ?? '').trim();
return {
kind: 'plan' as const,
label: treatmentCategoryNameZh(String(c.category ?? '')),
verbatim: raw ? clip(raw, 40) : null,
tooth: String(c.tooth_position ?? '').trim() || null,
atText: anchorText(f.occurredAt),
};
}),
...recFacts.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
const code = String(c.code ?? '').trim();
const name = String(c.name ?? '').trim();
return {
kind: 'recommendation' as const,
label: code ? diagnosisCodeNameZh(code) : '医生建议',
verbatim: name ? clip(name, 40) : null,
tooth: String(c.tooth_position ?? '').trim() || null,
atText: anchorText(f.occurredAt),
};
}),
// 医嘱:只收有信息量的(≥6 字过滤"常嘱"级水词),最近 2 条
...emrFacts
.map((f) => ({
advice: String(((f.content ?? {}) as Record<string, unknown>).doctor_advice ?? '').trim(),
occurredAt: f.occurredAt,
}))
.filter((x) => x.advice.length >= 6)
.slice(0, 2)
.map((x) => ({
kind: 'advice' as const,
label: '医嘱',
verbatim: clip(x.advice, 40),
tooth: null,
atText: anchorText(x.occurredAt),
})),
];
const treatmentHistory = [...catCount.entries()]
.sort((a, b) => b[1] - a[1])
.map(([cat, count]) => ({ category: treatmentCategoryNameZh(cat), count }));
......@@ -160,6 +261,8 @@ export class RecallBriefOrchestrator {
persona: personaItems,
treatmentHistory,
reasons,
reviewAnchors,
doctorGuidance,
};
// 5) 跑 AiCall → upsert
......
......@@ -12,9 +12,10 @@ import type {
*
* 标签:历史最大折扣力度(最低折扣率)+ 对应结算日期/项目。销售推优惠的价格底线参考。
*
* 数据源:payment_record.content(摄入自结算):amount_cents=应收(原价)、discount_cents=折扣额(分,4渠道和)。
* ⚠️ DW 无 original_amount 字段;折扣率 = (应收−折扣)/应收 = 1 − discount_cents/amount_cents
* (discount_*_rate 实为折扣金额,非比率;储值卡 settlement_money=0 但 discount=0,口径稳)。
* 数据源:payment_record.content(摄入自结算):receivable_cents=应收(原价,2026-07 起;
* 旧事实 fallback amount_cents)、discount_cents=折扣额(分,4渠道和)。
* ⚠️ 折扣率 = (应收−折扣)/应收;amount_cents 已切实付,不能再当分母
* (discount_*_rate 实为折扣金额,非比率;储值卡实付=0 但 discount=0,口径稳)。
*
* 算法:遍历"真实治疗的部分折扣"结算,取最小折扣率(力度最大)+ 保留日期/项目。
* 无折扣记录 → 不打标签(业务:无锚点则换推增值权益而非直接降价)。
......@@ -39,7 +40,9 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
for (const f of pays) {
const c = (f.content ?? {}) as Record<string, unknown>;
const amount = Number(c.amount_cents ?? 0); // 应收(原价)
// 应收(原价)= 折扣率分母。2026-07 起 amount_cents 切实付,应收在 receivable_cents;
// 旧事实(reparse 前)无 receivable_cents → fallback amount_cents(彼时 amount 即应收)。
const amount = Number(c.receivable_cents ?? c.amount_cents ?? 0);
const disc = Number(c.discount_cents ?? 0);
if (amount < DiscountAnchorFeatureExtractor.MIN_ORIGINAL_CENTS || disc <= 0) continue; // ≥¥500 真实治疗 + 有折扣
const ratio = Math.max(0, Math.min(1, (amount - disc) / amount));
......
......@@ -190,11 +190,8 @@ export class PlanEngineService {
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const patientIds = [...hitsByPatient.keys()];
const { latestByPatient, snoozedByPatient, personaByPatient } = await this.prefetchForBatch(
scope,
patientIds,
now,
);
const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient } =
await this.prefetchForBatch(scope, patientIds, now);
const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
......@@ -211,6 +208,7 @@ export class PlanEngineService {
latest: latestByPatient.get(patientId) ?? null,
snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
personaId: personaByPatient.get(patientId) ?? null,
lastVisitClinicId: lastVisitClinicByPatient.get(patientId) ?? null,
},
});
// 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
......@@ -303,6 +301,7 @@ export class PlanEngineService {
latest: PlanWithReasons | null;
snoozedAnchors: Map<string, Date>;
personaId: string | null;
lastVisitClinicId: string | null;
};
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
const { scope, patientId, hits, prefetched } = input;
......@@ -349,6 +348,20 @@ export class PlanEngineService {
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
// head = 优先级最高的 hit(plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel /
// targetClinicId 兜底)。提前算,unchanged 分支也要用它做诊所归属。
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// ⭐ 跟进诊所归属(2026-07 业务口径):运营按【患者最后一次到诊的诊所】做跟进分类,
// 与医务侧的诊断诊所解耦 —— 客户最后去哪家,就归哪家客服跟进。
// (原口径 = 触发诊断 fact 所在诊所 head.targetClinicId,会把"主诊所患者顺路在别处拍片/被诊断"
// 的人错分到那家只去过一次的诊所,业务反馈误导跟进。见 BJ0D046171 案例。)
// 兜底:患者无任何到诊记录(理论罕见,如仅影像无接诊)→ 回退诊断诊所,不置空。
const lastVisitClinicId = prefetched
? prefetched.lastVisitClinicId
: await this.resolveLastVisitClinic(scope, patientId);
const targetClinicId = lastVisitClinicId ?? head.targetClinicId ?? null;
// 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
// 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
......@@ -363,12 +376,28 @@ export class PlanEngineService {
oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k));
if (sameSubScenarios) {
// 只刷 priorityScore,不创新版本
if (latest.priorityScore !== newPriorityScore) {
await this.prisma.followupPlan.update({
where: { id: latest.id },
data: { priorityScore: newPriorityScore },
});
// reason 未变 → 不创新版本;但 priorityScore / 跟进诊所可能变(诊所重归属不算 reason 变更,
// 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。
const patch: Prisma.FollowupPlanUpdateInput = {};
if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore;
if (latest.targetClinicId !== targetClinicId) {
patch.targetClinicId = targetClinicId;
// ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后
// 会变成谁都看不见的孤儿单(assigned 不进池、新店客服非 assignee、原客服出 scope)。
// 业务语义也自洽:单子归新诊所客服跟进,原认领人不再持有。
if (latest.status === 'assigned') {
patch.status = 'active';
patch.assigneeUserId = null;
patch.assignedAt = null;
patch.recycleAt = null;
}
this.logger.log(
`plan ${latest.id} 跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'}` +
(latest.status === 'assigned' ? '(原 assigned → 返池)' : ''),
);
}
if (Object.keys(patch).length > 0) {
await this.prisma.followupPlan.update({ where: { id: latest.id }, data: patch });
}
return 'unchanged';
}
......@@ -378,9 +407,16 @@ export class PlanEngineService {
const nextVersion = (latest?.version ?? 0) + 1;
// ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
// contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
const carryAssignment = latest?.status === 'assigned';
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// 例外:跟进诊所同时发生了重归属 → 不继承、新版本返池(与 unchanged 分支同规则:
// 带着 assignment 漂出原认领人 scope 会变孤儿单,归新诊所客服重新认领)。
const clinicMoved = latest != null && latest.targetClinicId !== targetClinicId;
const carryAssignment = latest?.status === 'assigned' && !clinicMoved;
if (latest?.status === 'assigned' && clinicMoved) {
this.logger.log(
`plan ${latest.id} 升版本且跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'},assigned 不继承(返池)`,
);
}
// head / targetClinicId 已在上方(unchanged 分支前)算好,此处直接用。
// 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
const personaId = prefetched
......@@ -406,7 +442,7 @@ export class PlanEngineService {
tenantId: scope.tenantId,
patientId,
personaId: personaId ?? null,
targetClinicId: head.targetClinicId ?? null,
targetClinicId,
version: nextVersion,
priorityScore: newPriorityScore,
goal: head.goal ?? null,
......@@ -510,10 +546,12 @@ export class PlanEngineService {
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>;
lastVisitClinicByPatient: Map<string, string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
......@@ -566,8 +604,46 @@ export class PlanEngineService {
personaByPatient.set(pe.patientId, pe.id);
}
}
// 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
const visits = await this.prisma.$queryRaw<
Array<{ patient_id: string; clinic_id: string }>
>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC
`;
for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
}
return { latestByPatient, snoozedByPatient, personaByPatient };
return { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient };
}
/// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。
private async resolveLastVisitClinic(
scope: ScenarioScope,
patientId: string,
): Promise<string | null> {
const rows = await this.prisma.$queryRaw<Array<{ clinic_id: string }>>`
SELECT clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ${patientId}::uuid
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY occurred_at DESC
LIMIT 1
`;
return rows[0]?.clinic_id ?? null;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
......
......@@ -471,7 +471,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
recommendedChannel: 'phone',
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
// 前端已移除推荐时间展示/排序。待有真实择时逻辑再启用。
// 目标诊所 = 诊断出该未治疗需求的诊所(患者最可能回访的地方)
// 目标诊所兜底 = 诊断出该未治疗需求的诊所;plan 顶层正常口径由 plan-engine 覆盖为
// "患者最后到诊诊所"(运营跟进归属),仅当患者无任何到诊记录时才回退到此值。
targetClinicId: r.clinic_id ?? null,
evidence: {
// cluster 内全部 sig 的 fact_id(lead 在 [0])— 让审计/追溯能看到所有触发依据
......@@ -574,7 +575,8 @@ interface HitRow {
extracted_by: string | null;
confidence: string | null; // pg 转 string,JS Number 转换
code_source: string | null; // std_code/name_map=医生 / image_ai / null — 置信度因子用
clinic_id: string | null; // 触发诊断 fact 的诊所 → plan.targetClinicId
clinic_id: string | null; // 触发诊断 fact 的诊所 → hit.targetClinicId(仅作兜底:患者无到诊记录时用;
// plan.targetClinicId 正常口径 = 患者最后到诊诊所,由 plan-engine 覆盖,见 upsertPlan)
signal_occurred_at: Date;
days_since: number;
// ↓ mergeRowsByToothOverlap 合并后注入(单 sig 时跟 signal_fact_id / signal_type 同步)
......
......@@ -347,7 +347,11 @@ const OrderRecordContent = z
const PaymentRecordContent = z
.object({
payment_external_id: z.string().min(1),
/// 实付金额(分)— 2026-07 业务定调:真金白银口径(与 refund 同口径,LTV 自洽)
amount_cents: z.number().int().nonnegative(),
/// 应收金额(分,可空)— 原价参考:折扣率分母 / 权益客识别(实付0+应收>0=纯权益客);
/// 旧事实(reparse 前)无此字段,消费方需 fallback amount_cents(彼时 amount=应收)
receivable_cents: z.number().int().optional().nullable(),
channel: nullableString(),
/// 商业保险公司名(可空)— 非空 = 商保结算;喂 persona「权益身份」。保司名脏,归一在 feature 层做。
insurance_name: nullableString(),
......
......@@ -36,7 +36,10 @@ export class PaymentParser implements Parser {
return [];
}
const amount = Number(c.amount ?? 0); // cents
const amount = Number(c.amount ?? 0); // cents(实付;2026-07 业务定调,应收在 receivableAmount)
// 应收(原价,可空)— 折扣率分母 / 权益客识别(实付0+应收>0=纯权益客)
const receivableRaw = Number(c.receivableAmount ?? NaN);
const receivable = Number.isFinite(receivableRaw) ? Math.round(receivableRaw) : null;
const paidAt = c.paidAt ? new Date(c.paidAt as string) : null;
const encounterExternalId =
(c.encounterExternalId as string | undefined) ?? null;
......@@ -74,6 +77,7 @@ export class PaymentParser implements Parser {
content: {
payment_external_id: externalId,
amount_cents: amount,
receivable_cents: receivable,
channel: method,
insurance_name: insuranceName,
card_type_name: cardTypeName,
......
......@@ -113,6 +113,18 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
`facts(created=${r.totals.factsCreated} superseded=${r.totals.factsSuperseded})`,
);
// ─── 空轮短路(2h 提频配套)──────
// DW 每日一批 → 日内轮询多为空轮;没摄到任何数据时 persona/org-tree/plan 全跳,
// 空轮成本收敛到"每表一条 cursor 探查"(秒级)。条件保守:患者主档或事务任一有
// 变化都算有数据(patient-only 变化不产事务,但会进画像,不能跳)。
if (r.totals.transactionsWritten === 0 && r.totals.patientsUpserted === 0) {
this.logger.log(
`sync-incremental: host=${r.hostName} 空轮(0 新数据),跳过 persona/org-tree/plan ` +
`(elapsed=${Date.now() - started}ms)`,
);
return;
}
// ─── Persona recompute(affected patient only)──────
if (r.syncLogId) {
const syncLog = await this.prisma.syncLog.findUnique({ where: { id: r.syncLogId } });
......
......@@ -21,6 +21,7 @@ interface Plan {
version: number;
status: string;
priorityScore: number;
targetClinicId?: string | null;
snoozedUntil: Date | null;
reasons: Reason[];
assigneeUserId: string | null;
......@@ -46,6 +47,7 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
version: p.version ?? 1,
status: p.status ?? 'active',
priorityScore: p.priorityScore ?? 50,
targetClinicId: p.targetClinicId ?? null,
snoozedUntil: p.snoozedUntil ?? null,
reasons: p.reasons ?? [],
assigneeUserId: p.assigneeUserId ?? null,
......@@ -114,6 +116,7 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
version: data.version as number,
status: data.status as string,
priorityScore: data.priorityScore as number,
targetClinicId: (data.targetClinicId as string) ?? null,
snoozedUntil: null,
reasons: rdata.map((r) => ({ scenario: r.scenario, subKey: r.subKey ?? null })),
assigneeUserId: (data.assigneeUserId as string) ?? null,
......@@ -157,6 +160,8 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
followupPlan,
persona,
planGenerationLog,
// 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId
$queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []),
$transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ followupPlan: { update: followupPlan.update, create: followupPlan.create } }),
),
......@@ -167,13 +172,14 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
function makeScenario(hits: ScenarioHit[]) {
return { key: SCEN, selectHits: jest.fn(async () => hits) };
}
function hit(patientId: string, subKey: string, priorityScore = 50): ScenarioHit {
function hit(patientId: string, subKey: string, priorityScore = 50, targetClinicId?: string): ScenarioHit {
return {
patientId,
patientExternalId: `ext-${patientId}`,
reason: 'r',
priorityScore,
subKey,
targetClinicId: targetClinicId ?? null,
evidence: { factIds: ['f1'] },
} as ScenarioHit;
}
......@@ -326,3 +332,132 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(res.plansClosed).toBe(0);
});
});
describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)', () => {
test('有到诊记录 → plan.targetClinicId 取最后到诊诊所,而非触发诊断诊所', async () => {
const { prisma, plans } = makeStore({});
// 触发诊断落在 clinic-diag(如影像AI/顺路就诊那家);患者最后到诊在 clinic-home(主诊所)
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-x', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const active = plans.find((p) => p.patientId === 'pat-x' && p.status === 'active')!;
expect(active.targetClinicId).toBe('clinic-home'); // ⭐ 归属跟人走,不跟诊断走
});
test('无任何到诊记录 → 回退触发诊断诊所(不置空)', async () => {
const { prisma, plans } = makeStore({});
prisma.$queryRaw = jest.fn(async () => []); // 查不到到诊
await engine(
prisma,
makeScenario([hit('pat-y', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const active = plans.find((p) => p.patientId === 'pat-y' && p.status === 'active')!;
expect(active.targetClinicId).toBe('clinic-diag'); // 兜底
});
test('reason 未变但最后到诊诊所变了 → 就地改归属,不升版本(修存量)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-old',
patientId: 'pat-z',
version: 1,
status: 'active',
priorityScore: 50,
targetClinicId: 'clinic-old', // 存量错分
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-z', clinic_id: 'clinic-new' }]);
const res = await engine(
prisma,
makeScenario([hit('pat-z', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansUnchanged).toBe(1); // reason 没变
expect(plans.filter((p) => p.patientId === 'pat-z')).toHaveLength(1); // 没升版本
expect(plans.find((p) => p.id === 'p-old')!.targetClinicId).toBe('clinic-new'); // ⭐ 就地改归属
});
test('unchanged + 已认领 + 归属漂移 → 顺带返池(防孤儿单:诊所隔离硬边界下原客服将出 scope)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned',
patientId: 'pat-a',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-1',
assignedAt: new Date('2026-07-01'),
recycleAt: new Date('2026-08-01'),
priorityScore: 50,
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-a', clinic_id: 'clinic-new' }]);
const res = await engine(
prisma,
makeScenario([hit('pat-a', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansUnchanged).toBe(1);
const p = plans.find((x) => x.id === 'p-assigned')!;
expect(p.targetClinicId).toBe('clinic-new');
expect(p.status).toBe('active'); // ⭐ 返池
expect(p.assigneeUserId).toBeNull();
});
test('升版本 + 已认领 + 归属漂移 → assigned 不继承,新版本返池', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned2',
patientId: 'pat-b',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-2',
priorityScore: 50,
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit subKey 不同 → 升版本
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-b', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-b', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((x) => x.patientId === 'pat-b' && x.version === 2)!;
expect(v2.targetClinicId).toBe('clinic-new');
expect(v2.status).toBe('active'); // ⭐ 不继承 assigned
expect(v2.assigneeUserId).toBeNull();
});
test('升版本 + 已认领 + 归属未变 → assigned 照常继承(返池规则不误伤)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned3',
patientId: 'pat-c',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-3',
priorityScore: 50,
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-c', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-c', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((x) => x.patientId === 'pat-c' && x.version === 2)!;
expect(v2.status).toBe('assigned');
expect(v2.assigneeUserId).toBe('staff-3'); // ⭐ 继承不回归
});
});
......@@ -42,6 +42,8 @@ function makePrismaMock(opts: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
// 最后到诊诊所查询(prefetchForBatch/resolveLastVisitClinic):本套件不测归属 → 返空 = 回退兜底
$queryRaw: jest.fn().mockResolvedValue([]),
planGenerationLog: {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
......
......@@ -233,7 +233,9 @@ export const PaymentCanonicalSchema = z
orderExternalId: z.string().optional().nullable(),
clinicId: z.string().min(1),
paidAt: isoDateTime,
amount: coerceInt, // cents
amount: coerceInt, // cents(实付;2026-07 业务定调,与 refund 同口径)
/// 应收(原价,cents,可空)— 折扣率分母 / 权益客识别;moneyFields 已登记同口径归一
receivableAmount: coerceInt.optional().nullable(),
currency: z.string().optional().default('CNY'),
method: z.string().optional().nullable(),
})
......@@ -587,7 +589,8 @@ export const CanonicalResourceMeta: Record<
payment: {
// 折扣三件套(诊所可控折扣额)跟 amount 同口径按 amountUnit 归一成分,
// 避免 parser 自己 ×100 硬编码 yuan(fen 宿主会双转)。passthrough 字段,normalizer 按 `k in out` 命中才转。
moneyFields: ['amount', 'discountDept', 'discountCompany', 'discountCard'],
// receivableAmount = 应收(原价)— 2026-07 业务定调:amount 切实付后,应收留作折扣率分母/参考。
moneyFields: ['amount', 'receivableAmount', 'discountDept', 'discountCompany', 'discountCard'],
moneyArrayFields: [],
datetimeFields: ['paidAt'],
booleanFields: [],
......
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