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
ad73a12c
Commit
ad73a12c
authored
Jul 20, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/return-visit-entry → test(宿主回访模式 + 关闭机会闭环)
parents
7225e57e
a96424b5
Pipeline
#3400
failed in 0 seconds
Changes
13
Pipelines
1
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
405 additions
and
35 deletions
+405
-35
apps/pac-service/src/modules/plan/execution.service.ts
+3
-0
apps/pac-service/src/modules/plan/recall-suppression.ts
+17
-4
apps/pac-service/tests/recall-suppression.spec.ts
+80
-16
apps/pac-web/src/app/globals.css
+25
-0
apps/pac-web/src/components/host-admin/host-admin-app.tsx
+7
-0
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
+142
-0
apps/pac-web/src/components/plan-detail/execution-api.ts
+2
-0
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+0
-0
apps/pac-web/src/components/plans/patient-picker-rail.tsx
+8
-1
apps/pac-web/src/components/plans/use-patient-picker.ts
+4
-0
apps/pac-web/src/lib/host-message.ts
+55
-12
packages/types/src/enums/index.ts
+58
-2
packages/types/src/schemas/plan.ts
+4
-0
No files found.
apps/pac-service/src/modules/plan/execution.service.ts
View file @
ad73a12c
...
@@ -55,6 +55,8 @@ export interface SubmitExecutionInput {
...
@@ -55,6 +55,8 @@ export interface SubmitExecutionInput {
abandonOther
?:
string
;
abandonOther
?:
string
;
scheduledNextAt
?:
string
;
scheduledNextAt
?:
string
;
invalidReason
?:
string
;
invalidReason
?:
string
;
/** 关闭机会来源(宿主回访模式)—— 据 CLOSE_REASON_META 覆写抑制窗 */
closeReason
?:
string
;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId
?:
string
;
executorClinicId
?:
string
;
}
}
...
@@ -145,6 +147,7 @@ export class ExecutionService {
...
@@ -145,6 +147,7 @@ export class ExecutionService {
breakerTripped
,
breakerTripped
,
scheduledNextAt
:
input
.
scheduledNextAt
,
scheduledNextAt
:
input
.
scheduledNextAt
,
now
,
now
,
closeReason
:
input
.
closeReason
,
});
});
// ─── 4. 事务:写 execution + 更新 plan ───
// ─── 4. 事务:写 execution + 更新 plan ───
...
...
apps/pac-service/src/modules/plan/recall-suppression.ts
View file @
ad73a12c
import
{
import
{
CLOSE_REASON_META
,
CloseReason
,
EXECUTION_OUTCOME_META
,
EXECUTION_OUTCOME_META
,
ExecutionOutcome
,
ExecutionOutcome
,
BREAKER_SUPPRESS_DAYS
,
BREAKER_SUPPRESS_DAYS
,
...
@@ -12,10 +14,13 @@ const DAY_MS = 86_400_000;
...
@@ -12,10 +14,13 @@ const DAY_MS = 86_400_000;
* 优先级(高 → 低):
* 优先级(高 → 低):
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 2. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 2. 关闭机会覆写(closeReason → CLOSE_REASON_META.suppressDaysOverride):
* 弱语义关闭(识别不准/已治/其他)统一 14d 短档 —— 等 DW 同步 + 排除闸接手,
* 不套 outcome 的重抑制(如 marked_invalid 自带永久,识别不准是 PAC 的错,不罚患者)。
* 3. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
*
3
. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
*
4
. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
*
4
. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
5
. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
*
*
* @returns snoozedUntil Date,或 null(不抑制)
* @returns snoozedUntil Date,或 null(不抑制)
*/
*/
...
@@ -24,13 +29,21 @@ export function resolveSnoozedUntil(params: {
...
@@ -24,13 +29,21 @@ export function resolveSnoozedUntil(params: {
breakerTripped
:
boolean
;
breakerTripped
:
boolean
;
scheduledNextAt
?:
string
|
null
;
scheduledNextAt
?:
string
|
null
;
now
:
Date
;
now
:
Date
;
closeReason
?:
string
|
null
;
}):
Date
|
null
{
}):
Date
|
null
{
const
{
outcome
,
breakerTripped
,
scheduledNextAt
,
now
}
=
params
;
const
{
outcome
,
breakerTripped
,
scheduledNextAt
,
now
,
closeReason
}
=
params
;
if
(
breakerTripped
)
{
if
(
breakerTripped
)
{
return
new
Date
(
now
.
getTime
()
+
BREAKER_SUPPRESS_DAYS
*
DAY_MS
);
return
new
Date
(
now
.
getTime
()
+
BREAKER_SUPPRESS_DAYS
*
DAY_MS
);
}
}
const
closeOverride
=
closeReason
?
CLOSE_REASON_META
[
closeReason
as
CloseReason
]?.
suppressDaysOverride
:
undefined
;
if
(
closeOverride
!=
null
)
{
return
new
Date
(
now
.
getTime
()
+
closeOverride
*
DAY_MS
);
}
const
meta
=
EXECUTION_OUTCOME_META
[
outcome
as
ExecutionOutcome
];
const
meta
=
EXECUTION_OUTCOME_META
[
outcome
as
ExecutionOutcome
];
if
(
meta
?.
suppressDays
!=
null
)
{
if
(
meta
?.
suppressDays
!=
null
)
{
return
new
Date
(
now
.
getTime
()
+
meta
.
suppressDays
*
DAY_MS
);
return
new
Date
(
now
.
getTime
()
+
meta
.
suppressDays
*
DAY_MS
);
...
...
apps/pac-service/tests/recall-suppression.spec.ts
View file @
ad73a12c
...
@@ -2,9 +2,10 @@ import {
...
@@ -2,9 +2,10 @@ import {
EXECUTION_OUTCOME_META
,
EXECUTION_OUTCOME_META
,
SUPPRESS_PERMANENT_DAYS
,
SUPPRESS_PERMANENT_DAYS
,
BREAKER_SUPPRESS_DAYS
,
BREAKER_SUPPRESS_DAYS
,
CLOSE_REASON_META
,
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
resolveSnoozedUntil
}
from
'../src/modules/plan/recall-suppression'
;
import
{
resolveSnoozedUntil
}
from
'../src/modules/plan/recall-suppression'
;
import
{
computeLikelihoodBonus
}
from
'../src/modules/plan/engine/priority-scorer'
;
/**
/**
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
...
@@ -82,21 +83,8 @@ describe('召回抑制窗 snoozedUntil 计算', () => {
...
@@ -82,21 +83,8 @@ describe('召回抑制窗 snoozedUntil 计算', () => {
});
});
});
});
describe
(
'优先级:considering 不再加权(GAP2)'
,
()
=>
{
// 「优先级:considering 不再加权(GAP2)」测试块已删 —— computeLikelihoodBonus 在
test
(
'考虑中不应把患者顶到列表最前(冷静期内 + 不加权)'
,
()
=>
{
// 7eb8dbe(三源信号统一标定)重构中移除,likelihood 并入 calcPriority,该行为由引擎侧覆盖。
expect
(
computeLikelihoodBonus
(
3
,
[{
outcome
:
'considering'
}])).
toBe
(
0
);
});
test
(
'真实正向进展(成功转化 / 改约)仍 +4'
,
()
=>
{
expect
(
computeLikelihoodBonus
(
3
,
[{
outcome
:
'success_appointed'
}])).
toBe
(
4
);
expect
(
computeLikelihoodBonus
(
3
,
[{
outcome
:
'rescheduled'
}])).
toBe
(
4
);
});
test
(
'无近期正向执行 → 仅 riskBonus'
,
()
=>
{
expect
(
computeLikelihoodBonus
(
3
,
[{
outcome
:
'no_answer'
}])).
toBe
(
0
);
// risk=3 → riskBonus 0
expect
(
computeLikelihoodBonus
(
0
,
[])).
toBe
(
6
);
// risk=0 → (3-0)*2=6
});
});
describe
(
'抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays'
,
()
=>
{
describe
(
'抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays'
,
()
=>
{
test
(
'每个 outcome 都声明了 suppressDays(number 或 null),无遗漏'
,
()
=>
{
test
(
'每个 outcome 都声明了 suppressDays(number 或 null),无遗漏'
,
()
=>
{
...
@@ -110,3 +98,79 @@ describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () =
...
@@ -110,3 +98,79 @@ describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () =
}
}
});
});
});
});
describe
(
'关闭机会 closeReason 覆写(CLOSE_REASON_META.suppressDaysOverride)'
,
()
=>
{
const
NOW
=
new
Date
(
'2026-06-01T00:00:00Z'
);
const
daysFromNow
=
(
d
:
Date
|
null
)
=>
d
==
null
?
null
:
Math
.
round
((
d
.
getTime
()
-
NOW
.
getTime
())
/
86
_400_000
);
test
(
'识别不准确 → marked_invalid 自带永久被覆写成 14d 默认档'
,
()
=>
{
const
meta
=
CLOSE_REASON_META
.
inaccurate
;
const
r
=
resolveSnoozedUntil
({
outcome
:
meta
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'inaccurate'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
);
});
test
(
'已完成治疗 / 其他原因 → abandoned 自带 60d 被覆写成 14d'
,
()
=>
{
for
(
const
reason
of
[
'treated'
,
'other'
]
as
const
)
{
const
r
=
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
[
reason
].
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
reason
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
);
}
});
test
(
'无法联系客户 → 覆写成熔断口径 30d'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
unreachable
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'unreachable'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
30
);
});
test
(
'强语义原因无覆写 → 沿用 outcome 自带窗(拒绝 90d / 竞品永久)'
,
()
=>
{
expect
(
daysFromNow
(
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
no_intent
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'no_intent'
,
})),
).
toBe
(
90
);
expect
(
daysFromNow
(
resolveSnoozedUntil
({
outcome
:
CLOSE_REASON_META
.
competitor
.
outcome
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'competitor'
,
})),
).
toBe
(
SUPPRESS_PERMANENT_DAYS
);
});
test
(
'未知 / 空 closeReason → 不影响原有路径'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
'refused'
,
breakerTripped
:
false
,
now
:
NOW
,
closeReason
:
'bogus'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
90
);
const
r2
=
resolveSnoozedUntil
({
outcome
:
'refused'
,
breakerTripped
:
false
,
now
:
NOW
});
expect
(
daysFromNow
(
r2
)).
toBe
(
90
);
});
test
(
'熔断优先级仍高于 closeReason 覆写'
,
()
=>
{
const
r
=
resolveSnoozedUntil
({
outcome
:
'abandoned'
,
breakerTripped
:
true
,
now
:
NOW
,
closeReason
:
'treated'
,
});
expect
(
daysFromNow
(
r
)).
toBe
(
BREAKER_SUPPRESS_DAYS
);
});
test
(
'每个 closeReason 的 outcome 都是合法 EXECUTION_OUTCOME_META 键'
,
()
=>
{
for
(
const
meta
of
Object
.
values
(
CLOSE_REASON_META
))
{
expect
(
EXECUTION_OUTCOME_META
[
meta
.
outcome
]).
toBeDefined
();
}
});
});
apps/pac-web/src/app/globals.css
View file @
ad73a12c
...
@@ -180,3 +180,28 @@ body {
...
@@ -180,3 +180,28 @@ body {
to
{
opacity
:
1
;
transform
:
translateY
(
0
)
scale
(
1
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)
scale
(
1
);
}
}
}
.animate-lyricIn
{
animation
:
lyricIn
0.32s
cubic-bezier
(
0.22
,
1
,
0.36
,
1
);
}
.animate-lyricIn
{
animation
:
lyricIn
0.32s
cubic-bezier
(
0.22
,
1
,
0.36
,
1
);
}
/* ── 统一滚动条 —— Windows 默认滚动条过粗突兀;细轨 + slate 拇指,贴 PAC 中性色。
现代 Chrome/Edge/Firefox 走标准属性(scrollbar-width/color);
Safari 及旧 Chromium 走 ::-webkit-scrollbar(标准属性生效时自动忽略 webkit 版)。 */
*
{
scrollbar-width
:
thin
;
scrollbar-color
:
rgb
(
203
213
225
)
transparent
;
/* slate-300 / 轨道透明 */
}
::-webkit-scrollbar
{
width
:
8px
;
height
:
8px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
rgb
(
203
213
225
);
border-radius
:
4px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
rgb
(
148
163
184
);
/* slate-400 */
}
::-webkit-scrollbar-corner
{
background
:
transparent
;
}
apps/pac-web/src/components/host-admin/host-admin-app.tsx
View file @
ad73a12c
...
@@ -20,6 +20,7 @@ import {
...
@@ -20,6 +20,7 @@ import {
SelectTrigger
,
SelectTrigger
,
SelectValue
,
SelectValue
,
}
from
'@/components/ui/select'
;
}
from
'@/components/ui/select'
;
import
{
isPostMessageSentinel
}
from
'@/lib/host-message'
;
import
{
hostApi
}
from
'./host-api'
;
import
{
hostApi
}
from
'./host-api'
;
import
{
useConfirm
}
from
'./use-confirm'
;
import
{
useConfirm
}
from
'./use-confirm'
;
import
{
SecretRevealDialog
}
from
'./secret-reveal-dialog'
;
import
{
SecretRevealDialog
}
from
'./secret-reveal-dialog'
;
...
@@ -630,6 +631,12 @@ function ActionUrlsCard({
...
@@ -630,6 +631,12 @@ function ActionUrlsCard({
))
}
))
}
</
div
>
</
div
>
)
}
)
}
{
/* 哨兵 postMessage 依赖 HOST_ORIGIN 作 targetOrigin —— 缺配时动作按钮不会渲染 */
}
{
isPostMessageSentinel
(
val
)
&&
!
(
draft
.
HOST_ORIGIN
??
''
).
trim
()
&&
(
<
div
className=
"text-[10.5px] text-amber-600"
>
配了 postMessage 但未配 HOST_ORIGIN,该动作按钮不会显示
</
div
>
)
}
</
div
>
</
div
>
);
);
})
}
})
}
...
...
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
0 → 100644
View file @
ad73a12c
'use client'
;
import
{
useState
}
from
'react'
;
import
{
CLOSE_REASON_META
,
CloseReason
}
from
'@pac/types'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
,
DialogFooter
,
}
from
'@/components/ui/dialog'
;
/**
* 关闭机会弹窗 —— 宿主回访模式下(顶栏有「回访」按钮)替代通话结果区的关闭入口。
* 原因清单 / 说明必填 / outcome 映射 全部来自 CLOSE_REASON_META(@pac/types 单一真理源);
* 提交行为由调用方 onConfirm 承接(execution 通道 + 需要时双写召回反馈)。
*/
const
CLOSE_REASONS
=
(
Object
.
keys
(
CLOSE_REASON_META
)
as
CloseReason
[]).
map
((
key
)
=>
({
key
,
...
CLOSE_REASON_META
[
key
],
}));
export
type
CloseReasonKey
=
CloseReason
;
export
function
CloseOpportunityDialog
({
open
,
onOpenChange
,
onConfirm
,
}:
{
open
:
boolean
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
/** 确认关闭(reason + 其他原因时的说明)。未接后端前由调用方决定提示行为。 */
onConfirm
?:
(
reason
:
CloseReasonKey
,
note
?:
string
)
=>
void
;
})
{
const
[
reason
,
setReason
]
=
useState
<
CloseReasonKey
|
null
>
(
null
);
const
[
note
,
setNote
]
=
useState
(
''
);
const
selected
=
CLOSE_REASONS
.
find
((
r
)
=>
r
.
key
===
reason
);
const
needNote
=
selected
?.
needNote
===
true
;
const
canConfirm
=
!!
reason
&&
(
!
needNote
||
note
.
trim
().
length
>
0
);
const
reset
=
()
=>
{
setReason
(
null
);
setNote
(
''
);
};
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
(
v
)
=>
{
onOpenChange
(
v
);
if
(
!
v
)
reset
();
}
}
>
<
DialogContent
className=
"sm:max-w-[520px]"
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[15px]"
>
关闭机会
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-3"
>
<
div
className=
"text-[12.5px] font-medium text-slate-900"
>
选择关闭原因
<
span
className=
"text-rose-600"
>
*
</
span
>
</
div
>
{
/* 原因单选卡:两列;「其他原因」独占整行 */
}
<
div
className=
"grid grid-cols-2 gap-2"
>
{
CLOSE_REASONS
.
map
((
r
)
=>
{
const
active
=
reason
===
r
.
key
;
return
(
<
button
key=
{
r
.
key
}
type=
"button"
onClick=
{
()
=>
setReason
(
r
.
key
)
}
className=
{
cn
(
'flex items-center gap-2 rounded-md border px-3 py-2.5 text-left text-[12.5px] transition-colors'
,
r
.
key
===
'other'
&&
'col-span-2'
,
active
?
'border-teal-400 bg-teal-50 font-medium text-teal-800'
:
'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50/50'
,
)
}
>
<
span
className=
{
cn
(
'flex h-3.5 w-3.5 flex-none items-center justify-center rounded-full border'
,
active
?
'border-teal-600'
:
'border-slate-300'
,
)
}
>
{
active
&&
<
span
className=
"h-2 w-2 rounded-full bg-teal-600"
/>
}
</
span
>
{
r
.
labelZh
}
</
button
>
);
})
}
</
div
>
{
needNote
&&
(
<
div
className=
"space-y-1.5"
>
<
div
className=
"text-[12.5px] font-medium text-slate-900"
>
请描述具体原因
<
span
className=
"text-rose-600"
>
*
</
span
>
</
div
>
<
textarea
value=
{
note
}
onChange=
{
(
e
)
=>
setNote
(
e
.
target
.
value
)
}
rows=
{
3
}
placeholder=
"请详细描述关闭机会的具体原因,以便后续分析和改进…"
className=
"w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-200 focus:border-teal-400"
/>
</
div
>
)
}
{
selected
&&
(
<
div
className=
"rounded-md border border-teal-100 bg-teal-50/60 px-3 py-2"
>
<
div
className=
"text-[11.5px] font-semibold text-teal-800"
>
已选关闭原因
</
div
>
<
div
className=
"mt-0.5 text-[12px] text-slate-700"
>
{
selected
.
labelZh
}
·
{
selected
.
desc
}
</
div
>
</
div
>
)
}
</
div
>
<
DialogFooter
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
()
=>
onOpenChange
(
false
)
}
>
取消
</
Button
>
<
Button
size=
"sm"
disabled=
{
!
canConfirm
}
onClick=
{
()
=>
{
if
(
!
reason
)
return
;
onConfirm
?.(
reason
,
needNote
?
note
.
trim
()
:
undefined
);
onOpenChange
(
false
);
reset
();
}
}
>
确认关闭
</
Button
>
</
DialogFooter
>
</
DialogContent
>
</
Dialog
>
);
}
apps/pac-web/src/components/plan-detail/execution-api.ts
View file @
ad73a12c
...
@@ -16,6 +16,8 @@ export interface SubmitExecutionBody {
...
@@ -16,6 +16,8 @@ export interface SubmitExecutionBody {
abandonReasons
?:
string
[];
abandonReasons
?:
string
[];
abandonOther
?:
string
;
abandonOther
?:
string
;
scheduledNextAt
?:
string
;
scheduledNextAt
?:
string
;
/** 关闭机会来源(宿主回访模式弹窗)—— 服务端据此覆写抑制窗 */
closeReason
?:
string
;
}
}
/**
/**
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
ad73a12c
This diff is collapsed.
Click to expand it.
apps/pac-web/src/components/plans/patient-picker-rail.tsx
View file @
ad73a12c
...
@@ -116,10 +116,17 @@ export function PatientPickerRail({
...
@@ -116,10 +116,17 @@ export function PatientPickerRail({
}
}
},
[
view
,
loading
,
total
]);
},
[
view
,
loading
,
total
]);
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。
// 终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除,
// 与认领/返池同款处理;all 视图仍显示全部 → 只改状态角标。
const
sync
=
usePlanSyncStore
();
const
sync
=
usePlanSyncStore
();
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
sync
.
planId
)
return
;
if
(
!
sync
.
planId
)
return
;
const
terminal
=
sync
.
status
===
'completed'
||
sync
.
status
===
'abandoned'
;
if
(
terminal
&&
view
!==
'all'
)
{
removeItem
(
sync
.
planId
);
return
;
}
const
patch
:
Partial
<
PlanListItem
>
=
{};
const
patch
:
Partial
<
PlanListItem
>
=
{};
if
(
sync
.
status
)
patch
.
status
=
sync
.
status
as
PlanListItem
[
'status'
];
if
(
sync
.
status
)
patch
.
status
=
sync
.
status
as
PlanListItem
[
'status'
];
if
(
sync
.
outcome
)
patch
.
lastOutcome
=
sync
.
outcome
as
ExecutionOutcome
;
if
(
sync
.
outcome
)
patch
.
lastOutcome
=
sync
.
outcome
as
ExecutionOutcome
;
...
...
apps/pac-web/src/components/plans/use-patient-picker.ts
View file @
ad73a12c
...
@@ -55,6 +55,10 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
...
@@ -55,6 +55,10 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
try
{
try
{
const
q
:
Partial
<
ListPlansQuery
>
=
{
const
q
:
Partial
<
ListPlansQuery
>
=
{
view
:
filters
.
view
,
view
:
filters
.
view
,
// 「我的」= 我手头进行中的活(assigned)。服务端 view=mine 返回我名下全部状态
// (为旧列表页子 tab 设计),不显式过滤会把已结案/已放弃也列出来 —— 与召回池
// (服务端硬过滤 active)口径对齐,终态单不进工作台左栏。
status
:
filters
.
view
===
'mine'
?
'assigned'
:
undefined
,
keyword
:
filters
.
keyword
||
undefined
,
keyword
:
filters
.
keyword
||
undefined
,
sort
:
filters
.
sort
,
sort
:
filters
.
sort
,
phoneVerified
:
filters
.
phoneVerified
,
phoneVerified
:
filters
.
phoneVerified
,
...
...
apps/pac-web/src/lib/host-message.ts
View file @
ad73a12c
...
@@ -2,31 +2,74 @@
...
@@ -2,31 +2,74 @@
import
{
toast
}
from
'sonner'
;
import
{
toast
}
from
'sonner'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
useAuthStore
}
from
'@/stores/auth-store'
;
import
{
fillActionUrl
}
from
'@/lib/action-url'
;
/**
/**
* PAC → 宿主 的动作事件(postMessage)。单向,信封:source / type / action / payload。
* 宿主动作派发(潜在治疗 / 回访)—— 模式由 actionUrls[key] 的值形态决定:
* { source: 'pac', type: 'action', action, payload: { patientId } }
*
* (与对接方约定的契约一致 —— type 固定 'action',宿主据 type+source 过滤本类消息。)
* 未配置 → 动作不可用,按钮不渲染(PAC 默认无此动作)
* URL 模板 → 占位替换后打开宿主页(推荐姿势,同 CREATE_APPOINTMENT)
* 哨兵 'postMessage' → 向宿主父窗发动作事件,宿主监听后弹自己的组件
* (兜底通道,限"宿主动作是弹窗/组件、没有独立 URL"的场景;
* 需同时配 HOST_ORIGIN 作 targetOrigin,否则视为配置错误 → 不可用)
*
*
* 用于"宿主动作是弹窗/组件、没有独立 URL"的场景:PAC 只喊一声 + 带 patientId,
* postMessage 信封(与对接方约定的契约一致,宿主据 type+source 过滤):
* 宿主父页监听后弹自己的组件。targetOrigin 走 host 配置 actionUrls.HOST_ORIGIN(不允许 '*')。
* { source: 'pac', type: 'action', action, payload: { patientId } }
* 不做 iframe 判断:按钮固定就是这套;非嵌入时 targetOrigin 与当前窗口不符,浏览器自动丢弃(无副作用)。
* targetOrigin 走 actionUrls.HOST_ORIGIN(不允许 '*')。不做 iframe 判断:
* 非嵌入时 targetOrigin 与当前窗口不符,浏览器自动丢弃(无副作用)。
*/
*/
export
type
HostAction
=
'OPEN_POTENTIAL_TREATMENT'
|
'OPEN_RETURN_VISIT'
;
export
type
HostAction
=
'OPEN_POTENTIAL_TREATMENT'
|
'OPEN_RETURN_VISIT'
;
/** 哨兵值:配在 actionUrls[key] 上表示该动作走 postMessage 而非 URL 跳转(比较不区分大小写)。 */
export
const
POST_MESSAGE_SENTINEL
=
'postMessage'
;
/** 值是否哨兵 —— 大小写不敏感:裸词不可能是合法 URL 模板,宽容拼写不会误伤 URL 配置。 */
export
function
isPostMessageSentinel
(
value
:
string
|
null
|
undefined
):
boolean
{
return
value
?.
trim
().
toLowerCase
()
===
POST_MESSAGE_SENTINEL
.
toLowerCase
();
}
/** 宿主 origin(postMessage targetOrigin)—— 来自会话下发的 actionUrls.HOST_ORIGIN。 */
/** 宿主 origin(postMessage targetOrigin)—— 来自会话下发的 actionUrls.HOST_ORIGIN。 */
export
function
hostOrigin
():
string
|
undefined
{
export
function
hostOrigin
():
string
|
undefined
{
return
useAuthStore
.
getState
().
user
?.
actionUrls
?.
HOST_ORIGIN
||
undefined
;
return
useAuthStore
.
getState
().
user
?.
actionUrls
?.
HOST_ORIGIN
||
undefined
;
}
}
/** 向宿主父窗发一条动作事件。未配置 origin 时提示并返 false。 */
function
rawValue
(
key
:
HostAction
):
string
|
undefined
{
export
function
postToHost
(
action
:
HostAction
,
patientId
:
string
):
boolean
{
return
useAuthStore
.
getState
().
user
?.
actionUrls
?.[
key
]?.
trim
()
||
undefined
;
const
origin
=
hostOrigin
();
}
if
(
!
origin
)
{
toast
(
'未配置宿主 Origin'
,
{
description
:
'请在宿主管理页配置 actionUrls.HOST_ORIGIN'
});
/**
* 宿主动作可用性(按钮渲染依据)。
* 返回 undefined = 不可用(未配置,或配了哨兵却没配 HOST_ORIGIN)。
*/
export
function
hostActionMode
(
key
:
HostAction
):
'url'
|
'postMessage'
|
undefined
{
const
raw
=
rawValue
(
key
);
if
(
!
raw
)
return
undefined
;
if
(
isPostMessageSentinel
(
raw
))
return
hostOrigin
()
?
'postMessage'
:
undefined
;
return
'url'
;
}
/**
* 按配置模式派发宿主动作。不可用时提示并返 false(正常按钮已按 hostActionMode 隐藏,
* 此处兜底会话中途配置变化的竞态)。
*/
export
function
openHostAction
(
key
:
HostAction
,
ctx
:
Record
<
string
,
string
|
null
|
undefined
>
,
):
boolean
{
const
raw
=
rawValue
(
key
);
const
mode
=
hostActionMode
(
key
);
if
(
!
raw
||
!
mode
)
{
toast
(
'宿主动作未配置'
,
{
description
:
`请在宿主管理页配置 actionUrls.
${
key
}
`
});
return
false
;
return
false
;
}
}
if
(
mode
===
'url'
)
{
window
.
open
(
fillActionUrl
(
raw
,
ctx
),
'_top'
);
return
true
;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
window
.
parent
.
postMessage
({
source
:
'pac'
,
type
:
'action'
,
action
,
payload
:
{
patientId
}
},
origin
);
window
.
parent
.
postMessage
(
{
source
:
'pac'
,
type
:
'action'
,
action
:
key
,
payload
:
{
patientId
:
ctx
.
patientId
??
''
}
},
hostOrigin
()
!
,
);
return
true
;
return
true
;
}
}
packages/types/src/enums/index.ts
View file @
ad73a12c
...
@@ -103,8 +103,9 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde
...
@@ -103,8 +103,9 @@ export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholde
ADD_NOTE
:
{
label
:
'添加备注'
,
placeholders
:
[
'{patientId}'
]
},
ADD_NOTE
:
{
label
:
'添加备注'
,
placeholders
:
[
'{patientId}'
]
},
OPEN_WECOM_CHAT
:
{
label
:
'打开企微会话'
,
placeholders
:
[
'{patientId}'
,
'{wecomExternalUserId}'
]
},
OPEN_WECOM_CHAT
:
{
label
:
'打开企微会话'
,
placeholders
:
[
'{patientId}'
,
'{wecomExternalUserId}'
]
},
CALL_PATIENT
:
{
label
:
'拨号'
,
placeholders
:
[
'{phone}'
]
},
CALL_PATIENT
:
{
label
:
'拨号'
,
placeholders
:
[
'{phone}'
]
},
OPEN_POTENTIAL_TREATMENT
:
{
label
:
'打开潜在治疗(postMessage)'
,
placeholders
:
[
'{patientId}'
]
},
// OPEN_* 两键:配 URL 模板走跳转(推荐);宿主无独立页时配哨兵值 postMessage 走消息通道(需 HOST_ORIGIN)。未配置则按钮不渲染。
OPEN_RETURN_VISIT
:
{
label
:
'打开回访(postMessage)'
,
placeholders
:
[
'{patientId}'
]
},
OPEN_POTENTIAL_TREATMENT
:
{
label
:
'打开潜在治疗(URL 模板;或配 postMessage)'
,
placeholders
:
[
'{patientId}'
,
'{brandId}'
,
'{clinicId}'
,
'{medicalRecordNumber}'
]
},
OPEN_RETURN_VISIT
:
{
label
:
'打开回访(URL 模板;或配 postMessage)'
,
placeholders
:
[
'{patientId}'
,
'{brandId}'
,
'{clinicId}'
,
'{medicalRecordNumber}'
]
},
HOST_ORIGIN
:
{
label
:
'宿主 Origin(postMessage 目标源)'
,
placeholders
:
[]
},
HOST_ORIGIN
:
{
label
:
'宿主 Origin(postMessage 目标源)'
,
placeholders
:
[]
},
};
};
...
@@ -612,6 +613,61 @@ export const RECALL_FEEDBACK_OPTIONS = [
...
@@ -612,6 +613,61 @@ export const RECALL_FEEDBACK_OPTIONS = [
{
value
:
'not_worth'
,
labelZh
:
'不值得召'
,
hint
:
'价值低 / 不是真机会'
},
{
value
:
'not_worth'
,
labelZh
:
'不值得召'
,
hint
:
'价值低 / 不是真机会'
},
]
as
const
;
]
as
const
;
// =============================================================
// 关闭机会(宿主回访模式)— 关闭原因封闭集 + 到执行结果的映射
// =============================================================
/// 弱语义关闭原因的默认抑制档:一个 DW 同步周期 + 排除闸接手绰绰有余,
/// 又不至于把真机会闷死(介于「再考虑 7d」与「拒绝 90d」之间)。
export
const
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
=
14
;
export
const
CloseReason
=
{
NO_INTENT
:
'no_intent'
,
INACCURATE
:
'inaccurate'
,
COMPETITOR
:
'competitor'
,
PRICE
:
'price'
,
TREATED
:
'treated'
,
UNREACHABLE
:
'unreachable'
,
OTHER
:
'other'
,
}
as
const
;
export
type
CloseReason
=
(
typeof
CloseReason
)[
keyof
typeof
CloseReason
];
export
const
CloseReasonSchema
=
z
.
enum
([
'no_intent'
,
'inaccurate'
,
'competitor'
,
'price'
,
'treated'
,
'unreachable'
,
'other'
,
]);
/// 关闭原因单一真理源 —— 关闭走现有 execution 通道(channel='other'),不另起机制:
/// outcome — 映射到的执行结果(状态机 / 触达账本 / 熔断口径全继承)
/// suppressDaysOverride — 覆写 outcome 自带抑制窗;未设则用 EXECUTION_OUTCOME_META.suppressDays。
/// 弱语义项(识别不准/已治/其他)统一 14d 默认档;无法联系取熔断口径 30d
/// recallFeedbackDown — 该原因本质是「召回不准」→ 前端提交时双写 recall-feedback(down),
/// 宿主模式下反馈数据流不因拇指控件隐藏而断
/// needNote — 必填文字说明
export
const
CLOSE_REASON_META
:
Record
<
CloseReason
,
{
labelZh
:
string
;
desc
:
string
;
outcome
:
ExecutionOutcome
;
suppressDaysOverride
?:
number
;
recallFeedbackDown
?:
boolean
;
needNote
?:
boolean
;
}
>
=
{
no_intent
:
{
labelZh
:
'客户无意愿'
,
desc
:
'客户明确表示没有治疗意愿'
,
outcome
:
'refused'
},
inaccurate
:
{
labelZh
:
'机会识别不准确'
,
desc
:
'识别的治疗机会与患者实际情况不符'
,
outcome
:
'marked_invalid'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
recallFeedbackDown
:
true
},
competitor
:
{
labelZh
:
'选择竞品机构'
,
desc
:
'客户已选择或倾向其他机构治疗'
,
outcome
:
'external_treatment'
},
price
:
{
labelZh
:
'价格因素'
,
desc
:
'客户因价格原因暂不考虑治疗'
,
outcome
:
'refused'
},
treated
:
{
labelZh
:
'已完成治疗'
,
desc
:
'客户已完成该机会对应的治疗'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
},
unreachable
:
{
labelZh
:
'无法联系客户'
,
desc
:
'多次尝试后仍无法联系到客户'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
30
},
other
:
{
labelZh
:
'其他原因(需填写说明)'
,
desc
:
'需要进一步说明具体原因'
,
outcome
:
'abandoned'
,
suppressDaysOverride
:
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS
,
needNote
:
true
},
};
export
const
ExecutionChannel
=
{
export
const
ExecutionChannel
=
{
PHONE
:
'phone'
,
PHONE
:
'phone'
,
WECOM
:
'wecom'
,
WECOM
:
'wecom'
,
...
...
packages/types/src/schemas/plan.ts
View file @
ad73a12c
...
@@ -3,6 +3,7 @@ import {
...
@@ -3,6 +3,7 @@ import {
AbandonReasonSchema
,
AbandonReasonSchema
,
AssetSourceSchema
,
AssetSourceSchema
,
AssetStatusSchema
,
AssetStatusSchema
,
CloseReasonSchema
,
ExecutionChannelSchema
,
ExecutionChannelSchema
,
ExecutionOutcomeSchema
,
ExecutionOutcomeSchema
,
PlanStatusSchema
,
PlanStatusSchema
,
...
@@ -236,6 +237,9 @@ export const SubmitExecutionRequestSchema = z.object({
...
@@ -236,6 +237,9 @@ export const SubmitExecutionRequestSchema = z.object({
abandonReasons
:
z
.
array
(
AbandonReasonSchema
).
max
(
20
).
optional
(),
abandonReasons
:
z
.
array
(
AbandonReasonSchema
).
max
(
20
).
optional
(),
abandonOther
:
z
.
string
().
optional
(),
abandonOther
:
z
.
string
().
optional
(),
scheduledNextAt
:
z
.
string
().
optional
(),
scheduledNextAt
:
z
.
string
().
optional
(),
/// 关闭机会来源(宿主回访模式弹窗):服务端据 CLOSE_REASON_META 覆写抑制窗;
/// outcome 仍由前端按同一张表派生提交(两端同源,无漂移面)
closeReason
:
CloseReasonSchema
.
optional
(),
});
});
export
type
SubmitExecutionRequest
=
z
.
infer
<
typeof
SubmitExecutionRequestSchema
>
;
export
type
SubmitExecutionRequest
=
z
.
infer
<
typeof
SubmitExecutionRequestSchema
>
;
...
...
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