Commit 64b8d4fd by luoqi

feat(plan): S2.1 撤销整批 —— 限时 + 「已打开过的不收」

分错人 / 条件填错的唯一出口。POST /plans/assignments/:id/revoke。

##  「已动过」的判据只能用 view 事件

直觉会去查 plan_executions(有执行记录 = 动过),但回写率仅 **11%**
(生产 65 个认领单只有 7 条执行结果)—— 拿它判会把 89% **已经打过电话**的单
当成"没动过"收走,那通电话永久蒸发,而客服第二天才发现单子没了。
`contactAttempts` 与 plan_executions 同源(提交执行时才累加)、同为 7 条,一样不可用。

唯一可用的是 PV/UV 埋点的 `view` 事件 —— 客服打开过详情页 = 至少看过。
⇒ 已 view 的**跳过不收**,并在返回的成品句子里如实告诉主管有几条没收、为什么。
这是 PV/UV 埋点(本为统计而做)的意外收益。

## 撤销 ≠ 退回(T21)—— 写入纪律三条

  撤销 revoke  主管收回**整批**(分错人)     → auto_release + reason=revoked
  退回 release 客服退回**单条**(不是我的客户)→ release + ReleaseReason

 **不写 release_reason**:那列只属于客服的处置,混进去退回率的分子分母一起虚高
 **不清批次归因三列**:"这批曾经分过 20 条"是历史事实,批次头已标 revoked,
   统计按 status 排除即可。清了这次误操作影响了多少人就再也说不清
 **不动 snoozedUntil**(与退回、到期同一条纪律)
 账本 actor 记**主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责

## 授权与时限

授权(D-10)在 service 顶部**一次性**校验:`createdBy===actor || PLAN_VIEW_ALL`。
 不逐行调 assertCanRecycle —— 那是单条路径判"这单是不是你的",
   撤销判的是"这**批**是不是你的",逐行会变成"有一条不是你的就整批失败",语义不对。

窗口 30 分钟。语义是「**手滑/分错人**的补救」,不是「改主意重新调度」——
改主意应走"客服退回 + 重新分配",那条路有完整的原因记录。
️ 它是 **UX 摩擦不是安全边界**:leader 本就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
超窗口照样能逐条 recycle。 别基于「30 分钟后就锁死」去设计别的东西。
超窗口的报错**指路到逐条退回**,不是只说"不行"。

幂等:重复撤销不报错(主管手抖点两下很正常),返回零改动。
合成身份(企微 `wx:`)硬拒 —— 与写路径同一道闸。

## MCP 工具

`revoke_assignment` 是助手手里**唯一会改数据的工具**,描述里写死:
只在主管**明确要求**时调, 不主动建议、不试探性调用;
skippedTouched 不许说成"失败",note 原话转述。

## 验证

894 单测(新增 13 条断言)+ 本地真实数据端到端:
  20 条在手 / 9 条被打开过 → 收回 11 · **跳过 9** · 批次标 revoked
  归因 20 条全在(没清)· 退回原因 0 条(没混写)· 账本 actor=832 
