Commit 435cd818 by luoqi

feat(recall): 已完成治疗改永久抑制 + 未认领只能看不能操作

两项业务反馈驱动的改动。

## 1. 「已完成治疗」抑制期 14d → 永久

原 14d(WEAK_SIGNAL_SUPPRESS_DAYS)押的注是「等一个 DW 同步周期 + 排除闸
接手」,生产实测证伪:缺牙召回两例(吕学文 DW 无义齿完成记录、李石明义齿
在上颌而召回在下颌)排除闸都接不住,到期只会原样弹回来再挨一次投诉。
档位本身也不自洽 —— 「已在外院治疗」早就是永久,在别家做完永久闭嘴、
在自己家做完两周后再问,说不通。

误判不会把患者封死:抑制是**信号级**的(key=scenario|subKey,subKey 含
牙位)且带时间锚,结案后新发的同类信号照常放行。
WEAK_SIGNAL_SUPPRESS_DAYS 现仅剩「机会识别不准确」使用(算法问题,修好
规则后本就该重新召回)。

## 2. 认领闸 —— 未认领只能看,不能操作

submitExecution 原先只校验 status、**完全没看 assignee**,生产上因此
出现两条 plan 在 assignee IS NULL 时被直接关闭。危害不止撞单:
execution.service 特意保留 assigneeUserId 作业绩归属(清了会让
「我的已完成」/ 转化率永远为 0),未认领就提交 = 这条执行没有归属人,
统计里直接蒸发 —— 前端提示替代不了服务端约束。

新增 plan/claim-guard.ts,两种拒绝态分码(20504 未认领 / 20505 他人认领,
文案必须分开,否则组员以为自己没点对)。

过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
不过闸:
  · assign(认领本身是入口,过闸会死锁)
  · recycle(PLAN_RECYCLE 是 leader 独占「回收 staff 已认领的单」,
    按"必须自己认领"拦会把组长回收功能整个废掉)
  · recompute(ops 工具)
  · 只读端点 + 「潜在」(查看类)+ 刷新(拉数据,不改业务态)

前端同步:详情页按钮**不禁用**,点了才讲原因(禁用会让客服不知道为什么
点不动);顶栏未认领显「认领」按钮(闸的唯一出口)、他人认领显「仅查看」。

## 验证
- 314 个单测通过(新增 claim-guard 5 例 + treated 永久 2 例)
- 本地端到端:未认领 3 个入口全返 20504;他人认领返 20505 带占用人;
  自己认领后放行;treated 关闭落库 snoozed_until = 36500 天(99.9 年)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 3ff9c827
