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:
2026-09-30 09:02:55 +08:00
parent aa30fd2383
commit efde4627eb
14 changed files with 1973 additions and 10 deletions
@@ -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));
}
}
@@ -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<>();
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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<>();
}
}
@@ -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();
}
}
@@ -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) {
@@ -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;
}
}
@@ -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));
}
}
@@ -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;