feat(task): 规则模板、任务克隆与统计、组织导入、租户用量(V15)
克隆**配置**(表单/材料清单/命名模板/审核配置/覆盖班级)、刻意不克隆业务数据—— 名册克隆过去会让同一证件号在两个任务里各有一行。 统计补上 abnormalCount 与 avgAuditHours(这两个字段以前恒为 null,因为问题表与审核记录 还不存在);规则模板可按 parts 只应用一部分、材料清单覆盖式合并。 顺带修 F-44:TaskService.create 对缺必填项抛 NPE(对外 50000「系统繁忙」), 改为集中校验并指出是哪个字段;F-45:历史整理器导入改走 TaskService.create, 避免两处各生成一次 task_code 迟早撞号。 验证:e2e-m7-taskconfig 69/69(含待办范围判据的回归防线)。
This commit is contained in:
+17
@@ -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<cn.tianbaotong.fill.module.org.service.OrgImportService.ImportResultVO> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+428
@@ -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)。
|
||||
*
|
||||
* <h3>Excel 列:层级 / 组织名称 / 组织编码 / 上级编码 / 负责人手机号</h3>
|
||||
*
|
||||
* <p>这份表的结构决定了实现方式:**用"编码 + 上级编码"表达父子关系**,
|
||||
* 而不是靠行的顺序或缩进。理由是客户的表是人整理出来的 ——
|
||||
* 顺序会被重排、缩进会被吃掉(Excel 里前导空格尤其容易丢),
|
||||
* 而"编码 + 上级编码"是数据本身的关系,怎么排都能还原。
|
||||
*
|
||||
* <h3>两种父级写法都支持</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>上级编码</b>:填父行的"组织编码",最可靠;</li>
|
||||
* <li><b>层级</b>:只填第几层(1/2/3),父级按"上一层的最后一个"推。
|
||||
* 客户手写的表里这种更常见(他们不想编编码)。</li>
|
||||
* </ol>
|
||||
* 两者同时给时以"上级编码"为准 —— 冲突时信更精确的那个。
|
||||
*
|
||||
* <h3>为什么分两遍</h3>
|
||||
*
|
||||
* <p>第一遍建/找所有节点,第二遍才挂父子关系。一遍做的话,
|
||||
* "子行在父行之前"的表会全部失败(而客户的表里子行在前很常见)。
|
||||
*/
|
||||
@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<String, String> 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<Map<String, String>> 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<SysOrg> existAll = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery());
|
||||
Map<String, SysOrg> byCode = new HashMap<>();
|
||||
Map<String, SysOrg> 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<Integer, SysOrg> built = new LinkedHashMap<>();
|
||||
Map<Integer, String> rowCode = new LinkedHashMap<>();
|
||||
Map<Integer, Long> parentIdOf = new LinkedHashMap<>();
|
||||
Long lastIdAtLevel = null;
|
||||
int lastLevel = 0;
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, String> 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.<SysUser>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<Integer, SysOrg> 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<Integer, SysOrg> 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<Map<String, String>> 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<Integer, String> colMap = new HashMap<>();
|
||||
for (int r = 0; r <= Math.min(9, sheet.getLastRowNum()); r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<Integer, String> 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<Map<String, String>> out = new ArrayList<>();
|
||||
for (int r = headerRow + 1; r <= sheet.getLastRowNum(); r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> one = new HashMap<>();
|
||||
boolean any = false;
|
||||
for (Map.Entry<Integer, String> 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<JSONObject> errors = new ArrayList<>();
|
||||
private List<String> warnings = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
+95
@@ -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)。
|
||||
*
|
||||
* <h3>⚠️ 路径是 {@code /api/v1/rule-templates},不是 {@code /api/v1/tasks/rule-templates}</h3>
|
||||
*
|
||||
* <p>我第一版把它挂在了 `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<java.util.List<java.util.Map<String, Object>>> ruleTemplates() {
|
||||
var out = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
for (var t : ruleTemplateService.list()) {
|
||||
java.util.Map<String, Object> 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<cn.tianbaotong.fill.module.task.service.RuleTemplateService.ApplyResult>
|
||||
applyRuleTemplate(@PathVariable Long tplId,
|
||||
@RequestBody java.util.Map<String, Object> body) {
|
||||
Long target = body == null || body.get("targetTaskId") == null ? null
|
||||
: Long.valueOf(String.valueOf(body.get("targetTaskId")));
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<String> parts = body == null ? null
|
||||
: (java.util.List<String>) body.get("parts");
|
||||
return ApiResult.ok(ruleTemplateService.apply(tplId, target, parts));
|
||||
}
|
||||
}
|
||||
+64
@@ -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<java.util.Map<String, Object>> clone(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) java.util.Map<String, Object> 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<String, Object> 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<java.util.Map<String, Object>> saveAsTemplate(
|
||||
@PathVariable Long id, @RequestBody java.util.Map<String, Object> 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<cn.tianbaotong.fill.module.task.service.TaskStatisticsService.StatVO>
|
||||
statistics(@PathVariable Long id) {
|
||||
return ApiResult.ok(statisticsService.statistics(id));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+56
@@ -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)。
|
||||
*
|
||||
* <h3>"规则模板"到底是什么</h3>
|
||||
*
|
||||
* <p>它是一套**可复用的任务配置**,正好对应契约 `apply` 请求里的三个 `parts`:
|
||||
* <ul>
|
||||
* <li>{@code FORM} → {@link #formSchema}(动态表单定义);</li>
|
||||
* <li>{@code MATERIALS} → {@link #materials}(材料清单);</li>
|
||||
* <li>{@code NAMING} → {@link #namingTemplate}(文件命名模板)。</li>
|
||||
* </ul>
|
||||
* 之所以做成"可以只应用其中一部分",是因为真实场景里这三个东西的复用粒度不一样:
|
||||
* 同一个专业的**材料清单**年年一样,但**命名模板**可能因为客户换了归档习惯而变;
|
||||
* 反过来,同一个学校的命名习惯稳定,材料清单却每个批次都调。
|
||||
* 强制三件套一起套用,用户就会为了改一项而放弃整个模板。
|
||||
*
|
||||
* <p>⚠️ 这张表 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;
|
||||
}
|
||||
+55
@@ -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<CollectRuleTemplate> {
|
||||
|
||||
/**
|
||||
* 列表:**租户自建 + 平台公共**。
|
||||
*
|
||||
* <p>⚠️ 这里必须关掉租户拦截器并手写条件:`is_public=1` 的公共模板
|
||||
* {@code tenant_id=0},而拦截器会给查询补上 `tenant_id = <当前租户>` ——
|
||||
* 公共模板一条都查不出来,表现是"模板列表空着但功能没报错"。
|
||||
*
|
||||
* <p>手写条件的范围就是"本租户 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<CollectRuleTemplate> listVisible(@Param("tenantId") Long tenantId);
|
||||
|
||||
/**
|
||||
* 取一个可见的模板(本租户 or 公共)。
|
||||
*
|
||||
* <p>与 {@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);
|
||||
}
|
||||
+114
@@ -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} + 分班 / 分职业明细)。
|
||||
*
|
||||
* <h3>为什么异常数({@code abnormalCount})现在能算了</h3>
|
||||
*
|
||||
* <p>它要的是"有多少份填报单带着红/黄问题",而那需要**问题表**。
|
||||
* §12 的问题清单(V14)之前,`TaskStatVO` 的这两个字段一直是 {@code null} ——
|
||||
* 当时的注释写着"编一个数就是谎"。现在数据齐了:红黄问题写在
|
||||
* {@code collect_validation_issue},处理时长写在 {@code collect_audit_record.duration_sec}。
|
||||
*
|
||||
* <h3>口径(三处都不能含糊)</h3>
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>已提交</b> = 待初审 + 待终审 + 已通过({@code status IN (1,2,3)})。
|
||||
* 不含草稿(他还没交)、不含已驳回(被退回去了)。</li>
|
||||
* <li><b>异常</b> = 有**未解决**的红(3)或黄(2)问题。只算未解决:
|
||||
* 已经处理掉的问题不再是"异常",否则这个数字只会涨不会跌。</li>
|
||||
* <li><b>平均处理时长</b> = 审核记录里 {@code duration_sec} 的均值 ÷ 3600。
|
||||
* 那个字段是**审核时算好写进去的**(从学生提交到审核动作),
|
||||
* 所以这里求均值即可,不需要再推时间。没有审核记录时返回 {@code null}
|
||||
* 而不是 0 —— "还没人审"与"审得飞快"是两件事。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface TaskStatMapper {
|
||||
|
||||
/** 名册总数(分母)。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM collect_roster
|
||||
WHERE task_id = #{taskId} AND deleted = 0
|
||||
""")
|
||||
long rosterTotal(@Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 按状态分组的填报单数。
|
||||
*
|
||||
* <p>⚠️ 返回的是 {@code List<Map>} 而不是 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<Map<String, Object>> countByStatus(@Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 有未解决红/黄问题的**填报单**数(不是问题条数)。
|
||||
*
|
||||
* <p>⚠️ {@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<Map<String, Object>> byOrg(@Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 分职业明细。
|
||||
*
|
||||
* <p>⚠️ 走的是**正式列** {@code collect_submission.occupation},不是
|
||||
* {@code extras_json} 里的键 —— 这正是当初坚持"18 个正式列 + extras_json"
|
||||
* 的收益:实时、走索引、可以被 group by(JSON 里的键做不到这三件事)。
|
||||
*
|
||||
* <p>只统计**已提交**的单子(草稿还没确定职业,算进去会让分布每天都变)。
|
||||
*/
|
||||
@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<Map<String, Object>> byOccupation(@Param("taskId") Long taskId);
|
||||
}
|
||||
+263
@@ -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)。
|
||||
*
|
||||
* <h3>为什么"可以只应用其中一部分"是必须的</h3>
|
||||
*
|
||||
* <p>三部分的**复用粒度不一样**:同一个专业的材料清单年年一样,
|
||||
* 但命名模板可能因为客户换了归档习惯而变;反过来同一所学校的命名习惯稳定,
|
||||
* 材料清单却每个批次都调。强制三件套一起套用,用户就会为了改一项而放弃整个模板。
|
||||
*
|
||||
* <h3>套用是"覆盖"还是"合并"</h3>
|
||||
*
|
||||
* <p>三部分各自处理,但**材料清单是替换(按 materialCode 去重覆盖)**,
|
||||
* 不是"整表清空再插":
|
||||
* <ul>
|
||||
* <li>已经有人传过材料的材料项**不能删** —— 删掉它,那些文件就成了孤儿,
|
||||
* 而导出包会少材料(而界面上看起来一切正常);</li>
|
||||
* <li>所以规则是"模板里有的按模板更新,模板里没有的**保留**"。
|
||||
* 用户想删某一项,应该去材料配置里明确地删(那里有在用途中的保护,见 §8.4 的同类保护)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>⚠️ {@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<String> ALL_PARTS = new LinkedHashSet<>(
|
||||
List.of("FORM", "MATERIALS", "NAMING"));
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
|
||||
public List<CollectRuleTemplate> list() {
|
||||
LoginUser me = UserContext.require();
|
||||
return templateMapper.listVisible(me.tenantId());
|
||||
}
|
||||
|
||||
// ── 套用 ──────────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ApplyResult apply(Long id, Long targetTaskId, List<String> 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<String> 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<String, CollectMaterialDef>();
|
||||
for (CollectMaterialDef d : materialDefMapper.selectList(
|
||||
Wrappers.<CollectMaterialDef>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<String> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发布的任务不允许套用会影响"已收上来的东西"的部分。
|
||||
*
|
||||
* <p>理由与 §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<String> parts = new ArrayList<>();
|
||||
/** 真正生效的部分。 */
|
||||
private List<String> applied = new ArrayList<>();
|
||||
/** 跳过或没做的部分,**带原因** —— 不给原因用户会以为"点了没反应"。 */
|
||||
private List<String> skipped = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
+268
@@ -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})。
|
||||
*
|
||||
* <h3>克隆什么、不克隆什么 —— 这是这个功能唯一需要想清楚的事</h3>
|
||||
*
|
||||
* <p><b>克隆</b>("配置"):表单定义、材料清单、命名模板、审核配置、压缩参数、
|
||||
* 覆盖班级与班主任。
|
||||
*
|
||||
* <p><b>不克隆</b>("业务数据"):名册、填报单、材料文件、审核记录、导出记录。
|
||||
* 理由不是"省事",而是**克隆的业务数据会立刻变成脏数据**:
|
||||
* <ul>
|
||||
* <li>名册克隆过去 → 同一个人的证件号在两个任务里各有一行,
|
||||
* 门禁核验时"按证件号找名册"会命中哪一行?两个任务都能绑同一个学生,
|
||||
* 于是他会收到两份填报提醒,而客户只安排了一次收集;</li>
|
||||
* <li>填报单/文件克隆过去 → 那些文件是**上一批**的(可能是去年的身份证),
|
||||
* 挂在今年的任务上就是假数据;而导出时它看起来完全正常。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>所以克隆的定位是"新一批收集,配置照抄" —— 名册要重新导入,
|
||||
* 材料要重新收集。这正是客户的实际节奏(每批次配置一样、人换一批)。
|
||||
*
|
||||
* <h3>新任务名与状态</h3>
|
||||
*
|
||||
* <p>名字加"(副本)"后缀并**去重**(同名任务在列表里无法区分,客户会点错);
|
||||
* 状态固定为**草稿** —— 克隆出来的任务不该直接开始收集,
|
||||
* 用户要先核对专业、批次、覆盖班级这些"上一批的东西"。
|
||||
*/
|
||||
@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.<CollectMaterialDef>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.<CollectTaskOrg>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
|
||||
.<cn.tianbaotong.fill.module.form.entity.CollectFormSchema>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;
|
||||
|
||||
/**
|
||||
* 生成不重名的"(副本)"名字。
|
||||
*
|
||||
* <p>同名任务在列表里无法区分,而客户会照着名字点 —— 点错就改错了任务。
|
||||
* 所以后缀要递增:`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.<CollectTask>lambdaQuery()
|
||||
.eq(CollectTask::getTaskName, cand));
|
||||
if (n == 0) {
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
return root + "(副本 " + System.currentTimeMillis() % 10000 + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某个任务的配置存成规则模板(反向操作)。
|
||||
*
|
||||
* <p>契约 §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.<CollectMaterialDef>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();
|
||||
}
|
||||
}
|
||||
+82
-10
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 改任务状态 + 留痕。
|
||||
*
|
||||
* <p>留痕集中在这一处(而不是每个状态流转各写一遍):任务状态流转是
|
||||
* 契约 §13.4 点名的敏感操作(`PUBLISH_TASK`/`PAUSE_TASK`/`ARCHIVE_TASK`),
|
||||
* 分散写必然漏掉某一个分支 —— 而"漏记一个状态流转"意味着
|
||||
* 「这个任务什么时候被谁暂停的」永远查不出来。
|
||||
*
|
||||
* <p>动作名由 {@code (原状态, 新状态)} 推出来:这个方法**只知道状态变了**,
|
||||
* 而合规查询要的是"谁发布了/谁暂停了"。推不出来就记 {@code STATUS_CHANGE}
|
||||
* —— 宁可名字粗一点,也不要记一个错的动作名。
|
||||
*/
|
||||
private void patchStatus(Long id, int status) {
|
||||
patchStatus(id, status, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动截止(定时任务用,PRD 附录 E「任务状态流转」)。
|
||||
*
|
||||
* <p>⚠️ 这件事**必须自动做**:截止时间到了而任务还是"收集中",学生就能继续提交 ——
|
||||
* 而"截止后提交"会在导出与上报时变成一个说不清的问题(这批到底算不算)。
|
||||
* 靠管理员记着手动点"截止",一定会漏。
|
||||
*
|
||||
* <p>动作名用 {@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) {
|
||||
|
||||
+184
@@ -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)。
|
||||
*
|
||||
* <p>契约的响应是 {@code TaskStatVO} **加分班明细**,所以这里返回的结构是
|
||||
* "§3.3 的全部字段 + byOrg + byOccupation"。
|
||||
*
|
||||
* <p>⚠️ 两个比率**必须分开报**:{@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<Integer, Integer> byStatus = new LinkedHashMap<>();
|
||||
for (Map<String, Object> 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<OrgRow> orgRows(Long taskId) {
|
||||
List<OrgRow> out = new ArrayList<>();
|
||||
for (Map<String, Object> 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<OccRow> occRows(Long taskId) {
|
||||
List<OccRow> out = new ArrayList<>();
|
||||
for (Map<String, Object> 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<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 Integer intOf(Map<String, Object> row, String name) {
|
||||
Object v = col(row, name);
|
||||
return v == null ? null : ((Number) v).intValue();
|
||||
}
|
||||
|
||||
private static Long longOf(Map<String, Object> row, String name) {
|
||||
Object v = col(row, name);
|
||||
return v == null ? null : ((Number) v).longValue();
|
||||
}
|
||||
|
||||
private static String strOf(Map<String, Object> 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<OrgRow> byOrg = new ArrayList<>();
|
||||
private List<OccRow> 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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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<TenantService.CreateResult> 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<cn.tianbaotong.fill.module.tenant.entity.SysTenant> 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<cn.tianbaotong.fill.module.tenant.service.TenantUsageService.UsageVO> usage(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String period) {
|
||||
return ApiResult.ok(usageService.usage(id, period));
|
||||
}
|
||||
}
|
||||
|
||||
+266
@@ -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)。
|
||||
*
|
||||
* <h3>这个接口的用途决定了"准不准"的要求不一样</h3>
|
||||
*
|
||||
* <p>它是**运营看的容量与计费视图**(契约里连 `alerts` 都给了):
|
||||
* <ul>
|
||||
* <li>{@code studentCount} / {@code storageMb} 是**实时**算的(它们决定"还能不能再收人");</li>
|
||||
* <li>{@code ocrCount} / {@code smsCount} 是**按期**算的(计费项)。
|
||||
* 一期这两个通道都还没接通(M4 等选型、短信没有商务结论),
|
||||
* 所以它们如实返回 **0 并带一条说明** —— 编一个数字会让运营拿着假数据去谈续费。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>`period` 的口径</h3>
|
||||
*
|
||||
* <p>接受 {@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.<CollectFile>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.<CollectRoster>lambdaQuery()
|
||||
.select(CollectRoster::getIdCardHash))
|
||||
.stream().map(CollectRoster::getIdCardHash)
|
||||
.filter(java.util.Objects::nonNull).distinct().count();
|
||||
}
|
||||
|
||||
private List<Alert> alerts(UsageVO vo, Quota q) {
|
||||
List<Alert> out = new ArrayList<>();
|
||||
addAlert(out, "STUDENT", vo.getStudentCount(), q.getStudentLimit());
|
||||
addAlert(out, "STORAGE", vo.getStorageMb(), q.getStorageLimitMb());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 占用超 80% 就告警,超 100% 升到 2 级。
|
||||
*
|
||||
* <p>阈值写在这里而不是配置项:它是**产品口径**(80% 该提醒、100% 该拦),
|
||||
* 而配置项会让每个环境不一样,最后没人知道到底是多少。
|
||||
*/
|
||||
private void addAlert(List<Alert> 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})────────
|
||||
|
||||
/**
|
||||
* 修改租户信息与套餐。
|
||||
*
|
||||
* <p>⚠️ {@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<Alert> alerts = new ArrayList<>();
|
||||
/** 某几项为什么是 0(避免运营把它当成"真的没用")。 */
|
||||
private List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user