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;
}
}