diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java index 018a198..5ded026 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java @@ -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 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 cells, Map colOfField, List extraCols, - List extraKeys, Long orgId, RosterImportReport report, + List extraKeys, Long orgId, Map confirmedScope, + RosterImportReport report, Map seenInFile, List 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)); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java new file mode 100644 index 0000000..9afe66c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java @@ -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)。 + * + *

权限按契约走「管理」:范围一旦确认,就成了"追加名单要不要告警"的基准(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 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 save(@PathVariable Long id, @RequestBody PlanScopeSaveReq req) { + return ApiResult.ok(planScopeService.save(id, req)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeSaveReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeSaveReq.java new file mode 100644 index 0000000..e8554a7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeSaveReq.java @@ -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 追加告警的开关。 + * + *

⚠️ {@code rosterIds} 的并集必须恰好覆盖名册全员,否则返回 {@code 40024} 与差集。 + * 这条不是形式主义:遗漏任何一人,导出包就会缺人,而漏人在人工核对时极难发现 + * (28 个人的包少 1 个,打开时不会有人数出 27 个)。用接口约束代替人眼核对。 + * + *

所以"排除某人"的正确写法是把他所在的分组标成 {@code EXCLUDED}, + * 而不是把他从 {@code rosterIds} 里删掉 —— 后者会让差集校验失败, + * 而失败提示正好会告诉他"这些人没被覆盖,请明确排除而不是悄悄丢掉"。 + */ +@Getter +@Setter +@Schema(description = "报考范围确认") +public class PlanScopeSaveReq { + + /** 分组列表(通常是把 GET 的结果改一改再回传)。 */ + private List groups = new ArrayList<>(); + + /** + * 已确认可以忽略的问题码。 + * + *

例如官方 Excel 模板里「报考职业」本来就没有下拉,所以 + * {@code SCOPE_FREE_TEXT} 这类提示的意义是"让人确认一下", + * 而不是"必须改到字典里"。忽略要留痕,所以它在保存请求里,而不是前端隐掉。 + */ + private List 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 rosterIds, String status) { + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeVO.java new file mode 100644 index 0000000..7357b51 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/PlanScopeVO.java @@ -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)。 + * + *

它为什么不是"从文件导入的"

+ * + *

PRD V2.0 实测校正:认定机构并不会下发一份计划清单文件, + * 而真实名单里「报考职业 / 报考工种 / 报考等级」三列老师已经填好了(28/28)。 + * 所以:报考范围 = 名册里这三列的去重组合,不需要另立一份文件, + * 也不需要 {@code sys_eval_plan} 那几张表(它们已作废删除)。 + * + *

它的四个用途决定了这里为什么要回这么多东西: + *

    + *
  1. 名单内部一致性 —— 40 人里 38 人报「数控车工」、2 人报「车工」, + * 很可能是填错。所以体检要能指出"是哪几个人"({@code rosterIds}), + * 只说"存在不一致"是没用的。
  2. + *
  3. 追加名单时告警(FR-311)—— 中途补录的人若报了范围外的项, + * 要立刻提示。这要求范围是**已确认的快照**,而不是每次都现算。
  4. + *
  5. 学生端只读展示 —— 核验通过后直接告诉他"你报考:车工 / 数控车工 / 四级"。
  6. + *
  7. 导出写回 —— 汇总导出时原样写回 Excel 模板的 G/H/I 列。
  8. + *
+ * + *

⚠️ 手工修正是保留能力(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 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 rosterIds, String status, String issue) { + } + + /** 体检结论。{@code passed} = 没有任何**未被忽略**的问题。 */ + @Getter + @Setter + @Schema(description = "报考范围一致性体检") + public static class HealthCheck { + + private boolean passed = true; + + private List issues = new ArrayList<>(); + } + + /** + * 一处问题。 + * + *

⚠️ {@code level} 的口径:契约(§7.7)只给了每项的值、没给语义说明, + * 所以这里**只把它当排序与展示用**,判定 {@code passed} 时对所有级别一视同仁 —— + * 契约里 {@code ignoredIssueCodes} 的存在正说明"要不要放过"由人决定, + * 而不是由我们按级别猜一个"≤2 才拦"的规则。 + * + * @param rosterIds 涉及哪几个人。**必须给** —— 只说"存在不一致", + * 管理员没法去核对,而这三个字段老师要逐人改 + */ + public record Issue(int level, String code, String title, String detail, List rosterIds) { + } + + /** 已确认。 */ + public static final String STATUS_CONFIRMED = "CONFIRMED"; + /** 需人工确认(有异常)。 */ + public static final String STATUS_NEED_REVIEW = "NEED_REVIEW"; + /** 已排除,不参与本次认定。 */ + public static final String STATUS_EXCLUDED = "EXCLUDED"; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java index 49a760e..62ea44d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectTask.java @@ -72,6 +72,19 @@ public class CollectTask extends TenantEntity { /** 任务级压缩默认参数,jsonb 原文。 */ private String compressConfig; + /** + * 本期报考范围快照(jsonb 原文)—— 由名册聚合而来(FR-307),管理员可确认/修正(FR-309)。 + * + *

⚠️ 库里的列注释写的形状是 {@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; diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/PlanScopeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/PlanScopeMapper.java new file mode 100644 index 0000000..2710970 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/PlanScopeMapper.java @@ -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)。 + * + *

为什么单独一个 Mapper,而不是塞进 {@link TaskReadinessMapper}: + * 那个 Mapper 的定位是"**只放计数**"的任务发布体检,而这里要的是**逐行的报考三项** + * (聚合与体检都要知道"哪几个人属于哪一组")。两件事的关注点不同, + * 混在一起会让"发布条件"越来越难读。 + * + *

为什么不让 {@code PlanScopeService} 直接注入名册模块的 Mapper: + * 那会让任务模块反向依赖名册模块,而名册的增删改属 M2-01/02 —— 这里只需要"读三个字段", + * 用一条只读查询表达即可,边界清楚。 + */ +public interface PlanScopeMapper { + + /** + * 取本任务名册里做聚合需要的最小字段集。 + * + *

⚠️ 只取 {@code id / seqNo / realName / 报考三项}: + * 聚合与体检都不需要证件号或电话,而多取一个敏感字段就多一次泄露面。 + * + *

返回 {@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> listRowsForScope(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/PlanScopeService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/PlanScopeService.java new file mode 100644 index 0000000..23f2591 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/PlanScopeService.java @@ -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)。 + * + *

为什么是"从名册聚合"而不是"导入一份计划文件"

+ * + *

PRD V2.0 实测校正过:机构不下发计划清单,而真实名单里报考三项 + * 老师已经填好了(28/28 全是 车工 / 数控车工 / 四级中级工)。 + * 所以范围就是这三列的去重组合,`sys_eval_plan` 那几张表已作废删除。 + * + *

这个 Service 存在的唯一理由:把"人眼核对"换成"接口约束"

+ * + *

名单里 40 个人,若 38 人报「数控车工」、2 人报「车工」,那 2 个很可能是填错; + * 但这种错不会有人发现 —— 逐行核 40 行是做不到的(真实批次是几百人)。 + * 所以要机器算出分组、算出异常,并指出是哪几个人。 + * + *

同理,确认时必须恰好覆盖全员(差集报 {@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); + } + + /** + * 聚合 + 体检 + 合并已保存的确认状态。 + * + *

分组用 {@code LinkedHashMap}:顺序 = 名册里第一次出现的顺序, + * 这样管理员看到的组次序和名单一致(对着 Excel 核的时候少一次找)。 + */ + private PlanScopeVO build(Long tenantId, CollectTask task) { + List> rows = planScopeMapper.listRowsForScope(tenantId, task.getId()); + + Map> groupIds = new LinkedHashMap<>(); + Map groupKeys = new LinkedHashMap<>(); + Map rowTriple = new HashMap<>(); + Map nameOf = new HashMap<>(); + Set allIds = new LinkedHashSet<>(); + + for (Map 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 savedStatus = new HashMap<>(); + Set 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 needReview = new HashSet<>(); + for (Map.Entry> 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 issues = healthCheck(task, rows, rowTriple, groupIds); + // 把"涉及哪几个人"补进 detail:只说"存在不一致",管理员没法去核对, + // 而报考三项要老师**逐人**改(真实批次是几百人,找不到人等于没报) + List 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 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 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} 见下)。 + * + *

⚠️ {@code SCOPE_FREE_TEXT}("与字典拼写不符")没有实现,是有理由的: + * 它要一张国标职业字典,而 {@code sys_occupation_ref} 现在一行数据都没有, + * 接入字典本身是 P2(FR-312)。硬编一份"官方职业列表"进代码, + * 会在机构用别的写法时全部误报,而误报会让人整体忽略体检 —— 那比不做更糟。 + */ + private List healthCheck(CollectTask task, List> rows, + Map rowTriple, + Map> groupIds) { + List out = new ArrayList<>(); + + // ① 报考项为空(漏填红色必填项) + List unfilled = new ArrayList<>(); + for (Map.Entry 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>> byOccLevel = new TreeMap<>(); + for (Map.Entry 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>> e : byOccLevel.entrySet()) { + if (e.getValue().size() <= 1) { + continue; + } + // 少数派才算异常:多数派是"正常那一批" + List minor = new ArrayList<>(); + StringBuilder detail = new StringBuilder(); + int max = e.getValue().values().stream().mapToInt(List::size).max().orElse(0); + for (Map.Entry> 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 needWtLv = new ArrayList<>(); + List mustNotHave = new ArrayList<>(); + boolean isSpecial = task.getCertType() != null && task.getCertType() == 3; + for (Map.Entry 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 outOfTask = new ArrayList<>(); + for (Map.Entry 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 allOf(Map> m) { + List all = new ArrayList<>(); + for (List v : m.values()) { + all.addAll(v); + } + return all; + } + + // ── 确认 ──────────────────────────────────────── + + /** + * 确认/修正报考范围。 + * + *

覆盖校验是这一步的**全部价值**:{@code rosterIds} 的并集必须恰好等于名册全员, + * 每个 id 只能出现一次。差集原样返回,让管理员知道漏了谁 —— + * 而不是笼统地说"校验失败"。 + */ + @Transactional(rollbackFor = Exception.class) + public PlanScopeVO save(Long taskId, PlanScopeSaveReq req) { + LoginUser me = UserContext.require(); + CollectTask task = requireTask(taskId); + + List> rows = planScopeMapper.listRowsForScope(me.tenantId(), taskId); + Map nameOf = new LinkedHashMap<>(); + Map tripleOf = new HashMap<>(); + for (Map 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 seen = new HashMap<>(); + List 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 missing = new ArrayList<>(); + List 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 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 + .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 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 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; + } + } + + /** + * 分组键:三项都空值安全。 + * + *

公开是因为名册导入要用**同一个键**去比对已确认的范围(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 ids, Map nameOf, Map tripleOf) { + List 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 ids) { + return ids.size() <= 5 ? ids.toString() : ids.subList(0, 5) + "…"; + } + + private static List nullToEmpty(List 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 row, String name) { + for (Map.Entry 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; + } +} diff --git a/docs/交接摘要.md b/docs/交接摘要.md index c566c86..c14d153 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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 —— +> 见下一节(§一·二十五),那里记录了分析与待拍板的点。 diff --git a/frontend-pc/src/apis/xt/plan-scope.ts b/frontend-pc/src/apis/xt/plan-scope.ts new file mode 100644 index 0000000..d481f61 --- /dev/null +++ b/frontend-pc/src/apis/xt/plan-scope.ts @@ -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(`/tasks/${taskId}/plan-scope`) +} + +export function savePlanScope(taskId: number, data: PlanScopeSaveReq) { + return http.put(`/tasks/${taskId}/plan-scope`, data) +} + +/** 问题码 → 一句短说明(详情服务端已经写了,这里只做标签) */ +export const SCOPE_ISSUE_NAMES: Record = { + SCOPE_UNFILLED: '漏填报考项', + SCOPE_LEVEL_MIXED: '同职业多等级', + SCOPE_TYPEO_MISMATCH: '认定类型与工种/等级不匹配', + SCOPE_OUT_OF_TASK: '任务范围外的职业', + SCOPE_FREE_TEXT: '与字典拼写不符' +} + +/** 分组状态 */ +export const SCOPE_STATUS: Record = { + CONFIRMED: { text: '已确认', color: 'green' }, + NEED_REVIEW: { text: '需确认', color: 'orange' }, + EXCLUDED: { text: '已排除', color: 'gray' } +} diff --git a/frontend-pc/src/views/task/TaskRosterDrawer.vue b/frontend-pc/src/views/task/TaskRosterDrawer.vue index b35f76e..9547430 100644 --- a/frontend-pc/src/views/task/TaskRosterDrawer.vue +++ b/frontend-pc/src/views/task/TaskRosterDrawer.vue @@ -271,7 +271,94 @@ - + + +

+ 报考范围({{ scope?.rosterCount ?? 0 }} 人) + 已于 {{ fmtTime(scope?.updatedAt) }} 确认 + 尚未确认 +
+ + + + + + + + + + +
名册({{ total }} 人) @@ -363,11 +450,14 @@ @@ -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; + } } diff --git a/scripts/e2e-m2-scope.py b/scripts/e2e-m2-scope.py new file mode 100644 index 0000000..4861b6b --- /dev/null +++ b/scripts/e2e-m2-scope.py @@ -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)