feat(audit): 审核闭环后端(M5-01~05)—— 「谁能审谁」比「怎么审」重要
一、落地
module/audit:entity(CollectAuditRecord) + 4 个 mapper + AuditService
+ RejectReasonCatalog(附录 C 的 10 条,写在代码里,理由同权限码)
两端接口:PC /api/v1/audit/*(§11)、小程序 /api/v1/mp/teacher/audit/*(§10.2~10.5,只做初审)
待办的 MP 与 PC **共用一份 VO**(契约里那两份重合九成):做两份迟早分叉,
而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
二、★ 三件必须守住的事
① 权限 ≠ 范围:全校几十个班主任都有 audit:first,但只有这个班的那位能审这个学生。
初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式 1 = 0,
不是"不过滤")—— 那是这类查询最经典也最严重的写法错误。
E2E 专门造了个"同样是班主任但没被指定"的 T2:看不到待办、打不开详情、点不动通过。
② 有红问题不许通过(40032),且校验是**现场重算**的:提交时落库的 red_count 是快照,
而 OCR/识别是异步的、名册与材料规则也可能被改。"放过一份有问题材料"不可撤销。
③ 批量通过只带绿色项,红黄一律排除(onlyClean 服务端强制 true,传 false 不报错也不生效),
且跳过的每一条都要说清原因 —— 否则用户只会反复点那个大按钮。
三、红黄计数:列表用快照,动作/详情现场重算并**回写快照**(自己会收敛,最多错一次)
⚠️ 因此 detail() 不能标 readOnly=true —— 它要写回写。标了 PG 直接拒绝 UPDATE,
而这个 bug 只在"现场值与快照不一致"时炸,也就是最需要它工作的那一次。已踩并修。
四、⚠️ 三处文档与代码不一致,需要文档侧对齐(详见交接摘要 §一·二十七)
1. 撤回的动作码:DDL 注释 3=批量通过,而接口设计 §11.4 写"action = 3(撤回)"。
1~6 沿用 DDL,**撤回另取 7** —— 时间轴最不能混淆:把撤回显示成批量通过,
会让人以为这份单子被通过了。
2. 撤回的适用状态:§11.4 只列了状态 3;额外允许**状态 2**("班主任点错了通过"正是这个状态)。
状态机自洽:撤初审 → 1,撤终审 → 2。**需客户确认**。
3. 文件 source 的语义:DDL(2 班主任代传/3 管理员代传/4 整理器导入) 与 §10.6(source=4 代传)
冲突,以 DDL 为准;代传接口(M5-06)未做,时间轴已按 source∈{2,3} 预留。
五、顺带补掉两个"没人写过"的窟窿
· 名册行的 submit_status **全仓库只有读没有写** → 名册列表状态列恒为"未提交",
而 REPLACE 护栏只靠 bind_user_id 在生效。现在学生侧与审核侧都写它。
它**不是单调的**:被驳回(4)后重交必须回到 2,否则班主任一直以为他还欠一份。
· collect_submission 少映射了 class_audit_time / admin_audit_time(DDL 里一直有)。
口径是"**本轮**审核的状态"(驳回时清空,历史在 collect_audit_record 里一条不少)。
六、审核端看材料走单独通道
MaterialFileService 抽出 urlOf/contentOf(调用方授权),学生端仍 /mp/files/*、
审核端 /audit/files/*。不要为了"让管理员也能看"在学生那个方法里加 or 条件——那一刻学生端就漏了。
七、一个把「取值口径」讲清楚了的坑
SubmissionValidator 原本走 FillService.getForm(taskId)(学生视角:先过门禁、再拿自己的草稿),
审核人必然撞 40007 且拿到的是自己那份草稿。已抽 assembleFor(task, roster, sub),两条路径共用同一段装配。
紧接着暴露第二层:**改提交单的列不一定改变"组装出来的值"** —— 学生没动过的字段值会**回退到名册**。
想造红问题要用**名册里没有的字段**(E2E 最后用"本职业工作年限填 999"→ VAL-17)。
八、E2E:scripts/e2e-m5-audit.py → 87/87
复用了既有的两个脚本工具(import e2e-m3-submit 拿学生侧造数、import e2e-m2-roster 拿服务端工具),
不复制第二份 mint/upload。覆盖:范围隔离、详情与策略、40032 现场重算、幂等回放、
终审、批量通过的三种跳过、驳回与升级、学生重交、撤回的 40033/40034/40301 与"不能撤两次"。
夹具教训:身份证材料的目录默认长边下限是 1400,小图必判「清晰度偏低」=黄色警告,
于是"绿色单"前提不成立、批量断言集体失败 —— 失败的样子像功能坏了,其实只是夹具不满足前提。
九、没做的(都在计划内):代传(M5-06)、统计(M5-09)、通知(M5-12)、批量驳回/转交(P1)。
因为没有通知渠道,驳回响应**如实**回 notifySent=false + needManualNotice=true ——
不能为了让契约好看而写 true,那会让"学生到底收到没有"变成没人能回答的问题。
十、回归:全量 **753 条断言、0 失败**(新增 87);verify-docs 19 项通过。
下一步:M5 前端(班主任卡片流只右滑通过 / 学生详情 / 驳回页 / 一键通过确认 / PC 终审)。
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.controller;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||||
|
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
||||||
|
import cn.tianbaotong.fill.module.audit.service.AuditService;
|
||||||
|
import cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog;
|
||||||
|
import cn.tianbaotong.fill.module.file.service.MaterialFileService;
|
||||||
|
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.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端审核(《03-接口设计》§11)。
|
||||||
|
*
|
||||||
|
* <p>类上只做"粗筛"(初级或终审权限,满足其一即可)——因为<b>具体要哪一个权限取决于这一单的状态</b>:
|
||||||
|
* 待初审的单子要 {@code audit:first},待终审的要 {@code audit:final}。
|
||||||
|
* 这个分支没法在注解里表达,所以真正的判定在 {@code AuditService.requireAuditableNode}。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 这里有<b>两层</b>检查,缺一不可:<b>权限</b>(有没有这类动作的资格)
|
||||||
|
* 与<b>范围</b>(这一单归不归我)。全校几十个班主任都有初审权限,
|
||||||
|
* 但只有这个班的班主任能审这个学生 —— 只查权限就是把全校学生交给了每个班主任。
|
||||||
|
*/
|
||||||
|
@Tag(name = "管理端 · 审核")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/audit")
|
||||||
|
@RequirePerm({PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL})
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditController {
|
||||||
|
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
@GetMapping("/todos")
|
||||||
|
@Operation(summary = "待办列表",
|
||||||
|
description = "node=1 初审(只看到自己带的班)/ node=2 终审(全租户)。"
|
||||||
|
+ "counts 里的四个数字**不受 level 过滤影响**,否则点一下「有异常」就再也回不到「全部」。"
|
||||||
|
+ "batchApprovable 由服务端算好,前端不许自己判")
|
||||||
|
public ApiResult<AuditDtos.TodoPageVO> todos(
|
||||||
|
@RequestParam(defaultValue = "1") int node,
|
||||||
|
@RequestParam(required = false) Long taskId,
|
||||||
|
@RequestParam(required = false) Long orgId,
|
||||||
|
@RequestParam(required = false) Integer level,
|
||||||
|
@RequestParam(required = false) Integer waitingOverHours,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) Long pageNum,
|
||||||
|
@RequestParam(required = false) Long pageSize) {
|
||||||
|
return ApiResult.ok(auditService.todos(node, taskId, orgId, level,
|
||||||
|
waitingOverHours, keyword, pageNum, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{id}")
|
||||||
|
@Operation(summary = "审核详情",
|
||||||
|
description = "完整填报单 + 材料(含签名预览链接)+ 现场校验的问题清单 + 改动明细 + 时间轴。"
|
||||||
|
+ "**识别结果(detect)随本接口返回**(追溯矩阵 I10 的落点),M4 未做时该字段为 null")
|
||||||
|
public ApiResult<AuditDtos.DetailVO> detail(@PathVariable Long id) {
|
||||||
|
return ApiResult.ok(auditService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/approve")
|
||||||
|
@Operation(summary = "通过(初审 1→2 / 终审 2→3)",
|
||||||
|
description = "红问题存在时返回 40032(校验是**现场重算**的,不是提交时的快照)。"
|
||||||
|
+ "重复点通过是幂等的:返回 replayed=true 与首次相同的结果")
|
||||||
|
public ApiResult<AuditDtos.ApproveVO> approve(@PathVariable Long id,
|
||||||
|
@RequestBody(required = false) AuditDtos.ApproveReq req) {
|
||||||
|
return ApiResult.ok(auditService.approve(id, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/reject")
|
||||||
|
@Operation(summary = "驳回(→4)",
|
||||||
|
description = "reasonCode 取附录 C 的 10 条;选 R999 必须填 reasonText。"
|
||||||
|
+ "达驳回上限时 escalated=true(一期没有通知渠道,响应里如实地说明)")
|
||||||
|
public ApiResult<AuditDtos.RejectVO> reject(@PathVariable Long id,
|
||||||
|
@RequestBody AuditDtos.RejectReq req) {
|
||||||
|
return ApiResult.ok(auditService.reject(id, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/batch-approve")
|
||||||
|
@Operation(summary = "批量通过",
|
||||||
|
description = "**铁律:只带无问题的单,红黄一律排除**(服务端强制,前端传了红单也会被跳过并回报原因)。"
|
||||||
|
+ "单次 ≤200 条(超出返回 40011)")
|
||||||
|
public ApiResult<AuditDtos.BatchApproveVO> batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
|
||||||
|
return ApiResult.ok(auditService.batchApprove(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/revoke")
|
||||||
|
@Operation(summary = "撤回审核结论",
|
||||||
|
description = "只能撤回**最近一次通过**;已导出(40033)、已归档(40034)不可撤;"
|
||||||
|
+ "非原审核人且非管理员返回 40301")
|
||||||
|
public ApiResult<AuditDtos.RevokeVO> revoke(@PathVariable Long id,
|
||||||
|
@RequestBody AuditDtos.RevokeReq req) {
|
||||||
|
return ApiResult.ok(auditService.revoke(id, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{id}/timeline")
|
||||||
|
@Operation(summary = "审核时间轴", description = "倒序(最近的在最前)。含学生提交、初审、终审、撤回、代传")
|
||||||
|
public ApiResult<List<AuditDtos.TimelineItem>> timeline(@PathVariable Long id) {
|
||||||
|
return ApiResult.ok(auditService.timeline(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reason-codes")
|
||||||
|
@Operation(summary = "驳回原因字典",
|
||||||
|
description = "PRD 附录 C 的 10 条。⚠️ 契约里写的\"按 taskId 定制\"还没有配置入口,"
|
||||||
|
+ "所以不接收该参数 —— 假支持会让人配了半天发现不生效")
|
||||||
|
public ApiResult<List<AuditDtos.ReasonCodeVO>> reasonCodes() {
|
||||||
|
return ApiResult.ok(RejectReasonCatalog.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/files/{id}/url")
|
||||||
|
@Operation(summary = "取材料的访问链接(审核端)",
|
||||||
|
description = "七牛私有空间 → 签名链接(默认 30 分钟,**不要存库、不要转发**)。"
|
||||||
|
+ "授权口径:这份材料所属的填报单必须在我能看/能审的范围内")
|
||||||
|
public ApiResult<MaterialFileService.FileUrl> fileUrl(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean original,
|
||||||
|
@RequestParam(required = false) Long expireSeconds) {
|
||||||
|
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/files/{id}/content")
|
||||||
|
@Operation(summary = "取材料内容(审核端,本地存储预览用)",
|
||||||
|
description = "provider=qiniu 时走签名链接即可;本地开发用这个。两种实现都要求有审核/查看权限")
|
||||||
|
public ResponseEntity<byte[]> fileContent(@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean original) {
|
||||||
|
MaterialFileService.Content c = auditService.fileContent(id, original);
|
||||||
|
MediaType type = MediaTypeFactory.getMediaType(c.name())
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(type)
|
||||||
|
.header("Content-Disposition", "inline")
|
||||||
|
.body(c.bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.controller;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||||
|
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
||||||
|
import cn.tianbaotong.fill.module.audit.service.AuditService;
|
||||||
|
import cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班主任端审核(《03-接口设计》§10.2 ~ §10.5)。
|
||||||
|
*
|
||||||
|
* <p>与 PC 端({@code /api/v1/audit/*})<b>共用同一个服务</b>,差别只有三点:
|
||||||
|
* <ol>
|
||||||
|
* <li>这里只做<b>初审</b>({@code node} 固定为 1)—— 手机上不该出现"终审",
|
||||||
|
* 那是管理员在电脑上做的事(要对着几十份材料比对着看)</li>
|
||||||
|
* <li>权限只要 {@code audit:first}(班主任 / 副班主任)</li>
|
||||||
|
* <li>路径前缀是 {@code /mp/teacher}(小程序端)</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>左滑不做驳回</b>(客户确认过的原型):驳回必须点按钮 + 二次确认,
|
||||||
|
* 因为"通过"点错了还能撤回,而"驳回"点错了学生那边立刻收到通知,
|
||||||
|
* 一来一回的代价不对称。接口层面只做一件事:把 {@code actionSource}
|
||||||
|
* 记下来({@code SWIPE} / {@code BUTTON}),用来分析误触率。
|
||||||
|
*/
|
||||||
|
@Tag(name = "小程序 · 班主任审核")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/mp/teacher/audit")
|
||||||
|
@RequirePerm(PermissionDef.AUDIT_FIRST)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MpTeacherAuditController {
|
||||||
|
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
@GetMapping("/todos")
|
||||||
|
@Operation(summary = "待审卡片流",
|
||||||
|
description = "只返回**归我审的班**的待初审单子(权限不等于范围:全校几十个班主任都有初审权限,"
|
||||||
|
+ "但只有这个班的那位能审这个学生)。默认每页 10 张卡片")
|
||||||
|
public ApiResult<AuditDtos.TodoPageVO> todos(
|
||||||
|
@RequestParam(required = false) Long taskId,
|
||||||
|
@RequestParam(required = false) Long orgId,
|
||||||
|
@RequestParam(required = false) Integer level,
|
||||||
|
@RequestParam(required = false) Integer waitingOverHours,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) Long pageNum,
|
||||||
|
@RequestParam(required = false, defaultValue = "10") Long pageSize) {
|
||||||
|
return ApiResult.ok(auditService.todos(AuditService.NODE_CLASS, taskId, orgId, level,
|
||||||
|
waitingOverHours, keyword, pageNum, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{submissionId}")
|
||||||
|
@Operation(summary = "学生详情(材料预览 + 问题清单 + 变更高亮)",
|
||||||
|
description = "与 PC 端同一个 VO。预览链接是**签名 URL**(30 分钟),不要缓存到本地")
|
||||||
|
public ApiResult<AuditDtos.DetailVO> detail(@PathVariable Long submissionId) {
|
||||||
|
return ApiResult.ok(auditService.detail(submissionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{submissionId}/approve")
|
||||||
|
@Operation(summary = "通过(右滑或按钮)",
|
||||||
|
description = "actionSource 记 SWIPE/BUTTON 用于埋点。红问题存在时返回 40032")
|
||||||
|
public ApiResult<AuditDtos.ApproveVO> approve(@PathVariable Long submissionId,
|
||||||
|
@RequestBody(required = false) AuditDtos.ApproveReq req) {
|
||||||
|
return ApiResult.ok(auditService.approve(submissionId, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{submissionId}/reject")
|
||||||
|
@Operation(summary = "驳回(必须点按钮 + 二次确认)",
|
||||||
|
description = "reasonCode 必填(附录 C);R999 必须填 reasonText")
|
||||||
|
public ApiResult<AuditDtos.RejectVO> reject(@PathVariable Long submissionId,
|
||||||
|
@RequestBody AuditDtos.RejectReq req) {
|
||||||
|
return ApiResult.ok(auditService.reject(submissionId, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch-approve")
|
||||||
|
@Operation(summary = "一键通过(只带绿色项)",
|
||||||
|
description = "**铁律:红黄一律排除**。返回里逐条说明哪几个被跳过、为什么 —— "
|
||||||
|
+ "只回\"成功 3 条、跳过 1 条\"的话,用户只会反复点")
|
||||||
|
public ApiResult<AuditDtos.BatchApproveVO> batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
|
||||||
|
return ApiResult.ok(auditService.batchApprove(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reason-codes")
|
||||||
|
@Operation(summary = "驳回原因字典(附录 C 的 10 条)")
|
||||||
|
public ApiResult<List<AuditDtos.ReasonCodeVO>> reasonCodes() {
|
||||||
|
return ApiResult.ok(RejectReasonCatalog.list());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.dto;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.fill.dto.SubmitDtos;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核相关的请求 / 响应(《03-接口设计》§10.2 ~ §10.6、§11.1 ~ §11.7)。
|
||||||
|
*
|
||||||
|
* <h3>为什么 MP(班主任)与 PC(管理员)共用同一份 VO</h3>
|
||||||
|
*
|
||||||
|
* <p>契约里是两处(§10.2 的卡片流、§11.1 的待审表),但字段重合九成以上,
|
||||||
|
* 差异只是"这一端用不到哪几个"。做两份的代价不是多写一个类,而是<b>迟早分叉</b>:
|
||||||
|
* 一边加了"红问题摘要",另一边没有,于是同一个学生在手机上看到红、
|
||||||
|
* 在电脑上看到绿 —— 而这类不一致最容易让人不信任系统。
|
||||||
|
*
|
||||||
|
* <p>所以:一个超集 VO,两端各取所需。<b>字段只增不减</b>,
|
||||||
|
* 加字段时同步两端的展示说明。
|
||||||
|
*/
|
||||||
|
public final class AuditDtos {
|
||||||
|
|
||||||
|
private AuditDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 待办列表 ──────────────────────────────────
|
||||||
|
|
||||||
|
/** 待办卡片。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "待办卡片")
|
||||||
|
public static class TodoVO {
|
||||||
|
|
||||||
|
private Long submissionId;
|
||||||
|
|
||||||
|
private Long rosterId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String taskName;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 头像。一期没有头像(学生不填),恒为 null,保留字段供二期 */
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
private Long orgId;
|
||||||
|
|
||||||
|
private String orgName;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
/** 已等待小时数(保留 1 位小数)—— 列表按它正序,等得最久的排最前 */
|
||||||
|
private Double waitHours;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红黄绿分级:{@code 0} 无问题 / {@code 2} 仅警告 / {@code 3} 有红色。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>没有 {@code 1}</b> —— 契约 §10.2 就是这三档,
|
||||||
|
* 前端不要写"1 = 蓝色/信息级"的分支(写了永远不会触发)。
|
||||||
|
*/
|
||||||
|
private Integer level;
|
||||||
|
|
||||||
|
private Integer redCount;
|
||||||
|
|
||||||
|
private Integer yellowCount;
|
||||||
|
|
||||||
|
/** 红问题的第一句(卡片上直接显示"身份证号与名册不一致");无红时为 null */
|
||||||
|
private String redSummary;
|
||||||
|
|
||||||
|
/** 材料缩略图(签名链接,**有有效期**,不要缓存到本地或存库) */
|
||||||
|
private List<String> materialThumbs;
|
||||||
|
|
||||||
|
private Integer materialCount;
|
||||||
|
|
||||||
|
/** 学生改过的预填字段(审核时要一眼看到"这不是老师填的那份") */
|
||||||
|
private List<String> changedFields;
|
||||||
|
|
||||||
|
private Integer rejectCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 能否【批量通过】。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>由服务端算好,前端不许自己判</b>(契约 §10.2 的原文)。
|
||||||
|
* 规则是"红黄都为零",而前端只看得见红黄计数、看不见
|
||||||
|
* "这个计数是什么时候算的"。规则不落地到前端,才不会出现
|
||||||
|
* "前端说能、服务端说不能"这种让人反复点按钮的僵局。
|
||||||
|
*/
|
||||||
|
private Boolean batchApprovable;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待办列表分页结果。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "待办列表")
|
||||||
|
public static class TodoPageVO {
|
||||||
|
|
||||||
|
private List<TodoVO> list;
|
||||||
|
|
||||||
|
private long total;
|
||||||
|
|
||||||
|
private Counts counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部四个计数。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "待办计数")
|
||||||
|
public static class Counts {
|
||||||
|
|
||||||
|
private long all;
|
||||||
|
|
||||||
|
/** 无问题(可直接批量通过的那批) */
|
||||||
|
private long clean;
|
||||||
|
|
||||||
|
/** 有红或有黄(需要人看的那批) */
|
||||||
|
private long abnormal;
|
||||||
|
|
||||||
|
/** 已超过审核时限还没处理(时限取审计配置里的初审/终审小时数) */
|
||||||
|
private long overTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 详情 ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** 审核详情(§11.2)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "审核详情")
|
||||||
|
public static class DetailVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String statusText;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String taskName;
|
||||||
|
|
||||||
|
private Long orgId;
|
||||||
|
|
||||||
|
private String orgName;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 脱敏证件号。完整号要走单独接口并留痕(§3 的安全约定),审核详情里不给 */
|
||||||
|
private String idCardMask;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
private String occupation;
|
||||||
|
|
||||||
|
private String worktype;
|
||||||
|
|
||||||
|
private String jobLevel;
|
||||||
|
|
||||||
|
/** 表单值(内置 + 扩展),键是 snake_case */
|
||||||
|
private Map<String, Object> formData;
|
||||||
|
|
||||||
|
private List<String> changedFields;
|
||||||
|
|
||||||
|
/** 改动明细:老师填的是什么、学生改成了什么 */
|
||||||
|
private List<ChangedItem> changedDetail;
|
||||||
|
|
||||||
|
private List<MaterialGroup> materials;
|
||||||
|
|
||||||
|
/** 现场校验的问题清单(红/黄/蓝)。⚠️ 不是提交那一刻的快照,见 AuditService 的说明 */
|
||||||
|
private List<SubmitDtos.Issue> issues;
|
||||||
|
|
||||||
|
private Integer redCount;
|
||||||
|
|
||||||
|
private Integer yellowCount;
|
||||||
|
|
||||||
|
private Integer rejectCount;
|
||||||
|
|
||||||
|
private String lastRejectCode;
|
||||||
|
|
||||||
|
private String lastRejectText;
|
||||||
|
|
||||||
|
private ApprovePolicy approvePolicy;
|
||||||
|
|
||||||
|
private RevokePolicy revokePolicy;
|
||||||
|
|
||||||
|
private List<TimelineItem> timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一处改动。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ChangedItem {
|
||||||
|
|
||||||
|
private String field;
|
||||||
|
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
/** 名册里老师填的值(脱敏后的展示值) */
|
||||||
|
private String rosterValue;
|
||||||
|
|
||||||
|
/** 学生提交的值 */
|
||||||
|
private String studentValue;
|
||||||
|
|
||||||
|
/** ⚠️ 一期恒为 null:{@code changed_fields} 存的是字段名数组,没有逐字段的时间 */
|
||||||
|
private LocalDateTime changedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个材料项及其文件。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class MaterialGroup {
|
||||||
|
|
||||||
|
private String materialCode;
|
||||||
|
|
||||||
|
private String materialName;
|
||||||
|
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
/** 是否已交(有当前生效的文件) */
|
||||||
|
private Boolean done;
|
||||||
|
|
||||||
|
/** 条件材料是否对学生可见(不可见的材料不该出现在审核页上要求他交) */
|
||||||
|
private Boolean visible;
|
||||||
|
|
||||||
|
private List<FileItem> files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个文件。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class FileItem {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String originalName;
|
||||||
|
|
||||||
|
private String standardName;
|
||||||
|
|
||||||
|
/** 预览链接(签名,默认 30 分钟;七牛私有空间**不能长期保存**) */
|
||||||
|
private String previewUrl;
|
||||||
|
|
||||||
|
private String thumbUrl;
|
||||||
|
|
||||||
|
private Long finalSizeKb;
|
||||||
|
|
||||||
|
private Long maxSizeKb;
|
||||||
|
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
private Integer dpi;
|
||||||
|
|
||||||
|
/** {@code 0} 未校验 / {@code 1} 通过 / {@code 2} 警告 / {@code 3} 错误 */
|
||||||
|
private Integer validateStatus;
|
||||||
|
|
||||||
|
/** 上传来源:{@code 1} 本人 / {@code 2} 班主任代传 / {@code 3} 管理员代传 / {@code 4} 整理器导入 */
|
||||||
|
private Integer source;
|
||||||
|
|
||||||
|
/** 材料识别结果(M4 未做时字段存在但为空,前端要能接受 null) */
|
||||||
|
private Detect detect;
|
||||||
|
|
||||||
|
/** 文件级问题 */
|
||||||
|
private List<SubmitDtos.Issue> issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 材料识别结果(追溯矩阵 I10 的落点,随详情一起返回)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Detect {
|
||||||
|
|
||||||
|
private String materialCode;
|
||||||
|
|
||||||
|
private Double confidence;
|
||||||
|
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
private String sourceText;
|
||||||
|
|
||||||
|
private Boolean conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个单子现在能不能通过。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ApprovePolicy {
|
||||||
|
|
||||||
|
private Boolean canApprove;
|
||||||
|
|
||||||
|
private Boolean canBatch;
|
||||||
|
|
||||||
|
/** 不能通过时的一句话原因(红问题 / 不是我的班 / 状态不对) */
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
/** 阻断级别的红问题标题,前端直接列出来 */
|
||||||
|
private List<String> redTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个单子现在能不能撤回。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class RevokePolicy {
|
||||||
|
|
||||||
|
private Boolean canRevoke;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
/** 能撤回的话,撤哪条记录(前端原样回传即可) */
|
||||||
|
private Long auditRecordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间轴一条。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class TimelineItem {
|
||||||
|
|
||||||
|
/** 记录 id;{@code 0} 节点(学生提交)没有记录 id */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** {@code 0} 学生提交 / {@code 1} 初审 / {@code 2} 终审 / {@code 3} 材料 */
|
||||||
|
private Integer node;
|
||||||
|
|
||||||
|
private String nodeText;
|
||||||
|
|
||||||
|
/** {@link cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord} 的 {@code ACTION_*} */
|
||||||
|
private Integer action;
|
||||||
|
|
||||||
|
private String actionText;
|
||||||
|
|
||||||
|
private Long auditorId;
|
||||||
|
|
||||||
|
private String auditorName;
|
||||||
|
|
||||||
|
private String reasonCode;
|
||||||
|
|
||||||
|
private String reasonText;
|
||||||
|
|
||||||
|
private List<String> materialCodes;
|
||||||
|
|
||||||
|
private LocalDateTime time;
|
||||||
|
|
||||||
|
/** 处理耗时(秒) */
|
||||||
|
private Integer durationSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 请求 / 动作结果 ───────────────────────────
|
||||||
|
|
||||||
|
/** 通过(§10.3 / §11.3)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "审核通过")
|
||||||
|
public static class ApproveReq {
|
||||||
|
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/** {@code SWIPE} 右滑 / {@code BUTTON} 按钮 —— 只用于埋点分析误触率 */
|
||||||
|
private String actionSource;
|
||||||
|
|
||||||
|
/** 终审意见(§11.3 的终审额外字段) */
|
||||||
|
private String finalOpinion;
|
||||||
|
|
||||||
|
/** 终审额外字段,本轮未使用(保留契约形状) */
|
||||||
|
private Boolean needArchive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过的结果。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ApproveVO {
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String statusText;
|
||||||
|
|
||||||
|
private String nextNode;
|
||||||
|
|
||||||
|
private String nextNodeText;
|
||||||
|
|
||||||
|
/** true = 这次是重复请求的幂等回放(结果与第一次相同),不是真的又审了一次 */
|
||||||
|
private Boolean replayed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驳回(§10.4)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "审核驳回")
|
||||||
|
public static class RejectReq {
|
||||||
|
|
||||||
|
/** PRD 附录 C 的原因码,如 {@code R001} */
|
||||||
|
private String reasonCode;
|
||||||
|
|
||||||
|
/** 补充说明;{@code R999} 时必填 */
|
||||||
|
private String reasonText;
|
||||||
|
|
||||||
|
/** 是哪几项材料的问题 */
|
||||||
|
private List<String> materialCodes;
|
||||||
|
|
||||||
|
/** 语音转文字结果(可选输入方式) */
|
||||||
|
private String voiceText;
|
||||||
|
|
||||||
|
private Boolean suggestReupload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驳回的结果。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class RejectVO {
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String statusText;
|
||||||
|
|
||||||
|
private Integer rejectCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知是否已发出。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 一期恒为 {@code false}:订阅消息(M5-12)还没做。
|
||||||
|
* <b>不能为了让契约好看而写 true</b> —— 那会让"学生到底收到没有"
|
||||||
|
* 变成一个没人能回答的问题,而家长第二天就会打电话问。
|
||||||
|
*/
|
||||||
|
private Boolean notifySent;
|
||||||
|
|
||||||
|
/** 学生是否**没**收到通知(前端据此显示"请口头告知学生") */
|
||||||
|
private Boolean needManualNotice;
|
||||||
|
|
||||||
|
/** 驳回次数是否已达上限(达上限同时通知管理员,见 §10.4 的升级规则) */
|
||||||
|
private Boolean escalated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量通过(§10.5)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "批量通过")
|
||||||
|
public static class BatchApproveReq {
|
||||||
|
|
||||||
|
private List<Long> submissionIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否只通过无问题项。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>服务端强制 true</b>:客户端传 false 会被改回 true。
|
||||||
|
* 这是铁律(交接摘要 §二·9)—— 批量通过只能带绿色项,红黄一律排除。
|
||||||
|
* 字段保留只是为了契约形状一致。
|
||||||
|
*/
|
||||||
|
private Boolean onlyClean;
|
||||||
|
|
||||||
|
private String actionSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量通过的结果。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class BatchApproveVO {
|
||||||
|
|
||||||
|
private int approved;
|
||||||
|
|
||||||
|
private int skipped;
|
||||||
|
|
||||||
|
private List<BatchSkip> details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被跳过的原因(**必须逐条说清**,否则用户只会看到"选了 5 个,过了 3 个")。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class BatchSkip {
|
||||||
|
|
||||||
|
private Long submissionId;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
private boolean skipped;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
private Integer redCount;
|
||||||
|
|
||||||
|
private Integer yellowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤回(§11.4)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "撤回审核结论")
|
||||||
|
public static class RevokeReq {
|
||||||
|
|
||||||
|
private Long auditRecordId;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤回的结果。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class RevokeVO {
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String statusText;
|
||||||
|
|
||||||
|
private Long revokedRecordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驳回原因字典一项(§11.7)。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ReasonCodeVO {
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
/** true = 选中它必须填补充说明(只有 {@code R999}) */
|
||||||
|
private boolean requiresText;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办列表的一行(Mapper 直出,不是响应 VO)。
|
||||||
|
*
|
||||||
|
* <p>分开是因为它们回答的问题不同:这里只装"列表要用的列",
|
||||||
|
* 而 {@link AuditDtos.TodoVO} 还要装"算出来的东西"
|
||||||
|
* (等待小时数、红黄绿分级、材料缩略图、能否批量通过)。
|
||||||
|
* 把两者合成一个类,读代码的人就分不清哪些字段来自数据库、哪些是推导的。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class AuditTodoRow {
|
||||||
|
|
||||||
|
private Long submissionId;
|
||||||
|
|
||||||
|
private Long rosterId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String taskName;
|
||||||
|
|
||||||
|
private Long orgId;
|
||||||
|
|
||||||
|
private String orgName;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
private String idCardMask;
|
||||||
|
|
||||||
|
/** {@code collect_submission.status}。 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
private Integer redCount;
|
||||||
|
|
||||||
|
private Integer yellowCount;
|
||||||
|
|
||||||
|
private Integer rejectCount;
|
||||||
|
|
||||||
|
/** 学生改过的预填字段(JSON 数组字符串,可能为 null)。 */
|
||||||
|
private String changedFields;
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核记录(对应 {@code collect_audit_record})—— <b>审核时间轴的唯一来源</b>。
|
||||||
|
*
|
||||||
|
* <p>它只增不改(没有 {@code deleted} / {@code update_time} 列)。这不是省事,
|
||||||
|
* 而是语义:审核结论是**发生过的事实**。撤回不是"改掉那条记录",
|
||||||
|
* 而是<b>再写一条撤回记录</b> —— 于是时间轴上永远能看出
|
||||||
|
* "谁在什么时候通过了、后来谁在什么时候撤回了、为什么"。
|
||||||
|
* 把上一行删掉就只剩一个"当前状态",而状态是看不出这些的。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>本实体刻意不继承 {@code TenantEntity}</b>:那张基类带 {@code @TableLogic deleted},
|
||||||
|
* 会让每条查询自动加上 {@code deleted = 0},而这张表<b>没有这一列</b> ——
|
||||||
|
* 表现是运行时 {@code column deleted does not exist}。同 {@code CollectTaskOrg}。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code action = 3} 的语义在文档与 DDL 之间<b>冲突</b>,见 {@link #ACTION_REVOKE}。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("collect_audit_record")
|
||||||
|
public class CollectAuditRecord implements Serializable {
|
||||||
|
|
||||||
|
/** 初审(班主任)。 */
|
||||||
|
public static final String NODE_CLASS = "CLASS_AUDIT";
|
||||||
|
|
||||||
|
/** 终审(管理员)。 */
|
||||||
|
public static final String NODE_ADMIN = "ADMIN_AUDIT";
|
||||||
|
|
||||||
|
/** 通过。 */
|
||||||
|
public static final int ACTION_APPROVE = 1;
|
||||||
|
|
||||||
|
/** 驳回。 */
|
||||||
|
public static final int ACTION_REJECT = 2;
|
||||||
|
|
||||||
|
/** 批量通过。 */
|
||||||
|
public static final int ACTION_BATCH_APPROVE = 3;
|
||||||
|
|
||||||
|
/** 批量驳回。 */
|
||||||
|
public static final int ACTION_BATCH_REJECT = 4;
|
||||||
|
|
||||||
|
/** 转交(P1,未实现)。 */
|
||||||
|
public static final int ACTION_TRANSFER = 5;
|
||||||
|
|
||||||
|
/** 代传材料(P1,未实现)。 */
|
||||||
|
public static final int ACTION_PROXY_UPLOAD = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>这个值是我定的,因为文档与 DDL 冲突</b>:
|
||||||
|
* DDL 的注释写的是 {@code 1通过 2驳回 3批量通过 4批量驳回 5转交 6代传},
|
||||||
|
* 而《03-接口设计》§11.4 写"写入一条 <b>action = 3(撤回)</b>的记录"。
|
||||||
|
* 两者对 3 的解释正好相反 —— 而时间轴恰恰是<b>最不能混淆</b>的地方:
|
||||||
|
* 把"撤回"显示成"批量通过",等于让人以为这份单子被通过了。
|
||||||
|
*
|
||||||
|
* <p>所以:1~6 沿用 DDL(它们已发布、可能已有数据),撤回另取 <b>7</b>,
|
||||||
|
* 并在此写明冲突。这条会同步进交接摘要,供客户/文档侧对齐。
|
||||||
|
*/
|
||||||
|
public static final int ACTION_REVOKE = 7;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField(value = "tenant_id", fill = FieldFill.INSERT)
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private Long submissionId;
|
||||||
|
|
||||||
|
/** {@link #NODE_CLASS} / {@link #NODE_ADMIN}。 */
|
||||||
|
private String auditNode;
|
||||||
|
|
||||||
|
private Long auditorId;
|
||||||
|
|
||||||
|
/** 见上面各 {@code ACTION_*} 常量。 */
|
||||||
|
private Integer action;
|
||||||
|
|
||||||
|
/** 驳回原因编码(PRD 附录 C),只有驳回有。 */
|
||||||
|
private String reasonCode;
|
||||||
|
|
||||||
|
/** 驳回补充说明 / 撤回原因。 */
|
||||||
|
private String reasonText;
|
||||||
|
|
||||||
|
/** 被驳回的材料项,逗号分隔。 */
|
||||||
|
private String materialCodes;
|
||||||
|
|
||||||
|
/** 处理耗时(秒)—— 从学生提交到本次审核。 */
|
||||||
|
private Integer durationSec;
|
||||||
|
|
||||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核动作的前置检查:<b>这份数据是不是已经交付过了</b>。
|
||||||
|
*
|
||||||
|
* <p>「已导出打包 → 不可撤回」({@code 40033})这条约束需要一个"这个任务导出过没有"的查询。
|
||||||
|
* 导出模块(M6)还没做,而只有审核端用得到,所以先放在这里,
|
||||||
|
* <b>M6 落地后应把它搬到导出模块</b>(那时导出模块才是 {@code collect_export_task} 的归属方)。
|
||||||
|
*/
|
||||||
|
public interface AuditPreconditionMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个任务有没有<b>已完成</b>的导出包。
|
||||||
|
*
|
||||||
|
* <p>{@code status} 的取值见 DDL:{@code 0} 排队 / {@code 1} 处理中 / {@code 2} 完成 / {@code 3} 失败。
|
||||||
|
* 只把"完成"算数 —— 排队中或失败的那次没有产生任何交付物,
|
||||||
|
* 拿它去阻止撤回会让人莫名其妙("我明明没导出成功")。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM collect_export_task
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND status = 2
|
||||||
|
""")
|
||||||
|
long countFinishedExports(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.mapper;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核记录 Mapper。
|
||||||
|
*
|
||||||
|
* <p>没有任何手写 SQL:时间轴(按填报单倒序列出)与"最近一次结论"
|
||||||
|
* 都能用 {@code LambdaQueryWrapper} 表达,而**用 Wrapper 就自动带租户条件**
|
||||||
|
* ({@code sys_tenant} 之外的业务表都会被拦截器补上 {@code tenant_id = ?})。
|
||||||
|
* 手写 SQL 的场合才需要显式声明隔离条件 —— 那几处集中在
|
||||||
|
* {@link AuditScopeMapper} 与 {@link AuditTodoMapper},并有注释说明。
|
||||||
|
*/
|
||||||
|
public interface AuditRecordMapper extends BaseMapper<CollectAuditRecord> {
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.mapper;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核范围:<b>"这个人能审哪些班"</b>。
|
||||||
|
*
|
||||||
|
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
|
||||||
|
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
|
||||||
|
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
|
||||||
|
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
|
||||||
|
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
|
||||||
|
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
|
||||||
|
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
|
||||||
|
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
|
||||||
|
* 集中放置、显式带条件、并写清为什么 —— 同 {@code MpSceneMapper}。
|
||||||
|
*/
|
||||||
|
public interface AuditScopeMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在这个任务里,我能初审的班级 id。
|
||||||
|
*
|
||||||
|
* @return 班级 id;<b>空列表表示"一个班都不归我审"</b>。
|
||||||
|
* 调用方必须把它当成"看不见任何单子",而不是"不过滤"
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT cto.org_id
|
||||||
|
FROM collect_task_org cto
|
||||||
|
LEFT JOIN sys_org o
|
||||||
|
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||||
|
WHERE cto.tenant_id = #{tenantId}
|
||||||
|
AND cto.status = 1
|
||||||
|
<if test="taskId != null">
|
||||||
|
AND cto.task_id = #{taskId}
|
||||||
|
</if>
|
||||||
|
AND (
|
||||||
|
cto.head_user_id = #{userId}
|
||||||
|
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||||
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
|
)
|
||||||
|
ORDER BY cto.org_id
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<Long> classAuditOrgIds(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("taskId") Long taskId,
|
||||||
|
@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在我能初审的班里,是否有这一个班(单点判断,避免为一次校验拉全列表)。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM collect_task_org cto
|
||||||
|
LEFT JOIN sys_org o
|
||||||
|
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||||
|
WHERE cto.tenant_id = #{tenantId}
|
||||||
|
AND cto.task_id = #{taskId}
|
||||||
|
AND cto.org_id = #{orgId}
|
||||||
|
AND cto.status = 1
|
||||||
|
AND (
|
||||||
|
cto.head_user_id = #{userId}
|
||||||
|
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||||
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
long countMyOrg(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("taskId") Long taskId,
|
||||||
|
@Param("orgId") Long orgId,
|
||||||
|
@Param("userId") Long userId);
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.mapper;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办列表(班主任初审 / 管理员终审<b>共用一套查询</b>)。
|
||||||
|
*
|
||||||
|
* <p>两个端(小程序卡片流、PC 终审表)问的是同一个问题 ——
|
||||||
|
* "现在轮到我处理的单子有哪些"。差异只有三个过滤条件与排序,
|
||||||
|
* 所以做成一套查询 + 参数化,而不是两份 SQL:两份 SQL 一旦分叉,
|
||||||
|
* 迟早出现"小程序看得到、PC 看不到某一单"这种没人解释得清的现象。
|
||||||
|
*
|
||||||
|
* <h3>⚠️ 关掉租户拦截器,隔离条件在这里显式写明</h3>
|
||||||
|
*
|
||||||
|
* <p>这是一条带 3 个 join 的手写 SQL,隔离条件按本仓库的规矩集中写清、可审:
|
||||||
|
* <ul>
|
||||||
|
* <li>主表 {@code s.tenant_id = #{tenantId}}</li>
|
||||||
|
* <li>每个 join 的表都带 {@code tenant_id = #{tenantId}} ——
|
||||||
|
* 只靠 join 键相等是不够的:id 是各租户自增的,
|
||||||
|
* 万一哪天有个脏数据指向别的租户的名册行,join 就会把别人的姓名带出来</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>⚠️ {@code orgIds} 的"空列表"必须当成"看不见"</h3>
|
||||||
|
*
|
||||||
|
* <p>{@code orgIds = []} 与 {@code orgIds = null} 在业务上<b>完全不同</b>:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code null} = 不按班级过滤(终审:全租户)</li>
|
||||||
|
* <li>{@code []} = 这个老师一个班都不带 → <b>应该看不见任何单子</b></li>
|
||||||
|
* </ul>
|
||||||
|
* 把空列表当成"不过滤"是这类查询最经典、也最严重的写法错误:
|
||||||
|
* 一个刚被移出所有班级的班主任会突然看到全校的单子。
|
||||||
|
* 所以 SQL 里为它单独写了 {@code 1 = 0}(失败关闭),而不是静默放过。
|
||||||
|
*/
|
||||||
|
public interface AuditTodoMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询待办。
|
||||||
|
*
|
||||||
|
* @param status 要查的填报单状态({@code 1} 待初审 / {@code 2} 待终审)
|
||||||
|
* @param orgIds {@code null} = 全租户(终审);非 null = 只在这些班里(初审)。空列表 = 看不见
|
||||||
|
* @param level {@code 0} 无问题 / {@code 2} 仅警告 / {@code 3} 有红色;{@code null} = 全部
|
||||||
|
* @param keyword 姓名模糊搜索
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT s.id AS submission_id,
|
||||||
|
s.roster_id AS roster_id,
|
||||||
|
s.task_id AS task_id,
|
||||||
|
t.task_name AS task_name,
|
||||||
|
s.org_id AS org_id,
|
||||||
|
o.org_name AS org_name,
|
||||||
|
r.real_name AS real_name,
|
||||||
|
r.id_card_mask AS id_card_mask,
|
||||||
|
s.status AS status,
|
||||||
|
s.submit_time AS submit_time,
|
||||||
|
s.red_count AS red_count,
|
||||||
|
s.yellow_count AS yellow_count,
|
||||||
|
s.reject_count AS reject_count,
|
||||||
|
s.changed_fields AS changed_fields
|
||||||
|
FROM collect_submission s
|
||||||
|
LEFT JOIN collect_roster r
|
||||||
|
ON r.id = s.roster_id AND r.deleted = 0 AND r.tenant_id = #{tenantId}
|
||||||
|
LEFT JOIN sys_org o
|
||||||
|
ON o.id = s.org_id AND o.deleted = 0 AND o.tenant_id = #{tenantId}
|
||||||
|
LEFT JOIN collect_task t
|
||||||
|
ON t.id = s.task_id AND t.deleted = 0 AND t.tenant_id = #{tenantId}
|
||||||
|
WHERE s.tenant_id = #{tenantId}
|
||||||
|
AND s.deleted = 0
|
||||||
|
AND s.status = #{status}
|
||||||
|
<if test="taskId != null">
|
||||||
|
AND s.task_id = #{taskId}
|
||||||
|
</if>
|
||||||
|
<if test="orgId != null">
|
||||||
|
AND s.org_id = #{orgId}
|
||||||
|
</if>
|
||||||
|
<choose>
|
||||||
|
<when test="orgIds != null and orgIds.size() > 0">
|
||||||
|
AND s.org_id IN
|
||||||
|
<foreach item="oid" collection="orgIds" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</when>
|
||||||
|
<when test="orgIds != null">
|
||||||
|
AND 1 = 0
|
||||||
|
</when>
|
||||||
|
</choose>
|
||||||
|
<if test="level != null and level == 0">
|
||||||
|
AND s.red_count = 0 AND s.yellow_count = 0
|
||||||
|
</if>
|
||||||
|
<if test="level != null and level == 2">
|
||||||
|
AND s.red_count = 0 AND s.yellow_count > 0
|
||||||
|
</if>
|
||||||
|
<if test="level != null and level == 3">
|
||||||
|
AND s.red_count > 0
|
||||||
|
</if>
|
||||||
|
<if test="keyword != null and keyword != ''">
|
||||||
|
AND r.real_name LIKE ('%' || #{keyword} || '%')
|
||||||
|
</if>
|
||||||
|
<if test="waitingOverHours != null">
|
||||||
|
AND s.submit_time < now() - make_interval(hours => #{waitingOverHours})
|
||||||
|
</if>
|
||||||
|
ORDER BY s.submit_time ASC, s.id ASC
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
IPage<AuditTodoRow> pageTodos(IPage<AuditTodoRow> page,
|
||||||
|
@Param("tenantId") Long tenantId,
|
||||||
|
@Param("status") Integer status,
|
||||||
|
@Param("taskId") Long taskId,
|
||||||
|
@Param("orgId") Long orgId,
|
||||||
|
@Param("orgIds") List<Long> orgIds,
|
||||||
|
@Param("level") Integer level,
|
||||||
|
@Param("keyword") String keyword,
|
||||||
|
@Param("waitingOverHours") Integer waitingOverHours);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一批过滤条件下的四个计数(用于列表页顶部的"全部 / 无问题 / 有异常 / 超时")。
|
||||||
|
*
|
||||||
|
* <p><b>刻意不受 {@code level} 影响</b>:这几个数字是给用户"点哪一档"用的,
|
||||||
|
* 点进去之后再按 level 过滤。跟着 level 变的话,点一下"有异常",
|
||||||
|
* "全部"就变成了 1 —— 那样他再也回不到全部,只能刷新页面。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT COUNT(*) AS all_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.red_count = 0 AND s.yellow_count = 0) AS clean_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.red_count > 0 OR s.yellow_count > 0) AS abnormal_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.submit_time < now() - make_interval(hours => #{timeoutHours}))
|
||||||
|
AS overtime_count
|
||||||
|
FROM collect_submission s
|
||||||
|
LEFT JOIN collect_roster r
|
||||||
|
ON r.id = s.roster_id AND r.deleted = 0 AND r.tenant_id = #{tenantId}
|
||||||
|
WHERE s.tenant_id = #{tenantId}
|
||||||
|
AND s.deleted = 0
|
||||||
|
AND s.status = #{status}
|
||||||
|
<if test="taskId != null">
|
||||||
|
AND s.task_id = #{taskId}
|
||||||
|
</if>
|
||||||
|
<if test="orgId != null">
|
||||||
|
AND s.org_id = #{orgId}
|
||||||
|
</if>
|
||||||
|
<choose>
|
||||||
|
<when test="orgIds != null and orgIds.size() > 0">
|
||||||
|
AND s.org_id IN
|
||||||
|
<foreach item="oid" collection="orgIds" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</when>
|
||||||
|
<when test="orgIds != null">
|
||||||
|
AND 1 = 0
|
||||||
|
</when>
|
||||||
|
</choose>
|
||||||
|
<if test="keyword != null and keyword != ''">
|
||||||
|
AND r.real_name LIKE ('%' || #{keyword} || '%')
|
||||||
|
</if>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
Map<String, Object> counts(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("status") Integer status,
|
||||||
|
@Param("taskId") Long taskId,
|
||||||
|
@Param("orgId") Long orgId,
|
||||||
|
@Param("orgIds") List<Long> orgIds,
|
||||||
|
@Param("keyword") String keyword,
|
||||||
|
@Param("timeoutHours") Integer timeoutHours);
|
||||||
|
}
|
||||||
+1377
File diff suppressed because it is too large
Load Diff
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.service;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回事由字典(PRD 附录 C 的 10 条)。
|
||||||
|
*
|
||||||
|
* <p><b>为什么写在代码里而不是建一张字典表</b>:和权限码同理(见 {@code PermissionDef})——
|
||||||
|
* 它是<b>前后端的契约</b>,而且驳回是"把学生的材料打回去重做"的动作,
|
||||||
|
* 理由文案一旦能被某个人在后台改掉,历史记录里那句"材料不是本人"就再也无法解释清楚了。
|
||||||
|
* 契约 §11.7 说"仅返回启用的、可按 taskId 定制",这两件事都还没有配置入口 ——
|
||||||
|
* 所以现在返回全部 10 条,<b>不假装支持定制</b>(假支持会让机构配了半天发现不生效)。
|
||||||
|
*/
|
||||||
|
public final class RejectReasonCatalog {
|
||||||
|
|
||||||
|
private RejectReasonCatalog() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「其他」的码:选它必须手填说明,否则学生收到一条"其他"等于没收到。 */
|
||||||
|
public static final String CODE_OTHER = "R999";
|
||||||
|
|
||||||
|
private static final Map<String, String> TEXTS = Map.of(
|
||||||
|
"R001", "材料不清晰,请重新提供(建议找打印店重新扫描或索取原图)",
|
||||||
|
"R002", "身份证号与老师名单不一致",
|
||||||
|
"R003", "材料不是本人",
|
||||||
|
"R004", "文件超过 1M",
|
||||||
|
"R005", "缺少材料",
|
||||||
|
"R006", "工作证明未盖公章",
|
||||||
|
"R007", "材料不完整(缺正反面 / 缺页)",
|
||||||
|
"R008", "信息填写有误",
|
||||||
|
"R009", "照片背景不符合要求",
|
||||||
|
CODE_OTHER, "其他(手填)"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 展示顺序按 PRD 附录 C 的编号,不用 Map 的遍历顺序(那是不确定的)。 */
|
||||||
|
private static final List<String> ORDER = List.of(
|
||||||
|
"R001", "R002", "R003", "R004", "R005", "R006", "R007", "R008", "R009", CODE_OTHER);
|
||||||
|
|
||||||
|
public static List<AuditDtos.ReasonCodeVO> list() {
|
||||||
|
List<AuditDtos.ReasonCodeVO> out = new ArrayList<>(ORDER.size());
|
||||||
|
for (String code : ORDER) {
|
||||||
|
AuditDtos.ReasonCodeVO vo = new AuditDtos.ReasonCodeVO();
|
||||||
|
vo.setCode(code);
|
||||||
|
vo.setText(TEXTS.get(code));
|
||||||
|
vo.setRequiresText(CODE_OTHER.equals(code));
|
||||||
|
out.add(vo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String textOf(String code) {
|
||||||
|
return code == null ? null : TEXTS.get(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验原因码,并返回规范的文案。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 认不出就<b>拒绝</b>,不静默放过:驳回是学生唯一能看到的"为什么被打回",
|
||||||
|
* 存进去一个字典里没有的码,界面上就只剩一个空白原因,
|
||||||
|
* 而学生只会以为系统坏了 —— 他会打电话给班主任,班主任也说不清。
|
||||||
|
*/
|
||||||
|
public static String requireValid(String code, String reasonText) {
|
||||||
|
String c = code == null ? null : code.trim().toUpperCase();
|
||||||
|
if (c == null || c.isEmpty() || !TEXTS.containsKey(c)) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"请选择驳回原因(原因码不在字典里:" + code + ")");
|
||||||
|
}
|
||||||
|
if (CODE_OTHER.equals(c) && (reasonText == null || reasonText.isBlank())) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"选了「其他」就必须写清具体原因,否则学生不知道要改什么");
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 存库的展示文案:字典文案 + (有补充说明时)学生的补充,两段都要留痕。 */
|
||||||
|
public static String fullText(String code, String reasonText, String voiceText) {
|
||||||
|
String base = textOf(code);
|
||||||
|
StringBuilder sb = new StringBuilder(base == null ? "" : base);
|
||||||
|
if (reasonText != null && !reasonText.isBlank()) {
|
||||||
|
sb.append(";补充:").append(reasonText.trim());
|
||||||
|
}
|
||||||
|
if (voiceText != null && !voiceText.isBlank()) {
|
||||||
|
sb.append(";语音转写:").append(voiceText.trim());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -54,4 +54,42 @@ public interface FileMapper extends BaseMapper<CollectFile> {
|
|||||||
java.util.List<CollectFile> listMine(@Param("tenantId") Long tenantId,
|
java.util.List<CollectFile> listMine(@Param("tenantId") Long tenantId,
|
||||||
@Param("submissionId") Long submissionId,
|
@Param("submissionId") Long submissionId,
|
||||||
@Param("submitterId") Long submitterId);
|
@Param("submitterId") Long submitterId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核侧:一次取出一批填报单的当前生效文件(待办列表要显示材料缩略图)。
|
||||||
|
*
|
||||||
|
* <p>没有 {@code submitter_id} 条件 —— 审核看的正是别人的材料。
|
||||||
|
* 授权在上一层完成:<b>只有已经通过"这个班归我审"的填报单 id 才会传进来</b>
|
||||||
|
* (见 {@code AuditService.todos} 与 {@code requireAuditable})。
|
||||||
|
*
|
||||||
|
* <p>为什么要"一次一批"而不是每行查一次:待办一页 10~20 行,
|
||||||
|
* 每行一次查询就是典型的 N+1,而列表接口的耗时是最容易被用户感知的。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@org.apache.ibatis.annotations.Select("""
|
||||||
|
<script>
|
||||||
|
SELECT * FROM collect_file
|
||||||
|
WHERE tenant_id = #{tenantId} AND deleted = 0 AND is_current = 1
|
||||||
|
AND submission_id IN
|
||||||
|
<foreach item="sid" collection="submissionIds" open="(" separator="," close=")">#{sid}</foreach>
|
||||||
|
ORDER BY submission_id, material_code, version
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
java.util.List<CollectFile> listBySubmissions(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("submissionIds") java.util.List<Long> submissionIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核侧:按 id 取一份文件(不带归属条件)。
|
||||||
|
*
|
||||||
|
* <p>同样由调用方保证授权(先确认这份材料所属的填报单在审核范围内),
|
||||||
|
* 见 {@code AuditService.fileUrl}。为什么不让审核端走 {@code selectMine}:
|
||||||
|
* 那个方法的名字与语义都是"我的文件",让审核复用它,
|
||||||
|
* 早晚会有人为了"让管理员也能看"而在里面加一个 or 条件 —— 那时学生端就漏了。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@org.apache.ibatis.annotations.Select("""
|
||||||
|
SELECT * FROM collect_file
|
||||||
|
WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = 0
|
||||||
|
""")
|
||||||
|
CollectFile selectForAudit(@Param("id") Long id, @Param("tenantId") Long tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -316,6 +316,24 @@ public class MaterialFileService {
|
|||||||
if (f == null) {
|
if (f == null) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
|
||||||
}
|
}
|
||||||
|
return urlOf(f, original, expireSeconds, LOCAL_DOWNLOAD_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地存储时,下载/预览走哪个前缀。学生端与审核端各有一份(权限检查在各自的服务里)。 */
|
||||||
|
public static final String LOCAL_DOWNLOAD_PREFIX = "/api/v1/mp/files/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取链接的**实现**:调用方负责授权,本方法只管拼地址。
|
||||||
|
*
|
||||||
|
* <p>抽出来是为了让审核端也能取同一个文件的链接 —— 两边的差别只有两处:
|
||||||
|
* <b>谁有权看</b>(各自校验)与<b>本地存储时走哪个下载前缀</b>
|
||||||
|
* (学生端 {@code /api/v1/mp/files/} 校验"这份属于你",
|
||||||
|
* 审核端 {@code /api/v1/audit/files/} 校验"这个班归你审")。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 不要让审核端复用 {@link #url}:那个方法一开头就查 {@code submitter_id},
|
||||||
|
* 为了"让管理员也能看"而在里面加 or 条件,学生端就漏了。
|
||||||
|
*/
|
||||||
|
public FileUrl urlOf(CollectFile f, boolean original, Long expireSeconds, String localDownloadPrefix) {
|
||||||
// 取哪一份:优先按参数,**取不到就退到另一份**。
|
// 取哪一份:优先按参数,**取不到就退到另一份**。
|
||||||
// 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件;
|
// 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件;
|
||||||
// 这时退回到规范件并如实标注,比报"文件不存在"有用得多 ——
|
// 这时退回到规范件并如实标注,比报"文件不存在"有用得多 ——
|
||||||
@@ -336,7 +354,7 @@ public class MaterialFileService {
|
|||||||
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
|
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
|
||||||
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
|
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
|
||||||
// 而按 id 取能复用"这份文件属于你"的归属校验)
|
// 而按 id 取能复用"这份文件属于你"的归属校验)
|
||||||
url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + useRaw;
|
url = localDownloadPrefix + f.getId() + "/content?original=" + useRaw;
|
||||||
}
|
}
|
||||||
String name = useRaw
|
String name = useRaw
|
||||||
? f.getOriginalName()
|
? f.getOriginalName()
|
||||||
@@ -361,6 +379,17 @@ public class MaterialFileService {
|
|||||||
if (f == null) {
|
if (f == null) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
|
||||||
}
|
}
|
||||||
|
return contentOf(f, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取内容的**实现**:调用方负责授权(同 {@link #urlOf})。
|
||||||
|
*
|
||||||
|
* <p>为什么要按 id 流式提供:本地存储(开发环境)没有公网签名地址,
|
||||||
|
* 而预览必须是浏览器能直接打开的 URL。生产用七牛时走的是签名链接,
|
||||||
|
* 这个接口不会被用到 —— 但开发时它是唯一能看图的路径。
|
||||||
|
*/
|
||||||
|
public Content contentOf(CollectFile f, boolean original) {
|
||||||
boolean useRaw = original;
|
boolean useRaw = original;
|
||||||
String path = original ? f.getRawPath() : f.getStdPath();
|
String path = original ? f.getRawPath() : f.getStdPath();
|
||||||
if (path == null || path.isBlank()) {
|
if (path == null || path.isBlank()) {
|
||||||
|
|||||||
+12
@@ -105,8 +105,20 @@ public class CollectSubmission extends TenantEntity {
|
|||||||
|
|
||||||
private Long classAuditBy;
|
private Long classAuditBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初审时间。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 这一对(含 {@link #classAuditBy})是"<b>本轮</b>审核的状态",
|
||||||
|
* 不是历史:被驳回时会被清空(见 {@code AuditService.reject})。
|
||||||
|
* 历史请查 {@code collect_audit_record} —— 那里一条都不会少。
|
||||||
|
*/
|
||||||
|
private LocalDateTime classAuditTime;
|
||||||
|
|
||||||
private Long adminAuditBy;
|
private Long adminAuditBy;
|
||||||
|
|
||||||
|
/** 终审时间。语义同 {@link #classAuditTime}。 */
|
||||||
|
private LocalDateTime adminAuditTime;
|
||||||
|
|
||||||
public static final int STATUS_DRAFT = 0;
|
public static final int STATUS_DRAFT = 0;
|
||||||
public static final int STATUS_PENDING_CLASS = 1;
|
public static final int STATUS_PENDING_CLASS = 1;
|
||||||
public static final int STATUS_PENDING_ADMIN = 2;
|
public static final int STATUS_PENDING_ADMIN = 2;
|
||||||
|
|||||||
+26
@@ -42,4 +42,30 @@ public interface SubmissionMapper extends BaseMapper<CollectSubmission> {
|
|||||||
CollectSubmission selectMineById(@Param("id") Long id,
|
CollectSubmission selectMineById(@Param("id") Long id,
|
||||||
@Param("tenantId") Long tenantId,
|
@Param("tenantId") Long tenantId,
|
||||||
@Param("submitterId") Long submitterId);
|
@Param("submitterId") Long submitterId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核侧取填报单:只带租户,不带归属 —— <b>归属由审核范围(班级)决定,不由提交人决定</b>。
|
||||||
|
*
|
||||||
|
* <p>所以这里不能沿用 {@code selectMineById}:班主任要看的恰恰是<b>别人</b>的单子。
|
||||||
|
* 隔离靠两件事,缺一不可:
|
||||||
|
* <ol>
|
||||||
|
* <li>这里的 {@code tenant_id}(跨租户绝不可能)</li>
|
||||||
|
* <li>调用方必须先通过"这个班归不归我审"的检查(见 {@code AuditService})</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code FOR UPDATE} 是刻意的:审核是<b>并发动作</b> ——
|
||||||
|
* 两个端(手机 + 电脑)同时点通过、或者班主任和终审管理员同时处理,
|
||||||
|
* 没有行锁就会出现"两条审核记录 + 状态被后写的那次覆盖"。
|
||||||
|
* 那时时间轴上会有两条"通过",而库里只有一个状态,谁也说不清是怎么变的。
|
||||||
|
*
|
||||||
|
* <p>因此本方法的调用方<b>必须</b>在 {@code @Transactional} 里,锁才有意义。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM collect_submission
|
||||||
|
WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = 0
|
||||||
|
FOR UPDATE
|
||||||
|
""")
|
||||||
|
CollectSubmission selectForAudit(@Param("id") Long id,
|
||||||
|
@Param("tenantId") Long tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -82,11 +82,33 @@ public class FillService {
|
|||||||
"请先核对身份(填写姓名与身份证号)再开始填报");
|
"请先核对身份(填写姓名与身份证号)再开始填报");
|
||||||
}
|
}
|
||||||
|
|
||||||
FormSchemaVO schema = formSchemaService.get(taskId);
|
return assemble(task, roster, submissionMapper.selectMine(me.tenantId(), taskId, me.uid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装配表单(含值)—— <b>按指定的名册行与填报单,而不是按当前登录人</b>。
|
||||||
|
*
|
||||||
|
* <p>学生端走 {@link #getForm}(先过门禁,再拿自己的草稿);审核端走这一个:
|
||||||
|
* 审核人要看的正是<b>别人</b>的那份,走学生入口会被门禁拦掉
|
||||||
|
* (报"请先核对身份"),而且拿到的会是审核人自己的草稿。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 两条路径**必须共用同一段装配代码**。一旦分成两份,审核端看到的值
|
||||||
|
* 就会和学生看到的不一样,而那是"两条校验规则互相矛盾"的开端
|
||||||
|
* (见 {@code SubmissionValidator} 的类注释)。
|
||||||
|
*
|
||||||
|
* <p>这里<b>不判任务状态</b>:已截止甚至已归档的任务照样要能审(审的是历史数据)。
|
||||||
|
* 状态检查属于"能不能填",不属于"能不能看"。
|
||||||
|
*/
|
||||||
|
public StudentFormVO assembleFor(CollectTask task, CollectRoster roster, CollectSubmission sub) {
|
||||||
|
return assemble(task, roster, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentFormVO assemble(CollectTask task, CollectRoster roster, CollectSubmission draft) {
|
||||||
|
FormSchemaVO schema = formSchemaService.get(task.getId());
|
||||||
Map<String, Object> values = rosterValuesOf(roster);
|
Map<String, Object> values = rosterValuesOf(roster);
|
||||||
|
|
||||||
StudentFormVO vo = new StudentFormVO();
|
StudentFormVO vo = new StudentFormVO();
|
||||||
vo.setTaskId(taskId);
|
vo.setTaskId(task.getId());
|
||||||
vo.setTaskName(task.getTaskName());
|
vo.setTaskName(task.getTaskName());
|
||||||
vo.setFormVersion(schema.getVersion());
|
vo.setFormVersion(schema.getVersion());
|
||||||
vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
|
vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
|
||||||
@@ -96,7 +118,6 @@ public class FillService {
|
|||||||
// "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是
|
// "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是
|
||||||
// "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空),
|
// "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空),
|
||||||
// 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。
|
// 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。
|
||||||
CollectSubmission draft = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
|
|
||||||
Set<String> touched = new LinkedHashSet<>();
|
Set<String> touched = new LinkedHashSet<>();
|
||||||
if (draft != null) {
|
if (draft != null) {
|
||||||
vo.setSubmissionId(draft.getId());
|
vo.setSubmissionId(draft.getId());
|
||||||
@@ -164,7 +185,7 @@ public class FillService {
|
|||||||
vo.getSections().add(ex);
|
vo.getSections().add(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MaterialDefVO> materials = materialDefService.list(taskId);
|
List<MaterialDefVO> materials = materialDefService.list(task.getId());
|
||||||
vo.setMaterials(new ArrayList<>(materials));
|
vo.setMaterials(new ArrayList<>(materials));
|
||||||
|
|
||||||
StudentFormVO.SubmitPolicy policy = new StudentFormVO.SubmitPolicy();
|
StudentFormVO.SubmitPolicy policy = new StudentFormVO.SubmitPolicy();
|
||||||
@@ -190,6 +211,12 @@ public class FillService {
|
|||||||
*/
|
*/
|
||||||
public static Map<String, Object> rosterValuesOf(CollectRoster r) {
|
public static Map<String, Object> rosterValuesOf(CollectRoster r) {
|
||||||
Map<String, Object> m = new LinkedHashMap<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
if (r == null) {
|
||||||
|
// 审核端可能拿到没有名册行的填报单(一期不会有,社会收集才有)。
|
||||||
|
// 这里返回空映射而不是 NPE —— 让"没有名册"表现为"这些字段都空着",
|
||||||
|
// 由校验规则去说清"缺什么",而不是变成一个 500。
|
||||||
|
return m;
|
||||||
|
}
|
||||||
putIfPresent(m, "seq_no", r.getSeqNo());
|
putIfPresent(m, "seq_no", r.getSeqNo());
|
||||||
putIfPresent(m, "real_name", r.getRealName());
|
putIfPresent(m, "real_name", r.getRealName());
|
||||||
// ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面)
|
// ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面)
|
||||||
|
|||||||
+66
-14
@@ -55,6 +55,7 @@ public class SubmissionService {
|
|||||||
private final TaskMapper taskMapper;
|
private final TaskMapper taskMapper;
|
||||||
private final FormSchemaService formSchemaService;
|
private final FormSchemaService formSchemaService;
|
||||||
private final MpRosterMapper rosterMapper;
|
private final MpRosterMapper rosterMapper;
|
||||||
|
private final cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper collectRosterMapper;
|
||||||
private final SubmissionValidator validator;
|
private final SubmissionValidator validator;
|
||||||
|
|
||||||
// ── §9.3 取草稿(幂等)────────────────────────
|
// ── §9.3 取草稿(幂等)────────────────────────
|
||||||
@@ -107,10 +108,43 @@ public class SubmissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submissionMapper.insert(s);
|
submissionMapper.insert(s);
|
||||||
|
// 名册那一行的状态跟着走到「草稿」:老师看名册时才知道"这个学生已经点进来了"
|
||||||
|
markRosterDraft(roster.getId());
|
||||||
log.info("建草稿:task={} roster={} user={} id={}", taskId, roster.getId(), me.uid(), s.getId());
|
log.info("建草稿:task={} roster={} user={} id={}", taskId, roster.getId(), me.uid(), s.getId());
|
||||||
return new DraftDtos.CreateVO(s.getId(), s.getFormVersion(), true);
|
return new DraftDtos.CreateVO(s.getId(), s.getFormVersion(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生点进表单时:名册行的状态从「未提交」变成「草稿」。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>只在当前是「未提交」时才写</b>。学生被驳回后(4)再打开表单,
|
||||||
|
* 不该被退回成「草稿」—— 班主任看名册时要能看出"这份已经被驳回过",
|
||||||
|
* 否则他会以为这个学生从没交过。同理,已提交(2)之后也不用再改。
|
||||||
|
*/
|
||||||
|
private void markRosterDraft(Long rosterId) {
|
||||||
|
if (rosterId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collectRosterMapper.update(null, Wrappers.<CollectRoster>lambdaUpdate()
|
||||||
|
.eq(CollectRoster::getId, rosterId)
|
||||||
|
.eq(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_NONE)
|
||||||
|
.set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_DRAFT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册行的提交状态——<b>它反映"这一行现在处在哪一步",所以是可以往回走的</b>:
|
||||||
|
* 被驳回(4)之后学生改完重新交,它必须回到「待审」(2)。
|
||||||
|
* 不做这一步,名册列表会一直显示「已驳回」,而班主任据此挨个去催那些早就交了的。
|
||||||
|
*/
|
||||||
|
void markRosterStatus(Long rosterId, int status) {
|
||||||
|
if (rosterId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collectRosterMapper.update(null, Wrappers.<CollectRoster>lambdaUpdate()
|
||||||
|
.eq(CollectRoster::getId, rosterId)
|
||||||
|
.set(CollectRoster::getSubmitStatus, status));
|
||||||
|
}
|
||||||
|
|
||||||
// ── §9.4 保存草稿 ────────────────────────────
|
// ── §9.4 保存草稿 ────────────────────────────
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -288,7 +322,12 @@ public class SubmissionService {
|
|||||||
.eq(CollectSubmission::getId, id)
|
.eq(CollectSubmission::getId, id)
|
||||||
.set(CollectSubmission::getStatus, CollectSubmission.STATUS_PENDING_CLASS)
|
.set(CollectSubmission::getStatus, CollectSubmission.STATUS_PENDING_CLASS)
|
||||||
.set(CollectSubmission::getSubmitTime, java.time.LocalDateTime.now()));
|
.set(CollectSubmission::getSubmitTime, java.time.LocalDateTime.now()));
|
||||||
log.info("提交成功:submission={} 红={} 黄={} 已确认警告={}",
|
// 名册上那一行也要跟着走(0未提交/1草稿/2待审/3已通过/4已驳回)。
|
||||||
|
// ⚠️ 以前这里没写,于是名册列表的"状态"列永远是"未提交",
|
||||||
|
// 而 REPLACE 导入的护栏(bind_user_id IS NULL AND submit_status = 0)
|
||||||
|
// 实际只靠 bind_user_id 生效 —— 一旦哪天绑定被清掉,
|
||||||
|
// 已经提交过的行就会被当成"没动过"而遭整表替换。
|
||||||
|
markRosterStatus(s.getRosterId(), CollectRoster.SUBMIT_PENDING); log.info("提交成功:submission={} 红={} 黄={} 已确认警告={}",
|
||||||
id, red, yellow, confirmWarnings);
|
id, red, yellow, confirmWarnings);
|
||||||
return new SubmitDtos.Report(true, CollectSubmission.STATUS_PENDING_CLASS,
|
return new SubmitDtos.Report(true, CollectSubmission.STATUS_PENDING_CLASS,
|
||||||
SubmitDtos.Report.statusText(CollectSubmission.STATUS_PENDING_CLASS),
|
SubmitDtos.Report.statusText(CollectSubmission.STATUS_PENDING_CLASS),
|
||||||
@@ -353,27 +392,40 @@ public class SubmissionService {
|
|||||||
* <p>抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
|
* <p>抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
|
||||||
* 而漏掉的表现是"某一项填了没保存",且不报错。
|
* 而漏掉的表现是"某一项填了没保存",且不报错。
|
||||||
*/
|
*/
|
||||||
static final class SubValues {
|
public static final class SubValues {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有哪些字段在 {@code collect_submission} 里是可写列。
|
||||||
|
*
|
||||||
|
* <p>把它列成清单(而不是只看 switch 里有哪些 case)是为了让这个事实
|
||||||
|
* <b>只有一个来源</b>:加字段时先看表有没有这一列,而不是看代码里有没有分支。
|
||||||
|
*/
|
||||||
|
public static final java.util.List<String> KEYS = java.util.List.of(
|
||||||
|
"occupation", "worktype", "job_level", "education", "is_student",
|
||||||
|
"school", "major", "enroll_date", "work_unit", "is_retake",
|
||||||
|
"retake_theory", "retake_practice", "retake_overall", "work_years",
|
||||||
|
"cert_occupation", "cert_level", "cert_no");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这个字段在 {@code collect_submission} 里有没有对应的可写列。
|
* 这个字段在 {@code collect_submission} 里有没有对应的可写列。
|
||||||
*
|
*
|
||||||
* <p>⚠️ 姓名、身份证号、联系电话<b>只存在于名册</b>,提交表里没有这三列 ——
|
* <p>⚠️ 姓名、身份证号、联系电话<b>只存在于名册</b>,提交表里没有这三列 ——
|
||||||
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
|
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
|
||||||
* 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实<b>只有一个来源</b>:
|
|
||||||
* 加字段时先看表有没有这一列,而不是看代码里有没有 case。
|
|
||||||
*/
|
*/
|
||||||
static boolean hasColumn(String key) {
|
public static boolean hasColumn(String key) {
|
||||||
return switch (key) {
|
return KEYS.contains(key);
|
||||||
case "occupation", "worktype", "job_level", "education", "is_student",
|
|
||||||
"school", "major", "enroll_date", "work_unit", "is_retake",
|
|
||||||
"retake_theory", "retake_practice", "retake_overall", "work_years",
|
|
||||||
"cert_occupation", "cert_level", "cert_no" -> true;
|
|
||||||
default -> false;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void apply(CollectSubmission s, String key, Object v) {
|
/** 把这一行的全部业务列读成一个 map(审核详情要展示学生填了什么)。 */
|
||||||
|
public static Map<String, Object> allOf(CollectSubmission s) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
for (String key : KEYS) {
|
||||||
|
out.put(key, read(s, key));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void apply(CollectSubmission s, String key, Object v) {
|
||||||
String str = v == null ? null : String.valueOf(v);
|
String str = v == null ? null : String.valueOf(v);
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "occupation" -> s.setOccupation(blankToNull(str));
|
case "occupation" -> s.setOccupation(blankToNull(str));
|
||||||
@@ -400,7 +452,7 @@ public class SubmissionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Object read(CollectSubmission s, String key) {
|
public static Object read(CollectSubmission s, String key) {
|
||||||
return switch (key) {
|
return switch (key) {
|
||||||
case "occupation" -> s.getOccupation();
|
case "occupation" -> s.getOccupation();
|
||||||
case "worktype" -> s.getWorktype();
|
case "worktype" -> s.getWorktype();
|
||||||
|
|||||||
+13
-3
@@ -1,5 +1,7 @@
|
|||||||
package cn.tianbaotong.fill.module.fill.service;
|
package cn.tianbaotong.fill.module.fill.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.entity.CollectFile;
|
||||||
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
||||||
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
|
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
|
||||||
@@ -89,8 +91,16 @@ public class SubmissionValidator {
|
|||||||
public List<Issue> validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
|
public List<Issue> validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
|
||||||
List<Issue> out = new ArrayList<>();
|
List<Issue> out = new ArrayList<>();
|
||||||
|
|
||||||
// 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)
|
if (task == null) {
|
||||||
StudentFormVO form = fillService.getForm(s.getTaskId());
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里用 assembleFor(按**指定的**填报单装配),而不是学生入口 getForm ——
|
||||||
|
// 因为审核时"当前登录人"是班主任/管理员,他们既过不了门禁,
|
||||||
|
// 也拿不到别人那份草稿。两条路径共用同一段装配代码,见 FillService.assembleFor。
|
||||||
|
StudentFormVO form = fillService.assembleFor(task, roster, s);
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
Map<String, Object> values = new LinkedHashMap<>();
|
||||||
for (StudentFormVO.Section sec : form.getSections()) {
|
for (StudentFormVO.Section sec : form.getSections()) {
|
||||||
for (FormFieldVO f : sec.getFields()) {
|
for (FormFieldVO f : sec.getFields()) {
|
||||||
@@ -386,7 +396,7 @@ public class SubmissionValidator {
|
|||||||
* 学生在页面上看得见某个字段、后端却说"你没填"(或反过来)。
|
* 学生在页面上看得见某个字段、后端却说"你没填"(或反过来)。
|
||||||
* 这种问题极难排查 —— 因为两边各自看都"没错"。
|
* 这种问题极难排查 —— 因为两边各自看都"没错"。
|
||||||
*/
|
*/
|
||||||
static boolean visible(Map<String, Object> when, Map<String, Object> values) {
|
public static boolean visible(Map<String, Object> when, Map<String, Object> values) {
|
||||||
if (when == null) {
|
if (when == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -76,6 +76,28 @@ public class CollectRoster extends TenantEntity {
|
|||||||
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
|
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
|
||||||
private Integer submitStatus;
|
private Integer submitStatus;
|
||||||
|
|
||||||
|
// ── submit_status 的取值(唯一来源)────────────────
|
||||||
|
//
|
||||||
|
// 这几个值以前只写在列注释里,于是"谁来改它"没有归属:
|
||||||
|
// 结果是从名册导入到审核通过,没有一处写过它 —— 名册列表的状态列恒为「未提交」。
|
||||||
|
// 现在写入集中在两处:学生侧(提交/存草稿)与审核侧(通过/驳回),
|
||||||
|
// 都用这里的常量,避免各处写 0/1/2/3/4 的字面量。
|
||||||
|
|
||||||
|
/** 学生还没开始填(也与"没有草稿"同义)。 */
|
||||||
|
public static final int SUBMIT_NONE = 0;
|
||||||
|
|
||||||
|
/** 有草稿但还没提交。 */
|
||||||
|
public static final int SUBMIT_DRAFT = 1;
|
||||||
|
|
||||||
|
/** 已提交,等待初审。 */
|
||||||
|
public static final int SUBMIT_PENDING = 2;
|
||||||
|
|
||||||
|
/** 终审通过。 */
|
||||||
|
public static final int SUBMIT_APPROVED = 3;
|
||||||
|
|
||||||
|
/** 被驳回,等学生改。 */
|
||||||
|
public static final int SUBMIT_REJECTED = 4;
|
||||||
|
|
||||||
// ── 预填列(老师已填,学生端只核对)─────────────
|
// ── 预填列(老师已填,学生端只核对)─────────────
|
||||||
|
|
||||||
/** 报考职业(锁定,由老师确定)。 */
|
/** 报考职业(锁定,由老师确定)。 */
|
||||||
|
|||||||
+131
@@ -2123,3 +2123,134 @@ t1 域名免租户码登录 / 与手填租户码矛盾时明确报错 / **跨租
|
|||||||
|
|
||||||
回到 §一·二十二 的顺序:**第 4 步 = 审核闭环(后端)** ——
|
回到 §一·二十二 的顺序:**第 4 步 = 审核闭环(后端)** ——
|
||||||
初审 / 终审 / 撤回 / 批量通过(只带无问题项)/ 驳回(逐条填原因)。
|
初审 / 终审 / 撤回 / 批量通过(只带无问题项)/ 驳回(逐条填原因)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·二十七 审核闭环(M5-01~05 后端)—— 「谁能审谁」比「怎么审」重要
|
||||||
|
|
||||||
|
### 落地
|
||||||
|
|
||||||
|
| 位置 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `module/audit/entity/CollectAuditRecord` | 审核记录(只增不改。撤回是**再写一条**,不是删掉上一条) |
|
||||||
|
| `module/audit/mapper/AuditScopeMapper` | **谁能审哪些班**(三级回退:指定初审人 → 副班主任 → 班级负责人) |
|
||||||
|
| `module/audit/mapper/AuditTodoMapper` | 待办列表(初审/终审共用一套查询 + 四个计数) |
|
||||||
|
| `module/audit/mapper/AuditPreconditionMapper` | 「已导出打包」判定(M6 落地后应搬到导出模块) |
|
||||||
|
| `module/audit/service/AuditService` | 待办 / 详情 / 通过 / 驳回 / 批量通过 / 撤回 / 时间轴 / 审核端文件访问 |
|
||||||
|
| `module/audit/service/RejectReasonCatalog` | 附录 C 的 10 条原因码(**写在代码里**,理由同权限码) |
|
||||||
|
| `controller/AuditController` | PC:`/api/v1/audit/*`(§11) |
|
||||||
|
| `controller/MpTeacherAuditController` | 小程序:`/api/v1/mp/teacher/audit/*`(§10.2~10.5,只做初审) |
|
||||||
|
|
||||||
|
待办的 MP 端与 PC 端**共用一份 VO**(契约里那两份重合九成)——
|
||||||
|
两份迟早分叉,而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
|
||||||
|
|
||||||
|
### ★ 三件必须守住的事
|
||||||
|
|
||||||
|
**① 权限 ≠ 范围。** 全校几十个班主任都有 `audit:first`,但只有**这个班的**班主任能审这个学生。
|
||||||
|
所以初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式写 `1 = 0`,
|
||||||
|
而不是"不过滤")——那是这类查询最经典也最严重的写法错误:一个刚被移出所有班级的老师
|
||||||
|
会突然看到全校的单子。E2E 专门造了一个"同样是班主任但没被指定"的 T2:
|
||||||
|
他看不到待办、打不开详情、点不动通过(三条断言分别守着)。
|
||||||
|
|
||||||
|
**② 有红问题不许通过(`40032`),而且校验是「现场重算」的。**
|
||||||
|
提交时落库的 `red_count` 是**快照**:OCR / 识别是异步的,名册和材料规则也可能被改。
|
||||||
|
审核这一刻必须重算——"放过一份有问题的材料"不可撤销。
|
||||||
|
E2E 的做法是提交成功后把必填项改坏,再点通过:**若读快照就会通过**。
|
||||||
|
|
||||||
|
**③ 批量通过只带绿色项,红黄一律排除。** 契约里的 `onlyClean` 服务端**强制 true**
|
||||||
|
(传 false 不报错也不生效——报错会让旧客户端不可用,而这件事没有"客户端说了算"的版本)。
|
||||||
|
跳过的每一条都要**说清原因**(红/黄/不在我的班/状态不对),否则用户只会反复点那个大按钮。
|
||||||
|
|
||||||
|
### 红黄计数的口径(列表用快照,动作时重算并回写)
|
||||||
|
|
||||||
|
- **列表**用 `collect_submission.red_count/yellow_count`(要快,它本来就是概览)
|
||||||
|
- **详情**与**通过 / 批量通过**现场重算,**并回写快照** → 快照自己会收敛,最多错一次
|
||||||
|
|
||||||
|
⚠️ 因此 `detail()` **不能标 `readOnly = true`**:它要写那条回写。
|
||||||
|
标了之后 PG 直接拒绝 UPDATE(`cannot execute update in a read-only transaction`),
|
||||||
|
而这个 bug 的表现极其阴险:**计数没变时一切正常,一出现不一致就 500** ——
|
||||||
|
也就是它只在最需要工作的那一次崩掉。(已踩并修,注释写在方法上。)
|
||||||
|
|
||||||
|
### ⚠️ 两处「文档与代码不一致」,需要文档侧对齐
|
||||||
|
|
||||||
|
| # | 冲突 | 我的处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **撤回的动作码**:DDL 注释写 `3=批量通过`,而接口设计 §11.4 写"写入一条 `action = 3`(撤回)的记录" | 1~6 沿用 DDL(已发布、可能已有数据),**撤回另取 7**。理由是时间轴最不能混淆:把"撤回"显示成"批量通过",会让人以为这份单子被通过了 |
|
||||||
|
| 2 | **撤回的适用状态**:§11.4 的表格只列了"状态 3(已通过)" | 额外允许**状态 2** —— 最常见的真实场景是**班主任点错了通过**(那一步之后状态正是 2,不是 3)。状态机自洽:撤初审记录 → 回到 1;撤终审记录 → 回到 2。**这条要请客户确认**(若不允许,去掉一个分支即可) |
|
||||||
|
| 3 | 材料文件 `source` 的语义:DDL 写 `2 班主任代传 / 3 管理员代传 / 4 整理器导入`,§10.6 写"文件记录 `source = 4`(代传)" | 以 DDL 为准(更细,且已建表)。代传接口(M5-06)还没做,时间轴已按 `source ∈ {2,3}` 预留 |
|
||||||
|
|
||||||
|
### 顺带修掉的两个「没人写过」的窟窿
|
||||||
|
|
||||||
|
1. **名册行的 `submit_status` 以前从来没人写**(全仓库只有读,没有写)→
|
||||||
|
名册列表的「状态」列恒显示"未提交";而 REPLACE 导入的护栏
|
||||||
|
(`bind_user_id IS NULL AND submit_status = 0`)实际上只靠 `bind_user_id` 在生效 ——
|
||||||
|
一旦绑定被清掉,已经提交过的行会被当成"没动过"而遭整表替换。
|
||||||
|
现在写入点有两个:学生侧(草稿 / 提交)与审核侧(初审通过 / 终审通过 / 驳回 / 撤回)。
|
||||||
|
⚠️ 它**不是单调的**:被驳回(4)后学生重交必须回到 2,否则班主任会一直以为他还欠一份。
|
||||||
|
2. **`collect_submission` 少了两个实体字段**(`class_audit_time` / `admin_audit_time`)——
|
||||||
|
DDL 里一直有这两列,实体只映射了 `*_by`。审核要写时间才补上。
|
||||||
|
这两列的口径是「**本轮**审核的状态」(驳回时清空,历史在 `collect_audit_record` 里一条不少)。
|
||||||
|
|
||||||
|
### 审核端看材料:单独一条通道,不复用学生那个
|
||||||
|
|
||||||
|
`MaterialFileService` 抽出 `urlOf / contentOf`(调用方负责授权),学生端仍走
|
||||||
|
`/api/v1/mp/files/*`(查"这份是不是你传的"),审核端走 `/api/v1/audit/files/*`
|
||||||
|
(查"这个班归不归你审")。**不要**为了"让管理员也能看"而在学生那个方法里加 or 条件 ——
|
||||||
|
那一刻学生端就漏了。
|
||||||
|
|
||||||
|
### 一个把「取值口径」讲清楚了的坑(E2E 抓到的)
|
||||||
|
|
||||||
|
`SubmissionValidator` 原本通过 `FillService.getForm(taskId)` 取表单值 ——
|
||||||
|
那是**学生视角**:它先过门禁、再拿"当前登录人"的草稿。审核人当然过不了门禁(`40007`),
|
||||||
|
而且拿到的会是审核人自己那份草稿。已抽出 `FillService.assembleFor(task, roster, sub)`,
|
||||||
|
两条路径**共用同一段装配代码**(分成两份就是"两条校验规则互相矛盾"的开端)。
|
||||||
|
|
||||||
|
紧接着暴露出第二层:**改提交单里的列不一定改变"组装出来的值"** ——
|
||||||
|
学生**没动过**的字段,值会**回退到名册**。E2E 里想用"清空在读学校"造红问题,
|
||||||
|
对甲有效(甲改过该字段 → 在 `changed_fields` 里 → 以草稿为准,空也算),
|
||||||
|
对乙**完全无效**(乙没改过 → 值仍来自名册)。
|
||||||
|
最后改用名册里没有的字段(本职业工作年限填 999 → VAL-17)才稳定造出红问题。
|
||||||
|
👉 造红问题的正确姿势:**挑一个名册里没有的字段**。
|
||||||
|
|
||||||
|
### E2E:`scripts/e2e-m5-audit.py` → **87/87**
|
||||||
|
|
||||||
|
夹具**刻意做出一个"同样是班主任但没被指定"的人**,并复用了既有的两个脚本工具
|
||||||
|
(`import e2e-m3-submit` 拿学生侧的造数能力、`import e2e-m2-roster` 拿服务端工具)——
|
||||||
|
不复制第二份 `mint`/`upload`,否则两边迟早分叉。
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 两个学生提交:甲干净 / 乙有黄色(同一张图传两份 → VAL-09)
|
||||||
|
[2] 待办:T1 看到 2 份 + 四个计数 + batchApprovable;**T2 一份都看不到**;T1 查终审 → 403
|
||||||
|
[3] 详情:脱敏证件号 / 材料按定义分组 / 预览链接 / 改动明细 / 两份策略 / 时间轴倒序;**T2 打不开**
|
||||||
|
[4] 红问题:改坏必填项后通过 → **40032**;详情现场值 red=1 且**回写**了快照
|
||||||
|
[5] 初审通过 → 2 + 留痕 + 名册状态;**重复点通过 → replayed=true 且不重复写记录**
|
||||||
|
[6] 终审通过 → 3 + 名册 3
|
||||||
|
[7] 批量通过:黄的单跳过(说清原因)→ 清掉重复件后过 → 红的单跳过
|
||||||
|
[8] 驳回:原因必填 / 字典校验 / R999 必填说明;第 3 次 → escalated;**学生重交 → 状态与名册都回到待审**
|
||||||
|
[9] 撤回:**已导出 40033 / 已归档 40034**;非原审核人非管理员 40301;成功回退 + action=7 留痕;同一条不能撤两次
|
||||||
|
[10] 字典 10 条 + 批量上限 40011 + 学生调审核接口 40301
|
||||||
|
```
|
||||||
|
|
||||||
|
**全量 753 条断言、0 失败**(新增 87)。
|
||||||
|
|
||||||
|
### 另一个夹具教训
|
||||||
|
|
||||||
|
E2E 一开始给材料传了张 900×1200 的小图 —— 而**身份证材料项的目录默认长边下限是 1400**
|
||||||
|
(`MaterialCatalog` 的档位默认,接口传 `minLongSide` 也压不过它),
|
||||||
|
于是每份单子都被判「清晰度偏低」= **黄色警告**,接着"绿色单/批量通过"的断言集体失败。
|
||||||
|
**失败的样子像功能坏了,其实只是夹具不满足业务前提。** 换成 1600×1400 的图即可。
|
||||||
|
|
||||||
|
### 这一步没做的(都在计划内)
|
||||||
|
|
||||||
|
- M5-06 代传材料(`source=2/3` 的时间轴口径已预留)
|
||||||
|
- M5-09 审核统计(`/audit/statistics`:人均时长、驳回原因 TOP)
|
||||||
|
- M5-12 通知(订阅消息 / 站内信)→ 因此驳回响应**如实**回
|
||||||
|
`notifySent=false + needManualNotice=true`,并在日志里标 escalated。
|
||||||
|
**不能为了让契约好看而写 true** —— 那会让"学生到底收到没有"变成没人能回答的问题
|
||||||
|
- FR-806 批量驳回(P1)、FR-808 转交(P1)
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
**M5 的前端**:班主任端卡片流(原型 ②,**只右滑通过**、左滑不绑定驳回)、
|
||||||
|
学生详情页(原型 ③)、驳回页(原型 ⑤,原因快选 + 二次确认)、
|
||||||
|
一键通过确认页(原型 ④)、管理端终审列表与详情(原型 ⑥)。
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
审核闭环 E2E(M5-01 ~ M5-05):待办列表 / 详情 / 通过 / 驳回 / 批量通过 / 撤回。
|
||||||
|
|
||||||
|
**这个脚本要守住的三件事**(按重要性):
|
||||||
|
|
||||||
|
① **谁都不能审别人班的学生**。全校几十个班主任都有 `audit:first`,
|
||||||
|
但只有这个班的班主任能审这个学生 —— "权限"与"范围"是两回事,
|
||||||
|
只查权限等于把全校学生的身份证照片交给每一个班主任。
|
||||||
|
所以这里专门造一个**没被指定**的班主任 T2:他既看不到待办、也点不动通过。
|
||||||
|
|
||||||
|
② **有红问题不许通过**(40032),而且必须**现场重算**、不是拿提交时的快照。
|
||||||
|
做法:提交成功后用 SQL 把「在读学校」清空,造出一个 VAL-16 红问题
|
||||||
|
——如果服务端读的是快照(red_count=0),这次通过就会成功,而这正是最危险的 bug。
|
||||||
|
|
||||||
|
③ **批量通过只带绿色项**:红黄一律排除,且**黄的单也要被跳过并说明原因**
|
||||||
|
(原型上"一键通过"是个大按钮,最容易被手快全选)。
|
||||||
|
|
||||||
|
跑法:python scripts/e2e-m5-audit.py
|
||||||
|
前提:后端 8080 在跑、xt-pg 在跑。脚本自己造任务/老师/学生,跑完自己清理。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
# 两个"学生"(MP 端只认 token 里的 sub,不要求 sys_user 里真有这个人 —— 与 e2e-m3-* 同口径)
|
||||||
|
UID_A = 992201
|
||||||
|
UID_B = 992202
|
||||||
|
|
||||||
|
|
||||||
|
def _load(module_name: str, filename: str):
|
||||||
|
"""
|
||||||
|
直接 import 既有的 E2E 脚本,复用它们的工具与夹具。
|
||||||
|
|
||||||
|
自己再写一份 `mint`/`upload`/`form_values` 就等着两边分叉:
|
||||||
|
一边改了签名一边没改,表现是"这个脚本单独跑得过、一起跑就红"。
|
||||||
|
"""
|
||||||
|
path = os.path.join(ROOT, "scripts", filename)
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:造草稿 / 传材料 / 提交
|
||||||
|
R = _load("e2e_m2_roster", "e2e-m2-roster.py") # 服务端工具:登录 / 导名册 / 校验位
|
||||||
|
|
||||||
|
CREATED_TASK_IDS: list[int] = []
|
||||||
|
CREATED_USER_IDS: list[int] = []
|
||||||
|
TMP_FILES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def ok(name: str) -> None:
|
||||||
|
PASSED.append(name)
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(name: str, detail: str) -> None:
|
||||||
|
FAILED.append((name, detail))
|
||||||
|
print(f" [FAIL] {name}\n {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||||
|
if cond:
|
||||||
|
ok(name)
|
||||||
|
else:
|
||||||
|
fail(name, detail or "断言不成立")
|
||||||
|
return cond
|
||||||
|
|
||||||
|
|
||||||
|
def sql(stmt: str) -> str:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG_CONTAINER,
|
||||||
|
"psql", "-U", "fill", "-d", "filldb", "-t", "-A", "-c", stmt],
|
||||||
|
capture_output=True, text=True, timeout=40)
|
||||||
|
return (r.stdout or r.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def call(base: str, method: str, path: str, token: str | None = None,
|
||||||
|
body: dict | None = None) -> dict:
|
||||||
|
"""PC 侧调用:R.call 返回 (status, body),这里只要 body。"""
|
||||||
|
_, b = R.call(method, f"{base}{path}", token, body)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def build_roster(path: str, rows: list[tuple[str, str]]) -> None:
|
||||||
|
"""两行名册(姓名 + 身份证号),报考三项按任务声明填好。"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "24中技数控1班"
|
||||||
|
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
|
||||||
|
"报考职业", "报考工种", "报考等级", "是否补考",
|
||||||
|
"补考理论", "补考实操", "补考综合", "是否在校生",
|
||||||
|
"在读学校", "所学专业", "入学时间"]
|
||||||
|
for c, t in enumerate(header, start=1):
|
||||||
|
ws.cell(row=1, column=c, value=t)
|
||||||
|
for i, (name, idc) in enumerate(rows):
|
||||||
|
vals = [i + 1, name, idc, "中专", f"138001388{i}", "无",
|
||||||
|
"车工", "数控车工", "四级", "否", "", "", "", "是",
|
||||||
|
"示范职业学校", "数控加工", "2024-09-01"]
|
||||||
|
for c, v in enumerate(vals, start=1):
|
||||||
|
if v != "":
|
||||||
|
ws.cell(row=i + 2, column=c, value=v)
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def big_jpeg() -> bytes:
|
||||||
|
"""
|
||||||
|
一张**长边够长**的测试图。
|
||||||
|
|
||||||
|
⚠️ 不能用 e2e-m3-submit 里那张 900×1200 的小图:身份证材料项的目录默认
|
||||||
|
长边下限是 **1400**(`MaterialCatalog` 的档位默认,接口传 minLongSide 也压不过它),
|
||||||
|
低于它会被判「清晰度偏低」——那是**黄色警告**,
|
||||||
|
于是"干净单"这个前提不成立,批量通过的断言会集体失败,
|
||||||
|
而失败的样子像功能坏了。夹具不满足业务前提,是这类误判最常见的来源。
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (1600, 1400), (200, 210, 220)).save(buf, "JPEG", quality=90)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_student(tok: str, sid: int, task_id: int, fill: dict, dup_first_file: bool) -> dict:
|
||||||
|
"""
|
||||||
|
把一个学生推到"已提交"。
|
||||||
|
|
||||||
|
- 填表(只填可改字段;锁定字段的值来自名册)
|
||||||
|
- 按**表单里当前必传且条件成立**的材料逐项上传(不写死材料码)
|
||||||
|
- dup_first_file:第一项材料再传一份同一张图 → 制造一条可预期的黄色警告(VAL-09)
|
||||||
|
"""
|
||||||
|
S.call("PUT", f"/api/v1/mp/submissions/{sid}", tok, {"formData": fill})
|
||||||
|
img = big_jpeg()
|
||||||
|
values, mats = S.form_values(tok, task_id)
|
||||||
|
need = [m for m in mats if m.get("required") and S.visible(m.get("visibleWhen"), values)]
|
||||||
|
for i, m in enumerate(need):
|
||||||
|
code = m.get("materialCode")
|
||||||
|
up = S.upload(tok, sid, code, f"{m.get('materialName') or code}.jpg", img)
|
||||||
|
if up.get("code") != 0:
|
||||||
|
return {"_upload_error": up}
|
||||||
|
S.call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
|
||||||
|
if dup_first_file and i == 0:
|
||||||
|
up2 = S.upload(tok, sid, code, f"{m.get('materialName') or code}-重复.jpg", img)
|
||||||
|
if up2.get("code") == 0:
|
||||||
|
S.call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
|
||||||
|
return S.sub(tok, sid, True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="审核闭环 E2E")
|
||||||
|
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||||
|
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||||||
|
ap.add_argument("--password", default="Admin@123")
|
||||||
|
args = ap.parse_args()
|
||||||
|
base = args.base.rstrip("/")
|
||||||
|
suffix = uuid.uuid4().hex[:6]
|
||||||
|
|
||||||
|
print("=" * 76)
|
||||||
|
print(f"审核闭环 E2E(M5-01~05) base={base}")
|
||||||
|
print("=" * 76)
|
||||||
|
|
||||||
|
# ── 0. 夹具 ────────────────────────────────────────
|
||||||
|
print("\n[0] 准备:任务 + 名册 + 两个班主任(一个被指定、一个没被指定)")
|
||||||
|
_, b = R.login(base, "admin", args.password, args.tenant)
|
||||||
|
if b.get("code") != 0:
|
||||||
|
fail("管理员登录", f"{b}")
|
||||||
|
return 1
|
||||||
|
admin = b["data"]["token"]
|
||||||
|
ok("管理员登录")
|
||||||
|
|
||||||
|
_, tree = R.call("GET", f"{base}/api/v1/orgs/tree", admin)
|
||||||
|
classes = []
|
||||||
|
|
||||||
|
def walk(nodes):
|
||||||
|
for n in nodes or []:
|
||||||
|
if n.get("orgType") == 3:
|
||||||
|
classes.append(n)
|
||||||
|
walk(n.get("children"))
|
||||||
|
|
||||||
|
walk((tree.get("data") or []))
|
||||||
|
if not classes:
|
||||||
|
fail("取一个班级", "demo 租户里没有 orgType=3 的组织")
|
||||||
|
return 1
|
||||||
|
org_id, org_name = classes[0]["id"], classes[0]["orgName"]
|
||||||
|
ok(f"取到班级:{org_name}(id={org_id})")
|
||||||
|
|
||||||
|
teachers = {}
|
||||||
|
for i, tag in enumerate(("t1", "t2")):
|
||||||
|
uname = f"e2e{tag}{suffix}"
|
||||||
|
body = call(base, "POST", "/api/v1/users", admin, {
|
||||||
|
"username": uname, "realName": f"E2E{suffix}{tag}",
|
||||||
|
# 手机号要过唯一约束:用 suffix 算出来,别用 hash()(它是进程随机的)
|
||||||
|
"phone": "13" + str(int(suffix, 16) % 10 ** 8 + i).zfill(9),
|
||||||
|
"roleCodes": ["HEAD_TEACHER"], "userType": 3,
|
||||||
|
})
|
||||||
|
if body.get("code") != 0:
|
||||||
|
fail(f"建班主任 {tag}", f"{body}")
|
||||||
|
return 1
|
||||||
|
uid = body["data"]["id"]
|
||||||
|
pwd = body["data"]["initialPassword"]
|
||||||
|
CREATED_USER_IDS.append(uid)
|
||||||
|
_, lb = R.login(base, uname, pwd, args.tenant)
|
||||||
|
if lb.get("code") != 0:
|
||||||
|
fail(f"班主任 {tag} 登录", f"{lb}")
|
||||||
|
return 1
|
||||||
|
teachers[tag] = {"id": uid, "token": lb["data"]["token"], "name": f"E2E{suffix}{tag}"}
|
||||||
|
ok(f"两个班主任账号已建(T1 id={teachers['t1']['id']} 被指定为初审人,T2 id={teachers['t2']['id']} 不指定)")
|
||||||
|
|
||||||
|
body = call(base, "POST", "/api/v1/tasks", admin, {
|
||||||
|
"taskName": f"E2E审核 {suffix}", "collectMode": 1, "batchNo": "2007",
|
||||||
|
"majorName": "数控加工", "jobName": "车工", "certType": 1,
|
||||||
|
"startTime": "2026-09-28", "endTime": "2026-12-31",
|
||||||
|
})
|
||||||
|
if body.get("code") != 0:
|
||||||
|
fail("建任务", f"{body}")
|
||||||
|
return 1
|
||||||
|
task_id = body["data"]["id"]
|
||||||
|
CREATED_TASK_IDS.append(task_id)
|
||||||
|
|
||||||
|
fix = os.path.join(ROOT, "scripts", f"_tmp_audit_{suffix}.xlsx")
|
||||||
|
TMP_FILES.append(fix)
|
||||||
|
rows = [("审核甲", R.with_checksum("361029" + "20080101" + "011")),
|
||||||
|
("审核乙", R.with_checksum("361029" + "20080202" + "022"))]
|
||||||
|
build_roster(fix, rows)
|
||||||
|
# ⚠️ 必须显式带 orgId:名册导入的 orgId 是**整批归到哪个班**(为空则名册行不留 org_id)。
|
||||||
|
# 留空的后果不是"少个字段",而是提交单没有班级 → 审核范围判不出来(40001/40301)。
|
||||||
|
body = R.upload_roster(base, admin, task_id, fix,
|
||||||
|
{"dryRun": "false", "orgId": str(org_id)})
|
||||||
|
check("名册导入 2 人", (body.get("code") == 0 and (body.get("data") or {}).get("created") == 2),
|
||||||
|
f"{body}")
|
||||||
|
check("名册行带上了班级(审核范围要靠它)",
|
||||||
|
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} "
|
||||||
|
f"AND org_id={org_id} AND deleted=0") == "2",
|
||||||
|
sql(f"SELECT org_id FROM collect_roster WHERE task_id={task_id} AND deleted=0"))
|
||||||
|
|
||||||
|
body = call(base, "PUT", f"/api/v1/tasks/{task_id}/orgs", admin, {
|
||||||
|
"orgs": [{"orgId": org_id, "headUserId": teachers["t1"]["id"], "expectedCount": 2}],
|
||||||
|
})
|
||||||
|
check("覆盖班级 + 指定初审人 = T1", body.get("code") == 0, f"{body}")
|
||||||
|
body = call(base, "POST", f"/api/v1/tasks/{task_id}/materials", admin, {
|
||||||
|
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
|
||||||
|
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2,
|
||||||
|
# ⚠️ 把下限调到 1KB / 长边 100:E2E 用的小图(17KB、900×1200)会被
|
||||||
|
# 材料目录的默认下限判成「体积偏小 / 清晰度偏低」——那是**黄色警告**,
|
||||||
|
# 于是"绿色单"这个前提不成立,批量通过的断言全部跟着错。
|
||||||
|
# 这坑很值得记:断言失败的样子像功能坏了,其实只是夹具不满足前提。
|
||||||
|
"minSizeKb": 1, "minLongSide": 100,
|
||||||
|
})
|
||||||
|
check("配必传材料(身份证;下限调到 1KB 免得小图被判「体积偏小」)",
|
||||||
|
body.get("code") == 0, f"{body}")
|
||||||
|
body = call(base, "POST", f"/api/v1/tasks/{task_id}/publish", admin, {"force": True})
|
||||||
|
check("发布任务", body.get("code") == 0, f"{body}")
|
||||||
|
|
||||||
|
ids = sql(f"SELECT id, real_name FROM collect_roster WHERE task_id={task_id} AND deleted=0 "
|
||||||
|
f"ORDER BY id")
|
||||||
|
pairs = [ln.split("|") for ln in ids.splitlines() if ln]
|
||||||
|
if len(pairs) != 2:
|
||||||
|
fail("取名册行", ids)
|
||||||
|
return 1
|
||||||
|
rid_a, rid_b = int(pairs[0][0]), int(pairs[1][0])
|
||||||
|
|
||||||
|
cfg = S.read_cfg()
|
||||||
|
tok_a = S.mint(cfg["jwt"], cfg["issuer"], UID_A, 1)
|
||||||
|
tok_b = S.mint(cfg["jwt"], cfg["issuer"], UID_B, 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── 1. 两个学生提交 ────────────────────────────
|
||||||
|
print("\n[1] 学生提交:甲(干净)/ 乙(有一处黄色警告)")
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id={UID_A}, bind_type=1 WHERE id={rid_a}")
|
||||||
|
r = S.call("POST", "/api/v1/mp/submissions", tok_a, {"taskId": task_id})
|
||||||
|
sid_a = r["data"]["id"]
|
||||||
|
# 刻意改一个老师填过的字段(在读学校)→ 审核端要能看出"这不是老师填的那份"
|
||||||
|
rep_a = prepare_student(tok_a, sid_a, task_id, {
|
||||||
|
"work_years": "3", "school": "我校(学生改过)",
|
||||||
|
}, dup_first_file=False)
|
||||||
|
check("甲:提交成功", (rep_a.get("data") or {}).get("submitted") is True, f"{rep_a}")
|
||||||
|
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id={UID_B}, bind_type=1 WHERE id={rid_b}")
|
||||||
|
r = S.call("POST", "/api/v1/mp/submissions", tok_b, {"taskId": task_id})
|
||||||
|
sid_b = r["data"]["id"]
|
||||||
|
rep_b = prepare_student(tok_b, sid_b, task_id, {"work_years": "5"}, dup_first_file=True)
|
||||||
|
check("乙:带着黄色警告提交成功(学生确认过)",
|
||||||
|
(rep_b.get("data") or {}).get("submitted") is True, f"{rep_b}")
|
||||||
|
check("乙:确实是「有黄无红」——这才轮得到批量通过的排除逻辑",
|
||||||
|
((rep_b.get("data") or {}).get("yellowCount") or 0) > 0
|
||||||
|
and ((rep_b.get("data") or {}).get("redCount") or 0) == 0, f"{rep_b.get('data')}")
|
||||||
|
|
||||||
|
st = sql(f"SELECT status || '|' || red_count || '|' || yellow_count || '|' || "
|
||||||
|
f"submit_status FROM collect_submission s "
|
||||||
|
f"JOIN collect_roster r ON r.id = s.roster_id "
|
||||||
|
f"WHERE s.id={sid_a} AND s.deleted=0").split("|")
|
||||||
|
check("甲:状态 1(待初审)、红黄都是 0", st[0] == "1" and st[1] == "0" and st[2] == "0", f"{st}")
|
||||||
|
check("**名册行的提交状态跟着走到 2(待审)**——以前没人写过它,名册列表恒显示「未提交」",
|
||||||
|
st[3] == "2", f"submit_status={st[3]}")
|
||||||
|
|
||||||
|
# ── 2. 待办列表与范围隔离 ──────────────────────
|
||||||
|
print("\n[2] 待办列表:权限 ≠ 范围")
|
||||||
|
t1, t2 = teachers["t1"]["token"], teachers["t2"]["token"]
|
||||||
|
b1 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t1)
|
||||||
|
d1 = b1.get("data") or {}
|
||||||
|
check("T1(被指定的初审人)看得到 2 份", d1.get("total") == 2, f"{b1}")
|
||||||
|
check("counts.all = 2", (d1.get("counts") or {}).get("all") == 2, f"{d1.get('counts')}")
|
||||||
|
check("counts.clean = 1(只有甲是绿色)",
|
||||||
|
(d1.get("counts") or {}).get("clean") == 1, f"{d1.get('counts')}")
|
||||||
|
check("counts.abnormal = 1(乙有黄色)",
|
||||||
|
(d1.get("counts") or {}).get("abnormal") == 1, f"{d1.get('counts')}")
|
||||||
|
rows1 = {x.get("submissionId"): x for x in (d1.get("list") or [])}
|
||||||
|
ca, cb = rows1.get(sid_a) or {}, rows1.get(sid_b) or {}
|
||||||
|
check("甲 batchApprovable=true(服务端判定,前端不许自己算)",
|
||||||
|
ca.get("batchApprovable") is True, f"{ca}")
|
||||||
|
check("乙 batchApprovable=false", cb.get("batchApprovable") is False, f"{cb}")
|
||||||
|
check("卡片带姓名/班级/材料数/缩略图",
|
||||||
|
ca.get("realName") and ca.get("orgName") and (ca.get("materialCount") or 0) >= 1
|
||||||
|
and len(ca.get("materialThumbs") or []) >= 1, f"{ca}")
|
||||||
|
check("甲带「学生改过」的字段(changedFields 非空)",
|
||||||
|
"school" in (ca.get("changedFields") or []), f"{ca.get('changedFields')}")
|
||||||
|
check("等待小时数是算出来的(0.x 小时)",
|
||||||
|
isinstance(ca.get("waitHours"), (int, float)) and ca.get("waitHours") < 1, f"{ca.get('waitHours')}")
|
||||||
|
|
||||||
|
b2 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t2)
|
||||||
|
check("**T2(同样是班主任、但没被指定)→ 一份都看不到**",
|
||||||
|
(b2.get("data") or {}).get("total") == 0, f"{b2}")
|
||||||
|
|
||||||
|
b3 = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", t1)
|
||||||
|
check("T1 没有终审权限 → 403(不是空列表)", b3.get("code") == 40301, f"{b3}")
|
||||||
|
|
||||||
|
# ── 3. 详情 ────────────────────────────────────
|
||||||
|
print("\n[3] 审核详情:材料预览 + 改动明细 + 时间轴 + 能不能通过")
|
||||||
|
dt = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
|
||||||
|
d = dt.get("data") or {}
|
||||||
|
check("T1 打得开", dt.get("code") == 0, f"{dt}")
|
||||||
|
dt2 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t2)
|
||||||
|
check("**T2 打不开同一份(403)**", dt2.get("code") == 40301, f"{dt2}")
|
||||||
|
check("姓名与脱敏证件号(不给完整号)",
|
||||||
|
d.get("realName") == "审核甲" and "****" in (d.get("idCardMask") or ""), f"{d.get('realName')}/{d.get('idCardMask')}")
|
||||||
|
mats = d.get("materials") or []
|
||||||
|
idcard = next((m for m in mats if m.get("materialCode") == "IDCARD"), None)
|
||||||
|
check("材料按定义分组(缺的那项也会出现,不是只看传了什么)",
|
||||||
|
idcard is not None and idcard.get("done") is True, f"{mats}")
|
||||||
|
files = (idcard or {}).get("files") or []
|
||||||
|
check("文件带**预览链接**(七牛签名 URL 或本地走审核端下载通道,都行)",
|
||||||
|
bool(files) and ("token=" in (files[0].get("previewUrl") or "")
|
||||||
|
or "/api/v1/audit/files/" in (files[0].get("previewUrl") or "")),
|
||||||
|
f"{files}")
|
||||||
|
cd = d.get("changedDetail") or []
|
||||||
|
check("改动明细说得出「老师填的 vs 学生改的」",
|
||||||
|
len(cd) == 1 and cd[0].get("field") == "school"
|
||||||
|
and cd[0].get("rosterValue") == "示范职业学校"
|
||||||
|
and cd[0].get("studentValue") == "我校(学生改过)", f"{cd}")
|
||||||
|
check("approvePolicy.canApprove = true(无红)",
|
||||||
|
(d.get("approvePolicy") or {}).get("canApprove") is True, f"{d.get('approvePolicy')}")
|
||||||
|
check("approvePolicy.canBatch = true(无红无黄)",
|
||||||
|
(d.get("approvePolicy") or {}).get("canBatch") is True, f"{d.get('approvePolicy')}")
|
||||||
|
check("revokePolicy:还没有可撤回的结论",
|
||||||
|
(d.get("revokePolicy") or {}).get("canRevoke") is False, f"{d.get('revokePolicy')}")
|
||||||
|
tl = d.get("timeline") or []
|
||||||
|
check("时间轴是**倒序**:最近的在最前,最早的一格是「学生提交」",
|
||||||
|
tl and tl[-1].get("node") == 0 and tl[-1].get("nodeText") == "学生提交", f"{tl}")
|
||||||
|
|
||||||
|
db = call(base, "GET", f"/api/v1/audit/submissions/{sid_b}", t1)
|
||||||
|
check("乙的 canBatch = false(有黄色)",
|
||||||
|
((db.get("data") or {}).get("approvePolicy") or {}).get("canBatch") is False,
|
||||||
|
f"{(db.get('data') or {}).get('approvePolicy')}")
|
||||||
|
|
||||||
|
# ── 4. 红问题不许通过(现场重算)───────────────
|
||||||
|
print("\n[4] 有红问题不许通过 —— 而且必须**现场重算**,不是提交时的快照")
|
||||||
|
sql(f"UPDATE collect_submission SET school=NULL WHERE id={sid_a}") # VAL-16:在校生缺在读学校
|
||||||
|
ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
|
||||||
|
{"actionSource": "BUTTON"})
|
||||||
|
check("**清空必填项后通过 → 40032**(若读快照会误判为可过)",
|
||||||
|
ra.get("code") == 40032, f"{ra}")
|
||||||
|
check("提示里说了是哪一类问题", "在读学校" in (ra.get("msg") or ""), f"{ra.get('msg')}")
|
||||||
|
d4b = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
|
||||||
|
d4 = d4b.get("data") or {}
|
||||||
|
check("详情里的红计数也变成 1(现场值与快照已对齐)",
|
||||||
|
d4.get("redCount") == 1, f"{d4b}")
|
||||||
|
check("canApprove 变成 false,并给出原因",
|
||||||
|
(d4.get("approvePolicy") or {}).get("canApprove") is False
|
||||||
|
and "红色" in ((d4.get("approvePolicy") or {}).get("reason") or ""),
|
||||||
|
f"{d4.get('approvePolicy')}")
|
||||||
|
check("**详情这一读顺手把快照回写了**(列表下次读到的是现场值)",
|
||||||
|
sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}") == "1",
|
||||||
|
sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}"))
|
||||||
|
sql(f"UPDATE collect_submission SET school='示范职业学校' WHERE id={sid_a}")
|
||||||
|
|
||||||
|
# ── 5. 初审通过 ────────────────────────────────
|
||||||
|
print("\n[5] 初审通过:1 → 2,并留痕")
|
||||||
|
ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
|
||||||
|
{"actionSource": "SWIPE", "remark": "材料齐全"})
|
||||||
|
da = ra.get("data") or {}
|
||||||
|
check("通过成功", ra.get("code") == 0, f"{ra}")
|
||||||
|
check("状态 → 2 且说明下一步是「等待管理员终审」",
|
||||||
|
da.get("status") == 2 and da.get("nextNode") == "ADMIN_AUDIT", f"{da}")
|
||||||
|
check("replayed=false(这是真审了一次)", da.get("replayed") is False, f"{da}")
|
||||||
|
sql_status = sql(f"SELECT status || '|' || (class_audit_by IS NOT NULL)::text || '|' || "
|
||||||
|
f"(class_audit_time IS NOT NULL)::text FROM collect_submission WHERE id={sid_a}")
|
||||||
|
check("库里:状态 2 + 初审人/时间都写了", sql_status == "2|true|true", f"{sql_status}")
|
||||||
|
check("审核记录写了一条(audit_node=CLASS_AUDIT, action=1)",
|
||||||
|
sql(f"SELECT audit_node || '|' || action || '|' || auditor_id FROM collect_audit_record "
|
||||||
|
f"WHERE submission_id={sid_a} ORDER BY id DESC LIMIT 1")
|
||||||
|
== f"CLASS_AUDIT|1|{teachers['t1']['id']}",
|
||||||
|
sql(f"SELECT audit_node || '|' || action FROM collect_audit_record WHERE submission_id={sid_a}"))
|
||||||
|
|
||||||
|
ra2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1, {})
|
||||||
|
check("**重复点通过是幂等的**(返回 replayed=true,不报错、不重复写记录)",
|
||||||
|
ra2.get("code") == 0 and (ra2.get("data") or {}).get("replayed") is True, f"{ra2}")
|
||||||
|
check("幂等回放没有多写一条记录",
|
||||||
|
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "1",
|
||||||
|
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
|
||||||
|
|
||||||
|
# ── 6. 终审 ────────────────────────────────────
|
||||||
|
print("\n[6] 终审:2 → 3")
|
||||||
|
bt = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", admin)
|
||||||
|
check("管理员终审列表看到 1 份", (bt.get("data") or {}).get("total") == 1, f"{bt}")
|
||||||
|
rfin = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", admin,
|
||||||
|
{"finalOpinion": "同意认定", "needArchive": False})
|
||||||
|
check("终审通过 → 3", (rfin.get("data") or {}).get("status") == 3, f"{rfin}")
|
||||||
|
st = sql(f"SELECT status || '|' || (admin_audit_by IS NOT NULL)::text FROM collect_submission "
|
||||||
|
f"WHERE id={sid_a}")
|
||||||
|
check("库里状态 3 + 终审人已写", st.startswith("3|"), f"{st}")
|
||||||
|
check("名册行 → 3(已通过)",
|
||||||
|
sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}") == "3",
|
||||||
|
sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}"))
|
||||||
|
check("库里有 2 条审核记录(初审 + 终审);时间轴上的「学生提交」是合成条目",
|
||||||
|
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "2",
|
||||||
|
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
|
||||||
|
|
||||||
|
# ── 7. 批量通过:只带绿色项 ────────────────────
|
||||||
|
print("\n[7] 批量通过:红黄一律排除(铁律)")
|
||||||
|
# ① 乙此刻是「有黄无红」→ 必须被跳过,且要说清是黄色
|
||||||
|
rb = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
|
||||||
|
"submissionIds": [sid_b], "onlyClean": False,
|
||||||
|
})
|
||||||
|
db_ = rb.get("data") or {}
|
||||||
|
check("onlyClean 传 false 也不生效(服务端强制为 true)", rb.get("code") == 0, f"{rb}")
|
||||||
|
details = {x.get("submissionId"): x for x in (db_.get("details") or [])}
|
||||||
|
check("**有黄的单被跳过**:approved=0",
|
||||||
|
db_.get("approved") == 0 and db_.get("skipped") == 1, f"{db_}")
|
||||||
|
check("跳过原因说清了「黄色提醒需单独确认」(不是一句「跳过」)",
|
||||||
|
"黄色" in (details.get(sid_b, {}).get("reason") or ""), f"{details.get(sid_b)}")
|
||||||
|
|
||||||
|
# ② 把那份重复件停用 → 乙变成绿色 → 这次应该真的通过
|
||||||
|
sql(f"UPDATE collect_file SET is_current=0 WHERE submission_id={sid_b} AND id IN "
|
||||||
|
f"(SELECT id FROM collect_file WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1)")
|
||||||
|
rb2 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
|
||||||
|
"submissionIds": [sid_b], "onlyClean": True,
|
||||||
|
})
|
||||||
|
db2 = rb2.get("data") or {}
|
||||||
|
check("清掉重复件后:approved=1(乙过初审)", db2.get("approved") == 1, f"{rb2}")
|
||||||
|
check("批量通过记的是 action=3(批量通过),不是 1",
|
||||||
|
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b} "
|
||||||
|
f"ORDER BY id DESC LIMIT 1") == "3",
|
||||||
|
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b}"))
|
||||||
|
check("批量通过后状态 → 2",
|
||||||
|
sql(f"SELECT status FROM collect_submission WHERE id={sid_b}") == "2",
|
||||||
|
sql(f"SELECT status FROM collect_submission WHERE id={sid_b}"))
|
||||||
|
|
||||||
|
# ③ 把乙弄成红色 → 红色单同样必须被跳过
|
||||||
|
# ⚠️ 两个坑,都值得记:
|
||||||
|
# ① 先把状态放回 1:批量通过之后乙已经在"待终审"了,那时初审人再点它,
|
||||||
|
# 跳过原因会是「你没有终审权限」——正确但**不对题**,会掩盖这条用例要验的东西;
|
||||||
|
# ② 红问题要用**名册里没有的字段**来造(这里用「本职业工作年限」填 999 → VAL-17)。
|
||||||
|
# 一开始我用「在读学校」清空列,结果**根本不会触发**——
|
||||||
|
# 因为组装表单值时"学生没动过"的字段会**回退到名册值**(见 FillService.assemble),
|
||||||
|
# 提交单里那列是不是 null 并不重要。这正是"校验收敛到同一份取值口径"的体现。
|
||||||
|
sql(f"UPDATE collect_submission SET status=1, work_years=999 WHERE id={sid_b}")
|
||||||
|
rb3 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
|
||||||
|
{"submissionIds": [sid_b]})
|
||||||
|
details3 = {x.get("submissionId"): x for x in ((rb3.get("data") or {}).get("details") or [])}
|
||||||
|
check("**有红的单被跳过**:approved=0",
|
||||||
|
(rb3.get("data") or {}).get("approved") == 0, f"{rb3}")
|
||||||
|
check("跳过原因说清了「存在红色问题,需单独审核」",
|
||||||
|
"红色" in (details3.get(sid_b, {}).get("reason") or ""), f"{details3.get(sid_b)}")
|
||||||
|
sql(f"UPDATE collect_submission SET work_years=5, status=2 WHERE id={sid_b}")
|
||||||
|
|
||||||
|
# ── 8. 驳回 ────────────────────────────────────
|
||||||
|
print("\n[8] 驳回:原因必填 + 升级 + 学生重交")
|
||||||
|
# 让乙回到待初审:管理员先驳回(终审驳回直接生效,可越过初审)
|
||||||
|
sql(f"UPDATE collect_submission SET status=2 WHERE id={sid_b}")
|
||||||
|
r_no = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin, {})
|
||||||
|
check("不填原因 → 拒绝", r_no.get("code") != 0, f"{r_no}")
|
||||||
|
r_bad = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
|
||||||
|
{"reasonCode": "R404"})
|
||||||
|
check("原因码不在字典里 → 拒绝", r_bad.get("code") != 0, f"{r_bad}")
|
||||||
|
r_999 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
|
||||||
|
{"reasonCode": "R999"})
|
||||||
|
check("R999 不填说明 → 拒绝(学生收到一条「其他」等于没收到)",
|
||||||
|
r_999.get("code") != 0, f"{r_999}")
|
||||||
|
|
||||||
|
rej = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
|
||||||
|
{"reasonCode": "R002", "reasonText": "名册上是 2008 年", "materialCodes": ["IDCARD"]})
|
||||||
|
dj = rej.get("data") or {}
|
||||||
|
check("驳回成功 → 状态 4", dj.get("status") == 4, f"{rej}")
|
||||||
|
check("notifySent=false 且 needManualNotice=true(一期没有通知渠道,**如实说**)",
|
||||||
|
dj.get("notifySent") is False and dj.get("needManualNotice") is True, f"{dj}")
|
||||||
|
check("第 1 次驳回,escalated=false", dj.get("escalated") is False, f"{dj}")
|
||||||
|
st = sql(f"SELECT status || '|' || reject_count || '|' || last_reject_reason || '|' || "
|
||||||
|
f"(SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
|
||||||
|
f"FROM collect_submission WHERE id={sid_b}")
|
||||||
|
check("库里:状态 4 + 次数 1 + 原因 JSON(含码与文案)",
|
||||||
|
st.startswith("4|1|") and "R002" in st and "名册上是 2008 年" in st, f"{st}")
|
||||||
|
check("名册行 → 4(已驳回)", st.rstrip().endswith("|4"), f"{st}")
|
||||||
|
check("驳回记录写了一条(action=2 + 原因码 + 材料项)",
|
||||||
|
sql(f"SELECT action || '|' || reason_code || '|' || material_codes "
|
||||||
|
f"FROM collect_audit_record WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1")
|
||||||
|
== "2|R002|IDCARD",
|
||||||
|
sql(f"SELECT action || '|' || reason_code FROM collect_audit_record WHERE submission_id={sid_b}"))
|
||||||
|
|
||||||
|
# 升级:把次数改成 2,再驳一次就是第 3 次
|
||||||
|
sql(f"UPDATE collect_submission SET status=2, reject_count=2 WHERE id={sid_b}")
|
||||||
|
rej2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
|
||||||
|
{"reasonCode": "R005"})
|
||||||
|
check("第 3 次驳回 → escalated=true(达上限要升级给管理员)",
|
||||||
|
(rej2.get("data") or {}).get("escalated") is True, f"{rej2}")
|
||||||
|
|
||||||
|
# 学生重新提交 → 回到待初审,名册状态也要回来
|
||||||
|
sql(f"UPDATE collect_submission SET reject_count=3 WHERE id={sid_b}")
|
||||||
|
r_re = S.sub(tok_b, sid_b, True)
|
||||||
|
check("学生被驳回后可以重新提交", (r_re.get("data") or {}).get("submitted") is True, f"{r_re}")
|
||||||
|
st = sql(f"SELECT status || '|' || (SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
|
||||||
|
f"FROM collect_submission WHERE id={sid_b}")
|
||||||
|
check("**状态回到 1,名册行回到 2(待审)**——不回退的话班主任会一直以为他还欠一份",
|
||||||
|
st == "1|2", f"{st}")
|
||||||
|
|
||||||
|
# ── 9. 撤回 ────────────────────────────────────
|
||||||
|
print("\n[9] 撤回:只能撤最近一次通过;已导出/已归档不可撤")
|
||||||
|
d9 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", admin).get("data") or {}
|
||||||
|
rp = d9.get("revokePolicy") or {}
|
||||||
|
check("管理员看得到可撤回的结论(最近一条 = 终审那条)",
|
||||||
|
rp.get("canRevoke") is True and rp.get("auditRecordId"), f"{rp}")
|
||||||
|
admin_rec = rp.get("auditRecordId")
|
||||||
|
|
||||||
|
t2_rev = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", t2,
|
||||||
|
{"auditRecordId": admin_rec, "reason": "越权试试"})
|
||||||
|
check("T2(无撤回权限)→ 403", t2_rev.get("code") == 40301, f"{t2_rev}")
|
||||||
|
|
||||||
|
# 导出过 → 40033
|
||||||
|
sql(f"INSERT INTO collect_export_task (tenant_id, task_id, export_name, status, "
|
||||||
|
f"create_time) VALUES (1, {task_id}, 'E2E假导出', 2, now())")
|
||||||
|
r_exp = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
|
||||||
|
{"auditRecordId": admin_rec, "reason": "导出后还想撤"})
|
||||||
|
check("**已导出打包 → 40033**(已交付的数据不可追溯修改)",
|
||||||
|
r_exp.get("code") == 40033, f"{r_exp}")
|
||||||
|
sql(f"DELETE FROM collect_export_task WHERE task_id={task_id} AND export_name='E2E假导出'")
|
||||||
|
|
||||||
|
# 归档 → 40034
|
||||||
|
sql(f"UPDATE collect_task SET status=3 WHERE id={task_id}")
|
||||||
|
r_arc = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
|
||||||
|
{"auditRecordId": admin_rec, "reason": "归档后还想撤"})
|
||||||
|
check("已归档 → 40034", r_arc.get("code") == 40034, f"{r_arc}")
|
||||||
|
sql(f"UPDATE collect_task SET status=1 WHERE id={task_id}")
|
||||||
|
|
||||||
|
r_ok = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
|
||||||
|
{"auditRecordId": admin_rec, "reason": "发错了人,撤回重审"})
|
||||||
|
check("撤回终审结论 → 回到 2(待终审)",
|
||||||
|
(r_ok.get("data") or {}).get("status") == 2, f"{r_ok}")
|
||||||
|
check("撤回也留痕:多了一条 action=7(撤回)的记录",
|
||||||
|
sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a} "
|
||||||
|
f"AND action=7") == "1",
|
||||||
|
sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_a} ORDER BY id"))
|
||||||
|
check("时间轴里能看出「谁撤回了哪一条」",
|
||||||
|
"撤回记录 #" in sql(f"SELECT reason_text FROM collect_audit_record WHERE "
|
||||||
|
f"submission_id={sid_a} AND action=7"),
|
||||||
|
sql(f"SELECT reason_text FROM collect_audit_record WHERE submission_id={sid_a} AND action=7"))
|
||||||
|
|
||||||
|
# 撤回后再撤回同一条 → 已经是最新的那条被撤了,不能重复撤
|
||||||
|
r_twice = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
|
||||||
|
{"auditRecordId": admin_rec, "reason": "再撤一次"})
|
||||||
|
check("同一条不能撤两次(它已经不是「最近一次结论」了)",
|
||||||
|
r_twice.get("code") != 0, f"{r_twice}")
|
||||||
|
|
||||||
|
# ── 10. 字典与边界 ─────────────────────────────
|
||||||
|
print("\n[10] 驳回原因字典与边界")
|
||||||
|
rc = call(base, "GET", "/api/v1/audit/reason-codes", t1)
|
||||||
|
items = (rc.get("data") or [])
|
||||||
|
check("10 条原因码(PRD 附录 C)", len(items) == 10, f"{len(items)}")
|
||||||
|
check("R999 标了 requiresText=true",
|
||||||
|
any(x.get("code") == "R999" and x.get("requiresText") is True for x in items), f"{items[:2]}")
|
||||||
|
check("文案都是人话(不是 R001 这种码,也不是空)",
|
||||||
|
all(x.get("text") and x.get("text") != x.get("code") for x in items), f"{items}")
|
||||||
|
|
||||||
|
r_over = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
|
||||||
|
{"submissionIds": list(range(1, 300))})
|
||||||
|
check("批量超过 200 条 → 40011", r_over.get("code") == 40011, f"{r_over}")
|
||||||
|
r_none = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
|
||||||
|
{"submissionIds": []})
|
||||||
|
check("空列表 → 明确报错(不是静默成功)", r_none.get("code") != 0, f"{r_none}")
|
||||||
|
|
||||||
|
# 学生不该能碰审核接口
|
||||||
|
r_stu = S.call("GET", "/api/v1/audit/todos?node=1", tok_a)
|
||||||
|
check("学生拿自己的 token 调审核接口 → 40301", r_stu.get("code") == 40301, f"{r_stu}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
print("\n[11] 清理")
|
||||||
|
for tid in CREATED_TASK_IDS:
|
||||||
|
sql(f"DELETE FROM collect_audit_record WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_file WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_submission WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_roster WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_material_def WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_task_org WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_export_task WHERE task_id={tid}")
|
||||||
|
sql(f"DELETE FROM collect_task WHERE id={tid}")
|
||||||
|
for uid in CREATED_USER_IDS:
|
||||||
|
sql(f"DELETE FROM sys_user_role WHERE user_id={uid}")
|
||||||
|
sql(f"DELETE FROM sys_user WHERE id={uid}")
|
||||||
|
for p in TMP_FILES:
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
left = sql(f"SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E审核%'")
|
||||||
|
ok(f"已清理(残留任务 {left} 个)")
|
||||||
|
|
||||||
|
print("\n" + "=" * 76)
|
||||||
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
for n, dd in FAILED:
|
||||||
|
print(f" · {n}\n {dd}")
|
||||||
|
print("=" * 76)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user