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
+54
View File
@@ -491,6 +491,17 @@ def main() -> int:
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND bind_user_id IS NOT NULL")[1] == "1",
"绑定数不为 1")
# ── 3b. 任务统计快照(§3.3)现在该有分母了 ─────────────
print("\n[3b] 任务统计:名册接上之后,stat 不再恒为 null")
# TaskStatVO 的类注释里写着"名册为空时返回 null,由界面显示「待导入名册」"——
# 现在名册有 3 行,就该给出分母与比率,而不是继续显示"待导入名册"(那是**界面在说谎**)
_, det = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
st = (det.get("data") or {}).get("stat") or {}
check("stat 不再为 null(有名册就有分母)", st != {}, f"{st}")
check("rosterTotal = 名册行数 3", st.get("rosterTotal") == 3, f"{st}")
check("submitRate = 0%(3 人里还没人提交)", st.get("submitRate") == "0%", f"{st}")
check("unsubmittedCount = 3", st.get("unsubmittedCount") == 3, f"{st}")
# ── 4. 列表接口 ────────────────────────────────────
print("\n[4] 名册列表(§8.2)")
status, lst = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?pageNum=1&pageSize=10", admin)
@@ -557,6 +568,49 @@ def main() -> int:
check("指定了不存在的工作表 → 明确报错并列出可用表名",
r8.get("code") == 40001 and SHEET_NAME in str(r8.get("msg") or ""), f"{r8}")
# ── 8. 配置闭环:这是名册导入真正的意义 ────────────────
print("\n[8] 配置闭环:名册到位后,任务应当能发布")
# 发布有 5 项前置校验,其中「名册非空」(40016)就是本次补上的那一项。
# 也就是说:在这之前,任务在界面上**根本发布不了** —— 这是第 2 步的落地标志。
# 发布还缺「覆盖班级」与「必传材料」:它们各自的界面在 M1-02 / M1-04 已验过,
# 这里用接口配齐,验的是"三样齐了之后发布真的通"。
_, orgs = call("GET", f"{base}/api/v1/orgs/tree", admin)
classes: list[dict] = []
def collect(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
collect(n.get("children"))
collect(orgs.get("data"))
if not classes:
fail("配置闭环", "租户里没有班级节点,无法验证发布")
else:
org_id = classes[0]["id"]
_, r9 = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", admin, {
"orgs": [{"orgId": org_id, "expectedCount": 3}]
})
check("配覆盖班级成功", r9.get("code") == 0, f"{r9}")
_, r10 = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2
})
check("配必传材料成功", r10.get("code") == 0, f"{r10}")
# force=true 只跳过 P1 级提醒(本例里"没指定初审人"),
# 那 5 项阻断级校验(含 40016 名册非空)**force 也绕不过** —— 所以这条断言是真的
_, r11 = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", admin, {"force": True})
check("**发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了)",
r11.get("code") == 0, f"{r11}")
_, after = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
check("任务状态变成「收集中」",
(after.get("data") or {}).get("status") == 1, f"{(after.get('data') or {}).get('status')}")
check("发布后 stat 仍带分母(rosterTotal=3)",
((after.get("data") or {}).get("stat") or {}).get("rosterTotal") == 3,
f"{(after.get('data') or {}).get('stat')}")
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")