......@@ -9,9 +9,11 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permission, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import { ApiCode, Permission, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { BizError } from '../../common/errors/biz-error';
import { assertPlanClaimedBy } from '../plan/claim-guard';
import {
TenantScope,
TenantScopeContext,
......@@ -67,6 +69,24 @@ export class PlansAggregateController {
private readonly prisma: PrismaService,
) {}
/**
* 认领闸(见 plan/claim-guard.ts)—— 「未认领只能看,不能操作」。
* 本控制器的写操作都是**详情页上的按钮**(重生成话术 / 打反馈),按钮就该受这道闸;
* 只读端点(:id/full、recall-brief 等)不过闸 —— "看"始终开放。
*/
private async assertClaimed(
scope: TenantScopeContext,
planId: string,
userId: string,
): Promise<void> {
const plan = await this.prisma.followupPlan.findFirst({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { assigneeUserId: true },
});
if (!plan) throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} not found`);
assertPlanClaimedBy(plan, userId);
}
// ─────────────────────────────────────────────
// 详情聚合
// ─────────────────────────────────────────────
......@@ -121,6 +141,7 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
async regenerateScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Query('bustCache') bustCache?: string,
......@@ -128,6 +149,8 @@ export class PlansAggregateController {
@Query('tier') tier?: string,
@Body() body?: { dryRun?: boolean },
) {
// 认领闸:重生成要真花 AI 钱,不该让没认领的人点
await this.assertClaimed(scope, planId, user.sub);
const result = await this.planScript.generate(planId, {
bustCache: bustCache === 'true' || bustCache === '1',
modelIdOverride: model,
......@@ -163,12 +186,15 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' })
async streamScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Query('model') model: string | undefined,
@Query('tier') tier: string | undefined,
@Res() res: Response,
): Promise<void> {
// 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
await this.assertClaimed(scope, planId, user.sub);
await this.pipeSse(res, (signal) =>
renderAgentInStream(
this.planScript.generateStream(planId, {
......@@ -205,6 +231,7 @@ export class PlansAggregateController {
@Param('id') planId: string,
@Body() body: unknown,
) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback } = ScriptFeedbackSchema.parse(body);
const script = await this.prisma.planScript.findFirst({
where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
......@@ -230,9 +257,11 @@ export class PlansAggregateController {
})
async submitRecallFeedback(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Body() body: unknown,
) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body);
// 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层)
const res = await this.prisma.followupPlan.updateMany({
......
import { ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
/**
* 认领闸 —— 「未认领只能看,不能操作」的服务端硬约束。
*
* 为什么必须在服务端拦(不能只做前端禁用):
* 1. **执行必须有归属**。execution.service 特意保留 assigneeUserId 作业绩归属
* (见那里的注释:清了会让「我的已完成」/ 转化率永远为 0)。未认领就提交执行 =
* 这条执行没有任何归属人,统计里直接蒸发 —— 前端提示拦不住这个数据问题。
* 2. **防撞单**。两个客服同时打同一个患者,只有服务端能仲裁。
* 3. 生产实证(2026-07):缺牙召回有两条 plan 在 `assignee IS NULL` 状态下被直接关闭,
* 当时 submitExecution 只校验了 status、完全没看 assignee。
*
* ⚠️ 适用边界 —— 不是所有写操作都该过这道闸:
* 过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
* 不过闸:
* · `assign`(认领本身就是入口,过闸会变成死锁)
* · `recycle`(PLAN_RECYCLE 是 **leader 独占**,语义就是"回收 staff 已认领的单",
* 按"必须自己认领"拦会把组长回收功能整个废掉)
* · `recompute`(ops/dev 工具,走 PLAN_ASSIGN 权限)
*
* 两种拒绝态分开报,文案不同:没人认领 → 引导去认领;别人认领了 → 说清是谁,
* 否则组员会以为是自己没点对。
*/
export interface ClaimablePlan {
assigneeUserId: string | null;
}
export function assertPlanClaimedBy(plan: ClaimablePlan, userId: string): void {
if (!plan.assigneeUserId) {
throw new BizError(ApiCode.PLAN_NOT_CLAIMED);
}
if (plan.assigneeUserId !== userId) {
// details 带上占用人,前端可查字典换成姓名展示("已被 张三 认领")
throw new BizError(ApiCode.PLAN_CLAIMED_BY_OTHER, undefined, {
assigneeUserId: plan.assigneeUserId,
});
}
}
......@@ -3,6 +3,7 @@ import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service';
import { resolveSnoozedUntil } from './recall-suppression';
import { assertPlanClaimedBy } from './claim-guard';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/**
......@@ -90,6 +91,7 @@ export class ExecutionService {
status: true,
contactAttempts: true,
targetClinicId: true,
assigneeUserId: true,
},
});
if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
......@@ -101,6 +103,9 @@ export class ExecutionService {
`Plan ${planId} 当前状态 ${plan.status},不可写 execution`,
);
}
// ⭐ 认领闸:未认领 / 被别人认领 → 拒绝(见 claim-guard.ts)。
// 放在状态校验之后 —— "这单已经结案了"比"你没认领"更该先说。
assertPlanClaimedBy(plan, operatorUserId);
// ─── 2. 解析 executorClinicId ───
const executorClinicId =
......
import { ApiCode } from '@pac/types';
import { assertPlanClaimedBy } from '../src/modules/plan/claim-guard';
import { BizError } from '../src/common/errors/biz-error';
/**
* 认领闸回归 —— 「未认领只能看,不能操作」。
*
* 背景(2026-07 生产实证):submitExecution 当时只校验 status、**完全没看 assignee**,
* 结果缺牙召回有两条 plan 在 `assignee IS NULL` 状态下被直接关闭。
* 危害不止"撞单":execution.service 特意保留 assigneeUserId 作**业绩归属**
* (那里的注释写明清空会让「我的已完成」/ 转化率永远为 0)—— 未认领就提交执行,
* 这条执行没有任何归属人,统计里直接蒸发。所以闸必须在服务端,前端提示替代不了。
*
* 三条红线(下面各有用例锁住):
* 1. 没人认领 → 拒绝,且码是 PLAN_NOT_CLAIMED(引导去认领)
* 2. 别人认领 → 拒绝,且码**不同**(PLAN_CLAIMED_BY_OTHER),否则组员以为自己没点对
* 3. 自己认领 → 放行(闸不能把正常作业也拦了)
*/
const ME = 'u-me';
function catchErr(fn: () => void): BizError {
try {
fn();
} catch (e) {
return e as BizError;
}
throw new Error('期望抛错,但没抛');
}
describe('assertPlanClaimedBy — 认领闸', () => {
test('⭐ 红线③:自己认领 → 放行', () => {
expect(() => assertPlanClaimedBy({ assigneeUserId: ME }, ME)).not.toThrow();
});
test('⭐ 红线①:没人认领 → PLAN_NOT_CLAIMED', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: null }, ME));
expect(err).toBeInstanceOf(BizError);
expect(err.code).toBe(ApiCode.PLAN_NOT_CLAIMED);
expect(err.msg).toContain('认领');
});
test('⭐ 红线②:别人认领 → PLAN_CLAIMED_BY_OTHER(跟"没人认领"必须是不同的码)', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: 'u-other' }, ME));
expect(err.code).toBe(ApiCode.PLAN_CLAIMED_BY_OTHER);
expect(err.code).not.toBe(ApiCode.PLAN_NOT_CLAIMED); // 两态文案不能合并
// details 带占用人 —— 前端据此查字典换姓名展示("已被 张三 认领")
expect(err.details).toEqual({ assigneeUserId: 'u-other' });
});
test('空串 assignee 当作未认领(不是"被叫空串的人认领了")', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: '' }, ME));
expect(err.code).toBe(ApiCode.PLAN_NOT_CLAIMED);
});
test('用户 id 区分大小写 / 不做模糊匹配 —— 不是自己就是不行', () => {
expect(() => assertPlanClaimedBy({ assigneeUserId: 'U-ME' }, ME)).toThrow();
expect(() => assertPlanClaimedBy({ assigneeUserId: 'u-me-2' }, ME)).toThrow();
});
});
......@@ -216,7 +216,15 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取
// 「识别不准确」是 PAC 自己的信号有问题,不该按「患者拒绝」的重档罚人。
// 这条锁住 max 的种子不能是 outcome 默认档(否则 14d 永远生效不了)。
expect(snooze(['inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
expect(snooze(['treated'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
});
test('⭐ 「已完成治疗」= 永久,不是 14d —— 客服断言临床事实,不该两周后再问一遍', () => {
// 2026-07 业务反馈驱动的改动:原 14d 档押注「DW 同步 + 排除闸接手」,生产实测证伪
// (缺牙两例:DW 无完成记录 / 义齿在上颌而召回在下颌,排除闸都接不住)。
// 也修掉了原先的不自洽:「已在外院治疗」永久,「已完成治疗」却 14d。
expect(snooze(['treated'])).toBe(SUPPRESS_PERMANENT_DAYS);
// 与「已在外院治疗」同档 —— 在别家做完 vs 在自己家做完,抑制强度应一致
expect(snooze(['treated'])).toBe(snooze(['treated_elsewhere']));
});
test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => {
......
......@@ -162,6 +162,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
name: real.plan?.assigneeUserId ?? '(未分配)',
role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
},
/// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的
/// assignee.id:那里未认领时兜底成 'u_unknown',分不出"没人认领"和"某人叫 u_unknown"。
assigneeUserId: real.plan?.assigneeUserId ?? null,
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
......
......@@ -321,6 +321,8 @@ export const mockPlan = {
executorClinic: '望京旗舰店',
goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null,
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
/// 认领人原始值(null = 无人认领)—— 认领闸判定用,见 adapt-data 同名字段注释
assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
......
......@@ -528,9 +528,11 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 「弱语义」放弃原因的短抑制档(识别不准 / 已完成治疗):问题出在数据侧不在患者侧,
/// 一个 DW 同步周期 + 排除闸接手绰绰有余,不该按「患者拒绝」的重档罚人。
/// 「弱语义」放弃原因的短抑制档 —— 现仅用于「机会识别不准确」:问题出在**我们的算法**,
/// 修好规则后本就该重新召回,压太久等于把修复效果一起压掉;不该按「患者拒绝」的重档罚人。
/// 介于「再考虑 7d」与「明确拒绝 90d」之间。
/// ⚠️ 「已完成治疗」2026-07 已移出本档改永久 —— 那是对**临床事实**的断言,不是算法问题,
/// 且实测排除闸接不住(理由见 ABANDON_REASON_META.treated)。
export const WEAK_SIGNAL_SUPPRESS_DAYS = 14;
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
......@@ -711,7 +713,14 @@ export const ABANDON_REASON_META: Record<
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS },
// ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**,
// 不是"暂时不想做",没有理由两周后再问一遍。原 14d 档押的注是「等 DW 同步 + 排除闸接手」,
// 生产实测证伪:缺牙召回里两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,
// 到期只会原样弹回来再挨一次投诉。且档位本身不自洽 —— 「已在外院治疗」早就是永久,
// 在别家做完永久闭嘴、在自己家做完两周后再问,说不通。
// 误判不会把患者封死:抑制是**信号级**的(key=scenario|subKey,subKey 含牙位)且带时间锚,
// 结案后**新发**的同类信号照常放行(见 plan-engine.fetchSnoozedSignalKeys)。
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 },
// ── 共用 ──
......
......@@ -61,6 +61,11 @@ export const ApiCode = {
PLAN_ALREADY_ASSIGNED: 20501,
PLAN_ALREADY_COMPLETED: 20502,
PLAN_BREAKER_TRIPPED: 20503,
/// 未认领就想操作(提交通话结果 / 关闭机会 / 重生成话术 / 打反馈)—— 先认领再说。
/// 见 plan/claim-guard.ts:执行必须有归属,否则「我的已完成」/ 转化率统计里直接蒸发。
PLAN_NOT_CLAIMED: 20504,
/// 已被**别人**认领 —— 文案要跟"没人认领"区分开,否则组员以为自己没点对。
PLAN_CLAIMED_BY_OTHER: 20505,
// === 3xxxx Third-party / upstream =============================
// 308xx — sync engine (host data ingestion)
......@@ -107,6 +112,8 @@ export const API_CODE_MESSAGES: Record<number, string> = {
[ApiCode.PLAN_ALREADY_ASSIGNED]: '计划已被分配',
[ApiCode.PLAN_ALREADY_COMPLETED]: '计划已完成,不能再修改',
[ApiCode.PLAN_BREAKER_TRIPPED]: '计划联系次数超限,已熔断',
[ApiCode.PLAN_NOT_CLAIMED]: '请先认领后再操作',
[ApiCode.PLAN_CLAIMED_BY_OTHER]: '该计划已被其他同事认领,你无法操作',
[ApiCode.SYNC_HOST_UNREACHABLE]: '宿主接口不可达',
[ApiCode.SYNC_CONTRACT_DRIFT]: '宿主字段映射漂移',
[ApiCode.SYNC_HOST_AUTH_FAILED]: '宿主登录失败 / 凭据失效',
......
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