feat(audit): 审核台前端与审核统计 —— 批量通过逐条给原因,驳回走附录 C 原因码
待办台四个计数 + 分级过滤 + 批量通过(被跳过的条目逐条列原因,只说「过了 3 个」 会让人反复点那个大按钮);详情抽屉含材料/问题清单/改动明细/时间轴/通过·驳回·撤回。 前端不做范围过滤:权限 ≠ 范围,由服务端用 1 = 0 守死,前端再筛一次只会制造假象。 canApprove=false 时按钮直接禁用并显示原因,而不是等他点了再弹 40032。 审核端看材料走单独一条通道(不复用学生那条),材料 URL 用 ?thumb=true 取缩略图, 默认 false —— 详情页要看防伪纹。 验证:vue-tsc / eslint 0 错;浏览器实测四个菜单与批量通过弹窗。
This commit is contained in:
+47
-2
@@ -42,6 +42,9 @@ import java.util.List;
|
||||
public class AuditController {
|
||||
|
||||
private final AuditService auditService;
|
||||
private final cn.tianbaotong.fill.module.audit.service.AuditStatisticsService statisticsService;
|
||||
private final cn.tianbaotong.fill.module.mp.service.TeacherProxyService proxyService;
|
||||
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
|
||||
|
||||
@GetMapping("/todos")
|
||||
@Operation(summary = "待办列表",
|
||||
@@ -125,8 +128,9 @@ public class AuditController {
|
||||
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));
|
||||
@RequestParam(required = false) Long expireSeconds,
|
||||
@RequestParam(defaultValue = "false") boolean thumb) {
|
||||
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds, thumb));
|
||||
}
|
||||
|
||||
@GetMapping("/files/{id}/content")
|
||||
@@ -142,4 +146,45 @@ public class AuditController {
|
||||
.header("Content-Disposition", "inline")
|
||||
.body(c.bytes());
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════ 审核统计(§11.6)══════════════════
|
||||
|
||||
@GetMapping("/statistics")
|
||||
@RequirePerm(PermissionDef.PROGRESS_VIEW_ALL)
|
||||
@Operation(summary = "审核统计(§11.6)",
|
||||
description = "`byReason` 是给管理员看「**哪种问题最多**」的,用来反过来优化材料引导语 —— "
|
||||
+ "如果 60% 的驳回都是「工作证明没盖章」,那就该在填报页把它写成大红字。"
|
||||
+ "所以 `reasonText` 必须可读,只给码(REASON-03)管理员看不懂,这个统计就白做了。"
|
||||
+ "⚠️ 待审/已通过/已驳回按填报单**当前状态**数(不按审核记录数):"
|
||||
+ "被驳回 3 次又通过的单子在「已通过」里算 1、「已驳回」里算 0,"
|
||||
+ "这样三个数字加起来才等于人数 —— 加起来对不上,客户就不信整页数字了")
|
||||
public ApiResult<cn.tianbaotong.fill.module.audit.service.AuditStatisticsService.StatVO>
|
||||
statistics(@RequestParam Long taskId,
|
||||
@RequestParam(required = false) Long orgId,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to) {
|
||||
return ApiResult.ok(statisticsService.statistics(taskId, orgId, from, to));
|
||||
}
|
||||
|
||||
// ══════════════════ PC 端代传(§11.7)══════════════════
|
||||
|
||||
@PostMapping(value = "/submissions/{id}/proxy-upload",
|
||||
consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePerm(PermissionDef.FILE_PROXY_UPLOAD)
|
||||
@Operation(summary = "PC 端代传材料(§11.7,同 §10.6)",
|
||||
description = "与班主任端同一个服务,但**范围判定不同**:"
|
||||
+ "PC 端是管理/审核角色,管理员的范围是**整个租户** —— "
|
||||
+ "直接复用 MP 那套(只管自己负责的班)会让管理员在 PC 上报 40301,"
|
||||
+ "而界面上看起来像权限没配好")
|
||||
public ApiResult<cn.tianbaotong.fill.module.mp.dto.TeacherDtos.ProxyUploadVO> proxyUpload(
|
||||
@PathVariable Long id,
|
||||
@RequestParam String materialCode,
|
||||
@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
|
||||
@RequestParam(required = false) String proxyReason,
|
||||
jakarta.servlet.http.HttpServletRequest req) throws java.io.IOException {
|
||||
return ApiResult.ok(proxyService.proxyUploadAsAdmin(id, materialCode,
|
||||
file.getOriginalFilename(), file.getBytes(), proxyReason,
|
||||
cn.tianbaotong.fill.support.WebUtil.clientIp(req)));
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package cn.tianbaotong.fill.module.audit.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审核统计的聚合查询(《03-接口设计》§11.6)。
|
||||
*
|
||||
* <h3>三条口径</h3>
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>待审 / 已通过 / 已驳回</b> 都按**填报单当前状态**数,而不是按审核记录数:
|
||||
* 一份单子被驳回 3 次又通过,它在"已通过"里算 1、"已驳回"里算 0 ——
|
||||
* 按记录数会让数字加起来不等于人数,客户一眼就看出不对;</li>
|
||||
* <li><b>平均时长</b>用审核记录里的 {@code duration_sec}(审核时算好写进去的),
|
||||
* 按**审核人**分组时同样用它;</li>
|
||||
* <li><b>超时</b> = 提交时间已超过任务配置的节点时限**且仍未审完**。
|
||||
* 时限来自任务的 {@code audit_config}(初审 48h / 终审 72h 为默认),
|
||||
* 所以这里要拿任务的 JSON 配置来算 —— 而不是写死 48。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>为什么把 SQL 放这里而不是在 Service 里拼</h3>
|
||||
*
|
||||
* <p>这些查询都要**按 orgId 过滤**(契约的 Query 有它),而 org 过滤的路径是
|
||||
* "填报单 → 名册 → 班级"。写成一段可读的 SQL 比在 Java 里拼字符串可靠得多。
|
||||
*/
|
||||
public interface AuditStatMapper {
|
||||
|
||||
/**
|
||||
* 按状态数填报单。
|
||||
*
|
||||
* <p>{@code orgId} 为空时表示整个任务。⚠️ 用 {@code <script>} + {@code <if>}
|
||||
* 拼可选条件:PostgreSQL **推断不出 `#{orgId} IS NULL` 里参数的类型**(F-55)。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT s.status AS st, COUNT(*) AS cnt
|
||||
FROM collect_submission s
|
||||
<if test="orgId != null">
|
||||
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||
</if>
|
||||
WHERE s.task_id = #{taskId} AND s.deleted = 0
|
||||
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||
<if test="from != null"> AND s.submit_time >= #{from}::timestamp</if>
|
||||
<if test="to != null"> AND s.submit_time <= #{to}::timestamp</if>
|
||||
GROUP BY s.status
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> countByStatus(@Param("taskId") Long taskId,
|
||||
@Param("orgId") Long orgId,
|
||||
@Param("from") String from,
|
||||
@Param("to") String to);
|
||||
|
||||
/** 平均审核时长(小时),按任务整体。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT AVG(a.duration_sec) / 3600.0
|
||||
FROM collect_audit_record a
|
||||
<if test="orgId != null">
|
||||
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||
</if>
|
||||
WHERE a.task_id = #{taskId} AND a.duration_sec IS NOT NULL AND a.duration_sec > 0
|
||||
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||
</script>
|
||||
""")
|
||||
Double avgAuditHours(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||
@Param("from") String from, @Param("to") String to);
|
||||
|
||||
/** 按审核人分组(谁审了多少、平均多久)。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT u.real_name AS auditorName,
|
||||
COUNT(*) AS handled,
|
||||
AVG(a.duration_sec) / 3600.0 AS avgHours
|
||||
FROM collect_audit_record a
|
||||
LEFT JOIN sys_user u ON u.id = a.auditor_id AND u.deleted = 0
|
||||
<if test="orgId != null">
|
||||
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||
</if>
|
||||
WHERE a.task_id = #{taskId}
|
||||
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||
GROUP BY u.real_name
|
||||
ORDER BY handled DESC
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> byAuditor(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||
@Param("from") String from, @Param("to") String to);
|
||||
|
||||
/**
|
||||
* 按驳回原因分组。
|
||||
*
|
||||
* <p>{@code byReason} 的用途在契约里写得很清楚:给管理员看"**哪种问题最多**",
|
||||
* 用来反过来优化材料引导语 —— 也就是"如果 60% 的驳回都是『工作证明没盖章』,
|
||||
* 那就该在填报页把它写成大红字"。所以这里必须给出可读的 {@code reasonText},
|
||||
* 只给码(REASON-03)管理员看不懂,这个统计就白做了。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT a.reason_code AS reasonCode,
|
||||
MIN(a.reason_text) AS reasonText,
|
||||
COUNT(*) AS cnt
|
||||
FROM collect_audit_record a
|
||||
<if test="orgId != null">
|
||||
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||
</if>
|
||||
WHERE a.task_id = #{taskId}
|
||||
AND a.action = 2
|
||||
AND a.reason_code IS NOT NULL
|
||||
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||
GROUP BY a.reason_code
|
||||
ORDER BY cnt DESC
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> byReason(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||
@Param("from") String from, @Param("to") String to);
|
||||
|
||||
/**
|
||||
* 超时的填报单数:提交已超过节点时限、且仍未审完。
|
||||
*
|
||||
* <p>用的是**当前节点**的时限:待初审(status=1)比初审时限,
|
||||
* 待终审(status=2)比终审时限。⚠️ 不能只用初审时限 ——
|
||||
* 一份已通过初审、卡在终审的单子会被误判成"没超时"。
|
||||
*
|
||||
* <p>{@code classHours} / {@code adminHours} 由调用方从任务的
|
||||
* {@code audit_config} 里读出来传进来(那里有客户改过的值,默认才是 48/72)。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*) FROM collect_submission s
|
||||
<if test="orgId != null">
|
||||
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||
</if>
|
||||
WHERE s.task_id = #{taskId} AND s.deleted = 0
|
||||
AND s.submit_time IS NOT NULL
|
||||
AND (
|
||||
(s.status = 1 AND s.submit_time < NOW() - (#{classHours} || ' hours')::interval)
|
||||
OR
|
||||
(s.status = 2 AND s.submit_time < NOW() - (#{adminHours} || ' hours')::interval)
|
||||
)
|
||||
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||
</script>
|
||||
""")
|
||||
long timeoutCount(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||
@Param("classHours") int classHours, @Param("adminHours") int adminHours);
|
||||
|
||||
/**
|
||||
* 某个任务待我审的单数 —— 给审核台的"待办角标"用。
|
||||
*
|
||||
* <p>⚠️ 这里**不关**租户拦截器:查询按 {@code task_id} 过滤,
|
||||
* 而 taskId 一定属于某个租户,拦截器补上的 `tenant_id` 条件正好是我们要的。
|
||||
* **只有确实需要跨租户的查询才关它**(见 `MpSceneMapper` 与微信回调)——
|
||||
* 顺手关掉是这类隔离最常见的失效方式。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM collect_submission
|
||||
WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
|
||||
""")
|
||||
long pendingOfTask(@Param("taskId") Long taskId);
|
||||
}
|
||||
+54
@@ -81,6 +81,32 @@ public interface AuditTodoMapper {
|
||||
<if test="orgId != null">
|
||||
AND s.org_id = #{orgId}
|
||||
</if>
|
||||
<!--
|
||||
初审的范围:**按(任务, 班级)配对**判断,而不是"按班级 id 列表"。
|
||||
|
||||
原来用 `s.org_id IN orgIds`,而 orgIds 在 taskId 为空时是
|
||||
"我在**任意任务**里负责过的班级" —— 于是"我在任务 A 是这个班的初审人、
|
||||
在任务 B 不是"时,待办会列出任务 B 的单子,点通过却回 40301。
|
||||
换成 EXISTS 之后,待办与审批用的是同一套判据,
|
||||
不可能再出现「看得到但批不了」。
|
||||
-->
|
||||
<if test="auditorId != null">
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM collect_task_org cto
|
||||
LEFT JOIN sys_org so
|
||||
ON so.id = cto.org_id AND so.deleted = 0
|
||||
AND so.tenant_id = cto.tenant_id
|
||||
WHERE cto.tenant_id = #{tenantId}
|
||||
AND cto.task_id = s.task_id
|
||||
AND cto.org_id = s.org_id
|
||||
AND cto.status = 1
|
||||
AND (
|
||||
cto.head_user_id = #{auditorId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||
)
|
||||
)
|
||||
</if>
|
||||
<choose>
|
||||
<when test="orgIds != null and orgIds.size() > 0">
|
||||
AND s.org_id IN
|
||||
@@ -114,6 +140,7 @@ public interface AuditTodoMapper {
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("orgId") Long orgId,
|
||||
@Param("orgIds") List<Long> orgIds,
|
||||
@Param("auditorId") Long auditorId,
|
||||
@Param("level") Integer level,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("waitingOverHours") Integer waitingOverHours);
|
||||
@@ -145,6 +172,32 @@ public interface AuditTodoMapper {
|
||||
<if test="orgId != null">
|
||||
AND s.org_id = #{orgId}
|
||||
</if>
|
||||
<!--
|
||||
初审的范围:**按(任务, 班级)配对**判断,而不是"按班级 id 列表"。
|
||||
|
||||
原来用 `s.org_id IN orgIds`,而 orgIds 在 taskId 为空时是
|
||||
"我在**任意任务**里负责过的班级" —— 于是"我在任务 A 是这个班的初审人、
|
||||
在任务 B 不是"时,待办会列出任务 B 的单子,点通过却回 40301。
|
||||
换成 EXISTS 之后,待办与审批用的是同一套判据,
|
||||
不可能再出现「看得到但批不了」。
|
||||
-->
|
||||
<if test="auditorId != null">
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM collect_task_org cto
|
||||
LEFT JOIN sys_org so
|
||||
ON so.id = cto.org_id AND so.deleted = 0
|
||||
AND so.tenant_id = cto.tenant_id
|
||||
WHERE cto.tenant_id = #{tenantId}
|
||||
AND cto.task_id = s.task_id
|
||||
AND cto.org_id = s.org_id
|
||||
AND cto.status = 1
|
||||
AND (
|
||||
cto.head_user_id = #{auditorId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||
)
|
||||
)
|
||||
</if>
|
||||
<choose>
|
||||
<when test="orgIds != null and orgIds.size() > 0">
|
||||
AND s.org_id IN
|
||||
@@ -164,6 +217,7 @@ public interface AuditTodoMapper {
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("orgId") Long orgId,
|
||||
@Param("orgIds") List<Long> orgIds,
|
||||
@Param("auditorId") Long auditorId,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("timeoutHours") Integer timeoutHours);
|
||||
}
|
||||
|
||||
+143
-9
@@ -7,6 +7,8 @@ import cn.tianbaotong.fill.common.context.UserContext;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
||||
import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
|
||||
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
|
||||
import cn.tianbaotong.fill.module.notify.service.NotificationService;
|
||||
import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
|
||||
import cn.tianbaotong.fill.module.audit.mapper.AuditPreconditionMapper;
|
||||
import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper;
|
||||
@@ -142,6 +144,8 @@ public class AuditService {
|
||||
private final FormSchemaService formSchemaService;
|
||||
private final SubmissionValidator validator;
|
||||
private final PermissionChecker permissionChecker;
|
||||
private final NotificationService notificationService;
|
||||
private final cn.tianbaotong.fill.module.open.service.OpenPushService openPushService;
|
||||
|
||||
// ══════════════════ 待办列表 ══════════════════
|
||||
|
||||
@@ -167,14 +171,19 @@ public class AuditService {
|
||||
|
||||
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
|
||||
long ps = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
// ⚠️ 初审还要传 auditorId:让 SQL 按(任务, 班级)配对判断范围 ——
|
||||
// 只传 orgScope(班级 id 列表)会列出"我在别的任务里负责、但这个任务不归我"的单子,
|
||||
// 而用户点通过只会被 40301 拒(看得到但批不了)
|
||||
Long auditorId = node == NODE_CLASS ? me.uid() : null;
|
||||
IPage<AuditTodoRow> page = todoMapper.pageTodos(new Page<>(pn, ps),
|
||||
me.tenantId(), status, taskId, orgId, orgScope, level, kw, waitingOverHours);
|
||||
me.tenantId(), status, taskId, orgId, orgScope, auditorId, level, kw,
|
||||
waitingOverHours);
|
||||
|
||||
AuditDtos.TodoPageVO out = new AuditDtos.TodoPageVO();
|
||||
out.setList(assembleTodos(page.getRecords(), me.tenantId()));
|
||||
out.setTotal(page.getTotal());
|
||||
out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope,
|
||||
kw, timeoutHours(node, taskId))));
|
||||
auditorId, kw, timeoutHours(node, taskId))));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -696,11 +705,100 @@ public class AuditService {
|
||||
int newStatus = node == NODE_CLASS
|
||||
? CollectSubmission.STATUS_PENDING_ADMIN
|
||||
: CollectSubmission.STATUS_APPROVED;
|
||||
// 通知学生(站内信一定能看到;微信推送看有没有额度)——
|
||||
// 失败绝不影响审核结果(NotificationService 内部不抛)
|
||||
notifyApproved(sub, newStatus);
|
||||
// 终审通过 → 推送给认定机构(契约 §14.4)。
|
||||
// ⚠️ **必须等事务提交之后**:推送是外部 HTTP 调用,
|
||||
// 放在事务里会让审核被对方的响应时间绑架;更糟的是,
|
||||
// 如果事务之后回滚了,机构已经收到"这个人通过了"而我们的库里没通过 ——
|
||||
// 这种不一致没法撤回(对方的系统已经动了)。
|
||||
if (newStatus == CollectSubmission.STATUS_APPROVED) {
|
||||
Long tid = sub.getTaskId();
|
||||
Long sid = submissionId;
|
||||
registerAfterCommit(() -> pushToOpenApp(tid, sid));
|
||||
}
|
||||
log.info("审核通过:submission={} node={} → status={} 审计人={}",
|
||||
submissionId, node, newStatus, me.uid());
|
||||
return approveResult(newStatus, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把动作挂到"当前事务提交之后"执行;**没有事务时立刻执行**。
|
||||
*
|
||||
* <p>没有事务也要执行:审批可能被别处以非事务方式调用(例如脚本、内部重试),
|
||||
* 那时"等提交"这件事根本不存在 —— 漏掉推送比多推一次严重得多
|
||||
* (推送记录上有唯一索引,重复触发只会更新同一条)。
|
||||
*/
|
||||
private void registerAfterCommit(Runnable action) {
|
||||
if (!org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
.isSynchronizationActive()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
.registerSynchronization(
|
||||
new org.springframework.transaction.support.TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过之后通知学生。
|
||||
*
|
||||
* <p>初审通过**也发**:学生提交后最想知道的就是"我的东西进下一步了吗"。
|
||||
* 只在终审通过时发的话,中间那段等待期他会反复进来看。
|
||||
*
|
||||
* <p>文案按节点区分 —— "等待管理员终审"和"已通过"是两件不同的事,
|
||||
* 用同一句话会让他以为已经结束了。
|
||||
*/
|
||||
private void notifyApproved(CollectSubmission sub, int newStatus) {
|
||||
if (sub.getSubmitterId() == null) {
|
||||
return;
|
||||
}
|
||||
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
|
||||
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
|
||||
String content = finalPass
|
||||
? "本次认定的材料已收妥,无需再操作。"
|
||||
: "班主任已通过你的材料,接下来由管理员终审。";
|
||||
safeNotify(NotificationService.NotifyRequest.of(
|
||||
sub.getSubmitterId(),
|
||||
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
|
||||
title, content,
|
||||
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 终审通过后推送给认定机构(契约 §14.4)。
|
||||
*
|
||||
* <p>⚠️ 由调用方在**事务提交之后**调(见 {@code approve} 里的注释):
|
||||
* 推送是外部 HTTP 调用,可能慢到十几秒,放进事务里会让
|
||||
* "终审通过"这个动作被对方服务的响应时间绑架 —— 对方挂了,我们的审核就卡住。
|
||||
*
|
||||
* <p>失败也只影响推送记录(状态与下次重试时间落库,由定时任务接着试),
|
||||
* 不影响"审核已通过"这个事实。
|
||||
*/
|
||||
private void pushToOpenApp(Long taskId, Long submissionId) {
|
||||
try {
|
||||
openPushService.onSubmissionApproved(UserContext.tenantId(), taskId, submissionId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("推送认定机构失败(不影响审核结果)submissionId={}", submissionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知的统一入口:**绝不让通知失败影响业务事务**。 */
|
||||
private void safeNotify(NotificationService.NotifyRequest req) {
|
||||
try {
|
||||
notificationService.notify(req);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("发通知失败(不影响业务结果):user={} biz={}", req.userId(), req.bizType(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等回放:如果"我上一次的通过"仍然有效,直接返回当前状态。
|
||||
*
|
||||
@@ -840,21 +938,43 @@ public class AuditService {
|
||||
req.getMaterialCodes());
|
||||
markRoster(sub.getRosterId(), CollectRoster.SUBMIT_REJECTED);
|
||||
|
||||
// ★ 通知学生(§13.5 消息中心 + §13.6 订阅消息降级链)。
|
||||
// 在此之前这里只能回 notifySent=false + needManualNotice=true ——
|
||||
// 因为"通知渠道还没做",老师得自己跟学生说一声。
|
||||
// 现在站内信一定写得进去,所以 pushSent 才是"微信推送到没到",
|
||||
// 而"学生能不能知道"这件事有了确定答案。
|
||||
boolean pushSent = false;
|
||||
if (sub.getSubmitterId() != null) {
|
||||
try {
|
||||
var r = notificationService.notify(NotificationService.NotifyRequest.of(
|
||||
sub.getSubmitterId(), CollectMessage.BIZ_REJECT,
|
||||
"你的材料被退回了",
|
||||
fullText,
|
||||
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||
NotificationService.TPL_AUDIT_RESULT));
|
||||
pushSent = r.pushSent();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("驳回通知失败(不影响驳回结果):submission={}", submissionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (escalated) {
|
||||
// ⚠️ 通知(订阅消息 / 站内信,M5-12)还没做,所以这里只有日志。
|
||||
// 响应里如实地把 notifySent=false + needManualNotice=true 告诉前端,
|
||||
// 让班主任知道"得自己跟学生说一声"——比假装通知过了强得多。
|
||||
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
|
||||
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
|
||||
submissionId, sub.getRosterId(), rejectCount, code);
|
||||
}
|
||||
log.info("审核驳回:submission={} node={} 第 {} 次 原因={}", submissionId, node, rejectCount, code);
|
||||
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
|
||||
submissionId, node, rejectCount, code, pushSent);
|
||||
|
||||
AuditDtos.RejectVO vo = new AuditDtos.RejectVO();
|
||||
vo.setStatus(CollectSubmission.STATUS_REJECTED);
|
||||
vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED));
|
||||
vo.setRejectCount(rejectCount);
|
||||
vo.setNotifySent(false);
|
||||
vo.setNeedManualNotice(true);
|
||||
// ⚠️ 语义收窄了:以前 notifySent 表示"有没有通知渠道"(恒 false),
|
||||
// 现在表示"**微信推送**有没有发出去"。站内信一定发了,所以
|
||||
// needManualNotice 仍然为 true 时要老师口头说一声 —— 学生不一定会进小程序。
|
||||
vo.setNotifySent(pushSent);
|
||||
vo.setNeedManualNotice(!pushSent);
|
||||
vo.setEscalated(escalated);
|
||||
return vo;
|
||||
}
|
||||
@@ -1055,8 +1175,22 @@ public class AuditService {
|
||||
/** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */
|
||||
@Transactional(readOnly = true)
|
||||
public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds) {
|
||||
return fileUrl(fileId, original, expireSeconds, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取链接(可要缩略图)。
|
||||
*
|
||||
* @param thumb 审核**列表**传 true:一屏几百张,缩略图把 140MB 降到约 2MB;
|
||||
* 审核**详情页**传 false(要看防伪纹、要看有没有裁掉边角)。
|
||||
* ⚠️ 默认 false 是刻意的 —— 反过来默认缩略图的话,详情页会"看起来糊",
|
||||
* 而那种问题很难被归因到"某个默认值"上。
|
||||
*/
|
||||
public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds,
|
||||
boolean thumb) {
|
||||
CollectFile f = requireFileReadable(fileId);
|
||||
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX);
|
||||
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX,
|
||||
thumb ? MaterialFileService.THUMB_LONG_SIDE : 0);
|
||||
}
|
||||
|
||||
/** 审核端的内容流(本地存储时的预览通道)。 */
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package cn.tianbaotong.fill.module.audit.service;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.audit.mapper.AuditStatMapper;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审核统计(《03-接口设计》§11.6)。
|
||||
*
|
||||
* <h3>这个界面的用途决定了三个数字的口径</h3>
|
||||
*
|
||||
* <p>它不是"看看热闹"的仪表盘,而是**管理动作的输入**:
|
||||
* <ul>
|
||||
* <li>{@code pendingCount} + {@code timeoutCount} → "要不要催审核人/加人";</li>
|
||||
* <li>{@code byAuditor} → "谁手上积压了"(handled 少而 avgHours 高的那个);</li>
|
||||
* <li>{@code byReason} → "**哪种问题最多**",用来反过来优化材料引导语
|
||||
* (契约里的原话)。如果 60% 的驳回都是"工作证明没盖章",
|
||||
* 那就该在填报页把它写成大红字 —— 所以 {@code reasonText} 必须可读,
|
||||
* 只给码(REASON-03)管理员看不懂,这个统计就白做了。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>为什么"待审/已通过/已驳回"按填报单**当前状态**数</h3>
|
||||
*
|
||||
* <p>不按审核记录数:一份单子被驳回 3 次又通过,它在"已通过"里算 1、
|
||||
* "已驳回"里算 0。按记录数会让三个数字加起来**不等于人数**,
|
||||
* 而客户第一眼就会去加它们(加起来对不上,他就不信整页数字了)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditStatisticsService {
|
||||
|
||||
private final AuditStatMapper statMapper;
|
||||
private final TaskMapper taskMapper;
|
||||
|
||||
/** PRD 默认时限(与 `AuditService` 同一口径,读不到任务配置时用它)。 */
|
||||
private static final int DEFAULT_CLASS_TIMEOUT_HOURS = 48;
|
||||
private static final int DEFAULT_ADMIN_TIMEOUT_HOURS = 72;
|
||||
|
||||
public StatVO statistics(Long taskId, Long orgId, String from, String to) {
|
||||
if (taskId == null) {
|
||||
// ⚠️ 契约把 taskId 列为 Query 参数但没写必填;而"全平台审核统计"是另一个
|
||||
// 功能(要跨任务聚合,还要考虑每个任务自己的时限),一期不做。
|
||||
// 明确报错比返回一个"看起来是全平台、其实是随机某个任务"的数字安全得多
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"请指定 taskId(一期只做单任务审核统计)");
|
||||
}
|
||||
CollectTask task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||
}
|
||||
|
||||
Map<Integer, Integer> byStatus = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : statMapper.countByStatus(taskId, orgId, from, to)) {
|
||||
Integer st = intOf(row, "st");
|
||||
Integer cnt = intOf(row, "cnt");
|
||||
if (st != null && cnt != null) {
|
||||
byStatus.merge(st, cnt, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
int[] hours = timeoutHours(task);
|
||||
StatVO vo = new StatVO();
|
||||
vo.setPendingCount(byStatus.getOrDefault(1, 0) + byStatus.getOrDefault(2, 0));
|
||||
vo.setApprovedCount(byStatus.getOrDefault(3, 0));
|
||||
vo.setRejectedCount(byStatus.getOrDefault(4, 0));
|
||||
// ⚠️ 四舍五入到 2 位:直接透出 PostgreSQL 的双精度结果会变成
|
||||
// `0.00038888888888888887` 这种 17 位小数 —— 那不是"精确",是"没处理",
|
||||
// 既不可读又让人怀疑算错了。而且这个字段**开放接口也看得到**(§14)。
|
||||
// null 保持 null:「没有样本」与「0 小时」是两件事。
|
||||
vo.setAvgAuditHours(round2(statMapper.avgAuditHours(taskId, orgId, from, to)));
|
||||
vo.setTimeoutCount(statMapper.timeoutCount(taskId, orgId, hours[0], hours[1]));
|
||||
vo.setClassTimeoutHours(hours[0]);
|
||||
vo.setAdminTimeoutHours(hours[1]);
|
||||
|
||||
for (Map<String, Object> r : statMapper.byAuditor(taskId, orgId, from, to)) {
|
||||
AuditorRow a = new AuditorRow();
|
||||
a.setAuditorName(strOf(r, "auditorName"));
|
||||
a.setHandled(nz(intOf(r, "handled")));
|
||||
// 同上:byAuditor 的 avgHours 也是双精度结果,要求同样的口径
|
||||
a.setAvgHours(round2(doubleOf(r, "avgHours")));
|
||||
vo.getByAuditor().add(a);
|
||||
}
|
||||
for (Map<String, Object> r : statMapper.byReason(taskId, orgId, from, to)) {
|
||||
ReasonRow a = new ReasonRow();
|
||||
a.setReasonCode(strOf(r, "reasonCode"));
|
||||
a.setReasonText(strOf(r, "reasonText"));
|
||||
a.setCount(nz(intOf(r, "cnt")));
|
||||
vo.getByReason().add(a);
|
||||
}
|
||||
log.debug("审核统计 taskId={} orgId={} 待审 {} 超时 {}",
|
||||
taskId, orgId, vo.getPendingCount(), vo.getTimeoutCount());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 时限从任务配置读(客户可能改过),读不到才用 PRD 默认值。 */
|
||||
private int[] timeoutHours(CollectTask task) {
|
||||
int classH = DEFAULT_CLASS_TIMEOUT_HOURS;
|
||||
int adminH = DEFAULT_ADMIN_TIMEOUT_HOURS;
|
||||
String json = task.getAuditConfig();
|
||||
if (json != null && !json.isBlank()) {
|
||||
try {
|
||||
JSONObject o = JSONUtil.parseObj(json);
|
||||
Integer c = o.getInt("classAuditTimeoutHours");
|
||||
Integer a = o.getInt("adminAuditTimeoutHours");
|
||||
if (c != null && c > 0) {
|
||||
classH = c;
|
||||
}
|
||||
if (a != null && a > 0) {
|
||||
adminH = a;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// 任务配置坏了不该让统计整个失败 —— 用默认值并说清,
|
||||
// 因为"超时数"这个数字的价值在于趋势,而不在于精确到小时
|
||||
log.warn("任务 {} 的 audit_config 解析失败,超时统计改用默认时限",
|
||||
task.getId(), e);
|
||||
}
|
||||
}
|
||||
return new int[]{classH, adminH};
|
||||
}
|
||||
|
||||
private static Object col(Map<String, Object> row, String name) {
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
if (e.getKey().equalsIgnoreCase(name)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer intOf(Map<String, Object> row, String name) {
|
||||
Object v = col(row, name);
|
||||
return v == null ? null : ((Number) v).intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 四舍五入到 2 位小数(`null` 原样返回)。
|
||||
*
|
||||
* <p>用 {@code Math.round(v * 100) / 100.0} 而不是 {@code BigDecimal}:
|
||||
* 这里要的是"给显示用的短数字",不是金额那种必须精确的舍入。
|
||||
*/
|
||||
private static Double round2(Double v) {
|
||||
return v == null ? null : Math.round(v * 100) / 100.0;
|
||||
}
|
||||
|
||||
private static Double doubleOf(Map<String, Object> row, String name) {
|
||||
Object v = col(row, name);
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return ((Number) v).doubleValue();
|
||||
}
|
||||
|
||||
private static String strOf(Map<String, Object> row, String name) {
|
||||
Object v = col(row, name);
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static int nz(Integer v) {
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
|
||||
// ── 响应结构(契约 §11.6 的字段逐个对应)──
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class StatVO {
|
||||
private int pendingCount;
|
||||
private int approvedCount;
|
||||
private int rejectedCount;
|
||||
/** 平均处理时长(小时);没有审核记录时为 null("还没人审" ≠ "审得飞快")。 */
|
||||
private Double avgAuditHours;
|
||||
private long timeoutCount;
|
||||
private List<AuditorRow> byAuditor = new ArrayList<>();
|
||||
private List<ReasonRow> byReason = new ArrayList<>();
|
||||
/** 本次统计用的时限(来自任务配置),让界面能说明"超过多少小时算超时"。 */
|
||||
private int classTimeoutHours;
|
||||
private int adminTimeoutHours;
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class AuditorRow {
|
||||
private String auditorName;
|
||||
private int handled;
|
||||
private Double avgHours;
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class ReasonRow {
|
||||
private String reasonCode;
|
||||
private String reasonText;
|
||||
private int count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user