Commit 9f992b4f by luoqi

fix(分配): 提案封顶到单批硬上限 —— 23 人的诊所按默认值必然出一张确认不了的单

生产实遇(杭州大厦,2026-08-20):确认单 537 条,点「确认分配」只回一句
「请求字段校验失败」,主管在卡片上改什么都没用。

根因是**提案层不知道确认接口的上限**:

  基数 = 在岗人数 × 每人每天通话数 × 时限天数 = 23 × 15 × 3 = 1035
  target = min(基数, 候选总数)                = min(1035, 5996) = 1035
  落人后 items                                 = 537
  确认接口 CreateAssignmentRequestSchema        items.max(500)   ← 撞这里

️ 不是边界情况:**12 个客服以上、用默认值就必然触发**(12×15×3 = 540 > 500)。

修法一:target 多取一个 min。与既有的「候选不够就压低 target」完全同构,
且同样  不回写基数(回写会把主管的习惯值永久钉死在 500)。
 不是把 HARD_LIMIT 调大 —— 它是技术护栏(单事务 + PG bind 变量上限),
   不是业务旋钮,schema 那条注释写得很清楚。

修法二:让报错说人话。这一条独立于上面 —— 就算永不再触发,
下一个撞上 zod 校验的人也不该只看到七个字。

  · schema: items.max 带自定义 message,含具体数字与下一步动作
  · 前端:  确认失败时拼 ApiError.details 里的字段级 message
           (服务端 all-exceptions.filter 早就把 details 带出来了,前端只取了 msg)
  ️ 只拼 message, 不拼 path/code —— 「items」「too_big」对主管没有意义,
     只会让他觉得"这是 bug,不是我该处理的事"。

排查期间未做任何确认操作:plan_assignments 仍为 0 行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ba12851e
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
ASSIGNMENT_ITEMS_HARD_LIMIT,
DAILY_CALLS_PER_AGENT, DAILY_CALLS_PER_AGENT,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
...@@ -296,13 +297,24 @@ export class AssignmentProposalService { ...@@ -296,13 +297,24 @@ export class AssignmentProposalService {
`提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`, `提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`,
); );
/** /**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。 * 本批**实际**分多少 = `min(基数 N, 候选总数, 单批硬上限)`。
* *
* ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。 * ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
* 回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人, * 回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人,
* 而他完全看不出为什么变小了(界面上只会显示"沿用上次")。 * 而他完全看不出为什么变小了(界面上只会显示"沿用上次")。
*
* 🔴 **第三项 `ASSIGNMENT_ITEMS_HARD_LIMIT` 是 2026-08-20 补的,补的是一个必现的死路。**
* 基数默认 = `在岗人数 × 每人每天通话数 × 时限天数`,23 人的诊所按默认值就是
* `23 × 15 × 3 = 1035` —— 而确认接口的 `items` 有 `.max(500)` 的技术护栏
* (单事务 + PG bind 变量上限)。于是提案照样出 537 条的确认单,主管在上面
* 拖了半天、点确认,收到一句「请求字段校验失败」,**改什么都没用**。
* ⚠️ 不是边界情况:**12 个客服以上、用默认值就必然触发**(12×15×3 = 540 > 500)。
*
* ⛔ 别改成"把 HARD_LIMIT 调大" —— 它是技术护栏,不是业务旋钮(见 schema 那条注释)。
* ⛔ 也别回写进 `batchSize`:同上,那是这一批的偶然事实,回写会把主管的习惯值
* 永久钉死在 500。
*/ */
const target = Math.min(batchSize, candidateTotal); const target = Math.min(batchSize, candidateTotal, ASSIGNMENT_ITEMS_HARD_LIMIT);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用) // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>(); const seenPatient = new Set<string>();
......
...@@ -753,7 +753,7 @@ export function AssignmentConfirmSheet({ ...@@ -753,7 +753,7 @@ export function AssignmentConfirmSheet({
}), }),
); );
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '确认失败'); setError(describeConfirmError(e));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
...@@ -2002,3 +2002,26 @@ export function AssignmentConfirmSheet({ ...@@ -2002,3 +2002,26 @@ export function AssignmentConfirmSheet({
</Card> </Card>
); );
} }
/**
* 把确认失败讲成主管能照着做的话。
*
* 🔴 服务端对 zod 校验失败回的是一句**笼统**的「请求字段校验失败」(10002),
* 字段级原因在 `details` 里 —— 而这里原本只取 `e.message`,于是主管看到的
* 永远是那七个字:不知道哪一项不合格、更不知道该改成多少。
* 2026-08-20 生产实遇:537 条的确认单撞上 `items.max(500)`,主管在卡片上
* 反复改都没用,因为**改什么都不影响这条报错**。
*
* ⚠️ 只拼 `message`,⛔ 不拼 `path`/`code`:`items`、`too_big` 这类字样对主管
* 没有意义,只会让他觉得"这是个 bug,不是我能处理的事"。
*/
function describeConfirmError(e: unknown): string {
const detail = (e as { details?: unknown } | null)?.details;
if (Array.isArray(detail)) {
const msgs = detail
.map((d) => (d as { message?: unknown })?.message)
.filter((m): m is string => typeof m === 'string' && m.length > 0);
if (msgs.length > 0) return msgs.join(';');
}
return e instanceof Error && e.message ? e.message : '确认失败';
}
...@@ -68,7 +68,14 @@ export const CreateAssignmentRequestSchema = z.object({ ...@@ -68,7 +68,14 @@ export const CreateAssignmentRequestSchema = z.object({
* (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。 * (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
*/ */
expiresInDays: z.number().int().positive().max(90), expiresInDays: z.number().int().positive().max(90),
items: z.array(AssignmentItemSchema).min(1).max(ASSIGNMENT_ITEMS_HARD_LIMIT), items: z
.array(AssignmentItemSchema)
.min(1)
/// ⚠️ 消息要带数字:zod 的默认文案会被 all-exceptions.filter 包成一句笼统的
/// 「请求字段校验失败」,主管看不出是哪一项超了、上限是多少(2026-08-20 生产实遇)。
.max(ASSIGNMENT_ITEMS_HARD_LIMIT, {
message: `单次分配最多 ${ASSIGNMENT_ITEMS_HARD_LIMIT} 条,请减少本批人数后重试`,
}),
}); });
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>; export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;
...@@ -733,7 +740,7 @@ export const AssignmentProposalSchema = z.object({ ...@@ -733,7 +740,7 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'), target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数, 单批硬上限 500)'),
/** /**
* 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。 * 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
* ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**, * ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
......
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