Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
dbc1223d
Commit
dbc1223d
authored
Jul 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/incremental-sync-cadence → main(简报四问 + 消费口径实付 + 增量2h + 诊所重归属返池)
parents
736cc610
1242f896
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
472 additions
and
53 deletions
+472
-53
apps/pac-service/data/friday/assemblers/payment.yaml
+5
-3
apps/pac-service/data/jvs-dw/assemblers/payment.yaml
+8
-7
apps/pac-service/data/jvs-dw/manifest.yaml
+5
-1
apps/pac-service/src/modules/ai/calls/draft-recall-brief/call.ts
+9
-1
apps/pac-service/src/modules/ai/calls/draft-recall-brief/input.types.ts
+26
-1
apps/pac-service/src/modules/ai/calls/draft-recall-brief/prompt.ts
+45
-10
apps/pac-service/src/modules/ai/calls/draft-recall-brief/schema.ts
+3
-3
apps/pac-service/src/modules/ai/orchestrators/recall-brief.orchestrator.ts
+104
-1
apps/pac-service/src/modules/persona/features/discount-anchor.feature.ts
+7
-4
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+92
-16
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
+4
-2
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
+4
-0
apps/pac-service/src/modules/sync/pipeline/parsers/payment.parser.ts
+5
-1
apps/pac-service/src/queues/sync-incremental.scheduler.ts
+12
-0
apps/pac-service/tests/plan-engine-batch.spec.ts
+136
-1
apps/pac-service/tests/plan-engine-stale-close.spec.ts
+2
-0
packages/types/src/schemas/canonical.ts
+5
-2
No files found.
apps/pac-service/data/friday/assemblers/payment.yaml
View file @
dbc1223d
...
...
@@ -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
# 关联接诊
...
...
apps/pac-service/data/jvs-dw/assemblers/payment.yaml
View file @
dbc1223d
...
...
@@ -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
...
...
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
dbc1223d
...
...
@@ -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 告警(事件可能漏召)
...
...
apps/pac-service/src/modules/ai/calls/draft-recall-brief/call.ts
View file @
dbc1223d
...
...
@@ -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
()
...
...
apps/pac-service/src/modules/ai/calls/draft-recall-brief/input.types.ts
View file @
dbc1223d
...
...
@@ -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
{
...
...
apps/pac-service/src/modules/ai/calls/draft-recall-brief/prompt.ts
View file @
dbc1223d
import
type
{
DraftRecallBriefInput
}
from
'./input.types'
;
export
const
DRAFT_RECALL_BRIEF_PROMPT_VERSION
=
'draft_recall_brief@2026-0
6-26-a
'
;
export
const
DRAFT_RECALL_BRIEF_PROMPT_VERSION
=
'draft_recall_brief@2026-0
7-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
}
请按
三
问揉成一句话召回简报。`
;
请按
四
问揉成一句话召回简报。`
;
}
apps/pac-service/src/modules/ai/calls/draft-recall-brief/schema.ts
View file @
dbc1223d
...
...
@@ -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个月,可约洁牙检查顺带请医生评估种植
。"'
,
),
});
apps/pac-service/src/modules/ai/orchestrators/recall-brief.orchestrator.ts
View file @
dbc1223d
...
...
@@ -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
...
...
apps/pac-service/src/modules/persona/features/discount-anchor.feature.ts
View file @
dbc1223d
...
...
@@ -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
));
...
...
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
dbc1223d
...
...
@@ -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 长效)
...
...
apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
View file @
dbc1223d
...
...
@@ -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 同步)
...
...
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
View file @
dbc1223d
...
...
@@ -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
(),
...
...
apps/pac-service/src/modules/sync/pipeline/parsers/payment.parser.ts
View file @
dbc1223d
...
...
@@ -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
,
...
...
apps/pac-service/src/queues/sync-incremental.scheduler.ts
View file @
dbc1223d
...
...
@@ -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
}
});
...
...
apps/pac-service/tests/plan-engine-batch.spec.ts
View file @
dbc1223d
...
...
@@ -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'
);
// ⭐ 继承不回归
});
});
apps/pac-service/tests/plan-engine-stale-close.spec.ts
View file @
dbc1223d
...
...
@@ -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
({}),
...
...
packages/types/src/schemas/canonical.ts
View file @
dbc1223d
...
...
@@ -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
:
[],
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment