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:
+16
-2
@@ -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));
|
||||
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+48
@@ -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";
|
||||
}
|
||||
+13
@@ -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;
|
||||
|
||||
|
||||
+42
@@ -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);
|
||||
}
|
||||
+506
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user