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
+93
View File
@@ -1906,3 +1906,96 @@ input.dispatchEvent(new Event('change', { bubbles: true }))
> 做之前先确认一件事:`sys_eval_plan` / `sys_occupation_ref` 这几张"评价计划"表
> 库里只有 `sys_occupation_ref`(见 §一·二十二 的记录),
> **评价计划本身的导入(FR-311)还没做** —— 需要先定"计划从哪来"(机构下发文件?)
---
## §一·二十四 第 3 步完成:报考范围聚合与一致性体检(M2-04 / M2-05)
### 先纠正上一节里一个**我自己写错的待办**
§一·二十三 的"下一步"里我写了一句「先确认 `sys_eval_plan` 那几张表还没落地,需要先定计划从哪来」——
**这是错的**。PRD V2.0(L508)已经明确:**那三张计划表已作废删除**,因为
**机构根本不下发计划清单文件**,真实名单里「报考职业 / 报考工种 / 报考等级」三列
老师已经填好了(28/28)。所以**报考范围就是这三列的去重组合**,不需要任何外部文件。
> 教训与 §七 那两条同类:**引用/怀疑已有设计之前先查文档**,不凭记忆。
> 我当时是"记得有这三张表"就写了待办,差点让下一步去做一件不需要做的事。
### 落地了什么
| 文件 | 作用 |
|---|---|
| `task/mapper/PlanScopeMapper` | 只读一条查询:取聚合与体检需要的**最小字段集**(不含证件号/电话 —— 多取一个就多一次泄露面) |
| `task/service/PlanScopeService` | 聚合 + 五类体检 + 确认时的全覆盖校验 |
| `task/dto/PlanScopeVO` / `PlanScopeSaveReq` | §7.7 / §7.8 的契约形状 |
| `task/controller/PlanScopeController` | `GET` / `PUT /tasks/{id}/plan-scope` |
| 前端 `apis/xt/plan-scope.ts` + 抽屉里的「报考范围」区块 | 分组表 / 体检条目 / 逐项忽略 / 确认 |
`collect_task.plan_scope_json` + `plan_scope_confirmed` 两列**本来就在库里**(V1 建的),
只是实体没映射 —— 这次补上。
### 这一步真正的价值:把"人眼核对"换成"接口约束"
1. **确认时要求恰好覆盖全员**(`rosterIds` 并集 = 名册全员,每人只出现一次)→ 否则 `40024`。
漏一个人,导出包就缺人,而**打开导出包时不会有人数出 27 个**。
所以"排除某人"的正确做法是**把他所在的分组标成 `EXCLUDED`**,而不是从 `rosterIds` 里删掉 ——
删掉会校验失败,而失败提示正好会告诉他"请明确排除,不要悄悄丢掉"。
2. **体检要能指出是哪几个人**。40 人里 38 人报 A、2 人报 B,那 2 个很可能是填错,
而这种错逐行核是发现不了的(真实批次几百人)。所以每条 issue 都带 `rosterIds`,
服务端还把名字直接写进 `detail`("涉及:吴十(车工)")—— 只说"存在不一致"等于没说。
### 五类体检:实现四类,一类**有理由地缓做**
| code | 级别 | 实现 | 说明 |
|---|:--:|:--:|---|
| `SCOPE_UNFILLED` | 1 | ✅ | 报考职业为空(红必填)。**只判职业**:「无」是合法值 |
| `SCOPE_LEVEL_MIXED` | 2 | ✅ | 同职业多等级 —— **只把少数派算异常**(多数派是"正常那一批") |
| `SCOPE_TYPEO_MISMATCH` | 2 | ✅ | 认定类型 vs 有无工种/等级。一期只有自主认定,所以是"缺工种或等级" |
| `SCOPE_OUT_OF_TASK` | 3 | ✅ | 任务**显式声明过 `jobName`** 时才判(没声明就没有"范围外"可言)。**等级不比**:名单写「四级/中级工」而任务写「四级」,精确比必然误报 |
| `SCOPE_FREE_TEXT` | 1 | ⛔ **缓做** | 要国标职业字典,而 `sys_occupation_ref` **一行数据都没有**(FR-312 是 P2)。硬编一份列表会在机构用别的写法时**全部误报**,而误报会让人整体忽略体检 —— 那比不做更糟 |
> ⚠️ **`level` 的口径**:契约(§7.7)只给了每项的值、没给语义说明,而且
> `SCOPE_UNFILLED=1` 与 `SCOPE_FREE_TEXT=1` 的严重性明显不同。
> 所以代码里**只把 level 当排序与展示用**,判定 `passed` 时对所有级别一视同仁 ——
> 契约里 `ignoredIssueCodes` 的存在正说明"要不要放过"由人决定,
> 而不是由我们按级别猜一个"≤2 才拦"的规则。
### FR-311:范围确认后,追加名单要告警(P0,一并做了)
确认过范围之后,导入时若出现**范围外的报考组合**,导入体检报告里直接出
`SCOPE_OUT_OF_TASK` 警告("请确认是有意增补,还是名单拿错了")。
范围**内**的不告警 —— 两边都验了(不然就是"全报"或"全不报")。
### E2E:`scripts/e2e-m2-scope.py` → **38/38**
夹具刻意做出:6×车工/四级 + 1×车工/**三级**(少数派多等级)+ 1×铣工(任务声明的是车工)+ 1×**漏填**。
逐条验:聚合分组与人数、`rosterIds` 是谁、四类 issue 的码与"少数派"判定、
漏一人 → 40024 且**点名**、同人两组 → 40024、范围外的名册项 → 报错、
确认后 EXCLUDED 保留、**没忽略问题时 passed 仍为 false**(确认 ≠ 掩饰)、
显式忽略后 passed=true、落库、FR-311 的两个方向。
**浏览器实测**:抽屉里「报考范围」区块渲染正确 —— 分组表(4 组,状态与操作齐全)、
4 条体检(级别标签 + 说明 + **涉及谁的名字** + 忽略勾选)、
**确认按钮在 4 项未处理时禁用**(提示"还有 4 项体检问题未处理")、
勾选忽略后解禁、点确认后显示「已于 2026-09-29 21:25 确认」且体检变"通过"。
### 顺带抓到的两个(都在我的测试代码里)
1. **夹具的身份证号出生日期写成了 `0000`** → 9 行全被判格式非法阻断,
而**导入接口仍然回 `code=0`**(它只回体检报告)→ 看着像"导进去了"。
所以 E2E 里除了判 `code`,还必须判 `created == 9` —— 已补。
2. **`call()` 回元组、`upload_roster()` 回字典**,我在新脚本里弄混了。
与第 1/2 步同类:**新写的代码最容易在细节上错**。
### 回归
全量 **660 条断言、0 失败**(新增报考范围 38 条);`vue-tsc` 0 错、`eslint` 0 错。
### 下一步
按 §一·二十二 的顺序,第 4 步是**审核闭环(后端)**:初审 / 终审 / 撤回 /
批量通过(只带无问题项)/ 驳回(逐条填原因)。
> 但客户端提了一个**新的架构要求**(租户按域名前缀区分,如 `t1.xxx.com` / `t2.xxx.com`),
> 它会同时影响 PC 登录、H5 微信授权回调、小程序域名白名单与二维码 scene ——
> 见下一节(§一·二十五),那里记录了分析与待拍板的点。