Commit 8f79c94e by luoqi

merge: fix/sheet-edit-offroster → main(助手整批改派修复 + 主管工作台口径拆分)

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