Commit dbc1223d by luoqi

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

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