feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了

第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。

一、新增(frontend-pc/)
   src/apis/xt/roster.ts               导入(multipart)+ 分页 + 类型 + 状态字典
   src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
   views/task/index.vue                 加「名单管理」入口;已截止/已归档只读仍可查看
   config/xt-routes.ts                  顶层「名单管理」菜单说明改为指向任务行

二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
   配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
   force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。

三、界面上五个刻意的设计
   1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
      "成功 0 条",然后以为文件坏了
   2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
      用户会去改自己那份本来正确的表格
   3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
   4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
   5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
   原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)

四、顺带修掉一处「界面在说谎」
   名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
   既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
   · TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
   · **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
     而真相是"还没有分母"
   · abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
   · e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
     并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
   这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。

五、为什么顶层「名单管理」菜单还是"待建"
   名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
   「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
   同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。

六、浏览器验证(真链路,只绕开"手指点选")
   文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
     fetch 字节 → new File → DataTransfer → input.files → dispatch change
   实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
   夹具用完即删。

七、回归
   全量 622 条断言、0 失败(名册 E2E 62 → 67)
   frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
   verify-docs.py 19 项全通过

八、两个流程教训(已写进交接摘要)
   · `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
     于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
     必须看退出码 / BUILD SUCCESS。
   · `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
     与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。

九、下一步(第 3 步)
   报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
   先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
   需要先定"计划从哪来"。
