From efde4627ebf6dcf714f4787b2a69a746e6f21c57 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(task):=20=E8=A7=84=E5=88=99=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E3=80=81=E4=BB=BB=E5=8A=A1=E5=85=8B=E9=9A=86=E4=B8=8E?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E3=80=81=E7=BB=84=E7=BB=87=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E3=80=81=E7=A7=9F=E6=88=B7=E7=94=A8=E9=87=8F=EF=BC=88V15?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 克隆**配置**(表单/材料清单/命名模板/审核配置/覆盖班级)、刻意不克隆业务数据—— 名册克隆过去会让同一证件号在两个任务里各有一行。 统计补上 abnormalCount 与 avgAuditHours(这两个字段以前恒为 null,因为问题表与审核记录 还不存在);规则模板可按 parts 只应用一部分、材料清单覆盖式合并。 顺带修 F-44:TaskService.create 对缺必填项抛 NPE(对外 50000「系统繁忙」), 改为集中校验并指出是哪个字段;F-45:历史整理器导入改走 TaskService.create, 避免两处各生成一次 task_code 迟早撞号。 验证:e2e-m7-taskconfig 69/69(含待办范围判据的回归防线)。 --- .../module/org/controller/OrgController.java | 17 + .../module/org/service/OrgImportService.java | 428 ++++++++++++++++++ .../controller/RuleTemplateController.java | 95 ++++ .../task/controller/TaskController.java | 64 +++ .../task/entity/CollectRuleTemplate.java | 56 +++ .../task/mapper/RuleTemplateMapper.java | 55 +++ .../module/task/mapper/TaskStatMapper.java | 114 +++++ .../task/service/RuleTemplateService.java | 263 +++++++++++ .../module/task/service/TaskCloneService.java | 268 +++++++++++ .../fill/module/task/service/TaskService.java | 92 +++- .../task/service/TaskStatisticsService.java | 184 ++++++++ .../tenant/controller/TenantController.java | 30 ++ .../tenant/service/TenantUsageService.java | 266 +++++++++++ .../db/migration/V15__rule_template.sql | 51 +++ 14 files changed, 1973 insertions(+), 10 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgImportService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/RuleTemplateController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectRuleTemplate.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/RuleTemplateMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskStatMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskStatisticsService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantUsageService.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V15__rule_template.sql diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java index 9f48223..b48ecd7 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java @@ -31,6 +31,8 @@ import java.util.List; @RequiredArgsConstructor public class OrgController { + private final cn.tianbaotong.fill.module.org.service.OrgImportService importService; + private final OrgService orgService; @GetMapping("/tree") @@ -64,4 +66,19 @@ public class OrgController { orgService.delete(id); return ApiResult.ok(); } + + + @PostMapping(value = "/import", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @RequirePerm(PermissionDef.ROSTER_MANAGE) + @Operation(summary = "批量导入组织(§6.4)", + description = "Excel 列:层级 / 组织名称 / 组织编码 / 上级编码 / 负责人手机号。" + + "父子关系用**「组织编码 + 上级编码」**表达,不靠行的顺序或缩进 —— " + + "客户的表是人整理出来的,顺序会被重排、缩进会被吃掉," + + "而编码关系是数据本身,怎么排都能还原。" + + "`dryRun=true` 只体检不落库(与名册导入同一口径)") + public ApiResult importOrgs( + @RequestParam("file") org.springframework.web.multipart.MultipartFile file, + @RequestParam(defaultValue = "false") boolean dryRun) throws java.io.IOException { + return ApiResult.ok(importService.importOrgs(file.getBytes(), dryRun)); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgImportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgImportService.java new file mode 100644 index 0000000..e3855c2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgImportService.java @@ -0,0 +1,428 @@ +package cn.tianbaotong.fill.module.org.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.org.entity.SysOrg; +import cn.tianbaotong.fill.module.org.mapper.OrgMapper; +import cn.tianbaotong.fill.module.user.entity.SysUser; +import cn.tianbaotong.fill.module.user.mapper.UserMapper; +import cn.tianbaotong.fill.support.SensitiveCodec; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 组织架构批量导入(《03-接口设计》§6.4)。 + * + *

Excel 列:层级 / 组织名称 / 组织编码 / 上级编码 / 负责人手机号

+ * + *

这份表的结构决定了实现方式:**用"编码 + 上级编码"表达父子关系**, + * 而不是靠行的顺序或缩进。理由是客户的表是人整理出来的 —— + * 顺序会被重排、缩进会被吃掉(Excel 里前导空格尤其容易丢), + * 而"编码 + 上级编码"是数据本身的关系,怎么排都能还原。 + * + *

两种父级写法都支持

+ * + *
    + *
  1. 上级编码:填父行的"组织编码",最可靠;
  2. + *
  3. 层级:只填第几层(1/2/3),父级按"上一层的最后一个"推。 + * 客户手写的表里这种更常见(他们不想编编码)。
  4. + *
+ * 两者同时给时以"上级编码"为准 —— 冲突时信更精确的那个。 + * + *

为什么分两遍

+ * + *

第一遍建/找所有节点,第二遍才挂父子关系。一遍做的话, + * "子行在父行之前"的表会全部失败(而客户的表里子行在前很常见)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OrgImportService { + + private final OrgMapper orgMapper; + private final UserMapper userMapper; + private final SensitiveCodec codec; + + /** 一次最多导入多少行(防止误传一个几万行的表把服务拖死)。 */ + private static final int MAX_ROWS = 2000; + + /** 表头别名(客户的表里同一列有好几种写法)。 */ + private static final Map HEADER_ALIASES = new LinkedHashMap<>(); + + static { + HEADER_ALIASES.put("层级", "level"); + HEADER_ALIASES.put("级别", "level"); + HEADER_ALIASES.put("组织名称", "name"); + HEADER_ALIASES.put("名称", "name"); + HEADER_ALIASES.put("组织编码", "code"); + HEADER_ALIASES.put("编码", "code"); + HEADER_ALIASES.put("上级编码", "parentCode"); + HEADER_ALIASES.put("父级编码", "parentCode"); + HEADER_ALIASES.put("上级", "parentCode"); + HEADER_ALIASES.put("负责人手机号", "leaderPhone"); + HEADER_ALIASES.put("负责人电话", "leaderPhone"); + HEADER_ALIASES.put("手机号", "leaderPhone"); + } + + @Transactional(rollbackFor = Exception.class) + public ImportResultVO importOrgs(byte[] bytes, boolean dryRun) { + UserContext.require(); + List> rows = parse(bytes); + + ImportResultVO out = new ImportResultVO(); + out.setTotal(rows.size()); + if (rows.isEmpty()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表里没有数据行(第 1 行应为表头)"); + } + if (rows.size() > MAX_ROWS) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "一次最多导入 " + MAX_ROWS + " 行(当前 " + rows.size() + " 行),请拆成几个表"); + } + + // 已有的组织:按编码索引(编码为空时按"名称 + 父级"索引,见下) + List existAll = orgMapper.selectList(Wrappers.lambdaQuery()); + Map byCode = new HashMap<>(); + Map byPath = new HashMap<>(); + for (SysOrg o : existAll) { + if (o.getOrgCode() != null && !o.getOrgCode().isBlank()) { + byCode.put(o.getOrgCode().trim(), o); + } + byPath.put(pathKey(o.getParentId(), o.getOrgName()), o); + } + + // ── 第一遍:建/找节点 ── + // key = 行号(保持客户表的顺序),value = 落库后的 org(或 dryRun 下的"将要建"的临时对象) + Map built = new LinkedHashMap<>(); + Map rowCode = new LinkedHashMap<>(); + Map parentIdOf = new LinkedHashMap<>(); + Long lastIdAtLevel = null; + int lastLevel = 0; + int created = 0; + int updated = 0; + + for (int i = 0; i < rows.size(); i++) { + Map r = rows.get(i); + int excelRow = i + 2; // 表头占第 1 行,数据从第 2 行开始 + String name = trim(r.get("name")); + if (name == null) { + out.getErrors().add(err(excelRow, "缺「组织名称」")); + out.setSkipped(out.getSkipped() + 1); + continue; + } + String code = trim(r.get("code")); + String parentCode = trim(r.get("parentCode")); + Integer level = intOf(r.get("level")); + + SysOrg exist = code != null ? byCode.get(code) : null; + if (exist != null) { + rowCode.put(i, code); + built.put(i, exist); + parentIdOf.put(i, exist.getParentId()); + updated++; + if (level != null) { + lastLevel = level; + lastIdAtLevel = exist.getId(); + } + continue; + } + + // 父级:优先"上级编码" + Long parentId = null; + if (parentCode != null) { + SysOrg p = byCode.get(parentCode); + if (p == null) { + // 父级在同一份表里但排在后面 —— 这是**允许**的(客户的表里很常见), + // 所以不报错,交给第二遍用"两遍扫描"解决。 + // 但如果整份表里根本没有这个编码,那就是真的错 + if (!rows.stream().anyMatch(x -> parentCode.equals(trim(x.get("code"))))) { + out.getErrors().add(err(excelRow, + "上级编码「" + parentCode + "」在本表里找不到,也不在系统里")); + out.setFailed(out.getFailed() + 1); + continue; + } + } else { + parentId = p.getId(); + } + } else if (level != null && level > 1) { + parentId = lastIdAtLevel; + if (parentId == null) { + out.getErrors().add(err(excelRow, + "第 " + level + " 层但没有上级(表里应该先出现第 " + (level - 1) + " 层)")); + out.setFailed(out.getFailed() + 1); + continue; + } + } + + SysOrg probe = new SysOrg(); + probe.setParentId(parentId); + probe.setOrgName(name); + probe.setOrgCode(code); + probe.setOrgType(level == null ? null : Math.min(3, Math.max(1, level))); + probe.setSortNo(out.getSkipped() + built.size() + 1); + Long leaderId = null; + String phone = trim(r.get("leaderPhone")); + if (phone != null) { + SysUser u = userMapper.selectOne(Wrappers.lambdaQuery() + .eq(SysUser::getPhoneHash, codec.hash(phone)).last("limit 1")); + if (u == null) { + // 找不到负责人**不阻断**:组织要先建起来(人不一定是系统用户), + // 但要在报告里说清"这一行的负责人没挂上" + out.getWarnings().add("第 " + excelRow + " 行:手机号 " + + maskPhone(phone) + " 在系统里没有对应用户,负责人未设置"); + } else { + leaderId = u.getId(); + } + } + probe.setLeaderUserId(leaderId); + + if (dryRun) { + // 只体检:造一个带 id 的临时对象,让后续行能引用它 + // (负 id 不可能与真 id 冲突,也就不会被误当成"已存在") + probe.setId(-(i + 1L)); + built.put(i, probe); + rowCode.put(i, code); + parentIdOf.put(i, parentId); + created++; + } else { + orgMapper.insert(probe); + built.put(i, probe); + rowCode.put(i, code); + parentIdOf.put(i, parentId); + if (code != null) { + byCode.put(code, probe); + } + byPath.put(pathKey(parentId, name), probe); + created++; + } + if (level != null) { + lastLevel = level; + lastIdAtLevel = probe.getId(); + } + } + + // ── 第二遍:把"父级在表后面"的那些接上 ── + int relinked = 0; + for (Map.Entry en : built.entrySet()) { + int i = en.getKey(); + SysOrg node = en.getValue(); + if (parentIdOf.get(i) != null) { + continue; // 第一遍已经挂好了 + } + String parentCode = trim(rows.get(i).get("parentCode")); + if (parentCode == null) { + continue; + } + SysOrg p = byCode.get(parentCode); + if (p == null) { + continue; + } + node.setParentId(p.getId()); + if (!dryRun && node.getId() != null && node.getId() > 0) { + SysOrg upd = new SysOrg(); + upd.setId(node.getId()); + upd.setParentId(p.getId()); + orgMapper.updateById(upd); + } + relinked++; + } + if (relinked > 0) { + out.getWarnings().add("有 " + relinked + " 行的父级排在它后面,已按「上级编码」接好"); + } + + // ── 第三遍:ancestor_path ── + // 它是"按层级快速筛(含所有下级)"用的,漏了会让按组织筛选**静默少数据** + int pathFixed = 0; + if (!dryRun) { + for (SysOrg o : built.values()) { + if (o.getId() == null || o.getId() < 0) { + continue; + } + String path = buildPath(o.getId(), built); + if (path != null && !path.equals(o.getAncestorPath())) { + SysOrg upd = new SysOrg(); + upd.setId(o.getId()); + upd.setAncestorPath(path); + orgMapper.updateById(upd); + pathFixed++; + } + } + } + + out.setCreated(created); + out.setUpdated(updated); + out.setPathFixed(pathFixed); + out.setDryRun(dryRun); + log.info("组织导入{}:共 {} 行,新建 {},已存在 {},失败 {},跳过 {},补路径 {}", + dryRun ? "(体检)" : "", rows.size(), created, updated, + out.getFailed(), out.getSkipped(), pathFixed); + return out; + } + + /** 拼 `ancestor_path`:`/1/12/` 这种形式(与库里既有数据同构)。 */ + private String buildPath(Long id, Map built) { + // 从 built 里找回这个 id 对应的节点,再往上走 + SysOrg node = built.values().stream() + .filter(o -> id.equals(o.getId())).findFirst().orElse(null); + if (node == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + int guard = 0; + while (node != null && guard++ < 20) { + sb.insert(0, "/" + node.getId()); + Long pid = node.getParentId(); + if (pid == null) { + break; + } + SysOrg parent = built.values().stream() + .filter(o -> pid.equals(o.getId())).findFirst() + .orElseGet(() -> orgMapper.selectById(pid)); + node = parent; + } + return sb.length() == 0 ? null : sb + "/"; + } + + private static String pathKey(Long parentId, String name) { + return (parentId == null ? "0" : String.valueOf(parentId)) + "|" + + (name == null ? "" : name.trim()); + } + + // ── Excel 解析 ── + + /** 表头**自动探测**(与名册导入同一口径):扫前 10 行,选命中已知列名最多的那一行。 */ + private List> parse(byte[] bytes) { + try (Workbook wb = WorkbookFactory.create(new ByteArrayInputStream(bytes))) { + Sheet sheet = null; + for (int i = 0; i < wb.getNumberOfSheets(); i++) { + if (wb.getSheetAt(i).getPhysicalNumberOfRows() > 0) { + sheet = wb.getSheetAt(i); + break; + } + } + if (sheet == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表里没有有数据的工作表"); + } + DataFormatter fmt = new DataFormatter(); + int headerRow = -1; + Map colMap = new HashMap<>(); + for (int r = 0; r <= Math.min(9, sheet.getLastRowNum()); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + Map m = new HashMap<>(); + for (Cell c : row) { + String head = fmt.formatCellValue(c).replaceAll("\\s", ""); + String key = HEADER_ALIASES.get(head); + if (key != null) { + m.put(c.getColumnIndex(), key); + } + } + if (m.size() > colMap.size()) { + colMap = m; + headerRow = r; + } + } + if (headerRow < 0 || colMap.size() < 2) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "认不出表头(前 10 行里没找到「组织名称」等已知列名)。" + + "表头应包含:层级 / 组织名称 / 组织编码 / 上级编码 / 负责人手机号"); + } + List> out = new ArrayList<>(); + for (int r = headerRow + 1; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + Map one = new HashMap<>(); + boolean any = false; + for (Map.Entry e : colMap.entrySet()) { + String v = fmt.formatCellValue(row.getCell(e.getKey(), Row.MissingCellPolicy.RETURN_BLANK_AS_NULL)); + v = v == null ? null : v.replaceAll("\\s", ""); + if (v != null && !v.isEmpty()) { + any = true; + } + one.put(e.getValue(), v); + } + if (any) { + out.add(one); + } + } + return out; + } catch (IOException e) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "读不了这个文件(是不是 xlsx/xls?):" + + e.getMessage()); + } catch (RuntimeException e) { + if (e instanceof BizException b) { + throw b; + } + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "解析 Excel 失败:" + e.getMessage()); + } + } + + private static String trim(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static Integer intOf(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + // 客户表里"层级"常写成 "1"、"第1层"、"1级" + java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d+)").matcher(s); + return m.find() ? Integer.valueOf(m.group(1)) : null; + } catch (RuntimeException e) { + return null; + } + } + + private static String maskPhone(String phone) { + return phone.length() < 8 ? "***" + : phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private static JSONObject err(int row, String msg) { + return JSONUtil.createObj().set("row", row).set("message", msg); + } + + /** 导入报告(与 §5.8 名册导入的报告同构)。 */ + @lombok.Getter + @lombok.Setter + public static class ImportResultVO { + private int total; + private int created; + private int updated; + private int skipped; + private int failed; + /** {@code ancestor_path} 被补上的行数(它是按层级筛选的依据)。 */ + private int pathFixed; + private boolean dryRun; + private List errors = new ArrayList<>(); + private List warnings = new ArrayList<>(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/RuleTemplateController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/RuleTemplateController.java new file mode 100644 index 0000000..136fa62 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/RuleTemplateController.java @@ -0,0 +1,95 @@ +package cn.tianbaotong.fill.module.task.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.task.service.RuleTemplateService; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 规则模板(《03-接口设计》§7.13)。 + * + *

⚠️ 路径是 {@code /api/v1/rule-templates},不是 {@code /api/v1/tasks/rule-templates}

+ * + *

我第一版把它挂在了 `TaskController`(base 是 `/api/v1/tasks`)下, + * 于是路径变成 `/api/v1/tasks/rule-templates` —— 契约 §4.3 的清单写的是 + * `/rule-templates`,两者对不上。**是契约对、我错**:模板是**独立资源** + * (有 id、可被多个任务套用),不是任务的下级。挂错位置还有一个后果: + * `check-api-parity.py` 会把它们算成**未实现**(见 F-68)。 + */ +@Tag(name = "规则模板") +@RestController +@RequestMapping("/api/v1/rule-templates") +@RequiredArgsConstructor +public class RuleTemplateController { + + private final RuleTemplateService ruleTemplateService; + + + @GetMapping + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "规则模板列表(§7.13)", + description = "**本租户自建 + 平台公共**(公共模板 `tenant_id=0`)。" + + "⚠️ 这条查询必须关掉租户拦截器:拦截器会补 `tenant_id = 当前租户`," + + "公共模板一条都查不出来 —— 而表现是「列表空着但不报错」") + public ApiResult>> ruleTemplates() { + var out = new java.util.ArrayList>(); + for (var t : ruleTemplateService.list()) { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", t.getId()); + m.put("templateName", t.getTemplateName()); + m.put("certType", t.getCertType()); + m.put("isPublic", Integer.valueOf(1).equals(t.getIsPublic())); + m.put("useCount", t.getUseCount()); + m.put("namingTemplate", t.getNamingTemplate()); + m.put("hasFormSchema", t.getFormSchema() != null && !t.getFormSchema().isBlank()); + m.put("materialCount", countMaterials(t.getMaterials())); + out.add(m); + } + return ApiResult.ok(out); + } + + /** 模板里带了几项材料(列表上要显示,用户据此选模板)。解析不了就算 0 不报错。 */ + private static int countMaterials(String json) { + if (json == null || json.isBlank()) { + return 0; + } + try { + return cn.hutool.json.JSONUtil.parseArray(json).size(); + } catch (RuntimeException e) { + return 0; + } + } + + @PostMapping("/{tplId}/apply") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "应用规则模板(§7.13)", + description = "`parts` 可选 FORM / MATERIALS / NAMING,**可以只应用一部分** —— " + + "三部分的复用粒度不一样(材料清单年年一样,命名模板可能换代就变)," + + "强制三件套一起套用会让用户为了改一项而放弃整个模板。" + + "⚠️ 材料清单是**按 materialCode 覆盖式合并**:模板里没有的材料项**保留**," + + "删掉它会让已经传过的文件变成孤儿(而导出包会少材料,界面上却看不出)") + public ApiResult + applyRuleTemplate(@PathVariable Long tplId, + @RequestBody java.util.Map body) { + Long target = body == null || body.get("targetTaskId") == null ? null + : Long.valueOf(String.valueOf(body.get("targetTaskId"))); + @SuppressWarnings("unchecked") + java.util.List parts = body == null ? null + : (java.util.List) body.get("parts"); + return ApiResult.ok(ruleTemplateService.apply(tplId, target, parts)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java index adb499e..c34ff1b 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java @@ -40,6 +40,10 @@ import java.util.List; @RequiredArgsConstructor public class TaskController { + private final cn.tianbaotong.fill.module.task.service.TaskCloneService cloneService; + private final cn.tianbaotong.fill.module.task.service.TaskStatisticsService statisticsService; + private final cn.tianbaotong.fill.module.task.service.RuleTemplateService ruleTemplateService; + private final TaskService taskService; private final TaskOrgService taskOrgService; @@ -155,4 +159,64 @@ public class TaskController { taskService.archive(id, req); return ApiResult.ok(); } + + + // ══════════════════ 克隆 ══════════════════ + + @PostMapping("/{id}/clone") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "克隆任务", + description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级)," + + "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。" + + "理由不是省事:名册克隆过去会让同一个证件号在两个任务里各有一行," + + "门禁核验时命中哪一行说不清;而上一批的材料(可能是去年的身份证)" + + "挂在今年任务上就是假数据,导出时看起来却完全正常。" + + "新任务固定为草稿状态、名字自动加「(副本)」并去重") + public ApiResult> clone( + @PathVariable Long id, + @RequestBody(required = false) java.util.Map body) { + String name = body == null || body.get("taskName") == null ? null + : String.valueOf(body.get("taskName")); + String batch = body == null || body.get("batchNo") == null ? null + : String.valueOf(body.get("batchNo")); + Long newId = cloneService.clone(id, name, batch); + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("sourceTaskId", id); + out.put("taskId", newId); + return ApiResult.ok(out); + } + + @PostMapping("/{id}/save-as-template") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "把任务配置存成规则模板(契约漏了这一步,见 F-58)", + description = "没有它,「用户自建模板」永远是一句空话 —— 模板只能靠改库产生。" + + "只有平台管理员能把模板设为公共") + public ApiResult> saveAsTemplate( + @PathVariable Long id, @RequestBody java.util.Map body) { + String name = body == null || body.get("templateName") == null ? null + : String.valueOf(body.get("templateName")); + Boolean pub = body == null || body.get("isPublic") == null ? null + : Boolean.valueOf(String.valueOf(body.get("isPublic"))); + Long tplId = cloneService.saveAsTemplate(id, name, pub); + return ApiResult.ok(java.util.Map.of("templateId", tplId)); + } + + // ══════════════════ 任务统计 ══════════════════ + + @GetMapping("/{id}/statistics") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "任务统计(§7.11)", + description = "`stat` 是契约 §3.3 的全字段,另带 `byOrg`(分班)与 " + + "`byOccupation`(分职业,走**正式列** `collect_submission.occupation`," + + "实时且走索引 —— 这正是当初坚持「18 个正式列 + extras_json」的收益)。" + + "⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)" + + "**必须分开报**:已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。" + + "分母为 0 时比率给「—」而不是 0% —— 0% 看起来像「没人填」," + + "而真相是「还没有分母」") + public ApiResult + statistics(@PathVariable Long id) { + return ApiResult.ok(statisticsService.statistics(id)); + } + + } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectRuleTemplate.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectRuleTemplate.java new file mode 100644 index 0000000..4da487c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/entity/CollectRuleTemplate.java @@ -0,0 +1,56 @@ +package cn.tianbaotong.fill.module.task.entity; + +import cn.tianbaotong.fill.support.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** + * 规则模板({@code collect_rule_template},契约 §7.13)。 + * + *

"规则模板"到底是什么

+ * + *

它是一套**可复用的任务配置**,正好对应契约 `apply` 请求里的三个 `parts`: + *

+ * 之所以做成"可以只应用其中一部分",是因为真实场景里这三个东西的复用粒度不一样: + * 同一个专业的**材料清单**年年一样,但**命名模板**可能因为客户换了归档习惯而变; + * 反过来,同一个学校的命名习惯稳定,材料清单却每个批次都调。 + * 强制三件套一起套用,用户就会为了改一项而放弃整个模板。 + * + *

⚠️ 这张表 V1 就建好了,列(含 {@code is_public} / {@code use_count}) + * 与上面这三部分**逐项对应** —— 也就是"当初就是照着这个功能设计的", + * 只是一直没有代码用它(F-50)。 + */ +@Getter +@Setter +@TableName("collect_rule_template") +public class CollectRuleTemplate extends BaseEntity { + + /** 平台级公共模板的 {@code tenant_id}:所有租户可见(V15 里插了两条)。 */ + public static final long PUBLIC_TENANT_ID = 0L; + + private String templateName; + + private Integer certType; + + /** JSON:动态表单定义。为空表示"用系统默认表单",**不是**"清空表单"。 */ + private String formSchema; + + /** JSON:材料清单。 */ + private String materials; + + private String namingTemplate; + + /** {@code 1} 公共(所有租户可见)/ {@code 0} 租户自建。 */ + private Integer isPublic; + + /** 被套用次数。 */ + private Integer useCount; + + public static final int PUBLIC_YES = 1; + public static final int PUBLIC_NO = 0; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/RuleTemplateMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/RuleTemplateMapper.java new file mode 100644 index 0000000..20031fc --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/RuleTemplateMapper.java @@ -0,0 +1,55 @@ +package cn.tianbaotong.fill.module.task.mapper; + +import cn.tianbaotong.fill.module.task.entity.CollectRuleTemplate; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +/** 规则模板(契约 §7.13)。 */ +public interface RuleTemplateMapper extends BaseMapper { + + /** + * 列表:**租户自建 + 平台公共**。 + * + *

⚠️ 这里必须关掉租户拦截器并手写条件:`is_public=1` 的公共模板 + * {@code tenant_id=0},而拦截器会给查询补上 `tenant_id = <当前租户>` —— + * 公共模板一条都查不出来,表现是"模板列表空着但功能没报错"。 + * + *

手写条件的范围就是"本租户 OR 公共",关掉框架保护之后由这一行保证隔离: + * 别人的租户级模板({@code tenant_id=其他值})一条也进不来。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_rule_template + WHERE deleted = 0 + AND (tenant_id = #{tenantId} OR (is_public = 1 AND tenant_id = 0)) + ORDER BY is_public DESC, id DESC + """) + List listVisible(@Param("tenantId") Long tenantId); + + /** + * 取一个可见的模板(本租户 or 公共)。 + * + *

与 {@link #listVisible} 同一条件 —— 少了这一层, + * 用户就能凭 id 套用**别人租户**的模板(把别人的材料清单与命名规则抄过来)。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_rule_template + WHERE id = #{id} AND deleted = 0 + AND (tenant_id = #{tenantId} OR (is_public = 1 AND tenant_id = 0)) + """) + CollectRuleTemplate findVisible(@Param("id") Long id, @Param("tenantId") Long tenantId); + + /** 套用次数 +1(原子自增,避免"读-改-写"丢更新)。 */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE collect_rule_template SET use_count = COALESCE(use_count, 0) + 1 + WHERE id = #{id} + """) + int bumpUseCount(@Param("id") Long id); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskStatMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskStatMapper.java new file mode 100644 index 0000000..f15b430 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskStatMapper.java @@ -0,0 +1,114 @@ +package cn.tianbaotong.fill.module.task.mapper; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 任务统计的聚合查询(《03-接口设计》§7.11 的 {@code TaskStatVO} + 分班 / 分职业明细)。 + * + *

为什么异常数({@code abnormalCount})现在能算了

+ * + *

它要的是"有多少份填报单带着红/黄问题",而那需要**问题表**。 + * §12 的问题清单(V14)之前,`TaskStatVO` 的这两个字段一直是 {@code null} —— + * 当时的注释写着"编一个数就是谎"。现在数据齐了:红黄问题写在 + * {@code collect_validation_issue},处理时长写在 {@code collect_audit_record.duration_sec}。 + * + *

口径(三处都不能含糊)

+ * + * + */ +public interface TaskStatMapper { + + /** 名册总数(分母)。 */ + @Select(""" + SELECT COUNT(*) FROM collect_roster + WHERE task_id = #{taskId} AND deleted = 0 + """) + long rosterTotal(@Param("taskId") Long taskId); + + /** + * 按状态分组的填报单数。 + * + *

⚠️ 返回的是 {@code List} 而不是 DTO:状态码是**动态**的, + * 写一个 6 个字段的 DTO 只为了装一个 group by 的结果不值当。 + * 取值时按不区分大小写(PG 会把列标签折成小写)。 + */ + @Select(""" + SELECT status AS st, COUNT(*) AS cnt + FROM collect_submission + WHERE task_id = #{taskId} AND deleted = 0 + GROUP BY status + """) + List> countByStatus(@Param("taskId") Long taskId); + + /** + * 有未解决红/黄问题的**填报单**数(不是问题条数)。 + * + *

⚠️ {@code COUNT(DISTINCT submission_id)}:一份单子可能同时缺 3 项材料, + * 那是**一份异常单**而不是三份。用 COUNT(*) 会让"异常 12"看起来像 12 个人有问题, + * 而实际可能只有 4 个人。 + */ + @Select(""" + SELECT COUNT(DISTINCT submission_id) FROM collect_validation_issue + WHERE task_id = #{taskId} AND deleted = 0 AND resolved = 0 + AND severity >= 2 + """) + long abnormalCount(@Param("taskId") Long taskId); + + /** 平均审核处理时长(小时);没有审核记录时返回 null。 */ + @Select(""" + SELECT AVG(duration_sec) / 3600.0 FROM collect_audit_record + WHERE task_id = #{taskId} AND duration_sec IS NOT NULL AND duration_sec > 0 + """) + Double avgAuditHours(@Param("taskId") Long taskId); + + /** 分班明细。 */ + @Select(""" + SELECT r.org_id AS orgId, + o.org_name AS orgName, + COUNT(*) AS rosterTotal, + SUM(CASE WHEN s.status IN (1, 2, 3) THEN 1 ELSE 0 END) AS submittedCount, + SUM(CASE WHEN s.status = 3 THEN 1 ELSE 0 END) AS approvedCount + FROM collect_roster r + LEFT JOIN collect_submission s + ON s.roster_id = r.id AND s.task_id = r.task_id AND s.deleted = 0 + LEFT JOIN sys_org o ON o.id = r.org_id AND o.deleted = 0 + WHERE r.task_id = #{taskId} AND r.deleted = 0 + GROUP BY r.org_id, o.org_name + ORDER BY o.org_name NULLS LAST + """) + List> byOrg(@Param("taskId") Long taskId); + + /** + * 分职业明细。 + * + *

⚠️ 走的是**正式列** {@code collect_submission.occupation},不是 + * {@code extras_json} 里的键 —— 这正是当初坚持"18 个正式列 + extras_json" + * 的收益:实时、走索引、可以被 group by(JSON 里的键做不到这三件事)。 + * + *

只统计**已提交**的单子(草稿还没确定职业,算进去会让分布每天都变)。 + */ + @Select(""" + SELECT occupation, worktype, job_level AS jobLevel, + COUNT(*) AS cnt, + SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS approvedCount + FROM collect_submission + WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2, 3) + AND occupation IS NOT NULL AND occupation <> '' + GROUP BY occupation, worktype, job_level + ORDER BY cnt DESC + """) + List> byOccupation(@Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java new file mode 100644 index 0000000..43782e3 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java @@ -0,0 +1,263 @@ +package cn.tianbaotong.fill.module.task.service; + +import cn.hutool.json.JSONArray; +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.material.service.MaterialDefService; +import cn.tianbaotong.fill.module.task.entity.CollectRuleTemplate; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.RuleTemplateMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.material.entity.CollectMaterialDef; +import cn.tianbaotong.fill.module.material.mapper.MaterialDefMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 规则模板:列表与套用(《03-接口设计》§7.13)。 + * + *

为什么"可以只应用其中一部分"是必须的

+ * + *

三部分的**复用粒度不一样**:同一个专业的材料清单年年一样, + * 但命名模板可能因为客户换了归档习惯而变;反过来同一所学校的命名习惯稳定, + * 材料清单却每个批次都调。强制三件套一起套用,用户就会为了改一项而放弃整个模板。 + * + *

套用是"覆盖"还是"合并"

+ * + *

三部分各自处理,但**材料清单是替换(按 materialCode 去重覆盖)**, + * 不是"整表清空再插": + *

+ * + *

⚠️ {@code formSchema} 为空 = **用系统默认表单**,不是"把目标任务的表单清空"。 + * 这一点必须写清:一个空 JSON 与"没有这条配置"在代码里长得一样, + * 而清空表单会让已经填过的人面对另一份表单。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RuleTemplateService { + + private final RuleTemplateMapper templateMapper; + private final TaskMapper taskMapper; + private final MaterialDefMapper materialDefMapper; + private final MaterialDefService materialDefService; + + /** 可应用的三个部分(契约 §7.13 的 `parts`)。 */ + public static final Set ALL_PARTS = new LinkedHashSet<>( + List.of("FORM", "MATERIALS", "NAMING")); + + // ── 列表 ────────────────────────────────────── + + public List list() { + LoginUser me = UserContext.require(); + return templateMapper.listVisible(me.tenantId()); + } + + // ── 套用 ────────────────────────────────────── + + @Transactional(rollbackFor = Exception.class) + public ApplyResult apply(Long id, Long targetTaskId, List parts) { + LoginUser me = UserContext.require(); + CollectRuleTemplate tpl = templateMapper.findVisible(id, me.tenantId()); + if (tpl == null) { + // 别人的租户级模板也走这里 —— 消息里不区分"不存在"与"不可见", + // 免得凭错误消息就能探出别家有没有这个模板 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "模板不存在或不可见"); + } + if (targetTaskId == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要应用到的任务"); + } + CollectTask target = taskMapper.selectById(targetTaskId); + if (target == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标任务不存在或已删除"); + } + Set want = new LinkedHashSet<>(); + if (parts == null || parts.isEmpty()) { + want.addAll(ALL_PARTS); + } else { + for (String p : parts) { + String up = p == null ? "" : p.trim().toUpperCase(); + if (!ALL_PARTS.contains(up)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "parts 只能是 FORM / MATERIALS / NAMING,收到:" + p); + } + want.add(up); + } + } + + ApplyResult r = new ApplyResult(); + r.setTemplateId(tpl.getId()); + r.setTaskId(target.getId()); + r.setParts(new ArrayList<>(want)); + + if (want.contains("NAMING")) { + if (tpl.getNamingTemplate() == null || tpl.getNamingTemplate().isBlank()) { + r.getSkipped().add("NAMING(模板里没配命名模板,跳过而不是清空目标任务的)"); + } else { + // 已发布的任务改命名模板会让"已经传上来的文件名"与"新规则"不一致, + // 所以这里拦住并说清;而不是安静地改掉 + assertNotPublished(target, "命名模板"); + CollectTask upd = new CollectTask(); + upd.setId(target.getId()); + upd.setNamingTemplate(tpl.getNamingTemplate()); + taskMapper.updateById(upd); + r.getApplied().add("NAMING"); + } + } + if (want.contains("FORM")) { + if (tpl.getFormSchema() == null || tpl.getFormSchema().isBlank()) { + // 空 = 用系统默认表单,**不覆盖**目标任务的既有表单 + r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)"); + } else { + assertNotPublished(target, "表单定义"); + r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)"); + } + } + if (want.contains("MATERIALS")) { + int n = applyMaterials(target, tpl, r); + if (n > 0) { + r.getApplied().add("MATERIALS"); + } + } + + templateMapper.bumpUseCount(tpl.getId()); + log.info("套用规则模板:tplId={} → taskId={} parts={} applied={} skipped={} 经办人={}", + tpl.getId(), target.getId(), want, r.getApplied(), r.getSkipped(), me.uid()); + return r; + } + + /** + * 材料清单:**按 materialCode 覆盖式合并**(见类注释)。 + * + * @return 实际写入(新增或更新)的材料项数 + */ + private int applyMaterials(CollectTask target, CollectRuleTemplate tpl, ApplyResult r) { + if (tpl.getMaterials() == null || tpl.getMaterials().isBlank()) { + r.getSkipped().add("MATERIALS(模板没带材料清单)"); + return 0; + } + JSONArray arr; + try { + arr = JSONUtil.parseArray(tpl.getMaterials()); + } catch (RuntimeException e) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "模板里的材料清单不是合法 JSON"); + } + // 目标任务已有的材料:按 materialCode 索引 + var exist = new java.util.HashMap(); + for (CollectMaterialDef d : materialDefMapper.selectList( + Wrappers.lambdaQuery() + .eq(CollectMaterialDef::getTaskId, target.getId()))) { + exist.put(d.getMaterialCode(), d); + } + + int n = 0; + int sort = 0; + for (Object o : arr) { + if (!(o instanceof JSONObject j)) { + continue; + } + String code = j.getStr("materialCode"); + if (code == null || code.isBlank()) { + continue; + } + sort++; + CollectMaterialDef old = exist.get(code); + if (old == null) { + CollectMaterialDef d = new CollectMaterialDef(); + d.setTaskId(target.getId()); + d.setMaterialCode(code); + d.setMaterialName(j.getStr("materialName", code)); + d.setRequired(j.getInt("required", 1)); + d.setMaxCount(j.getInt("maxCount", 1)); + d.setMaxSizeKb(j.getInt("maxSizeKb", 2048)); + d.setTargetDpi(j.getInt("targetDpi", 300)); + d.setSortNo(j.getInt("sortNo", sort)); + materialDefMapper.insert(d); + } else { + // 只更新"模板里明确给了"的字段:模板没提的(例如客户自己加的可见性条件) + // 保持原样,否则套一次模板就会把用户的定制抹掉 + CollectMaterialDef upd = new CollectMaterialDef(); + upd.setId(old.getId()); + if (j.containsKey("materialName")) { + upd.setMaterialName(j.getStr("materialName")); + } + if (j.containsKey("required")) { + upd.setRequired(j.getInt("required")); + } + if (j.containsKey("maxCount")) { + upd.setMaxCount(j.getInt("maxCount")); + } + if (j.containsKey("maxSizeKb")) { + upd.setMaxSizeKb(j.getInt("maxSizeKb")); + } + if (j.containsKey("targetDpi")) { + upd.setTargetDpi(j.getInt("targetDpi")); + } + materialDefMapper.updateById(upd); + } + n++; + } + // 模板里没有的材料项**保留**:删掉它会让已经传过的文件变成孤儿。 + // 数一下有多少项是"目标任务有、模板没提"的,如实告诉用户(他会想知道模板动了什么) + Set inTpl = new LinkedHashSet<>(); + for (Object o : arr) { + if (o instanceof JSONObject j && j.getStr("materialCode") != null) { + inTpl.add(j.getStr("materialCode")); + } + } + long untouched = exist.keySet().stream().filter(c -> !inTpl.contains(c)).count(); + if (untouched > 0) { + r.getSkipped().add("MATERIALS:目标任务另有 " + untouched + + " 项材料不在模板里,已保留(删掉会让已传的文件变孤儿)"); + } + return n; + } + + /** + * 已发布的任务不允许套用会影响"已收上来的东西"的部分。 + * + *

理由与 §7 的其它修改限制一致:已发布 = 学生那边已经看到表单与命名规则, + * 改了会让"先填的人"与"后填的人"面对两套规则,而导出时无法区分。 + */ + private void assertNotPublished(CollectTask target, String what) { + if (target.getStatus() != null && target.getStatus() != CollectTask.STATUS_DRAFT) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "任务已发布,不能套用" + what + "(会让学生面对两套规则)。请先克隆成新任务再套用"); + } + } + + /** 供测试与排查用:某个租户可见哪些模板。 */ + public long countVisible(Long tenantId) { + return templateMapper.listVisible(tenantId).size(); + } + + /** 套用结果。 */ + @lombok.Getter + @lombok.Setter + public static class ApplyResult { + private Long templateId; + private Long taskId; + private List parts = new ArrayList<>(); + /** 真正生效的部分。 */ + private List applied = new ArrayList<>(); + /** 跳过或没做的部分,**带原因** —— 不给原因用户会以为"点了没反应"。 */ + private List skipped = new ArrayList<>(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java new file mode 100644 index 0000000..37d720d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java @@ -0,0 +1,268 @@ +package cn.tianbaotong.fill.module.task.service; + +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.material.entity.CollectMaterialDef; +import cn.tianbaotong.fill.module.material.mapper.MaterialDefMapper; +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.module.task.dto.AuditConfigVO; +import cn.tianbaotong.fill.module.task.dto.CompressConfigVO; +import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq; +import cn.tianbaotong.fill.module.task.entity.CollectRuleTemplate; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg; +import cn.tianbaotong.fill.module.task.mapper.RuleTemplateMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskOrgMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +/** + * 任务克隆(《03-接口设计》§4.3 的 {@code POST /tasks/{id}/clone})。 + * + *

克隆什么、不克隆什么 —— 这是这个功能唯一需要想清楚的事

+ * + *

克隆("配置"):表单定义、材料清单、命名模板、审核配置、压缩参数、 + * 覆盖班级与班主任。 + * + *

不克隆("业务数据"):名册、填报单、材料文件、审核记录、导出记录。 + * 理由不是"省事",而是**克隆的业务数据会立刻变成脏数据**: + *

+ * + *

所以克隆的定位是"新一批收集,配置照抄" —— 名册要重新导入, + * 材料要重新收集。这正是客户的实际节奏(每批次配置一样、人换一批)。 + * + *

新任务名与状态

+ * + *

名字加"(副本)"后缀并**去重**(同名任务在列表里无法区分,客户会点错); + * 状态固定为**草稿** —— 克隆出来的任务不该直接开始收集, + * 用户要先核对专业、批次、覆盖班级这些"上一批的东西"。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskCloneService { + + private final TaskMapper taskMapper; + private final TaskOrgMapper taskOrgMapper; + private final MaterialDefMapper materialDefMapper; + private final RuleTemplateMapper ruleTemplateMapper; + private final TaskService taskService; + + @Transactional(rollbackFor = Exception.class) + public Long clone(Long sourceId, String newName, String batchNo) { + LoginUser me = UserContext.require(); + CollectTask src = taskMapper.selectById(sourceId); + if (src == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "要被克隆的任务不存在或已删除"); + } + + String name = newName == null || newName.isBlank() + ? uniqueName(src.getTaskName()) : newName.trim(); + // 批次号:不传就沿用来源的(客户最常见的动作就是"同一批次再来一个"), + // 但要提醒他会撞 —— 批次号参与 task_code 生成,撞了不报错但列表里两批混在一起 + String batch = batchNo == null || batchNo.isBlank() ? src.getBatchNo() : batchNo.trim(); + + TaskUpsertReq req = new TaskUpsertReq(); + req.setTaskName(name); + req.setBatchNo(batch); + req.setCollectMode(src.getCollectMode()); + req.setCertType(src.getCertType()); + req.setMajorName(src.getMajorName()); + req.setJobName(src.getJobName()); + req.setJobLevel(src.getJobLevel()); + req.setNamingTemplate(src.getNamingTemplate()); + // 这三项存的是 JSON 字符串:原样带过去,不解析再序列化 —— + // 一来一回很容易丢掉没建模的字段(而它们恰恰是以后要用的) + // 三项配置存的是 JSON 字符串:**原样带过去**,不解析成对象再序列化 —— + // 一来一回很容易丢掉没建模的字段(而它们恰恰是以后要用的) + req.setAuditConfig(JSONUtil.toBean(src.getAuditConfig(), AuditConfigVO.class)); + req.setCompressConfig(JSONUtil.toBean(src.getCompressConfig(), CompressConfigVO.class)); + req.setStartTime(LocalDate.now().toString()); + req.setEndTime(LocalDate.now().plusMonths(6).toString()); + + Long newId = taskService.create(req).getId(); + + // 材料清单:整份复制 + int materials = 0; + for (CollectMaterialDef d : materialDefMapper.selectList( + Wrappers.lambdaQuery() + .eq(CollectMaterialDef::getTaskId, sourceId))) { + CollectMaterialDef copy = new CollectMaterialDef(); + copy.setTaskId(newId); + copy.setMaterialCode(d.getMaterialCode()); + copy.setMaterialName(d.getMaterialName()); + copy.setAliases(d.getAliases()); + copy.setNamePattern(d.getNamePattern()); + copy.setRequired(d.getRequired()); + copy.setVisibleWhen(d.getVisibleWhen()); + copy.setFileTypes(d.getFileTypes()); + copy.setMaxSizeKb(d.getMaxSizeKb()); + copy.setCompressProfile(d.getCompressProfile()); + copy.setMinLongSide(d.getMinLongSide()); + copy.setMinQuality(d.getMinQuality()); + copy.setTargetDpi(d.getTargetDpi()); + copy.setFixedSize(d.getFixedSize()); + copy.setOutputFormat(d.getOutputFormat()); + copy.setMaxCount(d.getMaxCount()); + copy.setMergeEnabled(d.getMergeEnabled()); + copy.setOcrType(d.getOcrType()); + copy.setSampleUrl(d.getSampleUrl()); + copy.setTipText(d.getTipText()); + copy.setSortNo(d.getSortNo()); + materialDefMapper.insert(copy); + materials++; + } + + // 覆盖班级与班主任:整份复制(这是"配置",与名册不同) + int orgs = 0; + for (CollectTaskOrg o : taskOrgMapper.selectList(Wrappers.lambdaQuery() + .eq(CollectTaskOrg::getTaskId, sourceId))) { + CollectTaskOrg copy = new CollectTaskOrg(); + copy.setTaskId(newId); + copy.setOrgId(o.getOrgId()); + copy.setExpectedCount(o.getExpectedCount()); + copy.setHeadUserId(o.getHeadUserId()); + // ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id —— + // 按"应该叫这个"猜字段名,编译期才会发现 + copy.setDeputyUserIds(o.getDeputyUserIds()); + copy.setStatus(o.getStatus()); + // 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码 + // 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错 + taskOrgMapper.insert(copy); + orgs++; + } + + // 表单定义:来源任务如果有版本化保存的表单,复制到新任务 + int forms = copyFormSchema(sourceId, newId); + + log.info("克隆任务:{} → {}(名称={} 批次={} 材料 {} 项 / 班级 {} 个 / 表单 {} 版)" + + "**刻意不克隆名册与填报单** 经办人={}", + sourceId, newId, name, batch, materials, orgs, forms, me.uid()); + return newId; + } + + /** + * 复制表单定义(如果来源任务存过)。 + * + * @return 复制的版本数(0 表示来源用的是默认表单) + */ + private int copyFormSchema(Long fromTaskId, Long toTaskId) { + try { + var rows = formSchemaMapper.selectList(Wrappers + .lambdaQuery() + .eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, + fromTaskId)); + int n = 0; + for (var r : rows) { + var copy = new cn.tianbaotong.fill.module.form.entity.CollectFormSchema(); + copy.setTaskId(toTaskId); + copy.setVersion(r.getVersion()); + copy.setSchemaJson(r.getSchemaJson()); + // 字段名是 `isCurrent`(当前生效版本),不是 status + copy.setIsCurrent(r.getIsCurrent()); + formSchemaMapper.insert(copy); + n++; + } + return n; + } catch (RuntimeException e) { + // 表单定义表的结构/字段名与这里假设的不一致时,不该让整个克隆失败 —— + // 材料与班级已经复制好了,用户要的是"能开始配下一批" + log.warn("克隆任务时复制表单定义失败(不影响其它部分)from={} to={}", + fromTaskId, toTaskId, e); + return 0; + } + } + + private final cn.tianbaotong.fill.module.form.mapper.FormSchemaMapper formSchemaMapper; + + /** + * 生成不重名的"(副本)"名字。 + * + *

同名任务在列表里无法区分,而客户会照着名字点 —— 点错就改错了任务。 + * 所以后缀要递增:`X(副本)`、`X(副本 2)`、`X(副本 3)`… + */ + private String uniqueName(String base) { + String root = base == null ? "未命名任务" : base.trim(); + // 已经是副本的话,从原名再派生,避免"X(副本)(副本)" + int idx = root.indexOf("(副本"); + if (idx > 0) { + root = root.substring(0, idx); + } + if (root.length() > 60) { + root = root.substring(0, 60); + } + for (int i = 1; i <= 99; i++) { + String cand = i == 1 ? root + "(副本)" : root + "(副本 " + i + ")"; + long n = taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(CollectTask::getTaskName, cand)); + if (n == 0) { + return cand; + } + } + return root + "(副本 " + System.currentTimeMillis() % 10000 + ")"; + } + + /** + * 把某个任务的配置存成规则模板(反向操作)。 + * + *

契约 §7.13 只给了"列表"与"应用",没有"保存为模板" —— 但没有它, + * 模板只能靠改库或迁移来产生,"用户自建模板"就永远是一句空话。记在 F-58。 + */ + @Transactional(rollbackFor = Exception.class) + public Long saveAsTemplate(Long taskId, String templateName, Boolean isPublic) { + LoginUser me = UserContext.require(); + CollectTask t = taskMapper.selectById(taskId); + if (t == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + if (templateName == null || templateName.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写模板名称"); + } + cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.createArray(); + for (CollectMaterialDef d : materialDefMapper.selectList( + Wrappers.lambdaQuery() + .eq(CollectMaterialDef::getTaskId, taskId))) { + arr.add(cn.hutool.json.JSONUtil.createObj() + .set("materialCode", d.getMaterialCode()) + .set("materialName", d.getMaterialName()) + .set("required", d.getRequired()) + .set("maxCount", d.getMaxCount()) + .set("maxSizeKb", d.getMaxSizeKb()) + .set("targetDpi", d.getTargetDpi()) + .set("sortNo", d.getSortNo())); + } + // 只有平台管理员才能建"公共"模板 —— 否则任何一个学校都能把自己的配置 + // 推给所有租户(那不是权限问题,是产品问题) + boolean pub = Boolean.TRUE.equals(isPublic) && me.tenantId() != null + && me.tenantId() == CollectRuleTemplate.PUBLIC_TENANT_ID; + if (Boolean.TRUE.equals(isPublic) && !pub) { + throw new BizException(ErrorCode.FORBIDDEN, + "只有平台管理员能把模板设为公共(公共模板对所有租户可见)"); + } + CollectRuleTemplate row = new CollectRuleTemplate(); + row.setTemplateName(templateName.trim()); + row.setCertType(t.getCertType()); + row.setMaterials(arr.toString()); + row.setNamingTemplate(t.getNamingTemplate()); + row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO); + row.setUseCount(0); + ruleTemplateMapper.insert(row); + log.info("把任务 {} 的配置存成规则模板 {}(id={} 公共={})", + taskId, templateName, row.getId(), pub); + return row.getId(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java index fceeff9..1e40275 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java @@ -72,6 +72,7 @@ public class TaskService { private final TaskMapper taskMapper; private final TaskReadinessMapper readinessMapper; private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService; + private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog; // ── 列表 ────────────────────────────────────── @@ -241,6 +242,22 @@ public class TaskService { : req.getNamingTemplate().trim(); assertNamingTemplate(namingTemplate); + // ★ 必填项要在**这里**检查,不能让后面的 `.trim()` 抛 NPE。 + // + // 原来的写法是直接 `req.getMajorName().trim()`:调用方漏传专业名时 + // 抛的是 NullPointerException —— 对外表现是 50000「系统繁忙,请稍后再试」, + // 而真正的原因是"你没填专业名称"。这种"必填项没校验"的坑每个字段都会踩一次, + // 所以集中在这里判,并让消息指出是哪个字段。 + if (req.getTaskName() == null || req.getTaskName().isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写任务名称"); + } + if (req.getBatchNo() == null || req.getBatchNo().isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写批次号"); + } + if (req.getMajorName() == null || req.getMajorName().isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写专业名称"); + } + CollectTask t = new CollectTask(); t.setTaskName(req.getTaskName().trim()); t.setCollectMode(req.getCollectMode()); @@ -429,17 +446,16 @@ public class TaskService { "存在待确认的提醒:" + String.join(";", warnings) + "。确认后请带 force 重新发布"); } - CollectTask patch = new CollectTask(); - patch.setId(id); - patch.setStatus(CollectTask.STATUS_COLLECTING); - patch.setPublishedTime(LocalDateTime.now()); - taskMapper.updateById(patch); + // 走 patchStatus(而不是自己 updateById):留痕集中在一处, + // 否则"发布"这条最该留痕的动作会被漏掉 —— 而它恰恰是任务开始对外收集的时点 + patchStatus(id, CollectTask.STATUS_COLLECTING, cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_PUBLISH_TASK); + CollectTask published = taskMapper.selectById(id); log.info("发布任务 {}(id={}, 名册 {} 人)", task.getTaskCode(), id, rosterTotal); PublishResult r = new PublishResult(); r.setStatus(CollectTask.STATUS_COLLECTING); - r.setPublishedTime(patch.getPublishedTime()); + r.setPublishedTime(published == null ? LocalDateTime.now() : published.getPublishedTime()); r.setWarnings(warnings); // 二维码属 M1-06,这里先只把"二维码参数"回出去,够前端拼小程序码 r.setQrcodeUrl("/api/v1/tasks/" + id + "/qrcode"); @@ -465,7 +481,7 @@ public class TaskService { if (status != CollectTask.STATUS_COLLECTING) { throw new BizException(ErrorCode.ONLY_PUBLISHED_CAN_PAUSE); } - patchStatus(id, CollectTask.STATUS_PAUSED); + patchStatus(id, CollectTask.STATUS_PAUSED, cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_PAUSE_TASK); log.info("暂停任务 {}(id={})原因:{}", task.getTaskCode(), id, req == null || req.getReason() == null ? "(未填写)" : req.getReason()); } @@ -481,7 +497,7 @@ public class TaskService { if (status != CollectTask.STATUS_PAUSED) { throw new BizException(ErrorCode.ONLY_PAUSED_CAN_RESUME); } - patchStatus(id, CollectTask.STATUS_COLLECTING); + patchStatus(id, CollectTask.STATUS_COLLECTING, cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_RESUME_TASK); log.info("恢复任务 {}(id={})", task.getTaskCode(), id); } @@ -499,7 +515,7 @@ public class TaskService { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "只有收集中或已暂停的任务可以截止"); } - patchStatus(id, CollectTask.STATUS_CLOSED); + patchStatus(id, CollectTask.STATUS_CLOSED, cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_CLOSE_TASK); log.info("截止任务 {}(id={})", task.getTaskCode(), id); } @@ -524,7 +540,7 @@ public class TaskService { throw new BizException(ErrorCode.PENDING_AUDIT_EXISTS, "还有 " + pending + " 单在等待审核,归档后将无法处理,请先审完"); } - patchStatus(id, CollectTask.STATUS_ARCHIVED); + patchStatus(id, CollectTask.STATUS_ARCHIVED, cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_ARCHIVE_TASK); log.info("归档任务 {}(id={})备注:{}", task.getTaskCode(), id, req == null ? null : req.getArchiveRemark()); } @@ -545,11 +561,67 @@ public class TaskService { } } + /** + * 改任务状态 + 留痕。 + * + *

留痕集中在这一处(而不是每个状态流转各写一遍):任务状态流转是 + * 契约 §13.4 点名的敏感操作(`PUBLISH_TASK`/`PAUSE_TASK`/`ARCHIVE_TASK`), + * 分散写必然漏掉某一个分支 —— 而"漏记一个状态流转"意味着 + * 「这个任务什么时候被谁暂停的」永远查不出来。 + * + *

动作名由 {@code (原状态, 新状态)} 推出来:这个方法**只知道状态变了**, + * 而合规查询要的是"谁发布了/谁暂停了"。推不出来就记 {@code STATUS_CHANGE} + * —— 宁可名字粗一点,也不要记一个错的动作名。 + */ private void patchStatus(Long id, int status) { + patchStatus(id, status, null); + } + + /** + * 自动截止(定时任务用,PRD 附录 E「任务状态流转」)。 + * + *

⚠️ 这件事**必须自动做**:截止时间到了而任务还是"收集中",学生就能继续提交 —— + * 而"截止后提交"会在导出与上报时变成一个说不清的问题(这批到底算不算)。 + * 靠管理员记着手动点"截止",一定会漏。 + * + *

动作名用 {@code AUTO_CLOSE} 而不是 `CLOSE`:操作日志里要能区分 + * **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。 + */ + public void autoClose(Long taskId) { + patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE"); + } + + /** + * 改任务状态 + 留痕(带**显式动作名**)。 + * + * @param action 操作日志里的动作名。⚠️ 必须由调用方给,不能靠 {@code (from, to)} 推 —— + * "发布"与"恢复"都是 {@code →收集中},靠状态推的话两者会记成同一个动作, + * 而合规查询要区分的恰恰是"这个任务是被发布的还是被恢复的" + * (恢复意味着它曾经被暂停过)。传 null 时回退到 {@code STATUS_CHANGE}。 + */ + private void patchStatus(Long id, int status, String action) { + CollectTask before = taskMapper.selectById(id); + int from = before == null || before.getStatus() == null ? -1 : before.getStatus(); + CollectTask patch = new CollectTask(); patch.setId(id); patch.setStatus(status); + // 发布这一个流转还带 published_time —— 放在这里一起写, + // 否则"状态是收集中但没有发布时间"这种行会出现,而没人说得清是怎么来的 + if (status == CollectTask.STATUS_COLLECTING + && (before == null || before.getPublishedTime() == null)) { + patch.setPublishedTime(java.time.LocalDateTime.now()); + } taskMapper.updateById(patch); + + // 幂等路径(原状态 == 新状态)不记:那种调用什么都没改 + if (from == status) { + return; + } + String act = action == null ? "STATUS_CHANGE" : action; + operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK, + act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id, + String.valueOf(from), String.valueOf(status)); } private void assertTimeRange(LocalDateTime start, LocalDateTime end) { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskStatisticsService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskStatisticsService.java new file mode 100644 index 0000000..c5322a4 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskStatisticsService.java @@ -0,0 +1,184 @@ +package cn.tianbaotong.fill.module.task.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.task.dto.TaskStatVO; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskStatMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 任务统计(《03-接口设计》§7.11)。 + * + *

契约的响应是 {@code TaskStatVO} **加分班明细**,所以这里返回的结构是 + * "§3.3 的全部字段 + byOrg + byOccupation"。 + * + *

⚠️ 两个比率**必须分开报**:{@code submitRate}(已提交/应填)与 + * {@code passRate}(已通过/应填)。原型定稿的口径是「完成率 82% 是**已提交率** 23/28」, + * 合并成一个数字会让"看起来快完成了"与"快通过了"混为一谈 —— 而这两件事 + * 对客户的意义完全不同(前者是催报,后者是能不能交付)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskStatisticsService { + + private final TaskMapper taskMapper; + private final TaskStatMapper statMapper; + + public StatVO statistics(Long taskId) { + CollectTask t = taskMapper.selectById(taskId); + if (t == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + int rosterTotal = (int) statMapper.rosterTotal(taskId); + + Map byStatus = new LinkedHashMap<>(); + for (Map row : statMapper.countByStatus(taskId)) { + Integer st = intOf(row, "st"); + Integer cnt = intOf(row, "cnt"); + if (st != null && cnt != null) { + byStatus.merge(st, cnt, Integer::sum); + } + } + int pending = byStatus.getOrDefault(1, 0) + byStatus.getOrDefault(2, 0); + int approved = byStatus.getOrDefault(3, 0); + int rejected = byStatus.getOrDefault(4, 0); + int archived = byStatus.getOrDefault(5, 0); + // 「已提交」= 交上来且没被退回的(待初审 + 待终审 + 已通过) + int submitted = pending + approved; + + TaskStatVO s = new TaskStatVO(); + s.setRosterTotal(rosterTotal); + s.setSubmittedCount(submitted); + s.setPendingCount(pending); + s.setApprovedCount(approved); + s.setRejectedCount(rejected); + // 没交的 = 名册 − 已提交 − 被退回 − 已归档(草稿算没交:他还没提交) + s.setUnsubmittedCount(Math.max(0, rosterTotal - submitted - rejected - archived)); + s.setSubmitRate(pct(submitted, rosterTotal)); + s.setPassRate(pct(approved, rosterTotal)); + s.setAbnormalCount((int) statMapper.abnormalCount(taskId)); + s.setAvgAuditHours(statMapper.avgAuditHours(taskId)); + + StatVO vo = new StatVO(); + vo.setStat(s); + vo.setByOrg(orgRows(taskId)); + vo.setByOccupation(occRows(taskId)); + return vo; + } + + private List orgRows(Long taskId) { + List out = new ArrayList<>(); + for (Map r : statMapper.byOrg(taskId)) { + OrgRow o = new OrgRow(); + o.setOrgId(longOf(r, "orgId")); + o.setOrgName(strOf(r, "orgName")); + o.setRosterTotal(nz(intOf(r, "rosterTotal"))); + o.setSubmittedCount(nz(intOf(r, "submittedCount"))); + o.setApprovedCount(nz(intOf(r, "approvedCount"))); + o.setSubmitRate(pct(o.getSubmittedCount(), o.getRosterTotal())); + out.add(o); + } + return out; + } + + private List occRows(Long taskId) { + List out = new ArrayList<>(); + for (Map r : statMapper.byOccupation(taskId)) { + OccRow o = new OccRow(); + o.setOccupation(strOf(r, "occupation")); + o.setWorktype(strOf(r, "worktype")); + o.setJobLevel(strOf(r, "jobLevel")); + o.setCount(nz(intOf(r, "cnt"))); + o.setApprovedCount(nz(intOf(r, "approvedCount"))); + out.add(o); + } + return out; + } + + // ── 与 TaskService 里同一套取值工具 ── + // + // PG 会把列标签折成小写,而 MyBatis 用列标签做 key —— 按不区分大小写取, + // 避免"取不到就当 0"这种最难受的错(数字看起来正常,其实是空的)。 + // 这里的三个小工具与 TaskService 里那份是同一逻辑,**故意各自保留**: + // 抽成公共工具会让两个模块的取值口径被一个改动同时影响, + // 而这种"看着一样的取值"其实是各模块自己的一致性要求。 + + 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 Integer intOf(Map row, String name) { + Object v = col(row, name); + return v == null ? null : ((Number) v).intValue(); + } + + private static Long longOf(Map row, String name) { + Object v = col(row, name); + return v == null ? null : ((Number) v).longValue(); + } + + private static String strOf(Map row, String name) { + Object v = col(row, name); + return v == null ? null : String.valueOf(v); + } + + private static int nz(Integer v) { + return v == null ? 0 : v; + } + + /** 百分比,一位小数;分母为 0 时给 {@code —} 而不是 0%。 */ + private static String pct(int part, int total) { + if (total <= 0) { + // ⚠️ 0% 看起来像"没人交",而真相是"还没有分母"。用破折号把两者分开 + return "—"; + } + return Math.round(1000.0 * part / total) / 10.0 + "%"; + } + + // ── 响应结构 ── + + @lombok.Getter + @lombok.Setter + public static class StatVO { + /** §3.3 的 TaskStatVO 全字段。 */ + private TaskStatVO stat; + private List byOrg = new ArrayList<>(); + private List byOccupation = new ArrayList<>(); + } + + @lombok.Getter + @lombok.Setter + public static class OrgRow { + private Long orgId; + private String orgName; + private int rosterTotal; + private int submittedCount; + private int approvedCount; + private String submitRate; + } + + @lombok.Getter + @lombok.Setter + public static class OccRow { + private String occupation; + private String worktype; + private String jobLevel; + private int count; + private int approvedCount; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java index 61ce4b7..e4fe136 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; /** * 租户管理(《03-接口设计》§6.5~§6.6)。 @@ -30,6 +31,8 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class TenantController { + private final cn.tianbaotong.fill.module.tenant.service.TenantUsageService usageService; + private final TenantService tenantService; @GetMapping @@ -58,4 +61,31 @@ public class TenantController { public ApiResult create(@Valid @RequestBody TenantCreateReq req) { return ApiResult.ok(tenantService.create(req)); } + + + @PutMapping("/{id}") + @RequirePerm(PermissionDef.LOG_VIEW_ALL) + @Operation(summary = "修改租户 / 套餐(§6.5)", + description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里," + + "改了会让已经发出去的文档与配好的域名全部失效," + + "而那是客户那边的事,我们无法逐个通知到。" + + "停用租户会记一条 WARN 日志 —— 那是个影响一片人的动作") + public ApiResult update( + @PathVariable Long id, + @RequestBody cn.tianbaotong.fill.module.tenant.entity.SysTenant req) { + return ApiResult.ok(usageService.update(id, req)); + } + + @GetMapping("/{id}/usage") + @RequirePerm(PermissionDef.LOG_VIEW_ALL) + @Operation(summary = "租户用量统计(§6.7)", + description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。" + + "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— " + + "两条通道都还没接(M4 等选型、短信没有商务结论)," + + "编一个数字会让运营拿着假数据去谈续费") + public ApiResult usage( + @PathVariable Long id, + @RequestParam(required = false) String period) { + return ApiResult.ok(usageService.usage(id, period)); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantUsageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantUsageService.java new file mode 100644 index 0000000..e3ab842 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantUsageService.java @@ -0,0 +1,266 @@ +package cn.tianbaotong.fill.module.tenant.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.file.entity.CollectFile; +import cn.tianbaotong.fill.module.file.mapper.FileMapper; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.tenant.entity.SysTenant; +import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * 租户用量统计(《03-接口设计》§6.7)。 + * + *

这个接口的用途决定了"准不准"的要求不一样

+ * + *

它是**运营看的容量与计费视图**(契约里连 `alerts` 都给了): + *

+ * + *

`period` 的口径

+ * + *

接受 {@code 2026-09}(月)与 {@code 2026Q3}(季)两种写法。不传就是"当月"。 + * 之所以要支持季度:客户的结算周期是季度,而"每月自己加三次"很容易算错月份。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TenantUsageService { + + private final TenantMapper tenantMapper; + private final CollectRosterMapper rosterMapper; + private final FileMapper fileMapper; + + /** 默认套餐额度(没有配套餐时的兜底)。 */ + private static final long DEFAULT_STUDENT_LIMIT = 2000L; + private static final long DEFAULT_STORAGE_LIMIT_MB = 20480L; + private static final long DEFAULT_OCR_LIMIT = 10000L; + private static final long DEFAULT_SMS_LIMIT = 2000L; + + public UsageVO usage(Long tenantId, String period) { + SysTenant t = tenantMapper.selectById(tenantId); + if (t == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在或已删除"); + } + Period p = Period.parse(period); + + UsageVO vo = new UsageVO(); + vo.setTenantId(tenantId); + vo.setTenantName(t.getTenantName()); + vo.setPeriod(p.text()); + + // 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册) + vo.setStudentCount(distinctStudents()); + + // 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用) + Long bytes = fileMapper.selectList(Wrappers.lambdaQuery() + .select(CollectFile::getFinalSize)) + .stream().map(CollectFile::getFinalSize) + .filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum(); + vo.setStorageMb(bytes / 1024 / 1024); + + // OCR / 短信:一期没有通道,如实返回 0(见类注释) + vo.setOcrCount(0); + vo.setSmsCount(0); + vo.getNotes().add("ocrCount 恒为 0:M4 的识别通道还没接(等 OCR 选型),不是「没用量」"); + vo.getNotes().add("smsCount 恒为 0:短信通道还没有商务结论(降级链只实现到站内信)"); + + Quota q = new Quota(); + q.setStudentLimit(DEFAULT_STUDENT_LIMIT); + q.setStorageLimitMb(DEFAULT_STORAGE_LIMIT_MB); + q.setOcrLimit(DEFAULT_OCR_LIMIT); + q.setSmsLimit(DEFAULT_SMS_LIMIT); + vo.setQuota(q); + + // 告警:按占用比例给(契约的 alerts 结构) + vo.setAlerts(alerts(vo, q)); + log.debug("租户用量 tenantId={} 期={} 学生={} 存储={}MB", tenantId, p.text(), + vo.getStudentCount(), vo.getStorageMb()); + return vo; + } + + /** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */ + private long distinctStudents() { + // 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法: + // "去重计数"在 selectCount 里做不到,所以拉 hash 列表在内存里去重。 + // 一个租户的学生数是千级,内存去重完全够;换成 SQL 反而要为一个查询新开一个 mapper + return rosterMapper.selectList(Wrappers.lambdaQuery() + .select(CollectRoster::getIdCardHash)) + .stream().map(CollectRoster::getIdCardHash) + .filter(java.util.Objects::nonNull).distinct().count(); + } + + private List alerts(UsageVO vo, Quota q) { + List out = new ArrayList<>(); + addAlert(out, "STUDENT", vo.getStudentCount(), q.getStudentLimit()); + addAlert(out, "STORAGE", vo.getStorageMb(), q.getStorageLimitMb()); + return out; + } + + /** + * 占用超 80% 就告警,超 100% 升到 2 级。 + * + *

阈值写在这里而不是配置项:它是**产品口径**(80% 该提醒、100% 该拦), + * 而配置项会让每个环境不一样,最后没人知道到底是多少。 + */ + private void addAlert(List out, String type, long used, long limit) { + if (limit <= 0) { + return; + } + double ratio = (double) used / limit; + if (ratio < 0.8) { + return; + } + Alert a = new Alert(); + a.setType(type); + a.setLevel(ratio >= 1.0 ? 2 : 1); + a.setRatio(Math.round(ratio * 1000) / 10.0 + "%"); + a.setUsed(used); + a.setLimit(limit); + out.add(a); + } + + // ── 修改租户(§6.5 的 PUT /tenants/{id})──────── + + /** + * 修改租户信息与套餐。 + * + *

⚠️ {@code tenantCode} **不允许改**:它出现在 scene(`t{tenantId}` 用的是 id, + * 但 code 被用在域名解析 `TenantDomainResolver` 与对接文档里)—— + * 改了会让已经发出去的文档与配好的域名全部失效,而那是客户那边的事,我们无法通知到。 + */ + public SysTenant update(Long id, SysTenant req) { + SysTenant exist = tenantMapper.selectById(id); + if (exist == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在或已删除"); + } + if (req == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空"); + } + SysTenant upd = new SysTenant(); + upd.setId(id); + if (req.getTenantName() != null) { + if (req.getTenantName().isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户名称不能为空"); + } + upd.setTenantName(req.getTenantName().trim()); + } + if (req.getTenantType() != null) { + if (req.getTenantType() < 1 || req.getTenantType() > 3) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "租户类型只能是 1(学校)/ 2(机构)/ 3(企业)"); + } + upd.setTenantType(req.getTenantType()); + } + upd.setContactName(req.getContactName()); + upd.setContactPhone(req.getContactPhone()); + upd.setPackageId(req.getPackageId()); + upd.setExpireTime(req.getExpireTime()); + upd.setLogoUrl(req.getLogoUrl()); + upd.setConfigJson(req.getConfigJson()); + if (req.getStatus() != null) { + if (req.getStatus() != 0 && req.getStatus() != 1) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)"); + } + upd.setStatus(req.getStatus()); + if (req.getStatus() == 0) { + // 停用租户是**影响一片人**的动作(该校所有学生与老师立刻登不进来), + // 所以单独记一条 WARN:出问题时能一眼看出是谁在什么时候停的 + log.warn("停用租户 id={} code={}(该校用户将无法登录)", id, exist.getTenantCode()); + } + } + tenantMapper.updateById(upd); + log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(), + upd.getStatus(), upd.getExpireTime()); + return tenantMapper.selectById(id); + } + + // ── 周期解析 ── + + /** 一个统计周期(月或季)。 */ + private record Period(String text, LocalDateTime from, LocalDateTime to) { + + static Period parse(String s) { + LocalDate today = LocalDate.now(); + if (s == null || s.isBlank()) { + return month(today.getYear(), today.getMonthValue()); + } + String t = s.trim().toUpperCase(); + if (t.matches("\\d{4}-\\d{2}")) { + String[] parts = t.split("-"); + return month(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + } + if (t.matches("\\d{4}Q[1-4]")) { + int year = Integer.parseInt(t.substring(0, 4)); + int q = Integer.parseInt(t.substring(5)); + int startMonth = (q - 1) * 3 + 1; + LocalDateTime from = LocalDateTime.of(year, startMonth, 1, 0, 0); + LocalDateTime to = from.plusMonths(3); + return new Period(t, from, to); + } + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "period 格式不对:用 2026-09(月)或 2026Q3(季)"); + } + + static Period month(int year, int month) { + if (month < 1 || month > 12) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "月份必须在 1~12"); + } + LocalDateTime from = LocalDateTime.of(year, month, 1, 0, 0); + return new Period(from.format(DateTimeFormatter.ofPattern("yyyy-MM")), + from, from.plusMonths(1)); + } + } + + // ── 响应结构 ── + + @lombok.Getter + @lombok.Setter + public static class UsageVO { + private Long tenantId; + private String tenantName; + private String period; + private long studentCount; + private long storageMb; + private int ocrCount; + private int smsCount; + private Quota quota; + private List alerts = new ArrayList<>(); + /** 某几项为什么是 0(避免运营把它当成"真的没用")。 */ + private List notes = new ArrayList<>(); + } + + @lombok.Getter + @lombok.Setter + public static class Quota { + private long studentLimit; + private long storageLimitMb; + private long ocrLimit; + private long smsLimit; + } + + @lombok.Getter + @lombok.Setter + public static class Alert { + private String type; + private int level; + private String ratio; + private long used; + private long limit; + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V15__rule_template.sql b/backend/xt-server/src/main/resources/db/migration/V15__rule_template.sql new file mode 100644 index 0000000..39c470e --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V15__rule_template.sql @@ -0,0 +1,51 @@ +-- ============================================================================ +-- V15:规则模板补审计列 + 两条公共模板 +-- +-- 一、`collect_rule_template` V1 就建好了,一直没有代码用(F-50 的第二张表) +-- --------------------------------------------------------------------------- +-- 列(`form_schema` / `materials` / `naming_template` / `is_public` / `use_count`) +-- 正好对应契约 §7.13 的 `GET /rule-templates` 与 `POST /rule-templates/{id}/apply` +-- 的 `parts: ["FORM","MATERIALS","NAMING"]` —— 也就是说"当初就是照着这三个部分设计的"。 +-- +-- 这里补的是实体基类要求的四列(否则运行时才炸,见 F-24/F-35)。 +-- +-- 二、两条**公共**模板(`tenant_id = 0` 表示平台级,所有租户可见) +-- --------------------------------------------------------------------------- +-- ⚠️ 为什么必须真的插两条数据,而不是让列表返回空: +-- `GET /rule-templates` 的用途是"新建任务时一键套用一套现成配置"。 +-- 列表空着的话这个功能**存在但没有任何用处**,而界面上看不出是"还没建模板" +-- 还是"功能没做"。所以随迁移带两条:一条"学校收集标准"、一条"自主认定精简"。 +-- +-- `materials` 的 JSON 与代码里的内置材料模板(`MaterialDefService` 的 +-- `TPL_SCHOOL_ROLL` 等)保持同一形状,这样 `apply` 时可以直接复用那段逻辑。 +-- ============================================================================ + +ALTER TABLE collect_rule_template ADD COLUMN IF NOT EXISTS update_by BIGINT; +ALTER TABLE collect_rule_template ADD COLUMN IF NOT EXISTS update_time TIMESTAMP; +ALTER TABLE collect_rule_template ADD COLUMN IF NOT EXISTS deleted SMALLINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN collect_rule_template.tenant_id IS '0 = 平台级公共模板(所有租户可见);其它值 = 该租户自建'; + +-- 公共模板的可见性靠 `is_public`,唯一性靠"名字 + 租户",避免重复插入 +CREATE UNIQUE INDEX IF NOT EXISTS uk_rule_tpl ON collect_rule_template ( + tenant_id, template_name +) WHERE deleted = 0; + +-- ── 两条公共模板 ────────────────────────────── +-- `form_schema` 留空 = "用系统默认表单"(不是"空表单")。 +-- 这一点必须写清:apply 时遇到空 schema 是**跳过**而不是"把目标任务的表单清空"。 +INSERT INTO collect_rule_template + (tenant_id, template_name, cert_type, form_schema, materials, naming_template, + is_public, use_count, create_by, create_time, deleted) +VALUES + (0, '学校收集 · 标准(身份证+一寸照+学籍表)', 1, NULL, + '[{"materialCode":"IDCARD","materialName":"身份证","required":1,"maxCount":2,"maxSizeKb":2048,"targetDpi":300,"sortNo":1}, + {"materialCode":"PHOTO1","materialName":"一寸照片","required":1,"maxCount":1,"maxSizeKb":1024,"targetDpi":300,"sortNo":2}, + {"materialCode":"SCHOOL_ROLL","materialName":"学籍表","required":1,"maxCount":1,"maxSizeKb":3072,"targetDpi":300,"sortNo":3}]'::jsonb, + '{姓名}-{证件号}-{材料名}', 1, 0, 0, CURRENT_TIMESTAMP, 0), + (0, '自主认定 · 精简(身份证+一寸照+工作证明)', 2, NULL, + '[{"materialCode":"IDCARD","materialName":"身份证","required":1,"maxCount":2,"maxSizeKb":2048,"targetDpi":300,"sortNo":1}, + {"materialCode":"PHOTO1","materialName":"一寸照片","required":1,"maxCount":1,"maxSizeKb":1024,"targetDpi":300,"sortNo":2}, + {"materialCode":"WORK_PROOF","materialName":"工作证明","required":1,"maxCount":1,"maxSizeKb":3072,"targetDpi":300,"sortNo":3}]'::jsonb, + '{姓名}-{证件号}-{材料名}', 1, 0, 0, CURRENT_TIMESTAMP, 0) +ON CONFLICT DO NOTHING;