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
@@ -11,6 +11,7 @@ import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.task.service.PlanScopeService;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.hutool.json.JSONUtil;
@@ -115,6 +116,9 @@ public class RosterImportService {
Long tenantId = me.tenantId();
CollectTask task = requireTask(taskId);
// FR-311:已确认过报考范围时,追加名单若带来范围外的报考项要**立刻告警**。
// 没确认过就没有"范围外"可言(范围本身就是从名单现算的),此时返回 null、不告警。
Map<String, String> confirmedScope = PlanScopeService.confirmedIndex(task);
// 与材料规则同口径:已截止/已归档的任务不许再动名册。
// 只拦"真导入"—— dryRun 不写库,看体检报告是安全的,不该被拦。
if (!dryRun) {
@@ -182,7 +186,7 @@ public class RosterImportService {
"名册一次最多导入 " + MAX_ROWS + " 行,请拆成多个文件分批导入");
}
parseRow(taskId, i + 1, cells, colOfField, extraCols, extraKeys, orgId,
report, seenInFile, pending);
confirmedScope, report, seenInFile, pending);
}
report.setTotal(total);
@@ -471,7 +475,8 @@ public class RosterImportService {
private void parseRow(Long taskId, int excelRow, Map<Integer, String> cells,
Map<String, Integer> colOfField, List<Integer> extraCols,
List<String> extraKeys, Long orgId, RosterImportReport report,
List<String> extraKeys, Long orgId, Map<String, String> confirmedScope,
RosterImportReport report,
Map<String, Integer> seenInFile, List<Pending> pending) {
Integer nameColIdx = colOfField.get(RosterColumnCatalog.FIELD_NAME);
Integer idColIdx = colOfField.get(RosterColumnCatalog.FIELD_ID_CARD);
@@ -591,6 +596,15 @@ public class RosterImportService {
letter(colOfField.get("occupation")), "occupation", null,
"报考职业为空(专项职业能力可留空;其它情况学生端的报考项会没有值)",
"EMPTY_OCCUPATION"));
} else if (confirmedScope != null
&& !confirmedScope.containsKey(PlanScopeService.key(
row.getOccupation(), row.getWorktype(), row.getJobLevel()))) {
// FR-311:范围已确认,这个人报的组合不在里面 —— 中途补录出现范围外的报考项
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
letter(colOfField.get("occupation")), "occupation",
row.getOccupation() + " / " + row.getWorktype() + " / " + row.getJobLevel(),
"这个报考组合不在已确认的报考范围内(本次新增了范围外的报考项),"
+ "请确认是有意增补,还是名单拿错了", "SCOPE_OUT_OF_TASK"));
}
pending.add(new Pending(excelRow, hash, row));
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.task.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.task.dto.PlanScopeSaveReq;
import cn.tianbaotong.fill.module.task.dto.PlanScopeVO;
import cn.tianbaotong.fill.module.task.service.PlanScopeService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 本期报考范围(《03-接口设计》§7.7 / §7.8)。
*
* <p>权限按契约走「管理」:范围一旦确认,就成了"追加名单要不要告警"的基准(FR-311),
* 也会被学生端只读展示与导出写回 —— 它不是一个"看看而已"的只读视图。
*/
@Tag(name = "报考范围")
@RestController
@RequestMapping("/api/v1/tasks")
@RequiredArgsConstructor
public class PlanScopeController {
private final PlanScopeService planScopeService;
@GetMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "读取本期报考范围(从名册聚合 + 一致性体检)",
description = "不存在独立的计划清单文件:范围 = 名册里「报考职业 × 报考工种 × 报考等级」的去重组合")
public ApiResult<PlanScopeVO> get(@PathVariable Long id) {
return ApiResult.ok(planScopeService.get(id));
}
@PutMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "确认 / 修正报考范围",
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
public ApiResult<PlanScopeVO> save(@PathVariable Long id, @RequestBody PlanScopeSaveReq req) {
return ApiResult.ok(planScopeService.save(id, req));
}
}
@@ -0,0 +1,48 @@
package cn.tianbaotong.fill.module.task.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 报考范围确认(《03-接口设计》§7.8)—— FR-309 手工修正 / FR-311 追加告警的开关。
*
* <p>⚠️ <b>{@code rosterIds} 的并集必须恰好覆盖名册全员</b>,否则返回 {@code 40024} 与差集。
* 这条不是形式主义:遗漏任何一人,导出包就会缺人,而<b>漏人在人工核对时极难发现</b>
* (28 个人的包少 1 个,打开时不会有人数出 27 个)。用接口约束代替人眼核对。
*
* <p>所以"排除某人"的正确写法是<b>把他所在的分组标成 {@code EXCLUDED}</b>,
* 而不是把他从 {@code rosterIds} 里删掉 —— 后者会让差集校验失败,
* 而失败提示正好会告诉他"这些人没被覆盖,请明确排除而不是悄悄丢掉"。
*/
@Getter
@Setter
@Schema(description = "报考范围确认")
public class PlanScopeSaveReq {
/** 分组列表(通常是把 GET 的结果改一改再回传)。 */
private List<Group> groups = new ArrayList<>();
/**
* 已确认可以忽略的问题码。
*
* <p>例如官方 Excel 模板里「报考职业」本来就没有下拉,所以
* {@code SCOPE_FREE_TEXT} 这类提示的意义是"让人确认一下",
* 而不是"必须改到字典里"。忽略要留痕,所以它在保存请求里,而不是前端隐掉。
*/
private List<String> ignoredIssueCodes = new ArrayList<>();
/** 备注(写进操作日志,比如"9028 待补报考信息,本轮先排除")。 */
private String remark;
/**
* 一个分组(提交用)。比响应里的 {@link PlanScopeVO.Group} 少 {@code count} 与 {@code issue} ——
* 那两个是服务端算出来的,客户端回传它们是多余的,传了也不会被采信。
*/
public record Group(String occupation, String worktype, String jobLevel, Integer certType,
List<Long> rosterIds, String status) {
}
}
@@ -0,0 +1,101 @@
package cn.tianbaotong.fill.module.task.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 本期报考范围(《03-接口设计》§7.7)。
*
* <h3>它为什么不是"从文件导入的"</h3>
*
* <p>PRD V2.0 实测校正:<b>认定机构并不会下发一份计划清单文件</b>,
* 而真实名单里「报考职业 / 报考工种 / 报考等级」三列<b>老师已经填好了</b>(28/28)。
* 所以:<b>报考范围 = 名册里这三列的去重组合</b>,不需要另立一份文件,
* 也不需要 {@code sys_eval_plan} 那几张表(它们已作废删除)。
*
* <p>它的四个用途决定了这里为什么要回这么多东西:
* <ol>
* <li><b>名单内部一致性</b> —— 40 人里 38 人报「数控车工」、2 人报「车工」,
* 很可能是填错。所以体检要能指出"是哪几个人"({@code rosterIds}),
* 只说"存在不一致"是没用的。</li>
* <li><b>追加名单时告警</b>(FR-311)—— 中途补录的人若报了范围外的项,
* 要立刻提示。这要求范围是**已确认的快照**,而不是每次都现算。</li>
* <li><b>学生端只读展示</b> —— 核验通过后直接告诉他"你报考:车工 / 数控车工 / 四级"。</li>
* <li><b>导出写回</b> —— 汇总导出时原样写回 Excel 模板的 G/H/I 列。</li>
* </ol>
*
* <p>⚠️ <b>手工修正是保留能力</b>(FR-309):范围是从名单推出来的,
* 而名单本身可能不完整(临时增补、机构特殊要求),所以管理员必须能改。
* 改完落 {@code collect_task.plan_scope_json}。
*/
@Getter
@Setter
@Schema(description = "本期报考范围(从名册聚合)")
public class PlanScopeVO {
/** 来源。当前恒为 {@code ROSTER_AGGREGATED}(不存在"从文件导入"的路径)。 */
private String source = "ROSTER_AGGREGATED";
/** 上次确认时间;未确认为 {@code null}。 */
private LocalDateTime updatedAt;
/** 名册人数(分母)。0 表示还没有名单,范围无从谈起。 */
private int rosterCount;
/** 管理员是否已确认。 */
private boolean confirmed;
/** 一个分组 = 职业 × 工种 × 等级 的一个组合。 */
private List<Group> groups = new ArrayList<>();
private HealthCheck healthCheck = new HealthCheck();
/**
* 一个报考分组。
*
* @param certType 认定类型。一期只有「自主认定(1)」,所以由任务级带下来;
* 专项(3) 的"无工种/无等级"要靠它区分(二期)
* @param status {@code CONFIRMED} 已确认 / {@code NEED_REVIEW} 需人工确认 / {@code EXCLUDED} 已排除
* @param issue 需要人工确认时的一句话说明(正常为 null)
*/
public record Group(String occupation, String worktype, String jobLevel, Integer certType,
int count, List<Long> rosterIds, String status, String issue) {
}
/** 体检结论。{@code passed} = 没有任何**未被忽略**的问题。 */
@Getter
@Setter
@Schema(description = "报考范围一致性体检")
public static class HealthCheck {
private boolean passed = true;
private List<Issue> issues = new ArrayList<>();
}
/**
* 一处问题。
*
* <p>⚠️ <b>{@code level} 的口径</b>:契约(§7.7)只给了每项的值、没给语义说明,
* 所以这里**只把它当排序与展示用**,判定 {@code passed} 时对所有级别一视同仁 ——
* 契约里 {@code ignoredIssueCodes} 的存在正说明"要不要放过"由人决定,
* 而不是由我们按级别猜一个"≤2 才拦"的规则。
*
* @param rosterIds 涉及哪几个人。**必须给** —— 只说"存在不一致",
* 管理员没法去核对,而这三个字段老师要逐人改
*/
public record Issue(int level, String code, String title, String detail, List<Long> rosterIds) {
}
/** 已确认。 */
public static final String STATUS_CONFIRMED = "CONFIRMED";
/** 需人工确认(有异常)。 */
public static final String STATUS_NEED_REVIEW = "NEED_REVIEW";
/** 已排除,不参与本次认定。 */
public static final String STATUS_EXCLUDED = "EXCLUDED";
}
@@ -72,6 +72,19 @@ public class CollectTask extends TenantEntity {
/** 任务级压缩默认参数,jsonb 原文。 */
private String compressConfig;
/**
* 本期报考范围快照(jsonb 原文)—— <b>由名册聚合而来</b>(FR-307),管理员可确认/修正(FR-309)。
*
* <p>⚠️ 库里的列注释写的形状是 {@code {occupations:[{name,worktypes:...}]}},
* 而实际存的是 {@code {groups:[{occupation,worktype,jobLevel,status,rosterIds}]}} ——
* 接口契约(§7.8)的 PUT 要能**原样回传**每个分组的状态与人员归属,
* 嵌套树那个形状表达不了"谁在哪个组、哪组被排除",所以以接口契约为准。
*/
private String planScopeJson;
/** 管理员是否已确认范围(未确认时追加名单不告警,见 FR-311)。 */
private Integer planScopeConfirmed;
/** 是否需要名册:固定为 1(两种收集模式都必填,名单门禁是产品根基)。 */
private Integer rosterRequired;
@@ -0,0 +1,42 @@
package cn.tianbaotong.fill.module.task.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* 「报考范围」聚合用的名册读取(《03-接口设计》§7.7 / PRD §5.3.1)。
*
* <p>为什么单独一个 Mapper,而不是塞进 {@link TaskReadinessMapper}:
* 那个 Mapper 的定位是"**只放计数**"的任务发布体检,而这里要的是**逐行的报考三项**
* (聚合与体检都要知道"哪几个人属于哪一组")。两件事的关注点不同,
* 混在一起会让"发布条件"越来越难读。
*
* <p>为什么不让 {@code PlanScopeService} 直接注入名册模块的 Mapper:
* 那会让任务模块反向依赖名册模块,而名册的增删改属 M2-01/02 —— 这里只需要"读三个字段",
* 用一条只读查询表达即可,边界清楚。
*/
public interface PlanScopeMapper {
/**
* 取本任务名册里做聚合需要的最小字段集。
*
* <p>⚠️ 只取 {@code id / seqNo / realName / 报考三项}:
* 聚合与体检都不需要证件号或电话,而多取一个敏感字段就多一次泄露面。
*
* <p>返回 {@code Map} 而不是实体:这不是"名册项"的业务视图,
* 只是一次聚合的输入。用实体会让调用方顺手去改它。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT id, seq_no, real_name, occupation, worktype, job_level
FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
ORDER BY seq_no NULLS LAST, id
""")
List<Map<String, Object>> listRowsForScope(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId);
}
@@ -0,0 +1,506 @@
package cn.tianbaotong.fill.module.task.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.task.dto.PlanScopeSaveReq;
import cn.tianbaotong.fill.module.task.dto.PlanScopeVO;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.PlanScopeMapper;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* 本期报考范围:从名册聚合 + 一致性体检(《03-接口设计》§7.7 / §7.8,PRD §5.3.1)。
*
* <h3>为什么是"从名册聚合"而不是"导入一份计划文件"</h3>
*
* <p>PRD V2.0 实测校正过:<b>机构不下发计划清单</b>,而真实名单里报考三项
* 老师已经填好了(28/28 全是 车工 / 数控车工 / 四级中级工)。
* 所以范围就是这三列的<b>去重组合</b>,`sys_eval_plan` 那几张表已作废删除。
*
* <h3>这个 Service 存在的唯一理由:把"人眼核对"换成"接口约束"</h3>
*
* <p>名单里 40 个人,若 38 人报「数控车工」、2 人报「车工」,那 2 个很可能是填错;
* 但这种错<b>不会有人发现</b> —— 逐行核 40 行是做不到的(真实批次是几百人)。
* 所以要机器算出分组、算出异常,并<b>指出是哪几个人</b>。
*
* <p>同理,确认时必须<b>恰好覆盖全员</b>(差集报 {@code 40024}):
* 导出包少 1 个人,打开时不会有人数出 27 个。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PlanScopeService {
/** 体检问题码(§7.7)。 */
private static final String CODE_UNFILLED = "SCOPE_UNFILLED";
private static final String CODE_LEVEL_MIXED = "SCOPE_LEVEL_MIXED";
private static final String CODE_TYPE_MISMATCH = "SCOPE_TYPEO_MISMATCH";
private static final String CODE_OUT_OF_TASK = "SCOPE_OUT_OF_TASK";
private final PlanScopeMapper planScopeMapper;
private final TaskMapper taskMapper;
// ── 读取 ────────────────────────────────────────
public PlanScopeVO get(Long taskId) {
LoginUser me = UserContext.require();
CollectTask task = requireTask(taskId);
return build(me.tenantId(), task);
}
/**
* 聚合 + 体检 + 合并已保存的确认状态。
*
* <p>分组用 {@code LinkedHashMap}:顺序 = 名册里第一次出现的顺序,
* 这样管理员看到的组次序和名单一致(对着 Excel 核的时候少一次找)。
*/
private PlanScopeVO build(Long tenantId, CollectTask task) {
List<Map<String, Object>> rows = planScopeMapper.listRowsForScope(tenantId, task.getId());
Map<String, List<Long>> groupIds = new LinkedHashMap<>();
Map<String, String[]> groupKeys = new LinkedHashMap<>();
Map<Long, String[]> rowTriple = new HashMap<>();
Map<Long, String> nameOf = new HashMap<>();
Set<Long> allIds = new LinkedHashSet<>();
for (Map<String, Object> r : rows) {
Long id = asLong(col(r, "id"));
if (id == null) {
continue;
}
String occ = text(col(r, "occupation"));
String wt = text(col(r, "worktype"));
String lv = text(col(r, "job_level"));
allIds.add(id);
rowTriple.put(id, new String[]{occ, wt, lv});
nameOf.put(id, text(col(r, "real_name")));
String key = key(occ, wt, lv);
groupIds.computeIfAbsent(key, k -> new ArrayList<>()).add(id);
groupKeys.putIfAbsent(key, new String[]{occ, wt, lv});
}
// 已保存的确认状态(按同一个 key 匹配)
JSONObject saved = parseSaved(task.getPlanScopeJson());
Map<String, String> savedStatus = new HashMap<>();
Set<String> ignored = new HashSet<>();
if (saved != null) {
for (Object o : saved.getJSONArray("groups") == null ? List.of() : saved.getJSONArray("groups")) {
JSONObject g = (JSONObject) o;
savedStatus.put(key(text(g.get("occupation")), text(g.get("worktype")), text(g.get("jobLevel"))),
g.getStr("status"));
}
if (saved.getJSONArray("ignoredIssueCodes") != null) {
for (Object o : saved.getJSONArray("ignoredIssueCodes")) {
ignored.add(String.valueOf(o));
}
}
}
PlanScopeVO vo = new PlanScopeVO();
vo.setRosterCount(allIds.size());
vo.setConfirmed(task.getPlanScopeConfirmed() != null && task.getPlanScopeConfirmed() == 1);
vo.setUpdatedAt(saved == null ? null : saved.getLocalDateTime("savedAt", null));
// ① 分组
Set<String> needReview = new HashSet<>();
for (Map.Entry<String, List<Long>> e : groupIds.entrySet()) {
String[] k = groupKeys.get(e.getKey());
String status = savedStatus.getOrDefault(e.getKey(), PlanScopeVO.STATUS_CONFIRMED);
if (PlanScopeVO.STATUS_CONFIRMED.equals(status) && needReview.contains(e.getKey())) {
status = PlanScopeVO.STATUS_NEED_REVIEW;
}
vo.getGroups().add(new PlanScopeVO.Group(k[0], k[1], k[2], task.getCertType(),
e.getValue().size(), e.getValue(), status, null));
}
// ② 体检
List<PlanScopeVO.Issue> issues = healthCheck(task, rows, rowTriple, groupIds);
// 把"涉及哪几个人"补进 detail:只说"存在不一致",管理员没法去核对,
// 而报考三项要老师**逐人**改(真实批次是几百人,找不到人等于没报)
List<PlanScopeVO.Issue> named = new ArrayList<>(issues.size());
for (PlanScopeVO.Issue is : issues) {
String detail = is.detail();
if (!is.rosterIds().isEmpty()) {
detail = detail + "(涉及:" + names(is.rosterIds(), nameOf, rowTriple) + ")";
}
named.add(new PlanScopeVO.Issue(is.level(), is.code(), is.title(), detail, is.rosterIds()));
}
issues = named;
// ③ 受影响的分组标成"需人工确认"(已经 EXCLUDED 的不动)
for (PlanScopeVO.Issue is : issues) {
if (ignored.contains(is.code())) {
continue;
}
for (Long id : is.rosterIds()) {
String[] k = rowTriple.get(id);
if (k != null) {
needReview.add(key(k[0], k[1], k[2]));
}
}
}
List<PlanScopeVO.Group> fixed = new ArrayList<>(vo.getGroups().size());
for (PlanScopeVO.Group g : vo.getGroups()) {
String k = key(g.occupation(), g.worktype(), g.jobLevel());
if (PlanScopeVO.STATUS_CONFIRMED.equals(g.status()) && needReview.contains(k)) {
fixed.add(new PlanScopeVO.Group(g.occupation(), g.worktype(), g.jobLevel(), g.certType(),
g.count(), g.rosterIds(), PlanScopeVO.STATUS_NEED_REVIEW, "这一组有体检问题,请确认"));
} else {
fixed.add(g);
}
}
vo.setGroups(fixed);
// ④ 被忽略的问题不再出现在待办里(契约 §7.8 的响应用的就是这个口径)
List<PlanScopeVO.Issue> left = new ArrayList<>();
for (PlanScopeVO.Issue is : issues) {
if (!ignored.contains(is.code())) {
left.add(is);
}
}
vo.getHealthCheck().setIssues(left);
vo.getHealthCheck().setPassed(left.isEmpty());
return vo;
}
/**
* 五类体检里实现四类({@code SCOPE_FREE_TEXT} 见下)。
*
* <p>⚠️ <b>{@code SCOPE_FREE_TEXT}("与字典拼写不符")没有实现,是有理由的</b>:
* 它要一张国标职业字典,而 {@code sys_occupation_ref} 现在<b>一行数据都没有</b>,
* 接入字典本身是 P2(FR-312)。硬编一份"官方职业列表"进代码,
* 会在机构用别的写法时<b>全部误报</b>,而误报会让人整体忽略体检 —— 那比不做更糟。
*/
private List<PlanScopeVO.Issue> healthCheck(CollectTask task, List<Map<String, Object>> rows,
Map<Long, String[]> rowTriple,
Map<String, List<Long>> groupIds) {
List<PlanScopeVO.Issue> out = new ArrayList<>();
// ① 报考项为空(漏填红色必填项)
List<Long> unfilled = new ArrayList<>();
for (Map.Entry<Long, String[]> e : rowTriple.entrySet()) {
if (e.getValue()[0] == null) {
unfilled.add(e.getKey());
}
}
if (!unfilled.isEmpty()) {
out.add(new PlanScopeVO.Issue(1, CODE_UNFILLED,
unfilled.size() + " 人未填报考职业",
"报考职业是红色必填项,为空的人无法生成导出文件名里的报考信息,请补填后再确认",
unfilled));
}
// ② 同一职业下出现多个等级 —— 很可能是"应该是两个任务"
Map<String, Map<String, List<Long>>> byOccLevel = new TreeMap<>();
for (Map.Entry<Long, String[]> e : rowTriple.entrySet()) {
String occ = e.getValue()[0];
String lv = e.getValue()[2];
if (occ == null || lv == null) {
continue;
}
byOccLevel.computeIfAbsent(occ, k -> new TreeMap<>())
.computeIfAbsent(lv, k -> new ArrayList<>()).add(e.getKey());
}
for (Map.Entry<String, Map<String, List<Long>>> e : byOccLevel.entrySet()) {
if (e.getValue().size() <= 1) {
continue;
}
// 少数派才算异常:多数派是"正常那一批"
List<Long> minor = new ArrayList<>();
StringBuilder detail = new StringBuilder();
int max = e.getValue().values().stream().mapToInt(List::size).max().orElse(0);
for (Map.Entry<String, List<Long>> lv : e.getValue().entrySet()) {
detail.append(lv.getKey()).append("(").append(lv.getValue().size()).append("人) ");
if (lv.getValue().size() < max) {
minor.addAll(lv.getValue());
}
}
out.add(new PlanScopeVO.Issue(2, CODE_LEVEL_MIXED,
"「" + e.getKey() + "」下同时存在多个等级",
"「" + e.getKey() + "」下有 " + detail.toString().trim()
+ ",请确认是否本应拆成两个任务(等级不同 = 认定批次不同)",
minor.isEmpty() ? allOf(e.getValue()) : minor));
}
// ③ 认定类型与"有没有工种/等级"矛盾
List<Long> needWtLv = new ArrayList<>();
List<Long> mustNotHave = new ArrayList<>();
boolean isSpecial = task.getCertType() != null && task.getCertType() == 3;
for (Map.Entry<Long, String[]> e : rowTriple.entrySet()) {
String wt = e.getValue()[1];
String lv = e.getValue()[2];
if (isSpecial) {
// 专项职业能力:无工种、无等级(PRD 规则 05/06/07)
if (wt != null || lv != null) {
mustNotHave.add(e.getKey());
}
} else if (wt == null || lv == null) {
// 「无」是合法值(该职业确实没有工种),所以只判"为空"
needWtLv.add(e.getKey());
}
}
if (!needWtLv.isEmpty()) {
out.add(new PlanScopeVO.Issue(2, CODE_TYPE_MISMATCH,
needWtLv.size() + " 人缺报考工种或等级",
"本次认定类型需要「报考工种」与「报考等级」(确实没有工种的填「无」,但不能留空)",
needWtLv));
}
if (!mustNotHave.isEmpty()) {
out.add(new PlanScopeVO.Issue(2, CODE_TYPE_MISMATCH,
mustNotHave.size() + " 人填了工种或等级,但本次是专项职业能力",
"专项职业能力不分工种与等级,请清空这两项",
mustNotHave));
}
// ④ 出现任务声明范围外的职业
// ⚠️ 只在任务**显式声明过** jobName 时才判 —— 没声明就没有"范围外"可言,
// 凭空拿一个默认值去比会全部误报。
// ⚠️ 等级不比:真实名单里写的是「四级/中级工」,而任务里写「四级」,
// 精确比对必然误报(这条等字典就位后再补)。
String taskJob = text(task.getJobName());
if (taskJob != null) {
List<Long> outOfTask = new ArrayList<>();
for (Map.Entry<Long, String[]> e : rowTriple.entrySet()) {
String occ = e.getValue()[0];
if (occ != null && !occ.equals(taskJob)) {
outOfTask.add(e.getKey());
}
}
if (!outOfTask.isEmpty()) {
out.add(new PlanScopeVO.Issue(3, CODE_OUT_OF_TASK,
outOfTask.size() + " 人报考的职业不在本任务范围内",
"本任务声明的是「" + taskJob + "」,这些人报的是别的职业,请确认名单是否拿错",
outOfTask));
}
}
return out;
}
private static List<Long> allOf(Map<String, List<Long>> m) {
List<Long> all = new ArrayList<>();
for (List<Long> v : m.values()) {
all.addAll(v);
}
return all;
}
// ── 确认 ────────────────────────────────────────
/**
* 确认/修正报考范围。
*
* <p>覆盖校验是这一步的**全部价值**:{@code rosterIds} 的并集必须恰好等于名册全员,
* 每个 id 只能出现一次。差集原样返回,让管理员知道漏了谁 ——
* 而不是笼统地说"校验失败"。
*/
@Transactional(rollbackFor = Exception.class)
public PlanScopeVO save(Long taskId, PlanScopeSaveReq req) {
LoginUser me = UserContext.require();
CollectTask task = requireTask(taskId);
List<Map<String, Object>> rows = planScopeMapper.listRowsForScope(me.tenantId(), taskId);
Map<Long, String> nameOf = new LinkedHashMap<>();
Map<Long, String[]> tripleOf = new HashMap<>();
for (Map<String, Object> r : rows) {
Long id = asLong(col(r, "id"));
if (id != null) {
nameOf.put(id, text(col(r, "real_name")));
tripleOf.put(id, new String[]{text(col(r, "occupation")),
text(col(r, "worktype")), text(col(r, "job_level"))});
}
}
if (req == null || req.getGroups() == null || req.getGroups().isEmpty()) {
throw new BizException(ErrorCode.PLAN_SCOPE_INCOMPLETE,
"报考范围不能为空:名册里的每个人都要被分到某一组");
}
Map<Long, Integer> seen = new HashMap<>();
List<Long> unknown = new ArrayList<>();
for (PlanScopeSaveReq.Group g : req.getGroups()) {
for (Long id : nullToEmpty(g.rosterIds())) {
if (!nameOf.containsKey(id)) {
unknown.add(id);
}
seen.merge(id, 1, Integer::sum);
}
}
if (!unknown.isEmpty()) {
throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
"有 " + unknown.size() + " 个名册项不属于本任务(可能已被删除):" + head(unknown));
}
List<Long> missing = new ArrayList<>();
List<Long> duplicated = new ArrayList<>();
for (Long id : nameOf.keySet()) {
Integer c = seen.get(id);
if (c == null) {
missing.add(id);
} else if (c > 1) {
duplicated.add(id);
}
}
if (!missing.isEmpty()) {
throw new BizException(ErrorCode.PLAN_SCOPE_INCOMPLETE,
"有 " + missing.size() + " 人没有被分到任何组:" + names(missing, nameOf, tripleOf)
+ "。请把他们明确分到某个组或标为「已排除」——**不要悄悄丢掉**"
+ "(导出包少了人,人工核对时发现不了)");
}
if (!duplicated.isEmpty()) {
throw new BizException(ErrorCode.PLAN_SCOPE_INCOMPLETE,
"有 " + duplicated.size() + " 人被分到了多个组:" + names(duplicated, nameOf, tripleOf)
+ "。一个人只能在一个组里");
}
// 落库:存的是"确认结果"(含每组的状态与人员归属),而不是仅凭名册现算的聚合
JSONObject json = new JSONObject();
json.set("savedAt", LocalDateTime.now());
json.set("ignoredIssueCodes", req.getIgnoredIssueCodes() == null ? List.of() : req.getIgnoredIssueCodes());
json.set("remark", req.getRemark());
List<JSONObject> gs = new ArrayList<>();
for (PlanScopeSaveReq.Group g : req.getGroups()) {
JSONObject o = new JSONObject();
o.set("occupation", g.occupation());
o.set("worktype", g.worktype());
o.set("jobLevel", g.jobLevel());
o.set("status", g.status() == null ? PlanScopeVO.STATUS_CONFIRMED : g.status());
o.set("rosterIds", g.rosterIds());
gs.add(o);
}
json.set("groups", gs);
taskMapper.update(null, com.baomidou.mybatisplus.core.toolkit.Wrappers
.<CollectTask>lambdaUpdate()
.eq(CollectTask::getId, taskId)
.set(CollectTask::getPlanScopeJson, JSONUtil.toJsonStr(json))
.set(CollectTask::getPlanScopeConfirmed, 1));
log.info("报考范围已确认:task={} 分组={} 人数={} 忽略问题={} 备注={}",
taskId, gs.size(), nameOf.size(),
req.getIgnoredIssueCodes() == null ? 0 : req.getIgnoredIssueCodes().size(), req.getRemark());
// 返回重新聚合的结果:状态与体检都是**现算**的,不会和库里存的对不上
return build(me.tenantId(), taskMapper.selectById(taskId));
}
// ── 给名册导入用的:已确认范围(FR-311 追加告警)────────
/**
* 读取已确认的范围,用于追加名单时告警。
*
* @return {@code key(职业|工种|等级) → 说明};未确认时返回 {@code null}
* (没确认过就没有"范围外"可言,不该拿现算的聚合去吓人)
*/
public static Map<String, String> confirmedIndex(CollectTask task) {
if (task == null || task.getPlanScopeConfirmed() == null || task.getPlanScopeConfirmed() != 1) {
return null;
}
JSONObject saved = parseSaved(task.getPlanScopeJson());
if (saved == null || saved.getJSONArray("groups") == null) {
return null;
}
Map<String, String> m = new LinkedHashMap<>();
for (Object o : saved.getJSONArray("groups")) {
JSONObject g = (JSONObject) o;
String occ = text(g.get("occupation"));
String wt = text(g.get("worktype"));
String lv = text(g.get("jobLevel"));
m.put(key(occ, wt, lv), occ + " / " + (wt == null ? "无" : wt) + " / " + (lv == null ? "无" : lv));
}
return m;
}
// ── 小工具 ─────────────────────────────────────
private CollectTask requireTask(Long taskId) {
CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
return task;
}
private static JSONObject parseSaved(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return JSONUtil.parseObj(raw);
} catch (Exception e) {
// 范围算不出来不该让"读名册"整个失败:当作"还没确认",管理员重确认一次即可
return null;
}
}
/**
* 分组键:三项都空值安全。
*
* <p>公开是因为名册导入要用**同一个键**去比对已确认的范围(FR-311 追加告警)——
* 两处各写一个"把三项拼成键"的写法,一旦不一致,追加告警就会全部误报或全部漏报。
*/
public static String key(String occ, String wt, String lv) {
return (occ == null ? "" : occ) + '\u0001' + (wt == null ? "" : wt) + '\u0001' + (lv == null ? "" : lv);
}
private static String names(List<Long> ids, Map<Long, String> nameOf, Map<Long, String[]> tripleOf) {
List<String> parts = new ArrayList<>();
int i = 0;
for (Long id : ids) {
if (i++ >= 5) {
parts.add("…共 " + ids.size() + " 人");
break;
}
String[] t = tripleOf.get(id);
parts.add(nameOf.get(id) + "(" + (t == null || t[0] == null ? "报考项为空" : t[0]) + ")");
}
return String.join("、", parts);
}
private static String head(List<Long> ids) {
return ids.size() <= 5 ? ids.toString() : ids.subList(0, 5) + "…";
}
private static <T> List<T> nullToEmpty(List<T> l) {
return l == null ? List.of() : l;
}
private static String text(Object o) {
if (o == null) {
return null;
}
String s = String.valueOf(o).trim();
return s.isEmpty() ? null : s;
}
private static Object col(Map<String, Object> row, String name) {
for (Map.Entry<String, Object> e : row.entrySet()) {
if (e.getKey().equalsIgnoreCase(name)) {
return e.getValue();
}
}
return null;
}
private static Long asLong(Object o) {
return o instanceof Number n ? n.longValue() : null;
}
}
+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 ——
> 见下一节(§一·二十五),那里记录了分析与待拍板的点。
+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' }
}
+226 -3
View File
@@ -271,7 +271,94 @@
</div>
</template>
<!-- ── 三、名册 ─────────────────────────────────── -->
<!-- ── 三、报考范围(从名册聚合,不是"上传计划")─────── -->
<a-divider />
<div class="roster__section">
报考范围({{ scope?.rosterCount ?? 0 }} 人)
<span v-if="scope?.confirmed">已于 {{ fmtTime(scope?.updatedAt) }} 确认</span>
<span v-else>尚未确认</span>
</div>
<a-alert type="info" :show-icon="false" class="roster__intro">
<template #title>
这里<strong>不是上传计划</strong>:范围是从名单里「报考职业 / 报考工种 / 报考等级」
三列<strong>归出来的</strong>(真实名单里这三列老师已经填好)。
确认之后,它就成为"追加名单是否出现范围外报考项"的基准。
</template>
</a-alert>
<a-spin :loading="scopeLoading">
<a-empty v-if="!scopeGroups.length" description="名册为空,还没有可聚合的报考范围" />
<template v-else>
<a-table :data="scopeGroups" :pagination="false" row-key="__key" size="small"
class="roster__table">
<template #columns>
<a-table-column title="报考组合(职业 / 工种 / 等级)" :width="330">
<template #cell="{ record }">
<div>{{ groupText(record) }}</div>
</template>
</a-table-column>
<a-table-column title="人数" :width="80" align="center" data-index="count" />
<a-table-column title="状态" :width="110" align="center">
<template #cell="{ record }">
<a-tag :color="SCOPE_STATUS[record.status]?.color ?? 'gray'" size="small">
{{ SCOPE_STATUS[record.status]?.text ?? record.status }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="110" align="center">
<template #cell="{ record }">
<a-link v-if="record.status !== 'EXCLUDED'" @click="toggleExclude(record)">
标为不参与
</a-link>
<a-link v-else @click="toggleExclude(record)">恢复参与</a-link>
</template>
</a-table-column>
</template>
</a-table>
<!-- 体检:必须说得出"是哪几个人" -->
<template v-if="scopeIssues.length">
<div class="roster__section roster__section--warn">
一致性体检发现 {{ scopeIssues.length }} 项,请逐项处理或确认忽略
</div>
<div v-for="is in scopeIssues" :key="is.code + is.title" class="roster__issue">
<div class="roster__issue-head">
<a-tag size="small" :color="is.level <= 1 ? 'red' : (is.level === 2 ? 'orange' : 'gray')">
级别 {{ is.level }}
</a-tag>
<strong>{{ is.title }}</strong>
<a-checkbox :model-value="ignoredCodes.includes(is.code)"
@change="toggleIgnore(is.code)">
已知悉,本次忽略
</a-checkbox>
</div>
<div class="roster__hint">{{ is.detail }}</div>
</div>
</template>
<a-alert v-else type="success" :show-icon="false" class="roster__intro">
体检通过:没有发现漏填、同职业多等级、范围外职业等问题。
</a-alert>
<div class="roster__footer">
<a-button type="primary" size="small" :loading="scopeSaving" :disabled="!canConfirmScope"
@click="confirmScope">
确认报考范围
</a-button>
<div class="roster__hint">
<template v-if="!canConfirmScope">
还有 {{ unhandledCount }} 项体检问题未处理:请修名单后重新导入,或逐项勾选「已知悉,本次忽略」。
</template>
<template v-else>
确认时会校验"名册里每个人都恰好被分到一组"——
漏掉一个人,导出包就会缺人,而打开导出包时看不出来。
</template>
</div>
</div>
</template>
</a-spin>
<!-- ── 四、名册明细 ─────────────────────────────── -->
<a-divider />
<div class="roster__section">
名册({{ total }} 人)
@@ -363,11 +450,14 @@
</template>
<script setup lang="ts">
import type { PlanScopeVO } from '@/apis/xt/plan-scope'
import type { RosterImportReport, RosterVO } from '@/apis/xt/roster'
import type { TaskVO } from '@/apis/xt/task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconRefresh, IconUpload } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as scopeApi from '@/apis/xt/plan-scope'
import { SCOPE_STATUS } from '@/apis/xt/plan-scope'
import * as rosterApi from '@/apis/xt/roster'
import { SUBMIT_STATUS } from '@/apis/xt/roster'
@@ -591,7 +681,7 @@ async function open(task: TaskVO) {
query.submitStatus = undefined
query.bindStatus = undefined
query.pageNum = 1
await Promise.all([loadClasses(), loadList()])
await Promise.all([loadClasses(), loadList(), loadScope()])
}
function close() {
@@ -701,7 +791,8 @@ async function confirmImport() {
const removedPart = d?.removed ? `、移除 ${d.removed} 人` : ''
Message.success(`导入完成:新增 ${d?.created ?? 0} 人、更新 ${d?.updated ?? 0} 人${removedPart}`)
query.pageNum = 1
await loadList()
// 名册变了 → 报考范围也要重算(它是从名册聚合出来的)
await Promise.all([loadList(), loadScope()])
emit('save-success')
} finally {
importing.value = false
@@ -709,6 +800,121 @@ async function confirmImport() {
}
}
// ── 报考范围(§7.7 / §7.8)──────────────────────────
const scope = ref<PlanScopeVO | null>(null)
const scopeLoading = ref(false)
const scopeSaving = ref(false)
/** 本次勾选"已忽略"的问题码(确认时提交,服务端会把它从待办里去掉) */
const ignoredCodes = ref<string[]>([])
/** 本地改过的分组状态(确认之前不落库) */
const localStatus = ref<Record<string, string>>({})
/**
* 分组列表 —— 带上本地改过的状态。
*
* `__key` 是给表格用的 row-key:一个分组的身份就是「职业/工种/等级」三项,
* 用它当 key 比用下标稳(重算之后顺序可能变,下标当 key 会让"排除"跟错行)。
*/
const scopeGroups = computed(() =>
(scope.value?.groups ?? []).map((g) => {
const k = scopeKey(g)
return { ...g, __key: k, status: localStatus.value[k] ?? g.status }
})
)
const scopeIssues = computed(() => scope.value?.healthCheck?.issues ?? [])
/** 还没被处理(或明确忽略)的问题数 */
const unhandledCount = computed(
() => scopeIssues.value.filter((i) => !ignoredCodes.value.includes(i.code)).length
)
/**
* 能不能确认。
*
* ⚠️ 要求"体检问题都已处理或明确忽略"才让确认:`passed` 是给人看的,
* 而"我确认了"这件事必须是真的 —— 否则确认就变成了一个把红字变绿的按钮。
*/
const canConfirmScope = computed(
() => !!scope.value && scope.value.rosterCount > 0 && unhandledCount.value === 0
)
async function loadScope() {
if (!taskId.value) {
return
}
scopeLoading.value = true
try {
const res = await scopeApi.getPlanScope(taskId.value)
scope.value = res.data ?? null
localStatus.value = {}
// 服务端回来的是"已扣掉被忽略项"的清单,所以本地从空开始
ignoredCodes.value = []
} finally {
scopeLoading.value = false
}
}
function scopeKey(g: { occupation: string | null, worktype: string | null, jobLevel: string | null }) {
return `${g.occupation ?? ''}|${g.worktype ?? ''}|${g.jobLevel ?? ''}`
}
/** 组合的人话说法:「车工 / 数控车工 / 四级」;空的写「(未填)」而不是留白 */
function groupText(g: { occupation: string | null, worktype: string | null, jobLevel: string | null }) {
return [g.occupation ?? '(未填)', g.worktype ?? '无', g.jobLevel ?? '无'].join(' / ')
}
function fmtTime(t: string | null | undefined) {
return t ? String(t).replace('T', ' ').slice(0, 16) : '—'
}
/**
* 「标为不参与」不是把这个人从名单里删掉,而是**把他所在的分组标成 EXCLUDED**。
*
* 服务端要求名册全员恰好被覆盖一次:删掉人就过不了校验(40024 会点名他)。
* 所以界面上的措辞是"标为不参与",而不是"删除"—— 排除必须留下痕迹。
*/
function toggleExclude(g: { __key: string, status: string }) {
localStatus.value = {
...localStatus.value,
[g.__key]: g.status === 'EXCLUDED' ? 'CONFIRMED' : 'EXCLUDED'
}
}
function toggleIgnore(code: string) {
ignoredCodes.value = ignoredCodes.value.includes(code)
? ignoredCodes.value.filter((c) => c !== code)
: [...ignoredCodes.value, code]
}
async function confirmScope() {
if (!taskId.value || !scope.value) {
return
}
scopeSaving.value = true
try {
const res = await scopeApi.savePlanScope(taskId.value, {
groups: scopeGroups.value.map((g) => ({
occupation: g.occupation,
worktype: g.worktype,
jobLevel: g.jobLevel,
certType: g.certType,
rosterIds: g.rosterIds,
status: g.status
})),
ignoredIssueCodes: ignoredCodes.value
})
scope.value = res.data ?? null
localStatus.value = {}
ignoredCodes.value = []
Message.success('报考范围已确认')
emit('save-success')
} finally {
scopeSaving.value = false
}
}
defineExpose({ open })
</script>
@@ -776,5 +982,22 @@ defineExpose({ open })
&__footer { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; margin: 12px 0 4px; }
&__search { width: 240px; }
&__filter { width: 130px; }
/* 体检问题条目:左边一条竖线,让人一眼看出这是"待处理的事情"而不是一段说明 */
&__issue {
padding: 8px 12px;
margin-bottom: 8px;
background: var(--color-fill-1);
border-left: 3px solid rgb(var(--warning-6));
border-radius: 4px;
}
&__issue-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
}
</style>
+375
View File
@@ -0,0 +1,375 @@
# -*- coding: utf-8 -*-
"""
报考范围 E2E(M2-04 / M2-05,《03-接口设计》§7.7 / §7.8,PRD §5.3.1)—— FR-307/308/309/311。
验的核心是四件事:
① **聚合**:不存在"计划清单文件",范围 = 名册里「职业 × 工种 × 等级」的去重组合。
所以要能按组合分组、给出每组人数与**是哪几个人**(`rosterIds`)。
② **一致性体检**:40 人里 38 人报 A、2 人报 B,那 2 个很可能是填错 ——
而这种错**逐行核是发现不了的**(真实批次几百人)。所以必须机器算出来,
并且指出是**哪几个人**。只说"存在不一致"等于没说。
③ **确认时的全覆盖约束**(这条是这一步真正的价值):
`rosterIds` 的并集必须恰好等于名册全员,差集返回 40024 并**点名**。
漏一个人,导出包就会缺人,而打开导出包时**不会有人数出 27 个**。
④ **FR-311 追加告警**:范围确认之后,中途补录的人若报了范围外的报考项,
导入体检报告里必须立刻出现提示。
跑法:python scripts/e2e-m2-scope.py
前提:后端 8080 在跑、xt-pg 容器在跑、装了 openpyxl。
"""
from __future__ import annotations
import argparse
import importlib.util
import io
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
TMP_FILES: list[str] = []
# ── 复用名册 E2E 的工具(登录取 token、multipart 上传、校验位)─────
def load_roster_e2e():
"""
直接 import e2e-m2-roster.py,避免夹具与工具出现两份 ——
两份就会漂移,而"夹具漂移"会让两个脚本一个过一个不过,且看不出谁对。
"""
path = os.path.join(ROOT, "scripts", "e2e-m2-roster.py")
spec = importlib.util.spec_from_file_location("e2e_m2_roster", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = load_roster_e2e()
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def cleanup_all() -> None:
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
sql(f"DELETE FROM collect_roster WHERE task_id IN ({ids})")
sql(f"DELETE FROM collect_material_def WHERE task_id IN ({ids})")
sql(f"DELETE FROM collect_task_org WHERE task_id IN ({ids})")
sql(f"DELETE FROM collect_submission WHERE task_id IN ({ids})")
sql(f"DELETE FROM collect_task WHERE id IN ({ids})")
for p in TMP_FILES:
try:
os.remove(p)
except OSError:
pass
# ── 夹具:一份故意做出"不一致"的名单 ───────────────────
# (姓名, 职业, 工种, 等级) —— 刻意构造出四类体检问题
ROWS = [
("曾伊豪", "车工", "数控车工", "四级"),
("杨晨", "车工", "数控车工", "四级"),
("赵六", "车工", "数控车工", "四级"),
("钱七", "车工", "数控车工", "四级"),
("孙八", "车工", "数控车工", "四级"),
("周九", "车工", "数控车工", "四级"),
("吴十", "车工", "数控车工", "**三级**"), # ← 同职业多等级(少数派)
("郑十一", "铣工", "数控铣工", "四级"), # ← 任务范围外的职业(任务声明的是"车工")
("王十二", "", "", ""), # ← 漏填报考职业
]
# 9 行 → 三个分组:车工/数控车工/四级(6人)、车工/数控车工/三级(1人)、铣工/数控铣工/四级(1人)、空(1人)
GROUP_COUNT = 4
def build_fixture(path: str) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "24中技数控1班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, occ, wt, lv) in enumerate(ROWS):
# ⚠️ 前 17 位必须是「6 位地区 + 8 位真实出生日期 + 3 位顺序」——
# 出生日期不存在(比如 0000)会被判格式非法,9 行全被阻断,
# 而导入接口仍然回 code=0(它只回体检报告),看着像"导进去了"。
idc = R.with_checksum(f"361029200501{i + 1:02d}{i:03d}")
row = i + 2
vals = [i + 1, name, idc, "中专", f"1380013800{i}", "无",
occ, wt, lv, "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=row, column=c, value=v)
wb.save(path)
def build_extra_fixture(path: str, occupation: str, worktype: str, level: str, name: str) -> None:
"""追加一个人(用来验 FR-311:范围外的报考项要告警)。"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "追加"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
idc = R.with_checksum("361029" + "19990101" + "001")
vals = [1, name, idc, "中专", "13800138999", "无", occupation, worktype, level, "否",
"", "", "", "是", "示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=2, column=c, value=v)
wb.save(path)
# ── 主流程 ───────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(description="报考范围 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 76)
print(f"报考范围 E2E(M2-04/05) base={base}")
print("=" * 76)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
_, body = R.login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"code={body.get('code')} msg={body.get('msg')}")
return 1
admin = body["data"]["token"]
ok("管理员登录")
# jobName 声明为「车工」:这样"铣工"那行才会被判成范围外(§7.7 的 SCOPE_OUT_OF_TASK)
_, body = R.call("POST", f"{base}/api/v1/tasks", admin, {
"taskName": f"E2E报考范围 {suffix}", "collectMode": 1, "batchNo": "2006",
"majorName": "数控加工", "jobName": "车工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if body.get("code") != 0:
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务(id={task_id},声明职业=车工)")
fix = os.path.join(ROOT, "scripts", f"_tmp_scope_{suffix}.xlsx")
TMP_FILES.append(fix)
build_fixture(fix)
body = R.upload_roster(base, admin, task_id, fix, {"dryRun": "false"})
check("名册导入成功(9 人)", body.get("code") == 0, f"{body}")
# 断言"新增 9 行"而不只是 code=0:导入接口对"9 行全被阻断"也是回 code=0,
# 只判 code 会把"一个人都没进去"当成功(这次就是这么被抓出来的)
check("导入报告:新增 9 行、0 阻断",
(body.get("data") or {}).get("created") == 9 and (body.get("data") or {}).get("failed") == 0,
f"created={(body.get('data') or {}).get('created')} "
f"failed={(body.get('data') or {}).get('failed')} "
f"errors={((body.get('data') or {}).get('errors') or [])[:2]}")
_, cnt = ("", sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND deleted=0"))
check("库里 9 行", cnt == "9", f"count={cnt}")
# ── 1. 聚合 ────────────────────────────────────────
print("\n[1] 聚合:范围 = 名册里「职业 × 工种 × 等级」的去重组合")
_, g = R.call("GET", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin)
d = g.get("data") or {}
check("读取成功", g.get("code") == 0, f"{g}")
check("source = ROSTER_AGGREGATED(不存在从文件导入的路径)",
d.get("source") == "ROSTER_AGGREGATED", f"{d.get('source')}")
check("rosterCount = 9(分母)", d.get("rosterCount") == 9, f"{d.get('rosterCount')}")
check("自动聚合成 4 个分组", len(d.get("groups") or []) == GROUP_COUNT,
f"{[(x.get('occupation'), x.get('worktype'), x.get('jobLevel'), x.get('count')) for x in (d.get('groups') or [])]}")
check("未确认(confirmed=false)", d.get("confirmed") is False, f"{d.get('confirmed')}")
groups = d.get("groups") or []
main_g = next((x for x in groups if x.get("occupation") == "车工" and x.get("jobLevel") == "四级"), None)
check("主分组:车工/数控车工/四级 = 6 人", main_g and main_g.get("count") == 6, f"{main_g}")
check("分组带 rosterIds(要说得出是哪几个人)",
main_g and len(main_g.get("rosterIds") or []) == 6, f"{main_g and main_g.get('rosterIds')}")
# ── 2. 一致性体检 ──────────────────────────────────
print("\n[2] 一致性体检:必须指出**是哪几个人**")
hc = d.get("healthCheck") or {}
check("passed = false(有未处理的问题)", hc.get("passed") is False, f"{hc.get('passed')}")
issues = hc.get("issues") or []
by_code = {i.get("code"): i for i in issues}
check("问题码齐全(漏填 / 同职业多等级 / 范围外职业)",
{"SCOPE_UNFILLED", "SCOPE_LEVEL_MIXED", "SCOPE_OUT_OF_TASK"} <= set(by_code),
f"{sorted(by_code)}")
check("SCOPE_UNFILLED:1 人漏填报考职业", by_code.get("SCOPE_UNFILLED", {}).get("rosterIds") and
len(by_code["SCOPE_UNFILLED"]["rosterIds"]) == 1, f"{by_code.get('SCOPE_UNFILLED')}")
check("SCOPE_UNFILLED 带人话说明(含「无法导出」的后果)",
"导出" in (by_code.get("SCOPE_UNFILLED", {}).get("detail") or ""),
f"{by_code.get('SCOPE_UNFILLED', {}).get('detail')}")
check("SCOPE_LEVEL_MIXED:指出**少数派**那 1 人(不是把 7 个人都算异常)",
len(by_code.get("SCOPE_LEVEL_MIXED", {}).get("rosterIds") or []) == 1,
f"{by_code.get('SCOPE_LEVEL_MIXED')}")
check("SCOPE_LEVEL_MIXED 的说明里点了等级与人数",
"四级" in (by_code.get("SCOPE_LEVEL_MIXED", {}).get("detail") or ""),
f"{by_code.get('SCOPE_LEVEL_MIXED', {}).get('detail')}")
check("SCOPE_OUT_OF_TASK:铣工那 1 人(任务声明的是车工)",
len(by_code.get("SCOPE_OUT_OF_TASK", {}).get("rosterIds") or []) == 1,
f"{by_code.get('SCOPE_OUT_OF_TASK')}")
check("有问题的那几组被标成 NEED_REVIEW",
any(x.get("status") == "NEED_REVIEW" for x in groups),
f"{[(x.get('occupation'), x.get('status')) for x in groups]}")
# ── 3. ★ 全覆盖约束:漏一个人必须被点名 ──────────────
print("\n[3] 确认时的全覆盖约束(这一步真正的价值)")
full = [{"occupation": x["occupation"], "worktype": x["worktype"], "jobLevel": x["jobLevel"],
"status": "CONFIRMED", "rosterIds": x["rosterIds"]} for x in groups]
missing_one = [dict(x, rosterIds=x["rosterIds"][:-1]) if i == 0 else x for i, x in enumerate(full)]
_, r1 = R.call("PUT", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin, {"groups": missing_one})
check("**漏一个人 → 40024**", r1.get("code") == 40024, f"{r1}")
check("并且**点名**漏了谁(不能只说校验失败)",
"人没有被分到" in (r1.get("msg") or ""), f"{r1.get('msg')}")
check("还说明了后果(导出包少人发现不了)",
"导出包" in (r1.get("msg") or ""), f"{r1.get('msg')}")
dup = [dict(full[0], rosterIds=full[0]["rosterIds"] + full[1]["rosterIds"])] + full[1:]
_, r2 = R.call("PUT", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin, {"groups": dup})
check("同一个人被分到两组 → 40024", r2.get("code") == 40024, f"{r2}")
check("提示里说明「一个人只能在一个组」", "多个组" in (r2.get("msg") or ""), f"{r2.get('msg')}")
_, r3 = R.call("PUT", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin,
{"groups": full + [{"occupation": "焊工", "worktype": "无", "jobLevel": "五级",
"status": "CONFIRMED", "rosterIds": [999999999]}]})
check("分组里带了不属于本任务的名册项 → 明确报错", r3.get("code") != 0, f"{r3}")
# ── 4. 确认(含排除与忽略)──────────────────────────
print("\n[4] 确认与修正(FR-309)")
unfilled_g = next(x for x in groups if not x.get("occupation"))
excluded = [dict(x, rosterIds=x["rosterIds"], status="CONFIRMED") for x in groups if x["occupation"]]
excluded.append({"occupation": unfilled_g["occupation"], "worktype": unfilled_g["worktype"],
"jobLevel": unfilled_g["jobLevel"], "status": "EXCLUDED",
"rosterIds": unfilled_g["rosterIds"]})
_, r4 = R.call("PUT", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin,
{"groups": excluded, "remark": "漏填报考项的那位本轮先排除"})
check("全员覆盖 → 确认成功", r4.get("code") == 0, f"{r4}")
d4 = r4.get("data") or {}
check("confirmed = true", d4.get("confirmed") is True, f"{d4.get('confirmed')}")
check("被排除的那组状态保留为 EXCLUDED",
any(x.get("status") == "EXCLUDED" for x in (d4.get("groups") or [])),
f"{[(x.get('occupation'), x.get('status')) for x in (d4.get('groups') or [])]}")
check("**没忽略问题时 passed 仍为 false**(确认 ≠ 掩饰)",
(d4.get("healthCheck") or {}).get("passed") is False,
f"{(d4.get('healthCheck') or {}).get('passed')}")
ignored = ["SCOPE_UNFILLED", "SCOPE_LEVEL_MIXED", "SCOPE_OUT_OF_TASK", "SCOPE_TYPEO_MISMATCH"]
_, r5 = R.call("PUT", f"{base}/api/v1/tasks/{task_id}/plan-scope", admin,
{"groups": excluded, "ignoredIssueCodes": ignored, "remark": "已知悉,本轮按此执行"})
d5 = r5.get("data") or {}
check("显式忽略后 passed = true", (d5.get("healthCheck") or {}).get("passed") is True,
f"{d5.get('healthCheck')}")
check("被忽略的问题不再出现在待办里",
(d5.get("healthCheck") or {}).get("issues") == [], f"{(d5.get('healthCheck') or {}).get('issues')}")
check("updatedAt 有值(确认时间留痕)", d5.get("updatedAt") is not None, f"{d5.get('updatedAt')}")
_, row = ("", sql(f"SELECT plan_scope_confirmed || '|' || coalesce(plan_scope_json::text,'-') "
f"FROM collect_task WHERE id={task_id}"))
check("库里 plan_scope_confirmed = 1", row.split("|")[0] == "1", f"{row[:80]}")
check("范围快照落库(plan_scope_json 非空且含分组)",
"rosterIds" in row and "EXCLUDED" in row, f"{row[:120]}")
# ── 5. FR-311:追加名单的范围外告警 ──────────────────
print("\n[5] FR-311:范围确认后,追加名单出现范围外的报考项要告警")
extra_bad = os.path.join(ROOT, "scripts", f"_tmp_extra_bad_{suffix}.xlsx")
TMP_FILES.append(extra_bad)
build_extra_fixture(extra_bad, "焊工", "电焊工", "五级", "临时新人")
rb = R.upload_roster(base, admin, task_id, extra_bad, {"dryRun": "false"})
codes_b = [w.get("code") for w in ((rb.get("data") or {}).get("warnings") or [])]
check("范围外的报考项 → 导入体检里出现 SCOPE_OUT_OF_TASK 警告",
"SCOPE_OUT_OF_TASK" in codes_b, f"{codes_b}")
warn = next((w for w in ((rb.get("data") or {}).get("warnings") or [])
if w.get("code") == "SCOPE_OUT_OF_TASK"), {})
check("告警说明了该怎么做(有意增补 / 名单拿错)",
"有意增补" in (warn.get("reason") or ""), f"{warn.get('reason')}")
extra_ok = os.path.join(ROOT, "scripts", f"_tmp_extra_ok_{suffix}.xlsx")
TMP_FILES.append(extra_ok)
build_extra_fixture(extra_ok, "车工", "数控车工", "四级", "临时新人二")
rg = R.upload_roster(base, admin, task_id, extra_ok, {"dryRun": "false"})
codes_g = [w.get("code") for w in ((rg.get("data") or {}).get("warnings") or [])]
check("范围**内**的报考项不告警(不能全报)",
"SCOPE_OUT_OF_TASK" not in codes_g, f"{codes_g}")
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
for n, det in FAILED:
print(f" · {n}\n {det}")
print("=" * 76)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except SystemExit:
raise
except Exception as e: # noqa: BLE001
print(f"\n ✗ 脚本异常:{type(e).__name__}: {e}")
code = 1
finally:
cleanup_all()
print(" 已清理:名册 / 任务 / 临时 Excel")
raise SystemExit(code)