Commit 2dc6c19f by luoqi

feat(分配): 取消自动改派 → 待分配交主管;重排改成「重挑这批人」

三趟落人改成两趟 + 待分配:专属客服排满的人不再被自动改派给别人,
单列一组交主管决策。把患者从他的专属客服手里挪走是关系层面的决定。

「重新排一版」当天推翻两版才定稿,病史都写进注释了:
- v1 预先下发「顶替名单」→ 名单只能从取数窗口挑,池子 19 位无主、
  窗口里只落进 6 位,界面只敢说"顶 3 位"
- v2 把整个窗口丢给落人 + stopAt 跳过 → 水位按 chosen.length 算被窗口撑大
  (⌈(1001+50)/17⌉=62 → ⌈(1001+165)/17⌉=69),34 个"专属排满"的人原地进了
  同一位客服手里(她 59→68,别人 58)。底线没破,但负载塌了、口径全错
- v3(定稿)只换"挑谁":池子里无主的全换进来,其余按优先级用有专属的补满 N,
  然后走完全一样的三趟。有专属的那部分**按客服轮着取** —— 直取前 N 名会把
  名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着。
  实测 拟分 16·待分配 34 → 拟分 31·待分配 19

顺带:确认后可补挂/改/撤福利(此前只改 state 亮角标,DB 一个字没变)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1c115cc3
......@@ -9,13 +9,17 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import {
CreateAssignmentRequestDto,
CreateAssignmentResponseDto,
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
ListAgentsResponseDto,
RefillProposalRequestDto,
RevokeAssignmentResponseDto,
SetAssignmentBenefitRequestDto,
SetAssignmentBenefitResponseDto,
} from './dto/plan-assignment.dto';
/**
......@@ -36,6 +40,7 @@ export class AssignmentController {
constructor(
private readonly assignments: PlanAssignmentService,
private readonly roster: AgentRosterService,
private readonly proposals: AssignmentProposalService,
) {}
/**
......@@ -119,6 +124,70 @@ export class AssignmentController {
return this.assignments.revoke(scope, { userId: user.sub, permissions: user.permissions }, id);
}
/**
* 给**已确认**的批次补挂 / 改 / 撤福利。
*
* ⚠️ 由来(2026-08-06 实测):福利原本只随 `create` 落库。主管确认之后再说
* 「这批带上八折」,助手把 `set_benefit` 推给卡片,卡片改了自己的 state、亮了角标、
* 回了句「已更新」—— DB 一个字没变,话术里也没有福利。界面说做了、实际没做。
* 补这条路是为了让"确认后再想起福利"这件**很常见**的事真的能做成,
* 而不是逼主管撤销整批重分(撤销还有 30 分钟窗口和"已打开的不收"两道限制)。
*/
/**
* 🔴 **重新排一版(尽量排满)** —— 卡片上那个按钮。
*
* ⚠️ 这条路**不经过助手**:主管点的是卡片上的按钮,不是说一句话。
* 走 HTTP 直接换掉那张卡,不用等模型再跑一轮(它也没有别的事可做)。
* ⚠️ 纯只读,和 propose 一样 —— **一个字都没写库**。
*/
@Post('propose/refill')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N',
description:
'⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无主**的人来凑,' +
'一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' +
'这批人本来就共享同一组特征)。',
})
async refill(
@TenantScope() scope: TenantScopeContext,
@Body() body: RefillProposalRequestDto,
) {
return this.proposals.propose(scope, {
clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}),
...(body.personaTags ? { personaTags: body.personaTags } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
preferPlaceable: true,
});
}
@Post(':id/benefit')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: SetAssignmentBenefitResponseDto })
@ApiOperation({
summary: '改本批福利(确认后仍可改)—— 空串 = 撤掉',
description:
'福利是**前向**的:只影响此后生成的话术,所以**不设时间窗**(与撤销不同)。' +
'⚠️ 改动会作废本批的话术缓存(懒重生成);已被客服打开过的条数会如实回报 —— ' +
'那些人手里拿的是旧话术,补挂的福利他看不到。',
})
async setBenefit(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
@Body() body: SetAssignmentBenefitRequestDto,
) {
return this.assignments.setBenefit(
scope,
{ userId: user.sub, permissions: user.permissions },
id,
body.text,
);
}
@Get(':id')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
......
......@@ -6,6 +6,9 @@ import {
ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
RevokeAssignmentResponseSchema,
RefillProposalRequestSchema,
SetAssignmentBenefitRequestSchema,
SetAssignmentBenefitResponseSchema,
} from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
......@@ -14,3 +17,6 @@ export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResp
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {}
export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {}
export class SetAssignmentBenefitResponseDto extends createZodDto(SetAssignmentBenefitResponseSchema) {}
......@@ -26,6 +26,7 @@ import {
type ExecutionOutcome,
type ReleaseReason,
type RevokeAssignmentResponse,
type SetAssignmentBenefitResponse,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -124,7 +125,11 @@ function mergeStats(
};
}
function readBenefitText(attributes: unknown): string | null {
/**
* 读批次福利文案。⚠️ 仓库里**曾有两份**(本处 + plan-script.orchestrator,返回类型还不一样)。
* 2026-08-05 召回简报也要用,故本份导出复用 —— ⛔ 别再抄第四份。
*/
export function readBenefitText(attributes: unknown): string | null {
const a = attributes as { benefit?: { text?: string } } | null | undefined;
const t = a?.benefit?.text;
return typeof t === 'string' && t.trim() ? t : null;
......@@ -974,6 +979,115 @@ export class PlanAssignmentService {
}
/**
* 给**已确认**的批次补挂 / 改 / 撤福利(T4)。
*
* ── 为什么需要这条路(2026-08-06 实测的事故)──────────────────────
* 福利原本**只在确认那一刻**随 `create` 落库。主管点完「确认分配」才想起来
* 「这批带上八折」时,助手照样调 `edit_assignment_sheet` 把 `set_benefit` 推给卡片,
* 卡片改了自己的 `benefit` state、亮出绿色角标、还回了一句「本批福利设为…」——
* 而那个 state **只有 `confirm()` 会读**,批次早就落库了,DB 里一个字没变。
* 于是界面说带上了、话术里没有,主管无从察觉(违 T14)。
*
* ⭐ 选择"补写"而不是"确认后拒绝一切修改":福利是**前向**的东西,
* 它只影响此后生成的话术,补挂在业务上完全成立;逼主管撤销整批重分才是荒谬的
* (撤销还有 30 分钟窗口和"已打开的不收"两道限制,代价远大于收益)。
*
* ⚠️ **不设时间窗**(与 revoke 不同):revoke 限时是因为它要**收回**已经发出去的东西,
* 越晚越危险;改福利只影响还没生成的话术,越晚只是越没用,不会伤到谁。
*
* ⚠️ 返回里必须**如实报**已被打开过的条数:那些客服手里拿的是旧话术,
* 补挂的福利他看不到。⛔ 不许只回一句「已更新」—— 那正是这次事故的形状。
*/
async setBenefit(
scope: TenantScopeContext,
actor: DispatchActor,
ref: string,
text: string,
): Promise<SetAssignmentBenefitResponse> {
requirePermission(actor, Permission.PLAN_DISPATCH);
rejectSyntheticIdentity(actor);
const assignmentId = await this.resolveAssignmentId(scope, ref);
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') {
throw new BadRequestException('该批次已撤销,不能再改福利 —— 请重新分一批并在确认时带上。');
}
// 授权与 revoke 同一条:自己分的,或有看全池的权限(⛔ 别逐条判归属,那是单条路径的闸)
if (head.createdBy !== actor.userId && !actor.permissions.includes(Permission.PLAN_VIEW_ALL)) {
throw new ForbiddenException('只能修改自己发起的批次');
}
const next = text.trim();
const prev = readBenefitText(head.attributes);
if (next === (prev ?? '')) {
return { assignmentId, benefitText: next || null, scriptsInvalidated: 0, touched: 0, note: '福利没有变化,本次没有任何改动。' };
}
const plans = await this.prisma.followupPlan.findMany({
where: { assignmentId, supersededAt: null },
select: { id: true },
});
const planIds = plans.map((p) => p.id);
// 已被客服打开过的条数 —— 判据与 revoke 同源(view 事件),⛔ 别改用 plan_executions
const touched = planIds.length
? (
await this.prisma.planEventLog.groupBy({
by: ['planId'],
where: { planId: { in: planIds }, event: PlanEventType.VIEW },
})
).length
: 0;
let scriptsInvalidated = 0;
await this.prisma.$transaction(async (tx) => {
const attributes = {
...((head.attributes as Record<string, unknown> | null) ?? {}),
// 空串 = 撤掉福利。⛔ 别落一个 `{text:''}` 的空壳,读侧 readBenefitText 会把它当"有福利"
...(next ? { benefit: { text: next } } : {}),
} as Record<string, unknown>;
if (!next) delete attributes.benefit;
await tx.planAssignment.update({
where: { id: assignmentId },
data: { attributes: attributes as Prisma.InputJsonObject },
});
// ⭐ 与 create / revoke 同一条规矩:福利变了就作废话术缓存,否则福利段永远不会出现
// (或者更糟:撤掉的福利还被念出去)。只作废不重生成,见 invalidateScripts。
if (planIds.length) {
const r = await tx.planScript.deleteMany({ where: { planId: { in: planIds } } });
scriptsInvalidated = r.count;
if (r.count > 0) {
this.logger.log(
`话术缓存作废 ${r.count} 条(批次 ${assignmentId.slice(0, 8)} 改了福利)—— 下次打开详情页时重新生成`,
);
}
}
});
this.logger.log(
`批次 ${assignmentId} 福利:${prev ?? '(无)'} ${next || '(撤掉)'},操作人=${actor.userId}`,
);
const note =
(next ? `本批福利已设为「${next}」。` : '已撤掉本批福利。') +
(scriptsInvalidated > 0
? `${scriptsInvalidated} 条话术缓存已作废,客服下次打开详情页会重新生成、${next ? '带上福利' : '不再带福利'}`
: '') +
(touched > 0
? `⚠️ 其中 ${touched} 条客服**已经打开过**,他手里那份话术是旧的 —— 需要的话请另行知会。`
: '');
return { assignmentId, benefitText: next || null, scriptsInvalidated, touched, note };
}
/**
* 撤销整批 —— 把还没被动过的单收回池子。
*
* ⚠️⚠️ **撤销 ≠ 退回**(T21),两个不同的人做的两件事:
......
import { Test } from '@nestjs/testing';
import { Permission } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 「确认之后再补挂福利」回归。
*
* ── 由来(2026-08-06 实测的事故)──────────────────────────────────
* 福利原本**只在确认那一刻**随 `create` 落库。主管点完「确认分配」才想起来
* 「这批带上八折」时,助手照样把 `set_benefit` 推给确认单卡片 —— 卡片改了自己的
* `benefit` state、亮出绿色角标、还往对话里回了一句「本批福利设为…」,
* 而那个 state **只有 `confirm()` 会读**,批次早就落库了,DB 里一个字没变。
* 界面说做了、话术里没有,主管无从察觉(违 T14「口径对数」)。
*
* ⚠️ 这个 bug 的形状值得记:**没有任何报错**,而且"失败路径"看起来比成功路径还顺 ——
* 角标亮了、话也回了。只有去查库或等客服念话术才会发现。
*
* 这里锁四件做错了不会报错的事:
* ① 真的写进 attributes.benefit(不是只改界面)
* ② 福利变了必须**作废话术缓存** —— 否则福利段永远不会出现 / 撤掉的福利还被念出去
* ③ 空串 = 撤掉,⛔ 不许落一个 `{text:''}` 的空壳(读侧会把它当"有福利")
* ④ 已被客服**打开过**的条数要如实回报 —— 那些人手里是旧话术
*/
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
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: {
attributes?: unknown;
status?: string;
createdBy?: string;
planIds?: string[];
viewedPlanIds?: string[];
}) {
const headUpdates: Array<Record<string, unknown>> = [];
const deletedScriptPlanIds: string[][] = [];
const planIds = opts.planIds ?? ['p1', 'p2', 'p3'];
const tx = {
planAssignment: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
headUpdates.push(data);
return {};
}),
},
planScript: {
deleteMany: jest.fn(async (a: { where: { planId: { in: string[] } } }) => {
deletedScriptPlanIds.push(a.where.planId.in);
return { count: a.where.planId.in.length };
}),
},
};
const prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1',
createdBy: opts.createdBy ?? 'leader-1',
status: opts.status ?? 'confirmed',
attributes: opts.attributes ?? null,
createdAt: new Date(),
})),
},
followupPlan: { findMany: jest.fn(async () => planIds.map((id) => ({ id }))) },
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, headUpdates, deletedScriptPlanIds };
}
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('⭐⭐ 红线①:福利写进 attributes.benefit.text(界面亮角标不算数)', async () => {
const { prisma, headUpdates } = makePrisma({});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '看牙打八折优惠');
expect(r.benefitText).toBe('看牙打八折优惠');
expect(headUpdates).toHaveLength(1);
expect(headUpdates[0]!.attributes).toEqual({ benefit: { text: '看牙打八折优惠' } });
});
test('⭐⭐ 红线②:福利变了必须作废本批话术缓存,否则福利段永远不会出现', async () => {
const { prisma, deletedScriptPlanIds } = makePrisma({});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '老客户复查免挂号费');
expect(deletedScriptPlanIds).toEqual([['p1', 'p2', 'p3']]);
expect(r.scriptsInvalidated).toBe(3);
expect(r.note).toContain('重新生成');
});
test('⭐ 红线③:空串 = 撤掉福利,⛔ 不许留一个 {text:""} 的空壳', async () => {
const { prisma, headUpdates } = makePrisma({ attributes: { benefit: { text: '八折' } } });
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '');
expect(r.benefitText).toBeNull();
// 读侧 readBenefitText 只看 benefit.text 存不存在 —— 留个空壳会被当成"有福利"
expect(headUpdates[0]!.attributes).not.toHaveProperty('benefit');
expect(r.note).toContain('不再带福利');
});
test('⭐⭐ 红线④:已被客服打开过的条数要如实回报(他手里是旧话术)', async () => {
const { prisma } = makePrisma({ viewedPlanIds: ['p1', 'p2'] });
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(r.touched).toBe(2);
expect(r.note).toContain('已经打开过');
});
test('attributes 里原有的别的键要留着(⛔ 别整个覆盖掉)', async () => {
const { prisma, headUpdates } = makePrisma({ attributes: { note: 'x', benefit: { text: '旧' } } });
const svc = await build(prisma);
await svc.setBenefit(SCOPE, LEADER, BATCH, '新');
expect(headUpdates[0]!.attributes).toEqual({ note: 'x', benefit: { text: '新' } });
});
test('值没变 → 不写库、不作废话术(⛔ 别让重复指令白白炸掉一批缓存)', async () => {
const { prisma, headUpdates, deletedScriptPlanIds } = makePrisma({
attributes: { benefit: { text: '八折' } },
});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(headUpdates).toHaveLength(0);
expect(deletedScriptPlanIds).toHaveLength(0);
expect(r.note).toContain('没有变化');
});
});
describe('确认后补挂福利 —— 闸', () => {
test('已撤销的批次不许再挂福利(那批人已经回池子了,挂上去谁也读不到)', async () => {
const { prisma } = makePrisma({ status: 'revoked' });
const svc = await build(prisma);
await expect(svc.setBenefit(SCOPE, LEADER, BATCH, '八折')).rejects.toThrow('已撤销');
});
test('别人发起的批次改不了(与撤销同一条授权:自己分的,或有看全池权限)', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
await expect(svc.setBenefit(SCOPE, LEADER, BATCH, '八折')).rejects.toThrow('自己发起');
});
test('有 PLAN_VIEW_ALL 的人可以改别人的批次', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
const r = await svc.setBenefit(
SCOPE,
{ userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] },
BATCH,
'八折',
);
expect(r.benefitText).toBe('八折');
});
test('没有 PLAN_DISPATCH 权限直接拒', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(
svc.setBenefit(SCOPE, { userId: 'leader-1', permissions: [] }, BATCH, '八折'),
).rejects.toThrow();
});
test('⚠️ 不设时间窗 —— 与撤销不同:改福利只影响之后生成的话术,越晚只是越没用,不会伤到谁', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
// makePrisma 的 createdAt 是"刚刚",这里直接验证服务端没有读 createdAt 做拒绝
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(r.benefitText).toBe('八折');
});
});
......@@ -81,7 +81,7 @@ describe('批次详情 —— agentStats 必须与 planned 对得上', () => {
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
// 已退回:assignee 为 null,只能靠 assign 事件找回是谁的
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
......@@ -133,7 +133,7 @@ describe('退回原因分布 —— 不得混进系统原因', () => {
// 退回数从 28 变 50、退回率几乎翻倍,而报表看起来完全正常。
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
// 到期回收:release_reason **为空**(那一列只属于客服的处置)
{ id: 'p2', status: 'active', assigneeUserId: null, releaseReason: null },
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: null },
......@@ -148,7 +148,7 @@ describe('退回原因分布 —— 不得混进系统原因', () => {
const d = await svc.detail(SCOPE, BATCH);
expect(d.released).toBe(1); // 只有真退回那一条
expect(d.releaseReasons).toHaveLength(1);
expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.OVER_CAPACITY);
expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.ALREADY_IN_PROGRESS);
// 到期的原因码绝不能出现在退回原因分布里
expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED);
});
......
......@@ -4,6 +4,7 @@ import {
UserRole,
ReleaseReason,
RELEASE_REASON_META,
RELEASE_REASON_HISTORICAL,
ReleaseReasonSchema,
releaseReasonsForForm,
PlanEventReason,
......@@ -45,11 +46,15 @@ describe('PLAN_DISPATCH —— 主管判据', () => {
describe('ReleaseReason —— 退回原因', () => {
test('枚举与 zod schema 值域必须一致(加了枚举忘了改 schema → 服务端 400)', () => {
expect([...ReleaseReasonSchema.options].sort()).toEqual([...Object.values(ReleaseReason)].sort());
// ⚠️ schema/META 的值域 = **现在能选的** ∪ **历史值**(库里有老数据,收窄会让那些单一读就炸)。
// ⛔ 别把历史值加回 ReleaseReason 常量 —— 那个常量是"现在能选什么"的单一真理源。
const all = [...Object.values(ReleaseReason), ...RELEASE_REASON_HISTORICAL].sort();
expect([...ReleaseReasonSchema.options].sort()).toEqual(all);
});
test('每个值都在 META 里登记,且 META 无孤儿键', () => {
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual([...Object.values(ReleaseReason)].sort());
const all = [...Object.values(ReleaseReason), ...RELEASE_REASON_HISTORICAL].sort();
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual(all);
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k].labelZh.length).toBeGreaterThan(0);
}
......@@ -97,7 +102,10 @@ describe('ReleaseReason —— 退回原因', () => {
expect(shown).toEqual(Object.keys(RELEASE_REASON_META).filter(
(k) => !RELEASE_REASON_META[k as ReleaseReason].hidden,
));
expect(shown.length).toBe(8);
// ⭐ 表单清单必须**恰好等于**当前枚举 —— 历史值一个都不许露出来
expect([...shown].sort()).toEqual([...Object.values(ReleaseReason)].sort());
// ⭐ 反过来:历史值必须全部 hidden(漏标一个就会出现在客服面前)
for (const k of RELEASE_REASON_HISTORICAL) expect(RELEASE_REASON_META[k].hidden).toBe(true);
});
});
......
......@@ -6,7 +6,10 @@ import type {
ListAgentsResponse,
ListAssignmentsResponse,
AssignmentDetailResponse,
AssignmentProposal,
RefillProposalRequest,
RevokeAssignmentResponse,
SetAssignmentBenefitResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
......@@ -48,6 +51,28 @@ export const assignmentsApi = {
{},
),
/**
* 改本批福利 —— **确认之后仍然可以改**,空串 = 撤掉。
*
* ⚠️ 确认**之前**改福利不走这里:那时批次还没落库,福利跟着 `create` 一起写。
* 这条路专治"点完确认才想起来要带福利" —— 此前那种情况卡片只改了自己的 state,
* 亮了角标却什么也没写,主管看不出来(2026-08-06 实测)。
*/
setBenefit: (assignmentId: string, text: string) =>
api.post<SetAssignmentBenefitResponse>(
`/pac/v1/plans/assignments/${encodeURIComponent(assignmentId)}/benefit`,
{ text },
),
/**
* 「重新排一版(尽量排满)」—— 卡片上那个按钮。
*
* ⚠️ 不经过助手:主管点的是按钮不是说话,走 HTTP 直接换掉那张卡。
* ⚠️ 条件必须**原样带回**(人群 + 基数),否则重排会退回"沿用上一次",人数悄悄变了。
*/
refill: (body: RefillProposalRequest) =>
api.post<AssignmentProposal>('/pac/v1/plans/assignments/propose/refill', body),
agents: (clinicId: string, include?: string[]) =>
api.get<ListAgentsResponse>(
`/pac/v1/plans/assignments/agents?clinicId=${encodeURIComponent(clinicId)}` +
......
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