This commit is contained in:
2026-09-29 17:17:55 +08:00
parent fdfe21345a
commit e85a25a97f
11 changed files with 1352 additions and 27 deletions
+131
View File
@@ -1775,3 +1775,134 @@ GET /api/v1/tasks/{id}/roster keyword / orgId / submitStatus / bindSta
5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了**
> 做完第 2 步,M1 的"配置闭环"就真的能在界面上从头走到尾了(里程碑 A 的完整形态)。
---
## §一·二十三 第 2 步完成:名册导入前端 —— **任务发布现在真的能在界面上走通了**
### 这一步的意义(一句话)
在此之前,「配置 → 任务就绪」这条链缺了**名册**那一环:班级能配、材料能配、表单能配,
但名册只能靠 SQL 造 —— 于是任务在界面上**发布不了**(发布前置校验 `40016` 名册非空)。
现在四个配置入口齐了,并且**验证过发布真的通**(`e2e-m2-roster.py` 第 8 节):
```
[8] 配置闭环:名册到位后,任务应当能发布
✓ 配覆盖班级成功
✓ 配必传材料成功
✓ **发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了)
✓ 任务状态变成「收集中」
✓ 发布后 stat 仍带分母(rosterTotal=3)
```
> `force=true` 只跳过 P1 级提醒(如"某班没指定初审人"),那 5 项阻断级校验
> **force 也绕不过** —— 所以这条断言证明的是真链路,不是绕过去的。
### 落地了什么
**新增**(`frontend-pc/`):
| 文件 | 作用 |
|---|---|
| `src/apis/xt/roster.ts` | 导入(multipart)+ 名册分页 + 类型 + 状态字典 |
| `src/views/task/TaskRosterDrawer.vue` | 「名单管理」抽屉:导入 → 体检报告 → 名册列表 |
**改动**:`views/task/index.vue` 加「名单管理」入口(草稿/收集中/已暂停可改,
已截止/已归档只读仍可查看)+ 发布失败提示改指本行入口;
`config/xt-routes.ts` 的顶层「名单管理」菜单说明改成指向任务行(见下)。
**接口**(第 1 步就做好了,这次只调):`POST /tasks/{id}/roster/import`、`GET /tasks/{id}/roster`。
### 界面上五个刻意的设计
| # | 设计 | 为什么 |
|:--:|---|---|
| 1 | **先体检、再导入(两步)** | 实测表头在第 1 行或第 6 行。一步导入的话用户只看到"成功 0 条",然后以为自己文件坏了 —— 而真相是系统把标题行当成了表头 |
| 2 | **明说"前面那 5 行是标题与说明,已跳过,这不是导错了"** | 看到"第 6 行"的人第一反应就是"导错了",然后会去改自己那份**本来正确**的表格 |
| 3 | **行号 + 列字母 + 原值 + 原因,四样一起给** | 只给"第 12 行有问题",用户还得自己回去找是哪一列 |
| 4 | **`dryRun` 默认 true** | 服务端也默认 true:不显式要求就不写库。避免"以为只是看看,结果已经导进去了" |
| 5 | **阻断行与警告行分两张表** | 阻断的要改;警告的已经入库、只是建议补。混在一起用户会以为整批都没进 |
另外:原因后面跟的是**机器码**(`IDCARD_CHECKSUM` 这种)而不是中文标签 ——
第一版给的是中文,结果和上面那句原因重复了一遍("姓名为空 姓名为空"),已改。
### 顺带修掉一处「界面在说谎」
名册导进来了,但任务列表还显示「待导入名册」—— 因为 `TaskStatVO` 恒为 `null`
(M1 阶段的既有缺口,类注释里写着"名册为空时返回 null,由界面显示「待导入名册」")。
现在名册有了,就该有分母了:
- `TaskService.fillStats()`:**一次算一页**(两条批量查询,不做逐任务查询 ——
任务列表是管理端打开次数最多的页面,10 行 × 2 次往返是白白多出来的)
- **名册为空时仍返回 `null`**,界面继续显示「待导入名册」—— 这条口径**没有放宽**:
显示"0%"看起来像"没人填",而真相是"还没有分母"
- `abnormalCount` / `avgAuditHours` **仍为 null**:前者要 M4 的校验问题表、
后者要 M5 的审核记录,现在编一个数就是谎
- E2E:`e2e-m1-task.py` 那条"stat 为 null"改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 `e2e-m2-roster.py` 补了 4 条"名册有值后 stat 该有值"
> 这条也是第 2 步**自己制造出来**的问题:能导名册之后,"待导入名册"就从"诚实"变成"说谎"。
### 关于顶层菜单「名单管理」为什么还是"待建"
`config/xt-routes.ts` 里本来就有一个顶层「名单管理」(`/collect/roster`,标着"待建 / M2")。
这次**没有**把它做成独立页,而是做进了**任务行**里的抽屉,理由:
- **名册是按任务(task)的**,独立页得先让用户选任务,多一步且容易选错;
- 它和「配置班级」「材料规则」「表单设计」是同一层的配置,放在一起才一致;
- 报考范围确认(M2-04/05)会接着做进**同一个抽屉**,那时也不需要新页面。
所以把那个菜单的说明改成指向任务行("名册是按任务的,入口在收集任务的每一行里"),
而不是留着一句已经过期的"对应里程碑 M2"。
### 浏览器验证:一个可复用的手法
文件选择框没法自动化(`uni.chooseImage` / `input[type=file]` 都会弹系统对话框),
但**可以喂进去**:
```js
const blob = await (await fetch('/某个夹具.xlsx')).blob()
const dt = new DataTransfer()
dt.items.add(new File([blob], 'x.xlsx', { type: '...' }))
input.files = dt.files
input.dispatchEvent(new Event('change', { bubbles: true }))
```
配合"把夹具放到 `frontend-pc/public/`"(Vite 会直接服务它),
就能让浏览器走**完整的真实链路**(选文件 → 体检 → 导入 → 列表刷新),
只绕开了"手指点选"那一步。这次就是这样验的:3 人被正确导入、
脱敏证件号、报考三项、扩展字段、状态列都对。
> 夹具用完即删(`frontend-pc/public/_web-roster-fixture.xlsx`),别提交。
### 两个流程教训
1. **`Test-Path jar` 不能当作"打包成功"**。这次 `mvn package` 失败了,
而 jar 是上一次的、还在 —— 检查文件存在会得到"打包 ✓",
然后测试对着**旧 jar** 全绿(或像这次一样四条新断言全红)。
**必须看 mvn 的退出码 / `BUILD SUCCESS`**。
2. **`call()` 返回的是 `(status, body)` 元组**,不是字典。我新写的那一节忘了解包,
报 `'tuple' object has no attribute 'get'` —— 与第 1 步里
"把已存在判断写反"是同一类:**新写的代码最容易在细节上错,而 E2E 正是抓它的地方**。
### 回归
- **全量 622 条断言、0 失败**(`e2e-m2-roster.py` 62 → **67**,新增配置闭环 5 条)
- `frontend-pc`:`vue-tsc --noEmit` **0 错**、`eslint` 目标文件 **0 错**
- `python scripts/verify-docs.py` → 19 项全通过
### 下一步(第 3 步)
**报考范围聚合与一致性体检(M2-04/05)** —— 这是**产品的核心增量**
(官方 Excel 的「报考职业」「报考工种」是自由填写、没有下拉,
我们要把它变成"从名单聚合 + 一致性体检"):
1. 按职业 / 工种 / 等级分组聚合,给出每个分组的**人数**与**名册行列表**
2. 5 类 issue 体检:非计划内职业、工种与职业不匹配、等级缺失、同一人多个职业…
3. 界面:做进**同一个抽屉**(「报考范围」页签 / 折叠区),支持"排除某行不参与本次认定"
4. 发布前置校验 `40024`(报考范围未覆盖全部名册)到那时才真正有意义
> 做之前先确认一件事:`sys_eval_plan` / `sys_occupation_ref` 这几张"评价计划"表
> 库里只有 `sys_occupation_ref`(见 §一·二十二 的记录),
> **评价计划本身的导入(FR-311)还没做** —— 需要先定"计划从哪来"(机构下发文件?)