seed 数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent efe60136
...@@ -25,6 +25,7 @@ const DISPATCHER_EXTRA = ` ...@@ -25,6 +25,7 @@ const DISPATCHER_EXTRA = `
2. 你出「全景确认单」——**直出,不追问** 2. 你出「全景确认单」——**直出,不追问**
3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库** 3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 七条硬约束(违反任何一条都会造成真实损失) ### 七条硬约束(违反任何一条都会造成真实损失)
......
...@@ -334,6 +334,27 @@ export class McpServerFactory { ...@@ -334,6 +334,27 @@ export class McpServerFactory {
); );
server.registerTool( server.registerTool(
'revoke_assignment',
{
description:
'撤销整批分配(限时)—— 主管说「刚才那批分错了 / 撤回」时用。' +
'\n⚠️ 这是**唯一一个会改数据的工具**,只在主管**明确要求撤销**时调,⛔ 不要主动建议、不要试探性调用。' +
'\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' +
'返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' +
'\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让主管走逐条退回。',
inputSchema: { assignmentId: z.string() },
},
async ({ assignmentId }) =>
jsonResult(
await this.assignments.revoke(
scope,
{ userId: scope.userId, permissions },
assignmentId,
),
),
);
server.registerTool(
'get_assignment_detail', 'get_assignment_detail',
{ {
description: description:
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
ListAssignmentsResponseDto, ListAssignmentsResponseDto,
AssignmentDetailResponseDto, AssignmentDetailResponseDto,
ListAgentsResponseDto, ListAgentsResponseDto,
RevokeAssignmentResponseDto,
} from './dto/plan-assignment.dto'; } from './dto/plan-assignment.dto';
/** /**
...@@ -100,6 +101,24 @@ export class AssignmentController { ...@@ -100,6 +101,24 @@ export class AssignmentController {
return this.assignments.list(scope, mine === '1' ? user.sub : undefined); return this.assignments.list(scope, mine === '1' ? user.sub : undefined);
} }
@Post(':id/revoke')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: RevokeAssignmentResponseDto })
@ApiOperation({
summary: '撤销整批(限时)—— 分错人 / 条件填错时的补救',
description:
'⚠️ 撤销 ≠ 退回:这是**主管收回整批**,不是客服退单条。' +
'已被客服打开过(view 事件)的单**不收**并如实回报 —— 他可能已经联系了患者。' +
'超出撤销窗口后请走逐条退回(那条路会留下原因)。',
})
async revoke(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
return this.assignments.revoke(scope, { userId: user.sub, permissions: user.permissions }, id);
}
@Get(':id') @Get(':id')
@RequirePermission(Permission.PLAN_DISPATCH) @RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AssignmentDetailResponseDto }) @ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
CreateAssignmentResponseSchema, CreateAssignmentResponseSchema,
ListAssignmentsResponseSchema, ListAssignmentsResponseSchema,
ListAgentsResponseSchema, ListAgentsResponseSchema,
RevokeAssignmentResponseSchema,
} from '@pac/types'; } from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {} export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
...@@ -12,3 +13,4 @@ export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentRe ...@@ -12,3 +13,4 @@ export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentRe
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {} export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {} export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {} export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_ITEMS_HARD_LIMIT, ASSIGNMENT_ITEMS_HARD_LIMIT,
Permission, Permission,
PlanEventReason,
PlanEventType, PlanEventType,
RELEASE_REASON_META, RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES,
type AssignmentAgentStat, type AssignmentAgentStat,
type AssignmentDetailResponse, type AssignmentDetailResponse,
type AssignmentSkipped, type AssignmentSkipped,
...@@ -12,10 +20,11 @@ import { ...@@ -12,10 +20,11 @@ import {
type CreateAssignmentResponse, type CreateAssignmentResponse,
type ListAssignmentsResponse, type ListAssignmentsResponse,
type ReleaseReason, type ReleaseReason,
type RevokeAssignmentResponse,
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { recordPlanEventsBulk } from './plan-event.recorder'; import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
import { assertAssignable } from './claim-guard'; import { assertAssignable } from './claim-guard';
import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard'; import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard';
import { timezoneToOffsetSuffix } from '../sync/assembler/field-mapper'; import { timezoneToOffsetSuffix } from '../sync/assembler/field-mapper';
...@@ -508,6 +517,150 @@ export class PlanAssignmentService { ...@@ -508,6 +517,150 @@ export class PlanAssignmentService {
return out; return out;
} }
/**
* 撤销整批 —— 把还没被动过的单收回池子。
*
* ⚠️⚠️ **撤销 ≠ 退回**(T21),两个不同的人做的两件事:
* 撤销 revoke —— **主管**收回**整批**(分错人 / 条件填错)
* 退回 release —— **客服**退回**单条**(不是我的客户),必须带原因
* 所以撤销**不写** `release_reason`(那列只属于客服的处置),
* 事件走 `auto_release` + `reason=revoked`。混了退回率的分子分母会一起虚高。
*
* ⭐⭐ **「已动过」的判据只能用 `view` 事件。**
* 直觉会去查 `plan_executions`(有执行记录 = 动过),但回写率仅 **11%**
* (生产 65 个认领单只有 7 条执行结果)—— 拿它判会把 89% **已经打过电话**的单
* 当成"没动过"收走,那通电话就永久蒸发了,而客服第二天才发现单子没了。
* `contactAttempts` 与 plan_executions 同源(提交执行时才累加)、同为 7 条,一样不可用。
* 唯一可用的是 PV/UV 埋点的 `view` 事件(生产已 1,024 条)——
* 客服打开过详情页 = 至少看过,不能当作没动。
* ⇒ 已 view 的**跳过不收**,并在返回里如实告诉主管有几条没收。
*/
async revoke(
scope: TenantScopeContext,
actor: DispatchActor,
assignmentId: string,
): Promise<RevokeAssignmentResponse> {
requirePermission(actor, Permission.PLAN_DISPATCH);
rejectSyntheticIdentity(actor);
const head = await this.prisma.planAssignment.findFirst({
where: {
id: assignmentId,
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
},
});
if (!head) throw new NotFoundException(`批次 ${assignmentId} 不存在`);
if (head.status === 'revoked') {
// 幂等:重复撤销不报错(主管手抖点两下很正常),返回上次的结果形状
return {
assignmentId,
revoked: 0,
skippedTouched: 0,
alreadyReleased: 0,
note: '该批次此前已撤销,本次没有任何改动。',
};
}
// ── 授权(D-10):自己分的,或者有看全池的权限 ──────────────
// ⚠️ 在这里**一次性**校验,⛔ 不逐行调 assertCanRecycle ——
// 那是单条路径的归属闸(判"这单是不是你的"),而撤销判的是"这**批**是不是你的",
// 逐行调会变成"只要有一条不是你的就整批失败",语义完全不对。
const canRevoke =
head.createdBy === actor.userId || actor.permissions.includes(Permission.PLAN_VIEW_ALL);
if (!canRevoke) {
throw new ForbiddenException('只能撤销自己发起的批次');
}
// ── 限时 ────────────────────────────────────────────────
const ageMin = (Date.now() - head.createdAt.getTime()) / 60_000;
if (ageMin > REVOKE_WINDOW_MINUTES) {
throw new BadRequestException(
`撤销窗口为 ${REVOKE_WINDOW_MINUTES} 分钟,本批已分配 ${Math.round(ageMin)} 分钟。` +
`如需收回请逐条退回,或让客服自行退回(那条路会留下原因,便于后续调整分配策略)。`,
);
}
const plans = await this.prisma.followupPlan.findMany({
where: { assignmentId, supersededAt: null },
select: { id: true, hostId: true, tenantId: true, patientId: true, status: true, assigneeUserId: true, assignedAt: true },
});
const inHand = plans.filter((p) => p.status === 'assigned' && p.assigneeUserId);
const alreadyReleased = plans.length - inHand.length;
// ⭐ 已被打开过的 → 跳过。见方法注释:唯一可用的"动过"信号。
const touched = inHand.length
? await this.prisma.planEventLog.groupBy({
by: ['planId'],
where: {
planId: { in: inHand.map((p) => p.id) },
tenantId: scope.tenantId,
event: PlanEventType.VIEW,
},
})
: [];
const touchedSet = new Set(touched.map((t) => t.planId));
const collectable = inHand.filter((p) => !touchedSet.has(p.id));
const now = new Date();
await this.prisma.$transaction(async (tx) => {
if (collectable.length > 0) {
await tx.followupPlan.updateMany({
// 带状态条件 = 并发安全(期间客服刚提交执行 / 自己退了 → 本次不生效)
where: { id: { in: collectable.map((p) => p.id) }, status: 'assigned' },
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
// ⛔ 不清 assignment_id / assigned_by / assign_strategy:
// "这批曾经分过 N 条"是历史事实,批次的 status 已经标了 revoked,
// 统计时按 status 排除即可。清了就再也说不清这次误操作影响了多少人。
// ⛔ 不写 release_reason —— 那是客服的处置,不是主管的收回。
},
});
await recordPlanEventsBulk(
tx,
collectable.map((p) => ({
hostId: p.hostId,
tenantId: p.tenantId,
planId: p.id,
patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
// ⭐ actor 记**主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责
actorUserId: actor.userId,
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.REVOKED,
})),
);
}
await tx.planAssignment.update({
where: { id: assignmentId },
data: { status: 'revoked', revokedAt: now, revokedBy: actor.userId },
});
});
const note =
`已撤销批次:收回 ${collectable.length} 条。` +
(touchedSet.size > 0
? `⚠️ 另有 ${touchedSet.size} 条客服**已经打开过**,未收回 —— ` +
`他可能已经联系了患者,收走会让那次触达查不到归属。如确需收回请逐条处理。`
: '') +
(alreadyReleased > 0 ? `另有 ${alreadyReleased} 条此前已退回或到期,本就不在人手上。` : '');
this.logger.log(`批次 ${assignmentId} 已撤销:收回 ${collectable.length} / 跳过 ${touchedSet.size},操作人=${actor.userId}`);
return {
assignmentId,
revoked: collectable.length,
skippedTouched: touchedSet.size,
alreadyReleased,
note,
};
}
/** 按幂等键回查已有批次(命中即视为重放) */ /** 按幂等键回查已有批次(命中即视为重放) */
private async findByRequestId( private async findByRequestId(
scope: TenantScopeContext, scope: TenantScopeContext,
......
import { Test } from '@nestjs/testing';
import { Permission, PlanEventType, REVOKE_WINDOW_MINUTES } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 撤销整批回归。
*
* ⚠️ **撤销 ≠ 退回**(T21):主管收整批 vs 客服退单条。
* 三条红线错一条都会造成静默损失:
* ① 已被打开过的单被收走 → 客服刚打完的电话永久蒸发,他第二天才发现单子没了
* ② 写了 release_reason → 退回率的分子分母一起虚高
* ③ 清了批次归因 → 这次误操作影响了多少人,事后再也说不清
*/
const SCOPE = {
hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'leader-1',
} as never;
const LEADER = { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH] };
function makePrisma(opts: {
createdBy?: string;
ageMinutes?: number;
status?: string;
plans?: Array<{ id: string; status: string; assigneeUserId: string | null }>;
viewedPlanIds?: string[];
}) {
const captured: { data?: Record<string, unknown>; ids?: string[] } = {};
const events: Array<Record<string, unknown>> = [];
const headUpdates: Array<Record<string, unknown>> = [];
const plans = opts.plans ?? [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a' },
{ id: 'p2', status: 'assigned', assigneeUserId: 'a' },
];
const tx = {
followupPlan: {
updateMany: jest.fn(async (a: { where: { id: { in: string[] } }; data: Record<string, unknown> }) => {
captured.data = a.data;
captured.ids = a.where.id.in;
return { count: a.where.id.in.length };
}),
},
planEventLog: {
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
},
planAssignment: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
headUpdates.push(data);
return {};
}),
},
};
const prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
id: 'b1', hostId: 'h1', tenantId: 't1', clinicId: 'c1',
createdBy: opts.createdBy ?? 'leader-1',
status: opts.status ?? 'confirmed',
createdAt: new Date(Date.now() - (opts.ageMinutes ?? 5) * 60_000),
})),
},
followupPlan: {
findMany: jest.fn(async () =>
plans.map((p) => ({
...p, hostId: 'h1', tenantId: 't1', patientId: `pat-${p.id}`,
assignedAt: new Date(Date.now() - 3600_000),
})),
),
},
planEventLog: {
groupBy: jest.fn(async () => (opts.viewedPlanIds ?? []).map((planId) => ({ planId }))),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService;
return { prisma, captured, events, headUpdates, tx };
}
async function build(prisma: PrismaService): Promise<PlanAssignmentService> {
const mod = await Test.createTestingModule({
providers: [PlanAssignmentService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanAssignmentService);
}
describe('撤销整批 —— 「已动过」的判据', () => {
test('⭐⭐ 红线①:客服**打开过**的单不收,并如实回报', async () => {
// 判据只能用 view 事件:plan_executions 回写率仅 11%,拿它判会把 89%
// 已经打过电话的单当成"没动过"收走,那通电话永久蒸发。
const { prisma, captured } = makePrisma({ viewedPlanIds: ['p1'] });
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1');
expect(r.revoked).toBe(1);
expect(r.skippedTouched).toBe(1);
expect(captured.ids).toEqual(['p2']); // p1 被跳过
expect(r.note).toContain('已经打开过');
});
test('全都没打开过 → 全收', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1');
expect(r.revoked).toBe(2);
expect(r.skippedTouched).toBe(0);
});
test('已退回 / 已到期的不计入(本就不在人手上)', async () => {
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a' },
{ id: 'p2', status: 'active', assigneeUserId: null },
],
});
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1');
expect(r.revoked).toBe(1);
expect(r.alreadyReleased).toBe(1);
});
});
describe('撤销整批 —— 写入纪律', () => {
test('⭐⭐ 红线②:**不写 release_reason**(那是客服的处置,不是主管的收回)', async () => {
const { prisma, captured } = makePrisma({});
const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1');
// 混进去会让退回率的分子分母一起虚高
expect(captured.data).not.toHaveProperty('releaseReason');
expect(captured.data).not.toHaveProperty('releaseNote');
});
test('⭐⭐ 红线③:**不清批次归因三列**(否则这次误操作影响了多少人再也说不清)', async () => {
const { prisma, captured } = makePrisma({});
const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1');
expect(captured.data).not.toHaveProperty('assignmentId');
expect(captured.data).not.toHaveProperty('assignedBy');
expect(captured.data).not.toHaveProperty('assignStrategy');
expect(captured.data).toMatchObject({ status: 'active', assigneeUserId: null, assignmentExpiresAt: null });
});
test('⭐ 红线④:绝不动 snoozedUntil', async () => {
const { prisma, captured } = makePrisma({});
const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1');
expect(captured.data).not.toHaveProperty('snoozedUntil');
});
test('⭐ 账本记 actor = **主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责', async () => {
const { prisma, events } = makePrisma({});
const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1');
expect(events[0]).toMatchObject({
event: PlanEventType.AUTO_RELEASE,
reason: 'revoked',
actorUserId: 'leader-1',
assigneeUserId: null,
});
});
test('批次头标 revoked + 记录撤销人与时刻', async () => {
const { prisma, headUpdates } = makePrisma({});
const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1');
expect(headUpdates[0]).toMatchObject({ status: 'revoked', revokedBy: 'leader-1' });
});
});
describe('撤销整批 —— 授权与时限', () => {
test('⭐ 别人分的批次 → 拒(除非有 PLAN_VIEW_ALL)', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, 'b1')).rejects.toThrow(/只能撤销自己发起的批次/);
});
test('有 PLAN_VIEW_ALL → 可撤别人的批次', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
const r = await svc.revoke(
SCOPE,
{ userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] },
'b1',
);
expect(r.revoked).toBe(2);
});
test('⭐ 超出撤销窗口 → 拒,并指路到逐条退回(那条路会留下原因)', async () => {
const { prisma } = makePrisma({ ageMinutes: REVOKE_WINDOW_MINUTES + 1 });
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, 'b1')).rejects.toThrow(/撤销窗口/);
});
test('⭐ 幂等:重复撤销不报错(主管手抖点两下很正常)', async () => {
const { prisma, tx } = makePrisma({ status: 'revoked' });
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1');
expect(r.revoked).toBe(0);
expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
});
test('⭐ 合成身份(企微)不能撤销', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(
svc.revoke(SCOPE, { userId: 'wx:someone', permissions: [Permission.PLAN_DISPATCH] }, 'b1'),
).rejects.toThrow(/合成身份/);
});
});
...@@ -1049,6 +1049,16 @@ export const PlanEventReason = { ...@@ -1049,6 +1049,16 @@ export const PlanEventReason = {
* 那一列只属于客服的处置。 * 那一列只属于客服的处置。
*/ */
ASSIGNMENT_EXPIRED: 'assignment_expired', ASSIGNMENT_EXPIRED: 'assignment_expired',
/**
* 主管撤销整批。
*
* ⚠️ **撤销 ≠ 退回**,是两个不同的人做的两件事(T21):
* 撤销 revoke —— **主管**收回**整批**(分错人 / 条件填错),粒度是批次
* 退回 release —— **客服**退回**单条**(不是我的客户 / 没时间),必须带原因
* 所以它进 auto_release(系统代主管执行的批量收回),而不是 release,
* 更**不写** followup_plans.release_reason —— 那列只属于客服的处置。
*/
REVOKED: 'revoked',
} as const; } as const;
export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventReason]; export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventReason];
...@@ -1056,8 +1066,7 @@ export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventRea ...@@ -1056,8 +1066,7 @@ export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventRea
/// ⚠️ 写入 reason 列的地方一律标这个类型,别用裸 string。 /// ⚠️ 写入 reason 列的地方一律标这个类型,别用裸 string。
export type PlanEventReasonValue = PlanEventReason | ReleaseReason; export type PlanEventReasonValue = PlanEventReason | ReleaseReason;
/// ⚠️ 还没登记、等产品决策落地后再加(**加的时候必须补进上面的枚举,不要现场写字符串**):
/// · 分配批次被主管撤销 —— 教条 T21 / 开发规划 P6.4
// ============================================================= // =============================================================
// Plan Scripts / Summaries(异步生成,状态机) // Plan Scripts / Summaries(异步生成,状态机)
......
...@@ -262,3 +262,40 @@ export const AssignmentProposalSchema = z.object({ ...@@ -262,3 +262,40 @@ export const AssignmentProposalSchema = z.object({
selectionNote: z.string(), selectionNote: z.string(),
}); });
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
// =============================================================
// 撤销整批 —— POST /pac/v1/plans/assignments/:id/revoke
// =============================================================
/**
* 撤销窗口(分钟)。
*
* ⚠️ 语义是「**手滑 / 分错人**的补救」,不是「改主意重新调度」——
* 改主意应该走"客服退回 + 重新分配",那条路有完整的原因记录;
* 撤销是把一次误操作当作没发生过,所以必须限时。
*
* ⚠️⚠️ 它是 **UX 摩擦,不是安全边界**:leader 本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
* 30 分钟后照样能逐条 recycle 把单收回来。⛔ **别基于「30 分钟后就锁死了」去设计别的东西**
* (比如"过了窗口就认为归属永久稳定"),那个前提不成立。
*/
export const REVOKE_WINDOW_MINUTES = 30;
export const RevokeAssignmentResponseSchema = z.object({
assignmentId: z.string(),
/// 真正收回池子的条数
revoked: z.number().int(),
/**
* ⭐ **客服已经打开过详情页、故意跳过没收**的条数。
*
* 判据只能用 `view` 事件 —— `plan_executions` 回写率仅 11%(生产 65 认领 / 7 条结果),
* 拿它当"动过没"会把 89% **已经打过电话**的单当成没动收走,那通电话就永久蒸发了
* (正是 claim-guard 第 1 条要防的:执行必须有归属,否则统计里直接消失)。
* `contactAttempts` 与 plan_executions 同源、同为 7 条,同样不可用。
*/
skippedTouched: z.number().int(),
/// 已经被客服退回 / 已到期回收的,本来就不在人手上,不计入
alreadyReleased: z.number().int(),
/// 给主管看的一句话(成品句子,助手照抄)
note: z.string(),
});
export type RevokeAssignmentResponse = z.infer<typeof RevokeAssignmentResponseSchema>;
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