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
8f79c94e
Commit
8f79c94e
authored
Aug 22, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: fix/sheet-edit-offroster → main(助手整批改派修复 + 主管工作台口径拆分)
parents
82282b6c
e46bd0dd
Pipeline
#3591
failed in 0 seconds
Changes
15
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
813 additions
and
111 deletions
+813
-111
apps/pac-service/src/modules/assistant/assistant-prompts.ts
+1
-1
apps/pac-service/src/modules/assistant/assistant.service.ts
+45
-1
apps/pac-service/src/modules/mcp/guides.ts
+5
-3
apps/pac-service/src/modules/plan/plan-assignment.service.ts
+95
-31
apps/pac-service/tests/booked-vs-scheduled-next.spec.ts
+248
-0
apps/pac-service/tests/mcp-clinic-scope.spec.ts
+17
-3
apps/pac-service/tests/plan-rearrange.spec.ts
+3
-1
apps/pac-service/tests/sheet-edit-batch-assign.spec.ts
+156
-0
apps/pac-web/src/components/assistant/assignment-confirm-sheet.tsx
+59
-8
apps/pac-web/src/components/assistant/assistant-chat.tsx
+1
-1
apps/pac-web/src/components/assistant/assistant-widget.tsx
+1
-1
apps/pac-web/src/components/supervisor/batch-tracking.tsx
+58
-28
apps/pac-web/src/components/supervisor/supervisor-workbench.tsx
+2
-2
apps/pac-web/src/components/supervisor/team-status.tsx
+32
-17
packages/types/src/schemas/plan-assignment.ts
+90
-14
No files found.
apps/pac-service/src/modules/assistant/assistant-prompts.ts
View file @
8f79c94e
...
@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
...
@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/
*/
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export
const
ASSISTANT_PROMPT_VERSION
=
'assistant@2026-08-2
0-f
'
;
export
const
ASSISTANT_PROMPT_VERSION
=
'assistant@2026-08-2
2-a
'
;
/**
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
...
...
apps/pac-service/src/modules/assistant/assistant.service.ts
View file @
8f79c94e
...
@@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto';
...
@@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto';
import
type
{
Prisma
}
from
'@prisma/client'
;
import
type
{
Prisma
}
from
'@prisma/client'
;
import
{
import
{
Permission
,
Permission
,
RearrangeEditOpSchema
,
SheetEditOpSchema
,
TEMPERATURE_TOOL_DESC
,
TEMPERATURE_TOOL_DESC
,
TEMPERATURE_TOOL_VALUES
,
TEMPERATURE_TOOL_VALUES
,
type
SheetSnapshot
,
type
SheetSnapshot
,
...
@@ -58,6 +60,39 @@ import type { AppConfig } from '../../config/configuration';
...
@@ -58,6 +60,39 @@ import type { AppConfig } from '../../config/configuration';
*/
*/
export
const
MAX_TOOL_STEPS
=
16
;
export
const
MAX_TOOL_STEPS
=
16
;
/**
* 🔴 **改单指令落地前先按 zod 校一遍** —— 校不过就退回给模型,⛔ 不推给界面。
*
* 由来(2026-08-22 实测):本批 5 人,主管说「这 5 人都分配给韩维」。
* 模型给了 `select:{group:'batch'}` + `action:'assign'` —— 表达是对的,
* 而当时 `SheetEditOpSchema` 里恰好有一条 refine 判它非法。
* 结果这条 refine **一次都没跑过**:两个改单工具的入参用的是**手写的 JSON Schema**
* (给模型看的那份),zod 那份从来没在运行时参与过。
* 于是指令原样推给了界面,界面解不出人,照样回了一句「已把整批铺给 1 位客服()」。
*
* ⇒ 两份 schema 只要有一份不落到运行时,它写的约束就只是注释。
* ⚠️ 一条不合法就**整组退回**:半组落地等于把主管的一句话执行一半,
* 而他看到的是"改好了"(正是这次事故的形状)。
* ⚠️ 返回值是给**模型**看的:说清哪一条、错在哪,它下一轮才改得对;
* ⛔ 别只回"参数错误",那它只会原样再发一遍。
*/
function
checkEditOps
(
ops
:
unknown
[],
schema
:
{
safeParse
:
(
v
:
unknown
)
=>
{
success
:
boolean
;
error
?:
{
issues
:
{
message
:
string
}[]
}
}
},
):
string
|
null
{
const
bad
:
string
[]
=
[];
ops
.
forEach
((
op
,
i
)
=>
{
const
r
=
schema
.
safeParse
(
op
);
if
(
!
r
.
success
)
{
const
why
=
(
r
.
error
?.
issues
??
[]).
map
((
x
)
=>
x
.
message
).
join
(
';'
)
||
'参数不合法'
;
bad
.
push
(
`第
${
i
+
1
}
条:
${
why
}
`
);
}
});
return
bad
.
length
?
JSON
.
stringify
({
没有执行
:
true
,
单子未改动
:
true
,
问题
:
bad
})
:
null
;
}
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const
PET_SYSTEM_PROMPT
=
`你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
const
PET_SYSTEM_PROMPT
=
`你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
...
@@ -812,7 +847,12 @@ export class AssistantService {
...
@@ -812,7 +847,12 @@ export class AssistantService {
enum
:
[
'patients'
,
'agent'
,
'pending'
,
'batch'
],
enum
:
[
'patients'
,
'agent'
,
'pending'
,
'batch'
],
description
:
description
:
'patients=点名的这几位患者;agent=某位客服名下的全部;'
+
'patients=点名的这几位患者;agent=某位客服名下的全部;'
+
'pending=待分配那一组;batch=整批。'
+
'pending=待分配那一组;'
+
'batch=整批,卡片上此刻还在的所有人(已排好的 + 还在待分配里的)。'
+
'
\
n他说「这批都给某某 / 全部给某某 / 这 N 人都分给某某」用 batch,'
+
'N 就是卡片顶上那个「共 N 人」。'
+
'batch 配 set_expiry 改的是整批时限,配 set_benefit 设的是本批福利;'
+
'batch 配 owner 做不到,专属关系只有待分配那组带着。'
+
'
\
n「把某某移出这批」里的某某默认是**患者**。'
+
'
\
n「把某某移出这批」里的某某默认是**患者**。'
+
'只有他明说「客服某某不参与 / 某某的单都别给他」才用 agent ——'
+
'只有他明说「客服某某不参与 / 某某的单都别给他」才用 agent ——'
+
'agent 一次动的是几十条,而同一个名字可能既是本批客服又是本批患者,'
+
'agent 一次动的是几十条,而同一个名字可能既是本批客服又是本批患者,'
+
...
@@ -889,6 +929,8 @@ export class AssistantService {
...
@@ -889,6 +929,8 @@ export class AssistantService {
execute
:
async
(
args
:
unknown
)
=>
{
execute
:
async
(
args
:
unknown
)
=>
{
const
ops
=
(
args
as
{
ops
?:
unknown
[]
})?.
ops
;
const
ops
=
(
args
as
{
ops
?:
unknown
[]
})?.
ops
;
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,确认单未改动。'
;
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,确认单未改动。'
;
const
bad
=
checkEditOps
(
ops
,
SheetEditOpSchema
);
if
(
bad
)
return
bad
;
push
({
type
:
'assignment_sheet_edit'
,
ops
});
push
({
type
:
'assignment_sheet_edit'
,
ops
});
/**
/**
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
...
@@ -1071,6 +1113,8 @@ export class AssistantService {
...
@@ -1071,6 +1113,8 @@ export class AssistantService {
execute
:
async
(
args
:
unknown
)
=>
{
execute
:
async
(
args
:
unknown
)
=>
{
const
ops
=
(
args
as
{
ops
?:
unknown
[]
})?.
ops
;
const
ops
=
(
args
as
{
ops
?:
unknown
[]
})?.
ops
;
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,调整单未改动。'
;
if
(
!
Array
.
isArray
(
ops
)
||
ops
.
length
===
0
)
return
'没有给出任何操作,调整单未改动。'
;
const
bad
=
checkEditOps
(
ops
,
RearrangeEditOpSchema
);
if
(
bad
)
return
bad
;
/**
/**
* 🔴 **⛔ 这里绝不能拦「本轮还没出过调整单」**(2026-08-20 实测栽过)。
* 🔴 **⛔ 这里绝不能拦「本轮还没出过调整单」**(2026-08-20 实测栽过)。
*
*
...
...
apps/pac-service/src/modules/mcp/guides.ts
View file @
8f79c94e
...
@@ -24,12 +24,14 @@ export const TRACKING_GUIDE = [
...
@@ -24,12 +24,14 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。'
,
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。'
,
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。'
,
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。'
,
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。'
,
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。'
,
'退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。'
,
'退回和没动一起报:「客服退回 5、另有 60 条一次都没动」——「没人动」和「动了但退回」是完全不同的信号,只报一个会把另一个藏起来。'
,
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。'
,
'主管工作台那一列叫「预约成功率」,是 booked / done(真打过的),⛔ 分子不含约定下次回访、⛔ 分母不含退回的(那些他压根没打)。'
,
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进客服的退回数,他就是在拿自己的动作当调下一批的依据。'
,
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。'
,
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。'
,
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。'
,
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。'
,
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。'
,
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。'
,
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。'
,
'outcomes.appointed(预约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「预约上了这么多」。'
,
'outcomes.success 是这两项的合计,⛔ 别单独报它 —— 报了就得解释,而要解释的数本身就该拆开说。'
,
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。'
,
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。'
,
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。'
,
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。'
,
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。'
,
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。'
,
...
...
apps/pac-service/src/modules/plan/plan-assignment.service.ts
View file @
8f79c94e
...
@@ -20,6 +20,8 @@ import {
...
@@ -20,6 +20,8 @@ import {
REVOKE_WINDOW_MINUTES
,
REVOKE_WINDOW_MINUTES
,
TEMPERATURE_META
,
TEMPERATURE_META
,
potentialTreatmentCardLabel
,
potentialTreatmentCardLabel
,
/// ⚠️ 值不是类型:SQL 里要用 `ExecutionOutcome.SUCCESS_APPOINTED` 这个字面量
ExecutionOutcome
,
type
TemperatureValue
,
type
TemperatureValue
,
type
AssignmentAgentStat
,
type
AssignmentAgentStat
,
type
AssignmentDetailResponse
,
type
AssignmentDetailResponse
,
...
@@ -27,7 +29,6 @@ import {
...
@@ -27,7 +29,6 @@ import {
type
CreateAssignmentRequest
,
type
CreateAssignmentRequest
,
type
CreateAssignmentResponse
,
type
CreateAssignmentResponse
,
type
ListAssignmentsResponse
,
type
ListAssignmentsResponse
,
type
ExecutionOutcome
,
type
ReleaseReason
,
type
ReleaseReason
,
type
RevokeAssignmentResponse
,
type
RevokeAssignmentResponse
,
type
SetAssignmentBenefitResponse
,
type
SetAssignmentBenefitResponse
,
...
@@ -780,13 +781,19 @@ export class PlanAssignmentService {
...
@@ -780,13 +781,19 @@ export class PlanAssignmentService {
}
}
/**
/**
* 「约上」—— **批次列表**那一列(2026-08-07 主管工作台)。
* 「预约上」与「约下次」—— **批次列表**那两列(2026-08-07 主管工作台)。
*
* 🔴 **2026-08-22 拆成两个数**。此前这里数的是整个 `close` 组
* (转化新预约 + 约定下次回访),界面上叫「约上」。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应,单子还挂在他手上。
* 合成一个数,主管看到「预约上 6」会当成 6 个人要来了。
* ⇒ `appointed` = `success_appointed` / `scheduledNext` = `scheduled_next`。
* ⚠️ 与 detail 的 `outcomes.appointed` / `outcomes.scheduledNext` **同一定义**,
* ⛔ 别在这里另立标准 —— 两处对不上主管就只能猜哪个对。
*
*
* ⚠️ 与 `outcomeStats` 同一口径,只是**一次问一批**:列表一屏 20 行,
* ⚠️ 与 `outcomeStats` 同一口径,只是**一次问一批**:列表一屏 20 行,
* 逐行调 outcomeStats 就是 20 次往返。⛔ 别在 map 里 await。
* 逐行调 outcomeStats 就是 20 次往返。⛔ 别在 map 里 await。
* ⚠️ 「约上」= EXECUTION_OUTCOME 里 `group='close'` 的那些(转化新预约 + **约定下次回访**)——
* 与 detail 的 `outcomes.success` **同一个定义**,⛔ 别在这里另立标准(只数转化会比详情页小,
* 而两处都写着"约上",主管对不上就只能猜哪个对)。
* ⚠️ 每条单只取**最近一次**执行结果(DISTINCT ON):同一条单可能打过好几通,
* ⚠️ 每条单只取**最近一次**执行结果(DISTINCT ON):同一条单可能打过好几通,
* 数行会把一个人算好几次。
* 数行会把一个人算好几次。
*/
*/
...
@@ -807,24 +814,31 @@ export class PlanAssignmentService {
...
@@ -807,24 +814,31 @@ export class PlanAssignmentService {
return
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
assignment_id
,
Number
(
r
.
n
)]));
return
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
assignment_id
,
Number
(
r
.
n
)]));
}
}
private
async
bookedByAssignment
(
ids
:
string
[]):
Promise
<
Map
<
string
,
number
>>
{
private
async
bookedByAssignment
(
ids
:
string
[],
):
Promise
<
Map
<
string
,
{
appointed
:
number
;
scheduledNext
:
number
}
>>
{
if
(
ids
.
length
===
0
)
return
new
Map
();
if
(
ids
.
length
===
0
)
return
new
Map
();
const
closeOutcomes
=
(
Object
.
keys
(
EXECUTION_OUTCOME_META
)
as
ExecutionOutcome
[]).
filter
(
const
rows
=
await
this
.
prisma
.
$queryRaw
<
(
o
)
=>
EXECUTION_OUTCOME_META
[
o
].
group
===
'close'
,
Array
<
{
assignment_id
:
string
;
appointed
:
bigint
|
number
;
next
:
bigint
|
number
}
>
);
>
(
if
(
closeOutcomes
.
length
===
0
)
return
new
Map
();
const
rows
=
await
this
.
prisma
.
$queryRaw
<
Array
<
{
assignment_id
:
string
;
n
:
bigint
|
number
}
>>
(
Prisma
.
sql
`
Prisma
.
sql
`
SELECT assignment_id, count(*) AS n FROM (
SELECT assignment_id,
count(*) FILTER (WHERE outcome =
${
ExecutionOutcome
.
SUCCESS_APPOINTED
}
) AS appointed,
count(*) FILTER (WHERE outcome =
${
ExecutionOutcome
.
SCHEDULED_NEXT
}
) AS next
FROM (
SELECT DISTINCT ON (plan_id) plan_id, assignment_id, outcome
SELECT DISTINCT ON (plan_id) plan_id, assignment_id, outcome
FROM plan_executions
FROM plan_executions
WHERE assignment_id IN (
${
Prisma
.
join
(
ids
.
map
((
i
)
=>
Prisma
.
sql
`
${
i
}
::uuid`
))}
)
WHERE assignment_id IN (
${
Prisma
.
join
(
ids
.
map
((
i
)
=>
Prisma
.
sql
`
${
i
}
::uuid`
))}
)
ORDER BY plan_id, created_at DESC
ORDER BY plan_id, created_at DESC
) latest
) latest
WHERE outcome IN (
${
Prisma
.
join
(
closeOutcomes
)}
)
GROUP BY assignment_id`
,
GROUP BY assignment_id`
,
);
);
return
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
assignment_id
,
Number
(
r
.
n
)]));
return
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
assignment_id
,
{
appointed
:
Number
(
r
.
appointed
),
scheduledNext
:
Number
(
r
.
next
)
},
]),
);
}
}
/**
/**
...
@@ -944,7 +958,8 @@ export class PlanAssignmentService {
...
@@ -944,7 +958,8 @@ export class PlanAssignmentService {
inHand
:
l
.
inHand
,
inHand
:
l
.
inHand
,
done
:
l
.
done
,
done
:
l
.
done
,
/// 与 detail 的 `outcomes.success` 同一定义(见 bookedByAssignment)
/// 与 detail 的 `outcomes.success` 同一定义(见 bookedByAssignment)
booked
:
booked
.
get
(
h
.
id
)
??
0
,
booked
:
booked
.
get
(
h
.
id
)?.
appointed
??
0
,
bookedNext
:
booked
.
get
(
h
.
id
)?.
scheduledNext
??
0
,
/// 执行口径的「已处置」—— 与 agentStats[].done 同源,⛔ 不是 done(池子口径)
/// 执行口径的「已处置」—— 与 agentStats[].done 同源,⛔ 不是 done(池子口径)
handled
:
handled
.
get
(
h
.
id
)
??
0
,
handled
:
handled
.
get
(
h
.
id
)
??
0
,
};
};
...
@@ -1161,10 +1176,39 @@ export class PlanAssignmentService {
...
@@ -1161,10 +1176,39 @@ export class PlanAssignmentService {
)
)
GROUP BY e.assignee_user_id`
);
GROUP BY e.assignee_user_id`
);
/**
* ⭐ 窗口内**真的预约上**的条数 —— 「预约成功率」的分子(2026-08-22)。
*
* ⚠️ 只数 `success_appointed`,⛔ **不含 `scheduled_next`**:
* 约定下次回访只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率,
* 而主管正是靠这一列判断谁真的把人叫回来了。
* ⚠️ 与别处同一条纪律:每条单只取**最近一次**执行(DISTINCT ON),
* 打了三次才约上是**一个**成功。
* ⚠️ 归属按**那条执行的操作人**算:A 打过、B 才约上的算 B 的。
* ⇒ 同一条单可能同时进 A 和 B 的 `done`(那是 count(DISTINCT plan_id)),
* 却只进 B 的 `booked` —— A 的成功率因此偏低,这是**对的**:约上的不是他。
*/
const
booked
=
await
this
.
prisma
.
$queryRaw
<
Array
<
{
uid
:
string
;
n
:
bigint
}
>>
(
Prisma
.
sql
`
SELECT operator_user_id AS uid, count(*) AS n FROM (
SELECT DISTINCT ON (plan_id) plan_id, operator_user_id, outcome
FROM plan_executions
WHERE host_id =
${
scope
.
hostId
}
::uuid
AND tenant_id =
${
scope
.
tenantId
}
AND created_at >=
${
since
}
AND operator_user_id IN (
${
Prisma
.
join
(
ids
)}
)
AND EXISTS (SELECT 1 FROM followup_plans p
WHERE p.id = plan_executions.plan_id AND p.target_clinic_id =
${
clinicId
}
)
ORDER BY plan_id, created_at DESC
) latest
WHERE outcome =
${
ExecutionOutcome
.
SUCCESS_APPOINTED
}
GROUP BY operator_user_id`
);
const
num
=
(
rows
:
Array
<
{
uid
:
string
;
n
:
bigint
}
>
)
=>
const
num
=
(
rows
:
Array
<
{
uid
:
string
;
n
:
bigint
}
>
)
=>
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
uid
,
Number
(
r
.
n
)]));
new
Map
(
rows
.
map
((
r
)
=>
[
r
.
uid
,
Number
(
r
.
n
)]));
const
relM
=
num
(
released
);
const
relM
=
num
(
released
);
const
doneM
=
num
(
done
);
const
doneM
=
num
(
done
);
const
bookedM
=
num
(
booked
);
const
asgM
=
num
(
assigned
);
const
asgM
=
num
(
assigned
);
const
liveM
=
new
Map
(
live
.
map
((
r
)
=>
[
r
.
uid
,
Number
(
r
.
in_hand
)]));
const
liveM
=
new
Map
(
live
.
map
((
r
)
=>
[
r
.
uid
,
Number
(
r
.
in_hand
)]));
const
ovdM
=
num
(
overdue
);
const
ovdM
=
num
(
overdue
);
...
@@ -1174,10 +1218,16 @@ export class PlanAssignmentService {
...
@@ -1174,10 +1218,16 @@ export class PlanAssignmentService {
const
inHand
=
liveM
.
get
(
a
.
userId
)
??
0
;
const
inHand
=
liveM
.
get
(
a
.
userId
)
??
0
;
const
doneN
=
doneM
.
get
(
a
.
userId
)
??
0
;
const
doneN
=
doneM
.
get
(
a
.
userId
)
??
0
;
const
relN
=
relM
.
get
(
a
.
userId
)
??
0
;
const
relN
=
relM
.
get
(
a
.
userId
)
??
0
;
// 已处置 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"
const
bookedN
=
bookedM
.
get
(
a
.
userId
)
??
0
;
const
handled
=
doneN
+
relN
;
/**
// 没动 = 窗口内分到的 − 已处置。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
* 「没动」的减数 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"。
const
idle
=
Math
.
max
(
0
,
(
asgM
.
get
(
a
.
userId
)
??
0
)
-
handled
);
* ⚠️ 这是个**局部量**,⛔ 别再把它当字段发出去(2026-08-22 删了):
* 它曾经叫 `handled`,而界面上的「已处置」指的是 `done`(只算写了结果的)——
* 同一个词两个意思,主管当场就问出来了。
*/
const
touched
=
doneN
+
relN
;
// 没动 = 窗口内分到的 − 动过的。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
const
idle
=
Math
.
max
(
0
,
(
asgM
.
get
(
a
.
userId
)
??
0
)
-
touched
);
return
{
return
{
userId
:
a
.
userId
,
userId
:
a
.
userId
,
name
:
a
.
name
,
name
:
a
.
name
,
...
@@ -1187,18 +1237,19 @@ export class PlanAssignmentService {
...
@@ -1187,18 +1237,19 @@ export class PlanAssignmentService {
overdueOldestDays
:
oldestM
.
get
(
a
.
userId
)
??
null
,
overdueOldestDays
:
oldestM
.
get
(
a
.
userId
)
??
null
,
done
:
doneN
,
done
:
doneN
,
released
:
relN
,
released
:
relN
,
handled
,
idle
,
idle
,
/**
/**
* ⭐ 成品句子,⛔ 前端别自己拼 —— 退回率必须**两个分母都带**,
* ⭐ 「预约成功率」的分子 —— 界面上出的是 `booked / done` **一个百分比**。
* 写在这里保证界面、助手、导出三处口径一致(T14 的"工具返回值"那一半)。
*
* 🔴 2026-08-22 由「退回率」改成「**预约成功率**」(产品定):
* · 分子 —— `booked`(真的预约上),⛔ 不含约定下次回访;
* · 分母 —— `done`(他**真打过**的),⛔ 不是 done+released:
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⚠️ 原来这一列下面还挂一句成品句子(`rateNote`,带退回/没动两个旁支),
* 2026-08-22 产品定"值就一个就行"删掉 ——
* ⛔ `released` / `idle` **没有消失**,仍在这一行上,只是不挂在成功率下面。
*/
*/
rateNote
:
handled
booked
:
bookedN
,
?
`退回
${
relN
}
/ 已处置
${
handled
}
=
${((
relN
/
handled
)
*
100
).
toFixed
(
1
)}
%`
+
(
idle
?
`,另有
${
idle
}
条没动`
:
''
)
:
idle
?
`这
${
days
}
天分到
${
idle
}
条,一条都还没动`
:
`这
${
days
}
天没有分到新的`
,
};
};
});
});
...
@@ -1494,6 +1545,16 @@ export class PlanAssignmentService {
...
@@ -1494,6 +1545,16 @@ export class PlanAssignmentService {
const
failed
=
sumOf
(
'give_up'
);
const
failed
=
sumOf
(
'give_up'
);
const
keepCount
=
sumOf
(
'keep'
);
const
keepCount
=
sumOf
(
'keep'
);
/**
/**
* 🔴 **成功组里的两项分开报**(2026-08-22 产品定)。
* 主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,第一句问的是"这个成功是什么意思"。
* ⚠️ 转化新预约 = 患者**进了预约表**、有个日子会来;
* 约定下次回访 = 客服答应了"那天再联系您",患者什么都没答应,单子还挂在他手上。
* ⛔ 靠一行小字解释的数,本身就该拆开摆。
* ⚠️ `appointed + scheduledNext === success` —— 回归里锁着。
*/
const
appointed
=
outcomeCounts
.
get
(
ExecutionOutcome
.
SUCCESS_APPOINTED
)
??
0
;
const
scheduledNext
=
outcomeCounts
.
get
(
ExecutionOutcome
.
SCHEDULED_NEXT
)
??
0
;
/**
* 「一次结果都没有」的人数 = 本批人数 − 有结果的人。
* 「一次结果都没有」的人数 = 本批人数 − 有结果的人。
*
*
* ⭐ 这个数才是主管最该先看的:它不是"效果不好",是**根本没做/没记**。
* ⭐ 这个数才是主管最该先看的:它不是"效果不好",是**根本没做/没记**。
...
@@ -1507,6 +1568,8 @@ export class PlanAssignmentService {
...
@@ -1507,6 +1568,8 @@ export class PlanAssignmentService {
const
noOutcome
=
Math
.
max
(
0
,
counts
.
planned
-
withOutcome
);
const
noOutcome
=
Math
.
max
(
0
,
counts
.
planned
-
withOutcome
);
const
outcomes
=
{
const
outcomes
=
{
success
,
success
,
appointed
,
scheduledNext
,
failed
,
failed
,
keep
:
keepCount
,
keep
:
keepCount
,
noOutcome
,
noOutcome
,
...
@@ -1591,9 +1654,10 @@ export class PlanAssignmentService {
...
@@ -1591,9 +1654,10 @@ export class PlanAssignmentService {
progress
,
progress
,
done
:
progress
.
done
,
done
:
progress
.
done
,
inHand
:
plans
.
filter
((
p
)
=>
p
.
status
===
'assigned'
).
length
,
inHand
:
plans
.
filter
((
p
)
=>
p
.
status
===
'assigned'
).
length
,
/// ⭐ 与列表那一列**同一个数**:列表走 bookedByAssignment,这里直接用 outcomes.success ——
/// ⭐ 与列表那两列**同一个数**:列表走 bookedByAssignment,这里直接用拆好的两项 ——
/// 两处都是 `group='close'` 的合计,⛔ 别让它们分叉(界面上都叫「约上」)。
/// ⛔ 别让它们分叉(界面上叫的是同一个词)。
booked
:
outcomes
.
success
,
booked
:
outcomes
.
appointed
,
bookedNext
:
outcomes
.
scheduledNext
,
/// 执行口径:本批真的落了通话结果的条数(= agentStats[].done 之和)
/// 执行口径:本批真的落了通话结果的条数(= agentStats[].done 之和)
handled
:
executed
.
size
,
handled
:
executed
.
size
,
agentStats
:
[...
byAgent
.
values
()].
filter
((
a
)
=>
a
.
userId
!==
RELEASED_BUCKET
),
agentStats
:
[...
byAgent
.
values
()].
filter
((
a
)
=>
a
.
userId
!==
RELEASED_BUCKET
),
...
...
apps/pac-service/tests/booked-vs-scheduled-next.spec.ts
0 → 100644
View file @
8f79c94e
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
{
EXECUTION_OUTCOME_META
,
ExecutionOutcome
}
from
'@pac/types'
;
/**
* 「约上」到底指什么 —— 口径收窄的回归(2026-08-22 产品定)。
*
* ── 由来 ────────────────────────────────────────────────────────
* 主管指着批次列表的「约上」和抽屉里的「成功」问:这两个数是什么意思。
* 当时两处都是 `EXECUTION_OUTCOME_GROUP='close'` 的合计,也就是
* 转化新预约(success_appointed) + 约定下次回访(scheduled_next)
* 加在一起,靠一行小字解释「含约定下次回访,不等于都成交了」。
*
* 🔴 那两件事差着一整个台阶:
* · 转化新预约 —— 患者**进了预约表**,有个日子会来;
* · 约定下次回访 —— 客服答应了"那天再联系您",患者什么都没答应,
* 单子还挂在他手上 snooze 到回访日。
* 合成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⛔ 而要靠小字解释的数,本身就该拆开摆。
*
* ⇒ 「约上」= 只数 success_appointed;约定下次回访单列「约下次」。
* 这条口径同时决定了主管工作台那一列「预约成功率」的分子。
*/
const
SVC
=
readFileSync
(
join
(
__dirname
,
'../src/modules/plan/plan-assignment.service.ts'
),
'utf8'
,
);
const
web
=
(
p
:
string
)
=>
readFileSync
(
join
(
__dirname
,
'../../pac-web/src'
,
p
),
'utf8'
);
const
LIST
=
web
(
'components/supervisor/batch-tracking.tsx'
);
const
TEAM
=
web
(
'components/supervisor/team-status.tsx'
);
describe
(
'① 两个结果项本来就分得开'
,
()
=>
{
test
(
'⭐⭐ success_appointed 与 scheduled_next 是两个独立的 outcome'
,
()
=>
{
expect
(
ExecutionOutcome
.
SUCCESS_APPOINTED
).
toBe
(
'success_appointed'
);
expect
(
ExecutionOutcome
.
SCHEDULED_NEXT
).
toBe
(
'scheduled_next'
);
});
/**
* ⚠️ 两者**都在 close 组**,这不是笔误 —— 约定下次回访确实是有效推进,
* 统计上不该跟「未接通」一起算进「保持」(见 EXECUTION_OUTCOME_META 那段)。
* ⛔ 但"归成功组"不等于"约上了":拆的是**显示**,⛔ 别去改 group。
*/
test
(
'⭐ 两者都归 close 组 —— 拆的是显示,⛔ 别顺手改 group'
,
()
=>
{
expect
(
EXECUTION_OUTCOME_META
.
success_appointed
.
group
).
toBe
(
'close'
);
expect
(
EXECUTION_OUTCOME_META
.
scheduled_next
.
group
).
toBe
(
'close'
);
});
test
(
'close 组就这两项 —— 多一项的话 appointed + scheduledNext 就加不出 success'
,
()
=>
{
const
close
=
(
Object
.
keys
(
EXECUTION_OUTCOME_META
)
as
ExecutionOutcome
[]).
filter
(
(
o
)
=>
EXECUTION_OUTCOME_META
[
o
].
group
===
'close'
,
);
expect
(
close
.
sort
()).
toEqual
([
'scheduled_next'
,
'success_appointed'
]);
});
/**
* 🔴 状态机上也是两回事,这才是"差一个台阶"的硬证据:
* 约上 → 工单结案;约下次 → 工单**留着**,snooze 到回访日。
*/
test
(
'⭐⭐ 约上结案、约下次不结案'
,
()
=>
{
expect
(
EXECUTION_OUTCOME_META
.
success_appointed
.
drivesStatus
).
toBe
(
'completed'
);
expect
(
EXECUTION_OUTCOME_META
.
scheduled_next
.
drivesStatus
).
toBe
(
'keep'
);
});
});
describe
(
'② 服务端:两个数分开取'
,
()
=>
{
test
(
'⭐⭐ 批次列表按 outcome 逐项 FILTER,⛔ 不再按 close 组合计'
,
()
=>
{
expect
(
SVC
).
toContain
(
'count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}) AS appointed'
,
);
expect
(
SVC
).
toContain
(
'count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SCHEDULED_NEXT}) AS next'
,
);
// ⛔ 老写法:把 close 组的 outcome 灌进 IN (...)
expect
(
SVC
).
not
.
toContain
(
"EXECUTION_OUTCOME_META[o].group === 'close'"
);
});
test
(
'⭐ 列表与详情**同一个数** —— 分叉了主管就只能猜哪个对'
,
()
=>
{
expect
(
SVC
).
toContain
(
'booked: outcomes.appointed'
);
expect
(
SVC
).
toContain
(
'bookedNext: outcomes.scheduledNext'
);
});
test
(
'详情把 close 组的两项分开报,success 仍是合计'
,
()
=>
{
expect
(
SVC
).
toContain
(
'const appointed = outcomeCounts.get(ExecutionOutcome.SUCCESS_APPOINTED) ?? 0;'
,
);
expect
(
SVC
).
toContain
(
'const scheduledNext = outcomeCounts.get(ExecutionOutcome.SCHEDULED_NEXT) ?? 0;'
,
);
expect
(
SVC
).
toContain
(
"const success = sumOf('close');"
);
});
test
(
'每条单只取最近一次执行 —— 打了三次才约上是一个成功不是三个'
,
()
=>
{
// DISTINCT ON 必须还在:换成 count(*) 会让成功数随拨打次数膨胀
expect
(
SVC
).
toMatch
(
/SELECT DISTINCT ON
\(
plan_id
\)
plan_id, assignment_id, outcome/
);
});
});
describe
(
'③ 主管工作台:退回率 → 预约成功率'
,
()
=>
{
/**
* 🔴 分子 ⛔ 不含约定下次回访:算进来的话,一个只会往后拖的人
* 能拖出一条漂亮的成功率,而主管正是靠这一列判断谁真的把人叫回来了。
*/
test
(
'⭐⭐ 分子只数 success_appointed'
,
()
=>
{
const
q
=
SVC
.
slice
(
SVC
.
indexOf
(
'const booked = await this.prisma.$queryRaw'
));
expect
(
q
).
toContain
(
'WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}'
);
expect
(
q
.
slice
(
0
,
1200
)).
not
.
toContain
(
'SCHEDULED_NEXT'
);
});
/**
* 🔴 分母是 `done`(他真打过的)⛔ 不是 done+released:
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
*/
test
(
'⭐⭐ 分母是「已处置」(他真打过的),⛔ 不含退回的'
,
()
=>
{
expect
(
TEAM
).
toContain
(
"a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : '—'"
);
// ⛔ 不许拿 released 参与计算
expect
(
TEAM
).
not
.
toContain
(
'a.released'
);
});
/**
* ⚠️ 2026-08-22 产品定"值就一个就行":成功率下面那行成品句子(`rateNote`)删掉。
* ⛔ 但 `released` / `idle` **没有消失** —— 它们仍在响应里,只是不挤在这一列下面。
*/
test
(
'⭐ 只出一个百分比,⛔ 下面不再挂小字'
,
()
=>
{
// ⚠️ 判据是**渲染**没了,不是"文件里搜不到这个词"(注释里还记着它的沿革)
expect
(
TEAM
).
not
.
toContain
(
'{a.rateNote}'
);
expect
(
SVC
).
not
.
toMatch
(
/^
\s
+rateNote:/m
);
// 数据还在
expect
(
SVC
).
toContain
(
'released: relN'
);
expect
(
SVC
).
toContain
(
'idle,'
);
});
/**
* 🔴 `handled`(=done+released)这个字段一起删了:它和界面上的「已处置」
* (只算写了结果的)**是两个意思**,而主管当场就问出来了。
*/
test
(
'⭐⭐ 同一个词只能有一个意思 —— handled 字段已删,只留局部量'
,
()
=>
{
expect
(
SVC
).
toContain
(
'const touched = doneN + relN;'
);
expect
(
SVC
).
not
.
toMatch
(
/^
\s
+handled,$/m
);
});
/**
* 🔴 **分母不筛结果项**(2026-08-22 产品确认)——判据只有一条:他有没有真打。
* 「没进展」那一桶(未接通 / 待跟进 / 已发短信…)照样进分母:打了没人接,那也是打了。
* ⚠️ 未接通往往占大头,所以这个数是「触达 × 转化」合起来的效率。
* ⛔ 别为了"好看"把 keep 组从分母里摘掉 —— 那会变成另一个指标,而名字没变。
*/
test
(
'⭐⭐ 分母 ⛔ 不筛 outcome —— 未接通也算他打过'
,
()
=>
{
const
q
=
SVC
.
slice
(
SVC
.
indexOf
(
'const done = await this.prisma.$queryRaw'
),
SVC
.
indexOf
(
'窗口内「分到了多少」'
),
);
expect
(
q
).
toContain
(
'count(DISTINCT plan_id)'
);
expect
(
q
).
toContain
(
'FROM plan_executions'
);
// ⛔ 一旦这里出现 outcome,分母就不再是"他打过的"了
expect
(
q
).
not
.
toContain
(
'outcome'
);
});
test
(
'⭐ 列头写「近 N 天预约成功率」,跟着切换器走'
,
()
=>
{
expect
(
TEAM
).
toContain
(
'近 {days} 天预约成功率'
);
expect
(
TEAM
).
toContain
(
'近 {days} 天已处置'
);
// ⛔ 「完成」听起来像"做成了",且与批次表那一列同一判据却叫了两个词
expect
(
TEAM
).
not
.
toMatch
(
/>
\s
*完成
\s
*</
);
expect
(
TEAM
).
not
.
toMatch
(
/>
\s
*退回率
\s
*</
);
});
});
describe
(
'④ 界面:分组改名 + 两列 + 五个桶'
,
()
=>
{
/**
* 🔴 「还在跑的」被主管读成"**分配还没完成**"(2026-08-22 实测)——
* 像是有个后台任务在跑。它说的其实是**批次**还没收尾。
* ⚠️ 「时限内」也不行:判据有两支,另一支(还有单压在手上)在时限过了之后仍成立。
*/
test
(
'⭐⭐ 「还在跑的」已改名,⛔ 一处都不许留'
,
()
=>
{
const
live
=
LIST
.
split
(
'
\
n'
)
.
filter
((
l
)
=>
!
l
.
trimStart
().
startsWith
(
'*'
)
&&
!
l
.
includes
(
'//'
))
.
join
(
'
\
n'
);
expect
(
live
).
not
.
toContain
(
'还在跑的'
);
expect
(
LIST
).
toContain
(
'还没收尾'
);
expect
(
LIST
).
toContain
(
'时限没到、或还有单压在客服手上'
);
});
/**
* 🔴 到期**不再自动回池**(2026-08-19 起回收器已删)——
* 过了时限的单就那么压在客服手上,批次靠 `inHand > 0` 留在这一组里。
* ⛔ 不写明的话主管会以为过了时限的批次都沉到历史里了,而那恰恰是最该动手的一批。
*/
test
(
'⭐⭐ 说明里要写明「含已经超期的」'
,
()
=>
{
expect
(
LIST
).
toContain
(
'时限没到、或还有单压在客服手上(含已经超期的)'
);
});
test
(
'判据本身没动 —— 改的是名字不是口径'
,
()
=>
{
expect
(
LIST
).
toContain
(
'return new Date(b.expiresAt).getTime() > now || b.inHand > 0;'
);
});
/**
* ⚠️ 叫「预约上」不叫「约上」(2026-08-22 第二轮):「约上」仍有歧义 ——
* "约好了"既可以是约了预约,也可以是约了下次再联系,而这两列分的正是这件事。
*/
test
(
'⭐ 列表拆成「预约上」「约下次」两列'
,
()
=>
{
expect
(
LIST
).
toContain
(
"'超期', '预约上', '约下次'"
);
expect
(
LIST
).
toContain
(
'{b.bookedNext}'
);
// ⚠️ 中性灰,⛔ 别跟「预约上」同一支绿 —— 同色会被加起来读成"成效"
expect
(
LIST
).
toContain
(
"bookedNext: 'text-slate-500'"
);
});
/**
* ⚠️ 五个桶**穷尽**(约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数)——
* 少一个就对不上数,而主管一对数发现少了人就再也信不过这块(T14)。
*/
test
(
'⭐⭐ 通话成效五个桶,⛔ 不再摆一个要靠小字解释的「成功」'
,
()
=>
{
expect
(
LIST
).
toContain
(
'grid-cols-5'
);
expect
(
LIST
).
toContain
(
"{ k: '预约上', n: d.outcomes.appointed"
);
expect
(
LIST
).
toContain
(
"{ k: '约下次', n: d.outcomes.scheduledNext"
);
expect
(
LIST
).
not
.
toContain
(
"{ k: '成功', n: d.outcomes.success"
);
// 旧的那句解释也该跟着走 —— 拆开之后它是废话
expect
(
LIST
).
not
.
toContain
(
'「成功」含约定下次回访'
);
});
test
(
'小字只解释两件不显然的事,⛔ 不重复标签本身'
,
()
=>
{
expect
(
LIST
).
toContain
(
'「预约上」是患者定下了来的日子'
);
expect
(
LIST
).
toContain
(
'单子还在客服手上'
);
});
test
(
'分组表头的 colSpan 仍从 NUM_COLS 推 —— 加列不许手抄数字'
,
()
=>
{
expect
(
LIST
).
toContain
(
'colSpan={NUM_COLS.length + 1}'
);
expect
(
LIST
).
not
.
toMatch
(
/colSpan=
\{\d
+
\}
/
);
});
});
describe
(
'⑤ 给模型的解读规范跟得上'
,
()
=>
{
const
GUIDES
=
readFileSync
(
join
(
__dirname
,
'../src/modules/mcp/guides.ts'
),
'utf8'
);
test
(
'⭐ 说清 appointed 与 scheduledNext 差在哪,⛔ 别让它把两个数加起来'
,
()
=>
{
expect
(
GUIDES
).
toContain
(
'outcomes.appointed(预约上)'
);
expect
(
GUIDES
).
toContain
(
'outcomes.scheduledNext(约下次)'
);
expect
(
GUIDES
).
toContain
(
'别加起来说成「预约上了这么多」'
);
});
test
(
'⭐ 旧那句「outcomes.success 含约定下次回访」已经不成立,不许留着'
,
()
=>
{
expect
(
GUIDES
).
not
.
toContain
(
"outcomes.success 含「约定下次回访」"
);
});
test
(
'预约成功率的分子分母都写明'
,
()
=>
{
expect
(
GUIDES
).
toContain
(
'booked / done'
);
expect
(
GUIDES
).
toContain
(
'分子不含约定下次回访'
);
});
});
apps/pac-service/tests/mcp-clinic-scope.spec.ts
View file @
8f79c94e
...
@@ -1014,7 +1014,16 @@ describe('确认单指令 —— 三个正交的轴', () => {
...
@@ -1014,7 +1014,16 @@ describe('确认单指令 —— 三个正交的轴', () => {
[
'set_benefit 没给 text'
,
{
select
:
{
group
:
'batch'
},
action
:
'set_benefit'
}],
[
'set_benefit 没给 text'
,
{
select
:
{
group
:
'batch'
},
action
:
'set_benefit'
}],
[
'福利挂到了个人头上'
,
{
select
:
{
group
:
'patients'
,
patients
:
[
'王强'
]
},
action
:
'set_benefit'
,
text
:
'x'
}],
[
'福利挂到了个人头上'
,
{
select
:
{
group
:
'patients'
,
patients
:
[
'王强'
]
},
action
:
'set_benefit'
,
text
:
'x'
}],
[
'整批"移出自己"'
,
{
select
:
{
group
:
'batch'
},
action
:
'remove'
}],
[
'整批"移出自己"'
,
{
select
:
{
group
:
'batch'
},
action
:
'remove'
}],
[
'整批当改派对象'
,
{
select
:
{
group
:
'batch'
},
action
:
'assign'
,
to
:
{
mode
:
'balance'
}
}],
/**
* 🔴 2026-08-22 改判:**整批当改派对象是对的**,拒掉的是 batch + owner。
* 实测本批 5 人,主管说「这5人都分配给韩维」—— batch + balance 是这句话
* 唯一自然的表达。原来这条把它判成非法,而那条判断**一次都没跑过**
* (工具入参走的是手写 JSON Schema),于是指令原样进了界面,
* 界面解出空名单还回了一句「已把整批铺给 1 位客服()」。
* 完整回归见 `sheet-edit-batch-assign.spec.ts`。
* ⚠️ owner 仍然不行:专属关系只有「待分配」那份数据带着。
*/
[
'整批配 owner(专属关系只有待分配那组带着)'
,
{
select
:
{
group
:
'batch'
},
action
:
'assign'
,
to
:
{
mode
:
'owner'
}
}],
[
'patients 一个都没给'
,
{
select
:
{
group
:
'patients'
,
patients
:
[]
},
action
:
'remove'
}],
[
'patients 一个都没给'
,
{
select
:
{
group
:
'patients'
,
patients
:
[]
},
action
:
'remove'
}],
])(
'⛔ %s → schema 直接拒掉'
,
(
_why
,
op
)
=>
{
])(
'⛔ %s → schema 直接拒掉'
,
(
_why
,
op
)
=>
{
expect
(
ok
(
op
)).
toBe
(
false
);
expect
(
ok
(
op
)).
toBe
(
false
);
...
@@ -1048,8 +1057,13 @@ describe('确认单指令 —— 三个正交的轴', () => {
...
@@ -1048,8 +1057,13 @@ describe('确认单指令 —— 三个正交的轴', () => {
test
(
'🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId'
,
()
=>
{
test
(
'🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId'
,
()
=>
{
expect
(
SHEET
).
toMatch
(
/moveDraft
\[
id
\]
= owner
\.
id/
);
expect
(
SHEET
).
toMatch
(
/moveDraft
\[
id
\]
= owner
\.
id/
);
// ⛔ 专属客服不在名册的分不下去,如实报数,别顺手改派给别人
/**
expect
(
SHEET
).
toMatch
(
/查不到在岗的专属客服,没动他们/
);
* ⛔ 分不下去的如实报数,别顺手改派给别人。
* ⚠️ 2026-08-22 拆成两句:「专属客服不在名册」与「卡片没带这条的专属关系」
* 是两回事,报成同一句等于替数据下结论(说人家没有专属客服)。
*/
expect
(
SHEET
).
toMatch
(
/专属客服不在本批名册里,没动他们/
);
expect
(
SHEET
).
toMatch
(
/卡片上不带他们的专属关系,没动/
);
});
});
test
(
'⭐ 两种铺法的回话措辞必须能一眼分开'
,
()
=>
{
test
(
'⭐ 两种铺法的回话措辞必须能一眼分开'
,
()
=>
{
...
...
apps/pac-service/tests/plan-rearrange.spec.ts
View file @
8f79c94e
...
@@ -559,7 +559,9 @@ describe('批次表要认得"主管回收"', () => {
...
@@ -559,7 +559,9 @@ describe('批次表要认得"主管回收"', () => {
test
(
'⭐ 界面:列头分「客服退回」与「回收」,两处都要'
,
()
=>
{
test
(
'⭐ 界面:列头分「客服退回」与「回收」,两处都要'
,
()
=>
{
// 列表行(常量里)
// 列表行(常量里)
expect
(
UI
).
toMatch
(
/const NUM_COLS =
\[
'条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'
\]
/
);
// ⚠️ 2026-08-22「约上」拆成「约上 / 约下次」—— 这条锁的是**退回与回收分列**,
// ⛔ 别把整个列表的列名写死在这里(那会让每次加列都在这条上假红)
expect
(
UI
).
toMatch
(
/const NUM_COLS =
\[
'条数', '已处置', '没动', '客服退回', '回收',/
);
// 详情里按客服拆
// 详情里按客服拆
expect
(
UI
).
toMatch
(
/'客服', '分到', '已处置', '没动', '客服退回', '回收'/
);
expect
(
UI
).
toMatch
(
/'客服', '分到', '已处置', '没动', '客服退回', '回收'/
);
// 原因分布只属于客服退回
// 原因分布只属于客服退回
...
...
apps/pac-service/tests/sheet-edit-batch-assign.spec.ts
0 → 100644
View file @
8f79c94e
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
{
SheetEditOpSchema
}
from
'@pac/types'
;
/**
* 「整批分给一个人」——「已把整批铺给 1 位客服()」的回归(2026-08-22 实测)。
*
* ── 事故经过 ──────────────────────────────────────────────────────
* 本批只有 5 人(已排好 4、待分配 1)。主管说「这5人都分配给韩维」。
* 模型给了 `select:{group:'batch'}` + `action:'assign'` + `to:{mode:'balance',agents:['韩维']}`
* —— 这是这句话唯一自然的表达,韩维就印在卡片下面那行「本批未分到」里。
*
* 界面回:「确认单已更新:已把整批按谁手上少先给谁铺给 1 位客服()。」
* 那对括号是空的。一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。
*
* ── 三层各错一处,每一处单独看都"没报错" ──────────────────────────
* ① `SheetEditOpSchema` 有一条 refine 判 batch+assign 非法 —— 判断本身就是错的
* ② 那条 refine **一次都没跑过**:改单工具的入参是**手写的 JSON Schema**,
* zod 那份从没进过运行时。两份 schema 各写各的,写在没跑的那份里的约束只是注释。
* ③ 界面把 batch 解成**空名单**(`planIds: []`),然后每个分支都照样 push 一句"已如何如何"。
*
* 🔴 最坏的地方:三层都没抛错,主管读到的是**成功**。
* 与 mcp-clinic-scope 那次(编了个诊所 id → 返回 0 → 模型去解释"为什么是空的")同一个形状:
* 一个合法的空结果,比一个报错危险得多。
*/
const
ASSISTANT
=
readFileSync
(
join
(
__dirname
,
'../src/modules/assistant/assistant.service.ts'
),
'utf8'
,
);
const
SHEET
=
readFileSync
(
join
(
__dirname
,
'../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'
),
'utf8'
,
);
describe
(
'① schema:整批可以作为改派的对象'
,
()
=>
{
test
(
'⭐⭐ batch + assign + balance —— 主管说「这批都给某某」'
,
()
=>
{
const
r
=
SheetEditOpSchema
.
safeParse
({
select
:
{
group
:
'batch'
},
action
:
'assign'
,
to
:
{
mode
:
'balance'
,
agents
:
[
'韩维'
]
},
});
expect
(
r
.
success
).
toBe
(
true
);
});
test
(
'batch + assign + balance 不点名(整批铺给全体在岗)也合法'
,
()
=>
{
expect
(
SheetEditOpSchema
.
safeParse
({
select
:
{
group
:
'batch'
},
action
:
'assign'
,
to
:
{
mode
:
'balance'
},
}).
success
,
).
toBe
(
true
);
});
/**
* ⚠️ owner 是**唯一**做不到的那个:专属关系只有「待分配」那份数据带着
* (`pending[].ownerUserId`),已排好的条目卡片上查不到它归谁专属。
* 放它过去的话那几条会被报成"查不到在岗的专属客服" —— 那是一句关于数据的断言。
*/
test
(
'⭐ batch + owner 仍然拦掉,且说清该改用什么'
,
()
=>
{
const
r
=
SheetEditOpSchema
.
safeParse
({
select
:
{
group
:
'batch'
},
action
:
'assign'
,
to
:
{
mode
:
'owner'
},
});
expect
(
r
.
success
).
toBe
(
false
);
const
msg
=
r
.
success
?
''
:
r
.
error
.
issues
.
map
((
i
)
=>
i
.
message
).
join
(
'|'
);
expect
(
msg
).
toContain
(
'pending'
);
});
test
(
'batch + remove 仍然拦掉 —— 整批移出等于把这批全作废'
,
()
=>
{
expect
(
SheetEditOpSchema
.
safeParse
({
select
:
{
group
:
'batch'
},
action
:
'remove'
}).
success
,
).
toBe
(
false
);
});
test
(
'pending / agent + owner 不受影响'
,
()
=>
{
expect
(
SheetEditOpSchema
.
safeParse
({
select
:
{
group
:
'pending'
},
action
:
'assign'
,
to
:
{
mode
:
'owner'
},
}).
success
,
).
toBe
(
true
);
});
});
describe
(
'② 运行时:zod 必须真的跑'
,
()
=>
{
/**
* 🔴 这条是本次事故的**根因**,不是 batch 那条判断错。
* 判断错了随时能改,而"写在没跑的那份 schema 里"意味着**下一条约束还会这样**。
*/
test
(
'⭐⭐ 两个改单工具都在推给界面之前按 zod 校一遍'
,
()
=>
{
expect
(
ASSISTANT
).
toContain
(
'checkEditOps(ops, SheetEditOpSchema)'
);
expect
(
ASSISTANT
).
toContain
(
'checkEditOps(ops, RearrangeEditOpSchema)'
);
for
(
const
[
schema
,
evt
]
of
[
[
'SheetEditOpSchema'
,
"push({ type: 'assignment_sheet_edit'"
],
[
'RearrangeEditOpSchema'
,
"push({ type: 'rearrange_sheet_edit'"
],
]
as
const
)
{
const
gate
=
ASSISTANT
.
indexOf
(
`checkEditOps(ops,
${
schema
}
)`
);
const
push
=
ASSISTANT
.
indexOf
(
evt
);
expect
(
gate
).
toBeGreaterThan
(
0
);
expect
(
push
).
toBeGreaterThan
(
gate
);
// ⛔ 校验必须在 push 之前
}
});
test
(
'校不过整组退回,⛔ 不许落一半'
,
()
=>
{
// 半组落地 = 把主管的一句话执行一半,而他看到的是"改好了"
expect
(
ASSISTANT
).
toContain
(
'单子未改动'
);
expect
(
ASSISTANT
).
toMatch
(
/if
\(
bad
\)
return bad;/
);
});
test
(
'退回给模型的话要说清哪一条错在哪 —— 只说"参数错误"它会原样再发一遍'
,
()
=>
{
expect
(
ASSISTANT
).
toMatch
(
/第
\$\{
i
\+
1
\}
条/
);
});
});
describe
(
'③ 界面:batch 解出来的是真名单,选中 0 人不许回报成功'
,
()
=>
{
test
(
'⭐⭐ ⛔ 不许再把整批解成空名单'
,
()
=>
{
expect
(
SHEET
).
not
.
toContain
(
"return { planIds: [], label: '整批' }"
);
});
test
(
'⭐ 整批 = 生效条目 + 还留在待分配里的(与顶栏「共 N 人」同一口径)'
,
()
=>
{
expect
(
SHEET
).
toContain
(
'const whole = ['
);
expect
(
SHEET
).
toContain
(
'...pendingRest().map((p) => p.planId)'
);
expect
(
SHEET
).
toMatch
(
/label: `整批
\$\{
whole
\.
length
\}
人`/
);
});
test
(
'⭐⭐ 选中 0 个人就到此为止 —— 下面每个分支都会 push 一句"已如何如何"'
,
()
=>
{
expect
(
SHEET
).
toContain
(
'if (sel.planIds.length === 0)'
);
expect
(
SHEET
).
toContain
(
'没有可动的人,没有执行'
);
});
test
(
'整批移出要吭声,⛔ 不许静默什么都不做'
,
()
=>
{
expect
(
SHEET
).
toContain
(
"op.action === 'remove' && op.select.group === 'batch'"
);
expect
(
SHEET
).
toContain
(
'整批移出等于把这批全作废了'
);
});
/**
* ⚠️ 「分不下去」有两种,报成同一句就等于替数据下结论:
* 卡片没带这条的专属关系 ≠ 这个人没有专属客服。
*/
test
(
'owner 分不下去的两种原因分开报'
,
()
=>
{
expect
(
SHEET
).
toContain
(
'let offRoster = 0'
);
expect
(
SHEET
).
toContain
(
'let unknown = 0'
);
expect
(
SHEET
).
not
.
toContain
(
'查不到在岗的专属客服,没动他们'
);
});
});
describe
(
'④ 给模型的那份描述要跟得上'
,
()
=>
{
test
(
'⭐ batch 那一格要说明它是整批、以及配什么动作'
,
()
=>
{
expect
(
ASSISTANT
).
toContain
(
'batch=整批'
);
expect
(
ASSISTANT
).
toContain
(
'batch 配 owner 做不到'
);
});
});
apps/pac-web/src/components/assistant/assignment-confirm-sheet.tsx
View file @
8f79c94e
...
@@ -917,8 +917,23 @@ export function AssignmentConfirmSheet({
...
@@ -917,8 +917,23 @@ export function AssignmentConfirmSheet({
}
}
return { planIds: rest.map((p) => p.planId), label: `
「待分配」的
$
{
rest
.
length
}
人
` };
return { planIds: rest.map((p) => p.planId), label: `
「待分配」的
$
{
rest
.
length
}
人
` };
}
}
// batch:整批 —— 只有 set_expiry / set_benefit 走得到这里(schema 已拦住其余)
/**
return { planIds: [], label: '整批' };
* batch:整批 —— **卡片上此刻还在的所有人**,已排好的 + 还留在「待分配」里的。
*
* 🔴 2026-08-22 实测:这里原来写死 `
planIds
:
[]
`,注释说"只有 set_expiry /
* set_benefit 走得到"。但那两件在上面就短路了(走批次级控件),
* 真正走到这一行的恰恰是 `
assign
` / `
remove
`。
* 本批 5 人,主管说「这 5 人都分配给韩维」,模型给了 batch + assign ——
* 空名单进了铺平分支,`
targets
` 有韩维、`
next
` 一条没有,
* 于是回了「已把整批铺给 1 位客服()」:括号是空的,人一个没动,而话说的是成了。
* ⚠️ 口径与卡片顶栏那行「共 N 人 · 已排好 X · 待分配 Y」**必须一致** ——
* 主管说"这 N 人"指的就是他看着的那个数。
*/
const whole = [
...items.filter((i) => !dropDraft.has(i.planId)).map((i) => i.planId),
...pendingRest().map((p) => p.planId),
];
return { planIds: whole, label: `
整批
$
{
whole
.
length
}
人
` };
};
};
// ── ② 干什么 ───────────────────────────────────────────────
// ── ② 干什么 ───────────────────────────────────────────────
...
@@ -932,8 +947,27 @@ export function AssignmentConfirmSheet({
...
@@ -932,8 +947,27 @@ export function AssignmentConfirmSheet({
notes.push(`
整批时限改为
$
{
op
.
days
}
天
`);
notes.push(`
整批时限改为
$
{
op
.
days
}
天
`);
continue;
continue;
}
}
/**
* ⛔ 整批移出 = 把这批全作废,而卡片上没有"撤销"。
* ⚠️ 落在这里说明它绕过了 schema(手写的那份 JSON Schema 表达不了这条约束)——
* ⛔ 那也不许静默:什么都不做而不吭声,正是这次事故的形状。
*/
if (op.action === 'remove' && op.select.group === 'batch') {
notes.push('整批移出等于把这批全作废了,没有执行 —— 不想发就别按确认,要移出哪几位请点名');
continue;
}
const sel = resolve();
const sel = resolve();
if (!sel) continue;
if (!sel) continue;
/**
* 🔴 **选中 0 个人就别再往下走**(2026-08-22)。
* 下面每个分支都会 `
notes
.
push
` 一句"已如何如何",而实际一条没动 ——
* 主管读到的是成功。⛔ 宁可说"没找到人",不许说空话。
*/
if (sel.planIds.length === 0) {
notes.push(`
$
{
sel
.
label
}
里没有可动的人
,
没有执行
`);
continue;
}
if (op.action === 'remove') {
if (op.action === 'remove') {
for (const id of sel.planIds) dropDraft.add(id);
for (const id of sel.planIds) dropDraft.add(id);
...
@@ -991,11 +1025,24 @@ export function AssignmentConfirmSheet({
...
@@ -991,11 +1025,24 @@ export function AssignmentConfirmSheet({
(sheet.pending ?? []).map((p) => [p.planId, { id: p.ownerUserId, name: p.ownerName }]),
(sheet.pending ?? []).map((p) => [p.planId, { id: p.ownerUserId, name: p.ownerName }]),
);
);
const done = new Map<string, number>();
const done = new Map<string, number>();
let orphan = 0;
/**
* 🔴 **分不下去有两种,⛔ 不能报成同一句**(2026-08-22)。
* · `
offRoster
` —— 查到了专属客服,但他不在本批名册里(已离岗 / 本期不管)
* · `
unknown
` —— 卡片**根本没带这条的专属关系**:已经排好的条目不带
* `
ownerUserId
`(只有「待分配」那份数据带着)。
* 原来两者都报「查不到在岗的专属客服」—— 后者读起来像"这些人没有专属客服",
* 那是一句关于数据的断言,而卡片没有资格下这个断言。
*/
let offRoster = 0;
let unknown = 0;
for (const id of sel.planIds) {
for (const id of sel.planIds) {
const owner = ownerOf.get(id);
const owner = ownerOf.get(id);
if (!owner?.id || !roster.has(owner.id)) {
if (!owner) {
orphan++;
unknown++;
continue;
}
if (!owner.id || !roster.has(owner.id)) {
offRoster++;
continue;
continue;
}
}
moveDraft[id] = owner.id;
moveDraft[id] = owner.id;
...
@@ -1003,11 +1050,15 @@ export function AssignmentConfirmSheet({
...
@@ -1003,11 +1050,15 @@ export function AssignmentConfirmSheet({
done.set(key, (done.get(key) ?? 0) + 1);
done.set(key, (done.get(key) ?? 0) + 1);
}
}
const detail = [...done].map(([n, c]) => `
$
{
n
}
$
{
c
}
条
`).join('、');
const detail = [...done].map(([n, c]) => `
$
{
n
}
$
{
c
}
条
`).join('、');
const tail =
(offRoster > 0 ? `
;
另有
$
{
offRoster
}
人的专属客服不在本批名册里
,
没动他们
` : '') +
(unknown > 0
? `
;
还有
$
{
unknown
}
人是已经排好的条目
,
卡片上不带他们的专属关系
,
没动
`
: '');
notes.push(
notes.push(
done.size === 0
done.size === 0
? `
一个也没分下去
:
$
{
sel
.
label
}
查不到在本批名册里的专属客服
(
可能已离岗
)
`
? `
一个也没分下去
:
$
{
sel
.
label
}
$
{
tail
.
replace
(
/^;/
,
''
)}
`
: `
已把
$
{
sel
.
label
}
中的
$
{
sel
.
planIds
.
length
-
orphan
}
人
**
各自还给自己的专属客服
**
(
$
{
detail
})
` +
: `
已把
$
{
sel
.
label
}
中的
$
{
sel
.
planIds
.
length
-
offRoster
-
unknown
}
人
**
各自还给自己的专属客服
**
(
$
{
detail
})
$
{
tail
}
`,
(orphan > 0 ? `
;
另有
$
{
orphan
}
人查不到在岗的专属客服
,
没动他们
` : ''),
);
);
continue;
continue;
}
}
...
...
apps/pac-web/src/components/assistant/assistant-chat.tsx
View file @
8f79c94e
...
@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [
...
@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* 他点了,模型还得去猜那是哪一档。
* 他点了,模型还得去猜那是哪一档。
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* (`outcomes.
success`),但它含「约定下次回访」,⛔ 不是"谈成了几个"
;
* (`outcomes.
appointed`=约上 / `outcomes.scheduledNext`=约下次,⛔ 别加起来说)
;
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
...
...
apps/pac-web/src/components/assistant/assistant-widget.tsx
View file @
8f79c94e
...
@@ -74,7 +74,7 @@ export function AssistantWidget() {
...
@@ -74,7 +74,7 @@ export function AssistantWidget() {
* 3. get_assignment_detail 单批下钻
* 3. get_assignment_detail 单批下钻
*
*
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* 分配数=planned、未动过=untouched、已处理=progress.done、
成功=outcomes.success
、
* 分配数=planned、未动过=untouched、已处理=progress.done、
约上=outcomes.appointed
、
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
...
...
apps/pac-web/src/components/supervisor/batch-tracking.tsx
View file @
8f79c94e
...
@@ -16,9 +16,9 @@ const PAGE = 20;
...
@@ -16,9 +16,9 @@ const PAGE = 20;
*
*
* 🔴 分组表头那一行的 `colSpan` 从它推(`NUM_COLS.length + 1`,+1 是最左边的「批次」列)。
* 🔴 分组表头那一行的 `colSpan` 从它推(`NUM_COLS.length + 1`,+1 是最左边的「批次」列)。
* ⛔ 别在那儿手写数字:2026-08-20 加「回收」那一列时就是这么栽的 ——
* ⛔ 别在那儿手写数字:2026-08-20 加「回收」那一列时就是这么栽的 ——
* 分组行少跨一格,最后一列在「还
在跑的
」「8 月」这两行上留出一块白,且不报任何错。
* 分组行少跨一格,最后一列在「还
没收尾
」「8 月」这两行上留出一块白,且不报任何错。
*/
*/
const
NUM_COLS
=
[
'条数'
,
'已处置'
,
'没动'
,
'客服退回'
,
'回收'
,
'超期'
,
'
约上
'
]
as
const
;
const
NUM_COLS
=
[
'条数'
,
'已处置'
,
'没动'
,
'客服退回'
,
'回收'
,
'超期'
,
'
预约上'
,
'约下次
'
]
as
const
;
/**
/**
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
...
@@ -27,10 +27,22 @@ const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '
...
@@ -27,10 +27,22 @@ const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '
const
IDLE_HEAVY
=
0.2
;
const
IDLE_HEAVY
=
0.2
;
/**
/**
* 「还在跑的」判据 —— **时限还没到,或还有没人动过的单**。
* 「还没收尾」判据 —— **时限还没到,或还有单压在客服手上(含已经超期的)**。
*
* 🔴 「含已经超期的」不是补充说明,是这一组**最主要的用处**:
* 到期**不再自动回池**(2026-08-19 起,回收器已删)—— 过了时限的单就那么压在
* 客服手上。所以一批可能时限早过了、却因为 `inHand > 0` 仍留在这一组里,
* 而那恰恰是主管最该动手的一批。⛔ 不写明的话,他会以为过了时限的批次都沉到历史里了。
*
* 🔴 **2026-08-22 由「还在跑的」改名**(产品实测有歧义):
* 主管把「还在跑的」读成"**分配还没完成**"——像是有个后台任务在跑、结果还没出来。
* 而它说的是**批次**的生命周期:这几批还来得及补救。
* ⚠️ 「时限内」也不行:判据有两支,另一支(还有单压在手上)在时限过了之后仍然成立,
* 写成「时限内」会让一批过了期、单还压着的批次莫名其妙地待在这一组里。
* ⇒ 「还没收尾」直接说的就是那件事,且不碰"分配"这个词。
*
*
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* 服务端的 `status` 只有 confirmed/revoked,没有"
跑没跑完
"这个概念,
* 服务端的 `status` 只有 confirmed/revoked,没有"
收没收尾
"这个概念,
* 而这个分组的意义纯粹是「哪几批还来得及补救」。
* 而这个分组的意义纯粹是「哪几批还来得及补救」。
* ⛔ 别为它去后端加一列 —— 口径一旦落库,改起来要迁移数据。
* ⛔ 别为它去后端加一列 —— 口径一旦落库,改起来要迁移数据。
*/
*/
...
@@ -166,20 +178,20 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
...
@@ -166,20 +178,20 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
const
shown
=
scope
===
'running'
?
running
:
items
;
const
shown
=
scope
===
'running'
?
running
:
items
;
/**
/**
* 按月分组(「还
在跑的
」单独一组排最前)。
* 按月分组(「还
没收尾
」单独一组排最前)。
* ⚠️ 分组只在「全部」视图里做 —— 「还
在跑的
」本身就是一组,再按月切没有意义。
* ⚠️ 分组只在「全部」视图里做 —— 「还
没收尾
」本身就是一组,再按月切没有意义。
*/
*/
const
groups
=
useMemo
(()
=>
{
const
groups
=
useMemo
(()
=>
{
const
out
:
BatchGroupData
[]
=
[];
const
out
:
BatchGroupData
[]
=
[];
if
(
scope
===
'running'
)
{
if
(
scope
===
'running'
)
{
return
[
return
[
{
key
:
'还
在跑的'
,
note
:
'时限未到、或还有没人动过的单
'
,
past
:
false
,
rows
:
shown
},
{
key
:
'还
没收尾'
,
note
:
'时限没到、或还有单压在客服手上(含已经超期的)
'
,
past
:
false
,
rows
:
shown
},
];
];
}
}
const
runIds
=
new
Set
(
running
.
map
((
b
)
=>
b
.
id
));
const
runIds
=
new
Set
(
running
.
map
((
b
)
=>
b
.
id
));
const
run
=
shown
.
filter
((
b
)
=>
runIds
.
has
(
b
.
id
));
const
run
=
shown
.
filter
((
b
)
=>
runIds
.
has
(
b
.
id
));
if
(
run
.
length
)
if
(
run
.
length
)
out
.
push
({
key
:
'还
在跑的'
,
note
:
'时限未到、或还有没人动过的单
'
,
past
:
false
,
rows
:
run
});
out
.
push
({
key
:
'还
没收尾'
,
note
:
'时限没到、或还有单压在客服手上
'
,
past
:
false
,
rows
:
run
});
let
lastKey
=
''
;
let
lastKey
=
''
;
for
(
const
b
of
shown
)
{
for
(
const
b
of
shown
)
{
if
(
runIds
.
has
(
b
.
id
))
continue
;
if
(
runIds
.
has
(
b
.
id
))
continue
;
...
@@ -210,7 +222,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
...
@@ -210,7 +222,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
:
'text-slate-500 hover:text-slate-700'
,
:
'text-slate-500 hover:text-slate-700'
,
)
}
)
}
>
>
还
在跑的
{
running
.
length
}
还
没收尾
{
running
.
length
}
</
button
>
</
button
>
<
button
<
button
type=
"button"
type=
"button"
...
@@ -280,7 +292,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
...
@@ -280,7 +292,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<
span
className=
"h-px flex-1 bg-slate-50"
/>
<
span
className=
"h-px flex-1 bg-slate-50"
/>
<
span
className=
"nums text-[10.5px] text-slate-400"
>
<
span
className=
"nums text-[10.5px] text-slate-400"
>
{
scope
===
'running'
{
scope
===
'running'
?
`还
在跑
的 ${running.length} 批已全部显示`
?
`还
没收尾
的 ${running.length} 批已全部显示`
:
cursor
:
cursor
?
`已显示 ${items.length} / ${total} 批 · 往下滚动继续加载`
?
`已显示 ${items.length} / ${total} 批 · 往下滚动继续加载`
:
`已显示全部 ${items.length} 批`
}
:
`已显示全部 ${items.length} 批`
}
...
@@ -298,13 +310,13 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
...
@@ -298,13 +310,13 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
type
BatchGroupData
=
{
key
:
string
;
note
:
string
;
past
:
boolean
;
rows
:
AssignmentBrief
[]
};
type
BatchGroupData
=
{
key
:
string
;
note
:
string
;
past
:
boolean
;
rows
:
AssignmentBrief
[]
};
/**
/**
* 「还
在跑的
」与「历史」两套配色(2026-08-11 产品:两者要在**颜色上**分得开)。
* 「还
没收尾
」与「历史」两套配色(2026-08-11 产品:两者要在**颜色上**分得开)。
*
*
* ⭐ 分法是「**主色 + 满饱和** vs 灰底 + 褪色」:
* ⭐ 分法是「**主色 + 满饱和** vs 灰底 + 褪色」:
* 在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色(绿=已处置、红=退回、
* 在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色(绿=已处置、红=退回、
* 琥珀=没动/到期)—— 它们是主管**还来得及动手**的;
* 琥珀=没动/到期)—— 它们是主管**还来得及动手**的;
* 历史批次退成灰底 + 同色相的低饱和,语义色仍在(还看得出哪列是什么),但整块往后退。
* 历史批次退成灰底 + 同色相的低饱和,语义色仍在(还看得出哪列是什么),但整块往后退。
* ⛔ 别把历史行做成纯灰(把语义色一起抹掉):那样「退回 12」和「约上 12」看起来一样,
* ⛔ 别把历史行做成纯灰(把语义色一起抹掉):那样「退回 12」和「
预
约上 12」看起来一样,
* 而回顾恰恰就是在比这几列。
* 而回顾恰恰就是在比这几列。
* ⛔ 也别只靠那条竖轨 —— 一条 2px 的线滚起来看不见,底色才是扫视时抓得到的。
* ⛔ 也别只靠那条竖轨 —— 一条 2px 的线滚起来看不见,底色才是扫视时抓得到的。
*
*
...
@@ -324,7 +336,13 @@ const TONE = {
...
@@ -324,7 +336,13 @@ const TONE = {
idleHeavy
:
'font-semibold text-amber-700'
,
idleHeavy
:
'font-semibold text-amber-700'
,
released
:
'text-rose-600'
,
released
:
'text-rose-600'
,
expired
:
'text-amber-700'
,
expired
:
'text-amber-700'
,
booked
:
'text-slate-900'
,
booked
:
'text-emerald-700'
,
/**
* ⚠️ 「约下次」是**中性灰**,⛔ 别跟「预约上」同一支绿 ——
* 两者差着一整个台阶(一个进了预约表、一个只是答应再联系),
* 同色会让主管把两列加起来读成"成效"。
*/
bookedNext
:
'text-slate-500'
,
// 组标题也带上主色(浅底 + 深字)—— 竖轨太细,标题这一条才是扫视时先看到的
// 组标题也带上主色(浅底 + 深字)—— 竖轨太细,标题这一条才是扫视时先看到的
head
:
'bg-brand-50'
,
head
:
'bg-brand-50'
,
headKey
:
'text-brand-800'
,
headKey
:
'text-brand-800'
,
...
@@ -341,7 +359,8 @@ const TONE = {
...
@@ -341,7 +359,8 @@ const TONE = {
idleHeavy
:
'font-medium text-amber-800/60'
,
idleHeavy
:
'font-medium text-amber-800/60'
,
released
:
'text-rose-800/55'
,
released
:
'text-rose-800/55'
,
expired
:
'text-amber-800/55'
,
expired
:
'text-amber-800/55'
,
booked
:
'text-slate-500'
,
booked
:
'text-emerald-800/55'
,
bookedNext
:
'text-slate-400'
,
head
:
'bg-slate-100/70'
,
head
:
'bg-slate-100/70'
,
headKey
:
'text-slate-500'
,
headKey
:
'text-slate-500'
,
},
},
...
@@ -364,8 +383,8 @@ function BatchGroup({
...
@@ -364,8 +383,8 @@ function BatchGroup({
<
tr
>
<
tr
>
{
/*
{
/*
🔴 **colSpan 必须从列表推**(2026-08-20 走查栽过):加了「回收」那一列之后,
🔴 **colSpan 必须从列表推**(2026-08-20 走查栽过):加了「回收」那一列之后,
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约
上
)在
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约
下次
)在
「还
在跑的
」「8 月」这两行上留出一块白,而且不报任何错。
「还
没收尾
」「8 月」这两行上留出一块白,而且不报任何错。
⚠️ `+1` 是最左边那一列「批次」,它不在 NUM_COLS 里。
⚠️ `+1` 是最左边那一列「批次」,它不在 NUM_COLS 里。
*/
}
*/
}
<
td
colSpan=
{
NUM_COLS
.
length
+
1
}
className=
{
cn
(
'border-t px-3.5 py-1.5'
,
t
.
rail
,
t
.
head
)
}
>
<
td
colSpan=
{
NUM_COLS
.
length
+
1
}
className=
{
cn
(
'border-t px-3.5 py-1.5'
,
t
.
rail
,
t
.
head
)
}
>
...
@@ -410,7 +429,9 @@ function BatchGroup({
...
@@ -410,7 +429,9 @@ function BatchGroup({
{
/* ⚠️ 回收是**中性**的(主管自己的调度),⛔ 别给退回那支红 —— 红会读成"出问题了" */
}
{
/* ⚠️ 回收是**中性**的(主管自己的调度),⛔ 别给退回那支红 —— 红会读成"出问题了" */
}
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
idle
)
}
>
{
b
.
recalled
}
</
td
>
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
idle
)
}
>
{
b
.
recalled
}
</
td
>
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
expired
)
}
>
{
b
.
expired
}
</
td
>
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
expired
)
}
>
{
b
.
expired
}
</
td
>
{
/* ⚠️ 「预约上」= 转化新预约(患者进了预约表),⛔ 不含约定下次回访 —— 那在下一列 */
}
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
booked
)
}
>
{
b
.
booked
}
</
td
>
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
booked
)
}
>
{
b
.
booked
}
</
td
>
<
td
className=
{
cn
(
'nums px-2.5 py-2 text-right'
,
t
.
bookedNext
)
}
>
{
b
.
bookedNext
}
</
td
>
</
tr
>
</
tr
>
);
);
})
}
})
}
...
@@ -453,7 +474,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
...
@@ -453,7 +474,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<>
<>
{
/*
{
/*
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 / 约上)
抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 /
预
约上)
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
...
@@ -472,7 +493,8 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
...
@@ -472,7 +493,8 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
{
d
&&
(
{
d
&&
(
<
div
className=
"nums mt-0.5 text-[11px] text-slate-600"
>
<
div
className=
"nums mt-0.5 text-[11px] text-slate-600"
>
已处置
{
d
.
handled
}
· 没动
{
d
.
inHand
}
· 客服退回
{
d
.
released
}
已处置
{
d
.
handled
}
· 没动
{
d
.
inHand
}
· 客服退回
{
d
.
released
}
{
d
.
recalled
>
0
&&
<>
· 回收
{
d
.
recalled
}
</>
}
· 超期
{
d
.
expired
}
· 约上
{
d
.
booked
}
{
d
.
recalled
>
0
&&
<>
· 回收
{
d
.
recalled
}
</>
}
· 超期
{
d
.
expired
}
· 预约上
{
d
.
booked
}
{
d
.
bookedNext
>
0
&&
<>
· 约下次
{
d
.
bookedNext
}
</>
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -576,14 +598,21 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
...
@@ -576,14 +598,21 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
它是写给**模型**看的(带 `**` 和 ⛔ 的指令),在界面上是原样渲染的乱码;
它是写给**模型**看的(带 `**` 和 ⛔ 的指令),在界面上是原样渲染的乱码;
真正给人看的成效数在下面这四个桶里。⛔ 别再把模型指令当界面文案用。 */
}
真正给人看的成效数在下面这四个桶里。⛔ 别再把模型指令当界面文案用。 */
}
<
div
className=
"mt-4 text-[11.5px] font-semibold text-slate-700"
>
通话成效
</
div
>
<
div
className=
"mt-4 text-[11.5px] font-semibold text-slate-700"
>
通话成效
</
div
>
{
/* ⚠️ 四个桶**穷尽**(success + failed + keep + noOutcome === 条数,回归里锁着)——
{
/* 🔴 **2026-08-22 由四个桶改成五个** —— 原来第一格叫「成功」,
所以四个都要摆出来,少一个就对不上数(T14)。
含**转化新预约 + 约定下次回访**两项,靠下面一行小字解释。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前三个分开、
产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
并且是唯一上色的:主管先要看的就是它。 */
}
第一句问的就是"这个成功是什么意思"。
<
div
className=
"mt-1.5 grid grid-cols-4 gap-2 text-center"
>
⛔ 要靠小字解释的数,本身就该拆开摆。
⚠️ 五个桶**穷尽**(预约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数,
回归里锁着)—— 少一个就对不上数(T14)。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前四个分开、
并且是唯一上琥珀的:主管先要看的就是它。 */
}
<
div
className=
"mt-1.5 grid grid-cols-5 gap-1.5 text-center"
>
{
(
{
(
[
[
{
k
:
'成功'
,
n
:
d
.
outcomes
.
success
,
c
:
'text-emerald-700'
},
{
k
:
'预约上'
,
n
:
d
.
outcomes
.
appointed
,
c
:
'text-emerald-700'
},
// ⚠️ 灰不是绿:它是"还没结束",不是成效
{
k
:
'约下次'
,
n
:
d
.
outcomes
.
scheduledNext
,
c
:
'text-slate-600'
},
{
k
:
'不成功'
,
n
:
d
.
outcomes
.
failed
,
c
:
'text-slate-600'
},
{
k
:
'不成功'
,
n
:
d
.
outcomes
.
failed
,
c
:
'text-slate-600'
},
{
k
:
'没进展'
,
n
:
d
.
outcomes
.
keep
,
c
:
'text-slate-600'
},
{
k
:
'没进展'
,
n
:
d
.
outcomes
.
keep
,
c
:
'text-slate-600'
},
{
{
...
@@ -593,15 +622,16 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
...
@@ -593,15 +622,16 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
},
},
]
as
const
]
as
const
).
map
((
o
)
=>
(
).
map
((
o
)
=>
(
<
div
key=
{
o
.
k
}
className=
"rounded-lg bg-slate-50 py-1.5"
>
<
div
key=
{
o
.
k
}
className=
"rounded-lg bg-slate-50 p
x-1 p
y-1.5"
>
<
div
className=
{
cn
(
'nums text-[15px] font-semibold'
,
o
.
c
)
}
>
{
o
.
n
}
</
div
>
<
div
className=
{
cn
(
'nums text-[15px] font-semibold'
,
o
.
c
)
}
>
{
o
.
n
}
</
div
>
<
div
className=
"text-[10.5px] text-slate-500"
>
{
o
.
k
}
</
div
>
<
div
className=
"text-[10.5px]
whitespace-nowrap
text-slate-500"
>
{
o
.
k
}
</
div
>
</
div
>
</
div
>
))
}
))
}
</
div
>
</
div
>
{
/* ⚠️
「成功」含**约定下次回访** —— 不写这句主管会读成"成交了这么多"
*/
}
{
/* ⚠️
拆开之后这行小字只解释**两件不显然的事**,⛔ 别再重复标签本身
*/
}
<
div
className=
"mt-1.5 text-[10.5px] leading-relaxed text-slate-400"
>
<
div
className=
"mt-1.5 text-[10.5px] leading-relaxed text-slate-400"
>
「成功」含约定下次回访,不等于都成交了;「没有结果」是这批里一次都没打/没记的。
「预约上」是患者定下了来的日子;「约下次」只是约好再联系一次,单子还在客服手上。
「没有结果」是这批里一次都没打/没记的。
</
div
>
</
div
>
{
/*
{
/*
...
...
apps/pac-web/src/components/supervisor/supervisor-workbench.tsx
View file @
8f79c94e
...
@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
...
@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下
退回率的两个
分母(见下方注释)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下
预约成功率下面那行
分母(见下方注释)。
*
*
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
...
@@ -77,7 +77,7 @@ export function SupervisorWorkbench() {
...
@@ -77,7 +77,7 @@ export function SupervisorWorkbench() {
<
BatchTracking
clinicId=
{
clinicId
}
/>
<
BatchTracking
clinicId=
{
clinicId
}
/>
</
section
>
</
section
>
</
div
>
</
div
>
{
/* ⚠️ 470px(设计稿宽度)装不下「
退回
率」那一列:
{
/* ⚠️ 470px(设计稿宽度)装不下「
预约成功
率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */
}
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */
}
...
...
apps/pac-web/src/components/supervisor/team-status.tsx
View file @
8f79c94e
...
@@ -17,9 +17,11 @@ const WINDOWS = [
...
@@ -17,9 +17,11 @@ const WINDOWS = [
*
*
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* · **已处置 / 预约成功率** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* ⚠️ 所以前两列写死「**当前**」、后两列写死「**近 N 天**」(2026-08-22 产品要求)。
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* 只写「在手」时实测会被读成"这 7 天分了 62 条";而「超期」如果不带「当前」,
* 摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* 反过来「已处置」「预约成功率」不带「近 N 天」,切了窗口数字变了却看不出为什么。
*
*
* 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
* · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
...
@@ -28,9 +30,22 @@ const WINDOWS = [
...
@@ -28,9 +30,22 @@ const WINDOWS = [
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
*
*
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 🔴 「**预约成功率**」2026-08-22 顶掉了原来的「退回率」(产品定):
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
* · 分子 —— 真的**约上**的(`success_appointed`,患者定下了来的日子),
* 只给百分比会把"没动"藏起来,而那个数本身就是信号。
* ⛔ **不含「约定下次回访」**:那只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率。
* · 分母 —— 「完成」(他**真打过**的),⛔ 不是"已处置":
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级到那行小字里 —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号。
*
* ⚠️ 成功率下面**不挂小字**(2026-08-22 产品定"值就一个就行"):
* 原来那行是服务端拼的 `rateNote`,带退回/没动两个旁支。
* ⛔ `released` / `idle` 没有消失,仍在响应里 —— 它们是另一个问题,不该挤在这一列下面。
*
* 🔴 「已处置」这一列 2026-08-22 **由「完成」改名**:主管当场问「完成和已处置
* 是同一个意思吗」—— 是同一个判据(真的落了通话结果),差的只是范围
* (本批 vs 近 N 天),而同一屏上叫了两个词。而且「完成」听起来像"做成了"。
*/
*/
export
function
TeamStatus
({
clinicId
}:
{
clinicId
:
string
|
null
})
{
export
function
TeamStatus
({
clinicId
}:
{
clinicId
:
string
|
null
})
{
const
[
days
,
setDays
]
=
useState
<
number
>
(
7
);
const
[
days
,
setDays
]
=
useState
<
number
>
(
7
);
...
@@ -38,7 +53,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
...
@@ -38,7 +53,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/
退回
率分母都变了(见 assignment-sync-store)
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/
成功
率分母都变了(见 assignment-sync-store)
const
syncSeq
=
useAssignmentSyncStore
((
s
)
=>
s
.
seq
);
const
syncSeq
=
useAssignmentSyncStore
((
s
)
=>
s
.
seq
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
clinicId
)
return
;
if
(
!
clinicId
)
return
;
...
@@ -135,11 +150,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
...
@@ -135,11 +150,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<
th
className=
"whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
<
th
className=
"whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
当前超期
当前超期
</
th
>
</
th
>
{
/* ⚠️ 「近 N 天」跟着切换器走 —— 不带的话切了窗口数字变了却看不出为什么。
⚠️ 「已处置」与批次表那一列同一个词:同一个判据(真的落了通话结果),
⛔ 别再叫「完成」——「完成」听起来像"做成了" */
}
<
th
className=
"whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
<
th
className=
"whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
完成
近
{
days
}
天已处置
</
th
>
</
th
>
{
/* ⚠️ 「预约」两个字不能省 —— 「成功率」会被读成"打通了就算成功" */
}
<
th
className=
"whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
<
th
className=
"whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
退回
率
近
{
days
}
天预约成功
率
</
th
>
</
th
>
</
tr
>
</
tr
>
</
thead
>
</
thead
>
...
@@ -188,14 +207,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
...
@@ -188,14 +207,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
)
}
)
}
</
td
>
</
td
>
<
td
className=
"nums px-2.5 py-2 text-right text-emerald-700"
>
{
a
.
done
}
</
td
>
<
td
className=
"nums px-2.5 py-2 text-right text-emerald-700"
>
{
a
.
done
}
</
td
>
<
td
className=
"px-3.5 py-2 text-right"
>
{
/* ⚠️ 分母是 `done`(他真打过的),⛔ 不含退回的 —— 见文件头那段。
{
/* ⚠️ 百分比与两个分母**分两行**:一行塞不下,而分母不能省 */
}
⚠️ 一个百分比,⛔ 下面不挂小字(2026-08-22 产品定) */
}
<
div
className=
"nums text-slate-900"
>
<
td
className=
"nums px-3.5 py-2 text-right text-slate-900"
>
{
a
.
handled
?
`${((a.released / a.handled) * 100).toFixed(1)}%`
:
'—'
}
{
a
.
done
?
`${((a.booked / a.done) * 100).toFixed(1)}%`
:
'—'
}
</
div
>
<
div
className=
"nums text-[10.5px] leading-snug text-slate-400"
>
{
a
.
rateNote
}
</
div
>
</
td
>
</
td
>
</
tr
>
</
tr
>
);
);
...
...
packages/types/src/schemas/plan-assignment.ts
View file @
8f79c94e
...
@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({
...
@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({
*/
*/
handled
:
z
.
number
().
int
(),
handled
:
z
.
number
().
int
(),
/**
/**
* 「
约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**)
。
* 「
预约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数
。
*
*
* ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约":
* 🔴 **2026-08-22 收窄口径**:此前它是整个 `close` 组的合计,
* 两处都写着"约上",小一号主管就只能猜哪个对。
* 也就是「转化新预约 + 约定下次回访」两项加在一起。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应。
* 混成一个数,主管看到「预约上 6」会当成 6 个人要来了。
* ⇒ 「预约上」= 只数真的预约上的;约了下次回访的走 `bookedNext`。
*
* ⚠️ 与 detail 的 `outcomes.appointed` **同一定义**,⛔ 别在两处各立标准。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
*/
*/
booked
:
z
.
number
().
int
(),
booked
:
z
.
number
().
int
(),
/**
* 「约下次」—— 最近一次通话结果是 `scheduled_next`(约定下次回访)的人数。
*
* ⚠️ 它**不是成效**,是**还没结束**:单子仍挂在客服手上、snooze 到回访日。
* ⛔ 别把它和 `booked` 加起来对外叫「预约上」—— 那正是收窄口径要拆开的东西。
* ⚠️ `booked + bookedNext === outcomes.success`(close 组的合计),回归里锁着。
*/
bookedNext
:
z
.
number
().
int
(),
});
});
export
type
AssignmentBrief
=
z
.
infer
<
typeof
AssignmentBriefSchema
>
;
export
type
AssignmentBrief
=
z
.
infer
<
typeof
AssignmentBriefSchema
>
;
...
@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
...
@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
*/
*/
outcomes
:
z
.
object
({
outcomes
:
z
.
object
({
/// 成功(EXECUTION_OUTCOME_GROUP close):转化新预约 + 约定下次回访
/**
* 成功组(EXECUTION_OUTCOME_GROUP `close`)的合计 = `appointed + scheduledNext`。
*
* ⚠️ 它**只是个合计**,⛔ 界面上不许单独摆一个「成功 N」——
* 2026-08-22 产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
* 问的第一句就是"这个成功是什么意思"。答案得靠下面那行小字,
* 而要靠小字解释的数,本身就该拆开摆。
*/
success
:
z
.
number
().
int
(),
success
:
z
.
number
().
int
(),
/**
* ⭐ 「预约上」—— `success_appointed`(转化新预约):患者**进了预约表**,有个日子会来。
* ⚠️ 与列表那一列 `booked` 同一个数,⛔ 别让它们分叉。
*/
appointed
:
z
.
number
().
int
(),
/**
* ⭐ 「约下次」—— `scheduled_next`(约定下次回访):客服答应了"那天再联系您",
* 患者什么都没答应,单子仍挂在他手上 snooze 到回访日。
* ⚠️ 它归 `close` 组是**统计口径**上的事(是有效推进,不该跟"未接通"混在一起),
* ⛔ 但它不是"预约上了" —— 见 EXECUTION_OUTCOME_META 里 scheduled_next 那段。
*/
scheduledNext
:
z
.
number
().
int
(),
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
failed
:
z
.
number
().
int
(),
failed
:
z
.
number
().
int
(),
/// 打了但没进展(keep):未接通 / 秒挂
/// 打了但没进展(keep):未接通 / 秒挂
...
@@ -1082,7 +1115,17 @@ export const SheetSelectSchema = z.discriminatedUnion('group', [
...
@@ -1082,7 +1115,17 @@ export const SheetSelectSchema = z.discriminatedUnion('group', [
z
.
object
({
group
:
z
.
literal
(
'agent'
),
agent
:
z
.
string
().
describe
(
'客服姓名'
)
}),
z
.
object
({
group
:
z
.
literal
(
'agent'
),
agent
:
z
.
string
().
describe
(
'客服姓名'
)
}),
/** 「待分配」那一组的全部(专属客服排满、还没落到人头上的那些) */
/** 「待分配」那一组的全部(专属客服排满、还没落到人头上的那些) */
z
.
object
({
group
:
z
.
literal
(
'pending'
)
}),
z
.
object
({
group
:
z
.
literal
(
'pending'
)
}),
/** 整批 —— 只对 `set_expiry` / `set_benefit` 有意义 */
/**
* 整批 —— 卡片上此刻还在的所有人(已排好的 + 还留在「待分配」里的)。
*
* 🔴 2026-08-22 实测补的:本批只有 5 人,主管说「这 5 人都分配给韩维」,
* 模型选了 `batch` + `assign`(整批是这句话唯一自然的表达),
* 而当时这一条被写死成"只对 set_expiry / set_benefit 有意义" ——
* 界面把它解成**空名单**,照样回了一句「已把整批铺给 1 位客服()」。
* 那对括号里是空的:一个人都没动,而回报说成了。
* ⚠️ `set_expiry` / `set_benefit` 配 batch 走的是**批次级**的那两个控件
* (整批时限 / 本批福利),⛔ 不逐条落到 planId 上。
*/
z
.
object
({
group
:
z
.
literal
(
'batch'
)
}),
z
.
object
({
group
:
z
.
literal
(
'batch'
)
}),
]);
]);
...
@@ -1136,8 +1179,17 @@ export const SheetEditOpSchema = z
...
@@ -1136,8 +1179,17 @@ export const SheetEditOpSchema = z
.
superRefine
((
op
,
ctx
)
=>
{
.
superRefine
((
op
,
ctx
)
=>
{
if
(
op
.
action
===
'assign'
)
{
if
(
op
.
action
===
'assign'
)
{
if
(
!
op
.
to
)
ctx
.
addIssue
({
code
:
'custom'
,
message
:
"action:'assign' 必须给 to(owner / balance)"
});
if
(
!
op
.
to
)
ctx
.
addIssue
({
code
:
'custom'
,
message
:
"action:'assign' 必须给 to(owner / balance)"
});
if
(
op
.
select
.
group
===
'batch'
)
/**
ctx
.
addIssue
({
code
:
'custom'
,
message
:
'整批不能作为改派的对象 —— 请选 pending / agent / patients'
});
* ⚠️ `batch` + `owner` 做不到:**专属关系只有「待分配」那份数据带着**
* (`pending[].ownerUserId`),已经排好的条目卡片上查不到它归谁专属。
* 放它过去的话,那几条会被报成"查不到在岗的专属客服" ——
* 听起来像"这些人没有专属客服",而事实是卡片没带这条关系。
*/
if
(
op
.
select
.
group
===
'batch'
&&
op
.
to
?.
mode
===
'owner'
)
ctx
.
addIssue
({
code
:
'custom'
,
message
:
"整批配不了 owner —— 专属关系只有「待分配」那组带着,要各自归专属请选 group:'pending'"
,
});
}
}
if
(
op
.
action
===
'remove'
&&
op
.
select
.
group
===
'batch'
)
if
(
op
.
action
===
'remove'
&&
op
.
select
.
group
===
'batch'
)
ctx
.
addIssue
({
code
:
'custom'
,
message
:
'整批不能"移出自己" —— 请选具体的人'
});
ctx
.
addIssue
({
code
:
'custom'
,
message
:
'整批不能"移出自己" —— 请选具体的人'
});
...
@@ -1191,20 +1243,44 @@ export const AgentWorkloadRowSchema = z.object({
...
@@ -1191,20 +1243,44 @@ export const AgentWorkloadRowSchema = z.object({
* 把它们的"压了多久"算进来是在说一件早就结束的事。
* 把它们的"压了多久"算进来是在说一件早就结束的事。
*/
*/
overdueOldestDays
:
z
.
number
().
int
().
nullable
(),
overdueOldestDays
:
z
.
number
().
int
().
nullable
(),
/// 窗口内写过通话结果的条数(按单去重)
/**
* 窗口内**真的落了通话结果**的条数(按单去重)—— 界面上的「近 N 天已处置」。
*
* ⚠️ 与批次那边的 `AssignmentBriefSchema.handled`(本批已处置)、
* `AssignmentAgentStatSchema.done`(批内按人)是**同一个判据** ——
* 都在问"有没有人真的做了事"。差的只是**范围**:本批 vs 近 N 天。
* 🔴 所以界面上必须用**同一个词**(2026-08-22 主管当场问「完成和已处置是同一个意思吗」)。
* ⛔ 原来这一列叫「完成」,而批次表那一列叫「已处置」 ——
* 同一屏上两个词指同一件事,主管只能猜。而且「完成」听起来像"做成了",
* 这正是整套口径反复要避免的读法。
*
* 🔴 **它同时是「预约成功率」的分母,⛔ 不筛结果项**(2026-08-22 产品确认)。
* 判据只有一条:**他有没有真打**。所以「没进展」那一桶(未接通 / 待跟进 /
* 需要找医生 / 已发短信 / 改约)照样进分母 —— 打了没人接,那也是打了。
* ⚠️ 未接通往往占分母的大头,所以这个数读的是「**触达 × 转化**」合起来的效率,
* ⛔ 不是"聊上了的人里谈成几成"。⛔ 别为了"好看"把 keep 组从分母里摘掉:
* 那会变成另一个指标,而名字没变。
* ⚠️ 唯一不在分母里的是「没有结果」—— 那些单**一条执行记录都没有**,
* 他压根没打,自然也不该算进"他打过的"。
*/
done
:
z
.
number
().
int
(),
done
:
z
.
number
().
int
(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
released
:
z
.
number
().
int
(),
released
:
z
.
number
().
int
(),
/// 窗口内已处置 = done + released(两者都是"这单他动过了")
handled
:
z
.
number
().
int
(),
/// 窗口内分到但一条都没动的
/// 窗口内分到但一条都没动的
idle
:
z
.
number
().
int
(),
idle
:
z
.
number
().
int
(),
/**
/**
* ⭐ 成品句子:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* ⭐ 窗口内**真的预约上**的条数 —— 最近一次通话结果是 `success_appointed`(转化新预约)。
* 🔴 **两个分母都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
*
* ⛔ 前端别自己拼:界面、助手、导出三处口径必须一致。
* 🔴 「预约成功率」= `booked / done`,界面上只出这**一个百分比**(2026-08-22 产品定)。
* ⚠️ `released`(退回)与 `idle`(没动)仍在这一行上,只是不再挂在成功率下面 ——
* ⛔ 它们没有消失,主管要看时是另一个问题、另一个位置。
*
* 🔴 ⛔ **不含「约定下次回访」**(2026-08-22 产品定):那只是客服答应了
* "那天再联系您",患者什么都没答应。算进来的话,一个只会往后拖的人
* 能拖出一条漂亮的成功率,而主管正是靠这一列判断谁真的把人叫回来了。
* ⚠️ 归属按**那条执行的操作人**算:同一条单 A 打过、B 才约上的,算 B 的。
*/
*/
rateNote
:
z
.
string
(),
booked
:
z
.
number
().
int
(),
});
});
export
type
AgentWorkloadRow
=
z
.
infer
<
typeof
AgentWorkloadRowSchema
>
;
export
type
AgentWorkloadRow
=
z
.
infer
<
typeof
AgentWorkloadRowSchema
>
;
...
...
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