feat(scope): 报考范围聚合与一致性体检(M2-04/05)—— 把"人眼核对"换成"接口约束"

一、先纠正上一节里我自己写错的待办
   §一·二十三 的下一步写着「sys_eval_plan 那几张表还没落地,需要先定计划从哪来」——
   这是错的。PRD V2.0(L508)已明确:那三张计划表**作废删除**,因为机构根本不下发
   计划清单,真实名单里报考三项老师已经填好(28/28)。所以范围 = 那三列的去重组合。
   教训与 §七 同类:引用/怀疑已有设计之前先查文档,不凭记忆。

二、落地
   后端 PlanScopeMapper / PlanScopeService / PlanScopeVO / PlanScopeSaveReq / Controller
   前端 apis/xt/plan-scope.ts + 名册抽屉里的「报考范围」区块
   实体补上 plan_scope_json / plan_scope_confirmed(V1 建过列,只是没映射)

三、这一步真正的价值:确认时要求**恰好覆盖全员**
   rosterIds 并集 ≠ 名册全员 → 40024 并点名。漏一个人,导出包就缺人,
   而打开导出包时**不会有人数出 27 个**。所以"排除某人"的正确做法是把他所在的分组
   标成 EXCLUDED,而不是从 rosterIds 里删掉 —— 删掉会校验失败,提示正好告诉他
   "请明确排除,不要悄悄丢掉"。
   体检也必须说得出**是哪几个人**(每条 issue 带 rosterIds,服务端还把名字写进 detail)。

四、五类体检实现四类,一类有理由地缓做
   ✅ SCOPE_UNFILLED(只判职业:「无」是合法值)
   ✅ SCOPE_LEVEL_MIXED(**只把少数派算异常**,多数派是"正常那一批")
   ✅ SCOPE_TYPEO_MISMATCH
   ✅ SCOPE_OUT_OF_TASK(只在任务显式声明 jobName 时才判;**等级不比** ——
      名单写「四级/中级工」而任务写「四级」,精确比必然误报)
   ⛔ SCOPE_FREE_TEXT:要国标职业字典,而 sys_occupation_ref 一行数据都没有(FR-312 是 P2)。
      硬编一份列表会在机构用别的写法时全部误报,而误报会让人整体忽略体检 —— 比不做更糟。

   ⚠️ level 的口径:契约只给了值、没给语义,且 UNFILLED=1 与 FREE_TEXT=1 严重性明显不同。
   所以代码里只把 level 当排序/展示用,判 passed 时一视同仁 —— ignoredIssueCodes 的存在
   正说明"要不要放过"由人决定,不该按级别猜规则。

五、FR-311(追加名单告警,P0)一并做了
   范围确认后导入,出现范围外的报考组合 → 导入体检里出 SCOPE_OUT_OF_TASK 警告。
   范围内不告警 —— 两个方向都验了(不然就是"全报"或"全不报")。

六、E2E:scripts/e2e-m2-scope.py → 38/38
   夹具刻意做出:6×车工/四级 + 1×车工/三级(少数派)+ 1×铣工(任务声明车工)+ 1×漏填。
   浏览器实测:分组表、4 条体检(级别 + 说明 + 涉及谁 + 忽略勾选)、
   确认按钮在未处理时禁用、勾选忽略后解禁、确认后显示「已于 … 确认」且体检变通过。

七、顺带抓到的两个(都在我的测试代码里)
   1. 夹具身份证号出生日期写成 0000 → 9 行全被阻断,而导入接口仍回 code=0(只回体检报告)
      → 看着像"导进去了"。所以 E2E 除了判 code 还必须判 created==9,已补。
   2. call() 回元组、upload_roster() 回字典,我在新脚本里弄混了。同类:新写的代码最容易在细节上错。

八、回归
   全量 660 条断言、0 失败(新增 38);vue-tsc 0 错、eslint 0 错;verify-docs 19 项通过

九、客户端新提了一个架构要求(见 §一·二十五):租户按域名前缀区分(t1.xxx.com / t2.xxx.com)
This commit is contained in:
2026-09-29 21:28:49 +08:00
parent e85a25a97f
commit f964cf45fe
11 changed files with 1560 additions and 5 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* 报考范围接口(《03-接口设计》§7.7 / §7.8)。
*
* ## 它不是"从文件导入的计划"
*
* PRD V2.0 实测校正:**机构不下发计划清单**,而真实名单里「报考职业 / 报考工种 / 报考等级」
* 三列老师已经填好了(28/28)。所以这个范围就是**名册里这三列的去重组合**,
* 不是一份独立的文件。界面上要按这个事实说话:不是"上传计划",而是"核对从名单里
* 归出来的范围"。
*
* ## 确认按钮为什么会被禁用
*
* `PUT` 要求 `rosterIds` 的并集**恰好覆盖名册全员**(差一个人就 40024 并点名)——
* 少一个人,导出包就缺人,而打开导出包时不会有人数出 27 个。
* 所以界面上"排除某人"是把他所在的分组标成 `EXCLUDED`,而不是把他从名单里删掉。
*/
import http from '@/utils/http'
/** 一个分组 = 职业 × 工种 × 等级 的一个组合。 */
export interface PlanScopeGroup {
occupation: string | null
worktype: string | null
jobLevel: string | null
certType: number | null
count: number
rosterIds: number[]
/** `CONFIRMED` 已确认 / `NEED_REVIEW` 需人工确认 / `EXCLUDED` 已排除 */
status: string
issue: string | null
}
export interface PlanScopeIssue {
/** 契约没给语义词,只当排序与展示用 */
level: number
code: string
title: string
detail: string
/** ⚠️ 必须给:只说"存在不一致",管理员没法去核对 */
rosterIds: number[]
}
export interface PlanScopeHealth {
passed: boolean
issues: PlanScopeIssue[]
}
export interface PlanScopeVO {
source: string
updatedAt: string | null
rosterCount: number
confirmed: boolean
groups: PlanScopeGroup[]
healthCheck: PlanScopeHealth
}
export interface PlanScopeSaveReq {
groups: Array<{
occupation: string | null
worktype: string | null
jobLevel: string | null
certType?: number | null
rosterIds: number[]
status: string
}>
ignoredIssueCodes?: string[]
remark?: string
}
export function getPlanScope(taskId: number) {
return http.get<PlanScopeVO>(`/tasks/${taskId}/plan-scope`)
}
export function savePlanScope(taskId: number, data: PlanScopeSaveReq) {
return http.put<PlanScopeVO>(`/tasks/${taskId}/plan-scope`, data)
}
/** 问题码 → 一句短说明(详情服务端已经写了,这里只做标签) */
export const SCOPE_ISSUE_NAMES: Record<string, string> = {
SCOPE_UNFILLED: '漏填报考项',
SCOPE_LEVEL_MIXED: '同职业多等级',
SCOPE_TYPEO_MISMATCH: '认定类型与工种/等级不匹配',
SCOPE_OUT_OF_TASK: '任务范围外的职业',
SCOPE_FREE_TEXT: '与字典拼写不符'
}
/** 分组状态 */
export const SCOPE_STATUS: Record<string, { text: string, color: string }> = {
CONFIRMED: { text: '已确认', color: 'green' },
NEED_REVIEW: { text: '需确认', color: 'orange' },
EXCLUDED: { text: '已排除', color: 'gray' }
}