From 6d7d1c9d9034efdb7f10b6f9efd3a3c032a38fad Mon Sep 17 00:00:00 2001 From: wanghu Date: Mon, 14 Sep 2026 08:48:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp-body.json | 1 - api-curl-test.sh | 5 +- .../controller/api/BaseApiController.java | 13 +- .../controller/api/DictController.java | 70 ++ .../controller/api/NoticeController.java | 48 +- .../yunhai/campusnotice/entity/Notice.java | 14 + .../model/notice/ApprovalListVo.java | 78 -- .../model/notice/NoticeDetailVo.java | 26 + .../model/notice/NoticeRequest.java | 16 +- .../campusnotice/model/notice/NoticeVo.java | 16 + .../model/notice/SubmitRequest.java | 7 +- .../repository/NoticeApprovalRepository.java | 3 + .../campusnotice/service/IClassService.java | 4 +- .../campusnotice/service/IDictService.java | 31 + .../campusnotice/service/INoticeService.java | 26 +- .../service/impl/ClassService.java | 11 +- .../service/impl/DictService.java | 119 +++ .../service/impl/NoticeService.java | 800 +++++++++++++++--- src/main/resources/static/api-test.html | 532 +++++++++--- 19 files changed, 1471 insertions(+), 349 deletions(-) delete mode 100644 .tmp-body.json delete mode 100644 src/main/java/com/yunhai/campusnotice/model/notice/ApprovalListVo.java diff --git a/.tmp-body.json b/.tmp-body.json deleted file mode 100644 index 56486aa..0000000 --- a/.tmp-body.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"【测试】curl-UTF8文件体","content":"

x

","type":1,"deptId":"1","deptName":"校长室","topmost":0,"receiveList":"ALL","fileIds":"","timingSendTime":null,"smsFlag":0,"receiptFlag":1,"publishType":"direct"} \ No newline at end of file diff --git a/api-curl-test.sh b/api-curl-test.sh index e80f3da..70b731e 100644 --- a/api-curl-test.sh +++ b/api-curl-test.sh @@ -91,10 +91,11 @@ if [ -n "$ID_DRAFT" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | id=%s CREATED_IDS+=("$ID_DRAFT") if [ -n "$ID_DRAFT" ]; then - printf '%s' "{\"serid\":$ID_DRAFT,\"approverAccounts\":[\"lijing\"],\"opinion\":\"请审批\"}" > "$TMPB" + # Submit 新契约(多步):steps 优先;匿名/无学校身份时预期 code=2(需登录态经 /api 或 manage+学校身份) + printf '%s' "{\"serid\":$ID_DRAFT,\"steps\":[{\"name\":\"校级审核\",\"vote\":\"any\",\"approvers\":[\"lijing\"]}],\"opinion\":\"请审批\"}" > "$TMPB" resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/Submit") code=$(node -e "try{console.log(JSON.parse(process.argv[1]).code)}catch(e){console.log('-')}" "$resp") - if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | 草稿→待审批 ✅\n" "Submit"; else BIZ=$((BIZ+1)); printf "%-26s | code=%s 🟡\n" "Submit" "$code"; fi + if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | 草稿→待审批(第1步激活) ✅\n" "Submit(steps)"; else BIZ=$((BIZ+1)); printf "%-26s | code=%s 🟡 (匿名无学校身份为预期)\n" "Submit(steps)" "$code"; fi fi req "Approve(匿名预期拒)" POST "/notice/Approve?serid=${ID_DRAFT:-0}&opinion=x" req "Reject(匿名预期拒)" POST "/notice/Reject?serid=${ID_DRAFT:-0}&opinion=x" diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java b/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java index b177e9e..ae872be 100644 --- a/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java @@ -82,14 +82,21 @@ public class BaseApiController { * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 */ @GetMapping("getStuTree") - public List getStuTree(BoaUser authUserWithDept) { + public List getStuTree(BoaUser authUserWithDept,@RequestParam boolean toUser) { Integer schId = authUserWithDept.getCurrentRole() != null ? authUserWithDept.getCurrentRole().getSchId() : null; - return classService.getStuTree(schId); + return classService.getStuTree(schId,toUser); + } + + @GetMapping("getTeaTree") + public List getTeaTree(BoaUser authUserWithDept,@RequestParam boolean toUser) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return classService.getTeaTree(schId,toUser); } @GetMapping("GetSchoolAll") - public List> getSchoolAll(BoaUser authUserWithDept) { + public List> GetSchoolAll(BoaUser authUserWithDept) { Integer schId = authUserWithDept.getCurrentRole() != null ? authUserWithDept.getCurrentRole().getSchId() : null; return userCenterService.getSchoolAllTree(schId); diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java index 27e9ed1..a50b5e7 100644 --- a/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java @@ -70,4 +70,74 @@ public class DictController { public void sort(@RequestParam("ids") String ids) { dictService.ordbyDicIds(ids); } + + /** + * 当前学校是否开通短信模块(前端写通知页据此展示「发送短信提醒」勾选)。 + * 对应前端 GET dict/Sync(见 api/module/notice.ts getSchModuleIsSms)。 + */ + @GetMapping("Sync") + public Boolean sync() { + return dictService.getSchModuleIsSms(); + } + + // ============================ 审批人池(审核设置,多步审批选人数据源) ============================ + + /** + * 审批人候选池(仅启用者):发公告「选择审批人」弹窗数据源。 + */ + @GetMapping("ApproverPool") + public List> approverPool() { + return dictService.getApproverPool(); + } + + /** + * 审批人管理列表(含停用):审核设置页表格。 + */ + @GetMapping("Approver") + public List> approverList() { + return dictService.getApproverList(); + } + + /** + * 新增审批人(JSON body:account/name/deptId)。 + */ + @PostMapping("AddApprover") + public void addApprover(@RequestBody Map body) { + dictService.addApprover(body.get("account"), body.get("name"), body.get("deptId")); + } + + /** + * 更新审批人(JSON body:id + 可选 name/deptId/enabled)。 + */ + @PostMapping("UpdateApprover") + public void updateApprover(@RequestBody Map body) { + dictService.updateApprover( + toInteger(body.get("id")), + (String) body.get("name"), + (String) body.get("deptId"), + toInteger(body.get("enabled"))); + } + + /** + * 删除审批人(form:id)。 + */ + @PostMapping("DelApprover") + public void delApprover(@RequestParam("id") Integer id) { + dictService.delApprover(id); + } + + /** Map 值转 Integer(数字/数字字符串兼容),空返回 null */ + private Integer toInteger(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java index 77b9371..3d6ff9c 100644 --- a/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java @@ -17,6 +17,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.web.SortDefault; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.validation.annotation.Validated; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import javax.print.attribute.standard.MediaSize; @@ -306,22 +307,23 @@ public class NoticeController { return noticeService.approverResolve(Arrays.asList(OtherUtil.convertToArray(ids)), schId); } - // ============================ 提交审批 ============================ + // ============================ 提交审批(多步流程) ============================ /** - * 提交审批(status: 0/3→2):发布人从教师树选定审批教师(可多选,或签), - * 写 notice_approval 提交行并 upsert 审批人记忆表。 + * 提交审批(status: 0/3→2):steps 多步审批流 [{name, vote, approvers:[登录账号]}] 优先, + * 缺省时旧 approverTeaIds 包装为单步或签。步骤落 notice_approval_step, + * 处理动作落 notice_approval_record,并 upsert 审批人记忆表。 */ @PostMapping("Submit") public void submit(@RequestBody SubmitRequest request) { - noticeService.submit(request.getSerid(), request.getApproverTeaIds(), request.getOpinion()); + noticeService.submit(request); } - // ============================ 审批通过 ============================ + // ============================ 审批通过(当前步) ============================ /** - * 审批通过(或签):任一审批人通过即 status→1 并触发真实发布; - * 被他人先审时返回带实际处理人/时间的冲突提示。 + * 审批通过:或签任一通过/会签全部通过后流转下一步,末步通过即真实发布; + * 被他人先审或已处理过时返回冲突提示。兼容 form 提交(serid/opinion)。 */ @PostMapping("Approve") public void approve(@RequestParam("serid") Integer serid, @@ -329,29 +331,43 @@ public class NoticeController { noticeService.approve(serid, opinion); } - // ============================ 审批驳回 ============================ + // ============================ 审批驳回(当前步) ============================ /** - * 审批驳回(或签):任一审批人驳回即 status→3,意见必填;发布人可改后重新提交。 + * 审批驳回:任一步驳回即 status→3,意见必填(remark 为前端别名);发布人可改后重新提交。 */ @PostMapping("Reject") public void reject(@RequestParam("serid") Integer serid, - @RequestParam("opinion") String opinion) { - noticeService.reject(serid, opinion); + @RequestParam(value = "opinion", required = false) String opinion, + @RequestParam(value = "remark", required = false) String remark) { + noticeService.reject(serid, StringUtils.hasText(opinion) ? opinion : remark); } // ============================ 审批列表(待审/已审合一) ============================ /** - * 审批列表:按审批人快照含当前账号过滤(schId 隔离)。 - * auditState=pending(默认) 待审 / reviewed 已审(含或签下他人已处理,带实际处理人)。 + * 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 / + * rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段: + * approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。 */ @GetMapping("ApprovalList") - public BasePageResult approvalList( - @RequestParam(value = "auditState", defaultValue = "pending") String auditState, + public BasePageResult approvalList( + @RequestParam(value = "auditState", required = false) String auditState, + @RequestParam(value = "status", required = false) String status, NoticeQuery query, @SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) { - return noticeService.approvalList(auditState, query, PageUtils.toPageable(query, sort)); + return noticeService.approvalList( + StringUtils.hasText(auditState) ? auditState : status, query, PageUtils.toPageable(query, sort)); + } + + // ============================ 待我审批数量(菜单角标) ============================ + + /** + * 待我审批数量:待审批且当前步审批人快照含当前登录人(账号或教师ID)。 + */ + @GetMapping("ApprovalCount") + public int approvalCount() { + return noticeService.approvalCount(); } /** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */ diff --git a/src/main/java/com/yunhai/campusnotice/entity/Notice.java b/src/main/java/com/yunhai/campusnotice/entity/Notice.java index 97c056a..a54e484 100644 --- a/src/main/java/com/yunhai/campusnotice/entity/Notice.java +++ b/src/main/java/com/yunhai/campusnotice/entity/Notice.java @@ -165,6 +165,20 @@ public class Notice extends BaseEntity { @Column(name = "approver_tea_ids", columnDefinition = "LONGTEXT") private String approverTeaIds; + /** + * 当前待审步骤的审批人账号快照(JSON 账号数组 ["zhd","lwj"]):随流程推进刷新为 + * 当前步审批人组,ApprovalList 按「待我审批」过滤(与 approverTeaIds 双轨匹配)。 + */ + @Column(name = "approver_accounts", columnDefinition = "LONGTEXT") + private String approverAccounts; + + /** + * 当前待审步骤下标(0 起,对齐前端 approvalLevel):0=第 1 步审批中; + * 走完全部步(已发布)= 步骤总数;无流程为 null。步骤全量见 notice_approval_step 表。 + */ + @Column(name = "approval_level") + private Integer approvalLevel; + /** * 定时发送已真正发布标记:到点调度后回写为 1,避免重复触发(改造方案 4.2)。 * 立即发布的通知在真实发布时一并置 1。 diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalListVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalListVo.java deleted file mode 100644 index 9707b2d..0000000 --- a/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalListVo.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.yunhai.campusnotice.model.notice; - -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 审批列表行 VO(notice/ApprovalList,待审/已审合一)。 - *

已审行携带最新处理信息:或签下含「他人已处理」(handledByMe=false), - * 实际处理人/动作/时间/意见取 notice_approval 最新通过/驳回行。 - */ -@Data -public class ApprovalListVo { - - private Integer id; - - private String title; - - /** 通知类型:1 普通 / 2 重要 */ - private Integer type; - - private String typeDesc; - - /** 状态:2 待审批 / 1 已发布 / 3 已驳回 */ - private Integer status; - - private String statusDesc; - - /** 发布人账号 */ - private String publisherAccount; - - /** 发布人姓名 */ - private String publisherName; - - /** 本轮审批人姓名(来自提交行 assignees 快照,列表页免查用户中心) */ - private List approverNames = new ArrayList<>(); - - /** 最新一轮提交时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime submitTime; - - /** 是否需要学员阅读回执 */ - private Integer receiptFlag; - - /** 定时发送时间(审批通过后到点真实发布) */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime timingSendTime; - - // ==================== 已审 Tab 字段(待审 Tab 为空) ==================== - - /** 是否已有处理结果(已审 Tab 恒 true,冗余字段便于前端判断) */ - private boolean handled; - - /** 是否由当前登录人处理(false=或签下他人已处理) */ - private boolean handledByMe; - - /** 实际处理人账号 */ - private String handledAccount; - - /** 实际处理人姓名 */ - private String handledName; - - /** 实际处理动作:2 通过 / 3 驳回 */ - private Integer handledAction; - - /** 实际处理动作描述 */ - private String handledActionDesc; - - /** 实际处理时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime handledTime; - - /** 处理意见(长文本建议进详情查看) */ - private String handledOpinion; -} diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java index 353e843..1e9e354 100644 --- a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java @@ -93,6 +93,32 @@ public class NoticeDetailVo { @JsonProperty("approverTeaIds") private List approverTeaIds; + // ==================== 多步审批流(审核改造,学员端 mobiapi 不返回) ==================== + + /** 是否启用审批流:0 否 / 1 是(存在审批步骤即为 1) */ + @JsonProperty("approvalEnabled") + private Integer approvalEnabled; + + /** 审批状态:none / pending / approved / rejected */ + @JsonProperty("approvalStatus") + private String approvalStatus; + + /** 审批状态文本(待审批带步骤进度) */ + @JsonProperty("approvalStatusDesc") + private String approvalStatusDesc; + + /** 当前待审步骤下标(0 起;已走完=步数;无流程=0) */ + @JsonProperty("approvalLevel") + private Integer approvalLevel; + + /** 当前用户是否为本单当前步待签审批人 */ + @JsonProperty("canApprove") + private Boolean canApprove; + + /** 多步审批流进度(含每步审核记录;也用于发布/编辑表单回显审批人配置) */ + @JsonProperty("approvalFlow") + private List approvalFlow; + /** * 附件项(旧 ChaoSend 数组元素)。 */ diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java index 0c351f1..32069e3 100644 --- a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java @@ -63,6 +63,20 @@ public class NoticeRequest { /** 发布方式:direct 直接发布(默认,兼容旧链路)/ draft 存为草稿 / approve 提交审批(需携带 approverTeaIds) */ private String publishType; - /** 提交审批时所选审批教师ID(教师树叶子节点ID,publishType=approve 时必填;独立 Submit 接口走 SubmitRequest) */ + /** 提交审批时所选审批教师ID(教师树叶子节点ID,旧链路兼容;publishType=approve 时与 flow 二选一) */ private List approverTeaIds; + + // ==================== 多步审批流(审核改造:步骤表 + 审核记录表) ==================== + + /** + * 多步审批流:[{name, vote, approvers:[登录账号]}],按序流转,任一步驳回整单退回。 + * 传了 flow 时忽略 approvers/vote/approverTeaIds。 + */ + private List flow; + + /** 单步审批人登录账号列表(flow 缺省时的单步简写;选人即送审) */ + private List approvers; + + /** 单步表决方式:any 或签(默认)/ all 会签(配合 approvers) */ + private String vote; } diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java index 755b9f7..61ec7f9 100644 --- a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java @@ -58,6 +58,22 @@ public class NoticeVo { /** 审批教师ID快照(JSON 数组串 [101,102],发布流程展示用) */ private String approverTeaIds; + // ==================== 多步审批流(审核改造) ==================== + + /** + * 审批状态(前端枚举):none 免审已发布 / pending 待审批 / approved 审批通过已发布 / rejected 已驳回。 + */ + private String approvalStatus; + + /** 审批状态文本:待审批时带步骤进度(如「第1/2步 校级审核·会签 0/2」) */ + private String approvalStatusDesc; + + /** 当前待审步骤下标(0 起;已走完=步数;无流程=0) */ + private Integer approvalLevel; + + /** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */ + private Boolean canApprove; + /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ private String UpFile; diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/SubmitRequest.java b/src/main/java/com/yunhai/campusnotice/model/notice/SubmitRequest.java index a44750f..bd75807 100644 --- a/src/main/java/com/yunhai/campusnotice/model/notice/SubmitRequest.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/SubmitRequest.java @@ -6,7 +6,7 @@ import java.util.List; /** * 提交审批入参(notice/Submit)。 - * 审批人可多选,或签语义:任一审批人通过即发布、任一驳回即驳回。 + * 审批人可多选;steps 多步流程优先,缺省时 approverTeaIds 包装为单步或签(旧链路兼容)。 */ @Data public class SubmitRequest { @@ -14,9 +14,12 @@ public class SubmitRequest { /** 通知ID */ private Integer serid; - /** 选定审批教师ID列表(教师树叶子节点ID,至少一人) */ + /** 选定审批教师ID列表(教师树叶子节点ID,旧链路:单步或签) */ private List approverTeaIds; + /** 多步审批流([{name, vote, approvers:[登录账号]}],传了则忽略 approverTeaIds) */ + private List steps; + /** 提交说明(可选) */ private String opinion; } diff --git a/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRepository.java index 677f98b..b7aa999 100644 --- a/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRepository.java @@ -29,4 +29,7 @@ public interface NoticeApprovalRepository extends JpaRepository getStuTree(Integer schId); + List getTeaTree(Integer schId,boolean toUser); + + List getStuTree(Integer schId,boolean toUser); } diff --git a/src/main/java/com/yunhai/campusnotice/service/IDictService.java b/src/main/java/com/yunhai/campusnotice/service/IDictService.java index c76d2c5..a822810 100644 --- a/src/main/java/com/yunhai/campusnotice/service/IDictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IDictService.java @@ -71,4 +71,35 @@ public interface IDictService { * @return 部门名称;无匹配时返回 null */ String resolveDeptName(String deptId); + + // ==================== 审批人池(审核设置,多步审批选人数据源) ==================== + + /** + * 审批人候选池(仅启用者):发公告选审批人数据源。 + * + * @return List<Map> 每项 {id, name, account, deptName, isParent} + */ + List> getApproverPool(); + + /** + * 审批人管理列表(含停用)。 + * + * @return List<Map> 每项 {id, account, name, deptId, deptName, enabled} + */ + List> getApproverList(); + + /** + * 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。 + */ + void addApprover(String account, String name, String deptId); + + /** + * 更新审批人(null 参数不改动;enabled 仅接受 0/1)。 + */ + void updateApprover(Integer id, String name, String deptId, Integer enabled); + + /** + * 删除审批人(仅本校记录)。 + */ + void delApprover(Integer id); } diff --git a/src/main/java/com/yunhai/campusnotice/service/INoticeService.java b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java index 62c7d77..75a4f38 100644 --- a/src/main/java/com/yunhai/campusnotice/service/INoticeService.java +++ b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java @@ -36,28 +36,34 @@ public interface INoticeService { BasePageResult draftList(NoticeQuery query, Pageable pageable); /** - * 提交审批(status: 0/3 → 2):发布人从教师树选定审批教师(可多人,或签), - * 后端按教师ID绑定学校教师校验;写 notice_approval 提交行并 upsert 审批人记忆表。 + * 提交审批(status: 0/3 → 2):request.steps 多步审批流([{name, vote, approvers:[登录账号]}]) + * 优先;缺省时旧 approverTeaIds 包装为单步或签(兼容链路)。 + * 步骤落 notice_approval_step,处理动作落 notice_approval_record。 */ - void submit(Integer serid, List approverTeaIds, String opinion); + void submit(SubmitRequest request); /** - * 审批通过(或签):任一审批人通过即 status: 2→1(CAS),并触发真实发布; - * 已被他人先行处理时抛业务冲突提示(带实际处理人与时间)。 + * 审批通过:当前步审批人处理;或签任一通过/会签全部通过后流转下一步, + * 末步通过 status: 2→1(CAS)并触发真实发布。已被他人先行处理时抛业务冲突提示。 */ void approve(Integer serid, String opinion); /** - * 审批驳回(或签):任一审批人驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。 + * 审批驳回:任一步驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。 */ void reject(Integer serid, String opinion); /** - * 审批列表(待审/已审合一):按 schId + 审批教师快照含当前教师ID过滤。 - * - * @param auditState pending(默认)=待审 status=2;reviewed=已审 status∈{1,3}(含或签下他人已处理) + * 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回; + * all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/ + * approvalLevel/canApprove)。 */ - BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable); + BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable); + + /** + * 待我审批数量(菜单角标):待审批且当前步审批人快照含当前登录人。 + */ + int approvalCount(); /** * 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。 diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java b/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java index 32de273..fefd60e 100644 --- a/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java @@ -135,12 +135,19 @@ public class ClassService implements IClassService { } @Override - public List getStuTree(Integer schId) { - return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId, + public List getStuTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser, new ParameterizedTypeReference>() { }, "获取学生树", "schId=" + schId); } + @Override + public List getTeaTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取教师树", "schId=" + schId); + } + // ============================ 通用调用 ============================ private List exchangeList(String url, ParameterizedTypeReference> type, diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java index 5ea097e..7f784bd 100644 --- a/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java @@ -1,14 +1,18 @@ package com.yunhai.campusnotice.service.impl; import com.yunhai.campusnotice.entity.Notice; +import com.yunhai.campusnotice.entity.SysApprover; import com.yunhai.campusnotice.entity.SysDict; import com.yunhai.campusnotice.repository.NoticeRepository; +import com.yunhai.campusnotice.repository.SysApproverRepository; import com.yunhai.campusnotice.repository.SysDictRepository; import com.yunhai.campusnotice.service.IDictService; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.auth.spring.CurrentBoaUser; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -37,6 +41,29 @@ public class DictService implements IDictService { @Autowired private NoticeRepository noticeRepository; + @Autowired + private SysApproverRepository sysApproverRepository; + + @Autowired + private CurrentBoaUser currentBoaUser; + + /** 当前登录人学校ID(多租户隔离,取 BoaUser.currentRole.schId) */ + private Integer currentSchId() { + BoaUser boaUser = currentBoaUser.get(); + if (boaUser == null || boaUser.getCurrentRole() == null) { + return null; + } + return boaUser.getCurrentRole().getSchId(); + } + + private Integer requireSchId() { + Integer schId = currentSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + return schId; + } + // ============================ GetSysDic ============================ @Override @@ -250,4 +277,96 @@ public class DictService implements IDictService { LoginUser user = UserContextHolder.get(); return user == null ? null : user.getLoginName(); } + + // ============================ 审批人池(审核设置) ============================ + + @Override + public List> getApproverPool() { + Integer schId = requireSchId(); + List> result = new ArrayList<>(); + for (SysApprover approver : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) { + Map item = new LinkedHashMap<>(); + item.put("id", approver.getId()); + item.put("name", StringUtils.hasText(approver.getName()) ? approver.getName() : approver.getAccount()); + item.put("account", approver.getAccount()); + item.put("deptName", approver.getDeptName()); + item.put("isParent", false); + result.add(item); + } + return result; + } + + @Override + public List> getApproverList() { + Integer schId = requireSchId(); + List> result = new ArrayList<>(); + for (SysApprover approver : sysApproverRepository.findBySchIdOrderByNameAsc(schId)) { + Map item = new LinkedHashMap<>(); + item.put("id", approver.getId()); + item.put("account", approver.getAccount()); + item.put("name", approver.getName()); + item.put("deptId", approver.getDeptId()); + item.put("deptName", approver.getDeptName()); + item.put("enabled", approver.getEnabled()); + result.add(item); + } + return result; + } + + @Override + @Transactional + public void addApprover(String account, String name, String deptId) { + Integer schId = requireSchId(); + if (!StringUtils.hasText(account)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号"); + } + String acc = account.trim(); + if (sysApproverRepository.findBySchIdAndAccount(schId, acc).isPresent()) { + throw new BizException(ErrorCode.PARAM_ERROR, "审批人「" + acc + "」已存在"); + } + SysApprover approver = new SysApprover(); + approver.setSchId(schId); + approver.setAccount(acc); + approver.setName(StringUtils.hasText(name) ? name.trim() : acc); + approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null); + approver.setDeptName(resolveDeptName(approver.getDeptId())); + approver.setEnabled(1); + sysApproverRepository.save(approver); + } + + @Override + @Transactional + public void updateApprover(Integer id, String name, String deptId, Integer enabled) { + Integer schId = requireSchId(); + SysApprover approver = requireOwnSchool(schId, id); + if (StringUtils.hasText(name)) { + approver.setName(name.trim()); + } + if (deptId != null) { + approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null); + approver.setDeptName(resolveDeptName(approver.getDeptId())); + } + if (enabled != null) { + approver.setEnabled(enabled == 0 ? 0 : 1); + } + sysApproverRepository.save(approver); + } + + @Override + @Transactional + public void delApprover(Integer id) { + Integer schId = requireSchId(); + SysApprover approver = requireOwnSchool(schId, id); + sysApproverRepository.delete(approver); + } + + /** 定位本校审批人记录(跨校 ID 一律视为不存在) */ + private SysApprover requireOwnSchool(Integer schId, Integer id) { + if (id == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + return sysApproverRepository.findById(id) + .filter(a -> schId != null && schId.equals(a.getSchId())) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "审批人不存在: " + id)); + } } diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java index 2f9e761..35664a9 100644 --- a/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java @@ -2,10 +2,13 @@ package com.yunhai.campusnotice.service.impl; import com.yunhai.campusnotice.entity.Notice; import com.yunhai.campusnotice.entity.NoticeApproval; +import com.yunhai.campusnotice.entity.NoticeApprovalRecord; +import com.yunhai.campusnotice.entity.NoticeApprovalStep; import com.yunhai.campusnotice.entity.NoticeApproverMemory; import com.yunhai.campusnotice.entity.NoticePushScope; import com.yunhai.campusnotice.entity.NoticeRead; import com.yunhai.campusnotice.entity.NoticeReceiver; +import com.yunhai.campusnotice.entity.SysApprover; import com.yunhai.campusnotice.entity.UpFile; import com.yunhai.campusnotice.enums.ApprovalActionEnum; import com.yunhai.campusnotice.enums.NoticeStatusEnum; @@ -19,12 +22,15 @@ import com.yunhai.campusnotice.model.bsch.SchInfoDto; import com.yunhai.campusnotice.model.bsch.SchStudentDto; import com.yunhai.campusnotice.model.bsch.SchSystemDto; import com.yunhai.campusnotice.model.notice.*; +import com.yunhai.campusnotice.repository.NoticeApprovalRecordRepository; import com.yunhai.campusnotice.repository.NoticeApprovalRepository; +import com.yunhai.campusnotice.repository.NoticeApprovalStepRepository; import com.yunhai.campusnotice.repository.NoticeApproverMemoryRepository; import com.yunhai.campusnotice.repository.NoticePushScopeRepository; import com.yunhai.campusnotice.repository.NoticeReadRepository; import com.yunhai.campusnotice.repository.NoticeReceiverRepository; import com.yunhai.campusnotice.repository.NoticeRepository; +import com.yunhai.campusnotice.repository.SysApproverRepository; import com.yunhai.campusnotice.repository.UpFileRepository; import com.yunhai.campusnotice.service.IClassService; import com.yunhai.campusnotice.service.IFileService; @@ -40,6 +46,7 @@ import com.yunhai.platform.openapi.client.YschBaseOpenApi; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaUser; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; @@ -95,9 +102,21 @@ public class NoticeService implements INoticeService { @Autowired private NoticeApprovalRepository approvalRepository; + @Autowired + private NoticeApprovalStepRepository approvalStepRepository; + + @Autowired + private NoticeApprovalRecordRepository approvalRecordRepository; + + @Autowired + private SysApproverRepository sysApproverRepository; + @Autowired private NoticeApproverMemoryRepository approverMemoryRepository; + @Autowired + private ObjectMapper objectMapper; + @Autowired private UpFileRepository upFileRepository; @@ -185,6 +204,9 @@ public class NoticeService implements INoticeService { public Integer publish(NoticeRequest request) { String publishType = request.getPublishType() == null ? "direct" : request.getPublishType().trim().toLowerCase(); + // 选人即送审:带审批流(flow 多步 / approvers 单步 / 旧 approverTeaIds)即进审批, + // 不依赖 publishType(对齐前端「勾选审批才发 flow」的行为) + List flow = resolveNoticeFlow(request); Notice entity = new Notice(); BeanUtils.copyProperties(request, entity); @@ -203,11 +225,12 @@ public class NoticeService implements INoticeService { entity.setStatus(DRAFT_CODE); noticeRepository.save(entity); savePushScopes(entity.getId(), request.getPushScopes()); + saveFlowDefinition(entity, flow, request.getApproverTeaIds()); fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); return entity.getId(); } - if ("approve".equals(publishType)) { - doSubmitApproval(entity, request.getApproverTeaIds(), null); + if ("approve".equals(publishType) || !flow.isEmpty()) { + doSubmitApproval(entity, flow, null, null); savePushScopes(entity.getId(), request.getPushScopes()); fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); return entity.getId(); @@ -243,8 +266,9 @@ public class NoticeService implements INoticeService { entity.setReceiptFlag(request.getReceiptFlag() == null ? 0 : request.getReceiptFlag()); entity.setTimingPublished(0); noticeRepository.save(entity); - // 草稿仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人 + // 草稿仅存内容 + 范围快照 + 附件 + 审批流定义,不圈人、不发短信、不落接收人 savePushScopes(entity.getId(), request.getPushScopes()); + saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds()); fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); return entity.getId(); } @@ -447,58 +471,157 @@ public class NoticeService implements INoticeService { } } - // ============================ 审批(选人、多人或签、记忆,改造方案 4.3) ============================ + // ============================ 审批(多步流程:步骤表 + 审核记录表) ============================ @Override @Transactional - public void submit(Integer serid, List approverTeaIds, String opinion) { - Notice notice = loadNotice(serid); - doSubmitApproval(notice, approverTeaIds, opinion); + public void submit(SubmitRequest request) { + Notice notice = loadNotice(request.getSerid()); + doSubmitApproval(notice, resolveRequestFlow(request.getSteps(), request.getApproverTeaIds()), + request.getApproverTeaIds(), request.getOpinion()); } /** - * 提交审批装配:按学校教师绑定校验 → status: 0/3→2 → 审批教师快照落主表 → - * notice_approval 提交行(assignee_tea_ids 快照)→ upsert 记忆表。 + * 提交审批装配:流程绑定校验 → status: 0/3→2 → 主表落库拿通知ID → + * 步骤落 notice_approval_step(第 1 步审批中,其余待流转)→ 当前步快照落主表 → + * notice_approval 提交行 → upsert 记忆表。 + * 草稿已存流程定义(全待流转步)且本次未重选人时,直接激活存量定义。 */ - private void doSubmitApproval(Notice entity, List approverTeaIds, String opinion) { - LinkedHashMap nameById = bindApprovers(entity.getSchId(), approverTeaIds); + private void doSubmitApproval(Notice entity, List flowItems, List legacyTeaIds, String opinion) { int status = entity.getStatus() == null ? PUBLISHED_CODE : entity.getStatus(); if (status != DRAFT_CODE && status != REJECTED_CODE) { throw new BizException(ErrorCode.FORBIDDEN, "仅草稿或已驳回状态的通知可提交审批"); } + List steps = buildSteps(entity, flowItems, legacyTeaIds); + if (steps.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + // 全待流转 = 草稿流程定义复用;否则视为新构建流程(清掉上一轮旧步骤) + boolean reuseDefinition = steps.stream() + .allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING); + NoticeApprovalStep first = steps.get(0); entity.setStatus(PENDING_CODE); - entity.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + entity.setApprovalLevel(0); + entity.setApproverAccounts(first.getApproverAccounts()); + entity.setApproverTeaIds(first.getApproverTeaIds()); noticeRepository.save(entity); + Integer noticeId = entity.getId(); - saveApprovalRow(entity.getId(), NoticeApproval.ACTION_SUBMIT, - currentAccount(), currentName(), opinion, toJsonTeaIds(new ArrayList<>(nameById.keySet()))); - upsertApproverMemory(entity.getSchId(), currentTeaId(), new ArrayList<>(nameById.keySet())); - log.info("[submit] 通知 {} 提交审批, 发布人 {}, 审批教师 {}", - entity.getId(), currentAccount(), nameById.keySet()); + if (!reuseDefinition && entity.getId() != null) { + approvalStepRepository.deleteByNoticeId(noticeId); + approvalStepRepository.flush(); + } + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + step.setNoticeId(noticeId); + step.setStepNo(i + 1); + step.setStatus(i == 0 ? NoticeApprovalStep.STATUS_ACTIVE : NoticeApprovalStep.STATUS_WAITING); + approvalStepRepository.save(step); + } + + saveApprovalRow(noticeId, NoticeApproval.ACTION_SUBMIT, + currentAccount(), currentName(), opinion, first.getApproverTeaIds()); + List firstTeaIds = parseTeaIds(first.getApproverTeaIds()); + if (!firstTeaIds.isEmpty()) { + upsertApproverMemory(entity.getSchId(), currentTeaId(), firstTeaIds); + } + log.info("[submit] 通知 {} 提交审批, 发布人 {}, 共 {} 步, 第 1 步审批人 {}", + noticeId, currentAccount(), steps.size(), parseAccounts(first.getApproverAccounts())); } @Override @Transactional public void approve(Integer serid, String opinion) { Notice notice = loadNotice(serid); - Integer me = currentTeaId(); - requireApprover(notice, me); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { throw alreadyHandledError(serid); } - // 或签 CAS:任一审批人先通过即生效,后到者影响行数为 0 → 冲突提示(改造方案 4.4) - if (noticeRepository.casApprove(serid, LocalDateTime.now()) == 0) { + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + if (steps.isEmpty()) { + // 存量单步数据(步骤表改造前提交)按旧或签语义走通 + approveLegacy(notice, meTeaId, opinion); + return; + } + int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); + NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1)); + if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { throw alreadyHandledError(serid); } + requireStepApprover(current, meAccount, meTeaId); + int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT); + boolean acted = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream() + .anyMatch(r -> meAccount != null && meAccount.equals(r.getApproverAccount())); + if (acted) { + throw new BizException(ErrorCode.FORBIDDEN, "您已审批过本步骤,请等待其他审批人处理"); + } + saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_APPROVE, opinion); + + // 会签:本步全部通过才流转;或签:任一通过即流转 + Set actedAccounts = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream() + .map(NoticeApprovalRecord::getApproverAccount) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List stepAccounts = parseAccounts(current.getApproverAccounts()); + boolean stepDone = "all".equals(current.getVote()) + ? !stepAccounts.isEmpty() && actedAccounts.containsAll(stepAccounts) + : true; + if (!stepDone) { + log.info("[approve] 通知 {} 第 {} 步会签进度 {}/{}", serid, current.getStepNo(), + actedAccounts.size(), stepAccounts.size()); + return; + } + current.setStatus(NoticeApprovalStep.STATUS_APPROVED); + current.setHandleTime(LocalDateTime.now()); + approvalStepRepository.save(current); + + if (level + 1 < steps.size()) { + NoticeApprovalStep next = steps.get(level + 1); + next.setStatus(NoticeApprovalStep.STATUS_ACTIVE); + approvalStepRepository.save(next); + notice.setApprovalLevel(level + 1); + notice.setApproverAccounts(next.getApproverAccounts()); + notice.setApproverTeaIds(next.getApproverTeaIds()); + noticeRepository.save(notice); + log.info("[approve] 通知 {} 第 {} 步通过, 流转至第 {} 步", serid, current.getStepNo(), next.getStepNo()); + } else { + publishAfterFinalApproval(notice, steps.size()); + } + } + + /** 存量无步骤数据(旧或签单步)通过:CAS 发布,保留旧冲突提示语义 */ + private void approveLegacy(Notice notice, Integer meTeaId, String opinion) { + requireApprover(notice, meTeaId); + if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) { + throw alreadyHandledError(notice.getId()); + } notice.setStatus(PUBLISHED_CODE); - saveApprovalRow(serid, NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), opinion, null); - // 通过即真实发布;定时未到点的交给调度器(timingPublished 保持 0) + saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), opinion, null); if (isImmediatePublish(notice)) { doRealPublish(notice); } else { noticeRepository.save(notice); } - log.info("[approve] 通知 {} 审批通过, 审批教师 {}", serid, me); + log.info("[approve] 通知 {} 审批通过(旧单步), 审批教师 {}", notice.getId(), meTeaId); + } + + /** 末步通过 → status→1(CAS 防并发重复发布)→ 真实发布(定时未到点交调度器) */ + private void publishAfterFinalApproval(Notice notice, int stepCount) { + if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) { + throw alreadyHandledError(notice.getId()); + } + notice.setStatus(PUBLISHED_CODE); + notice.setApprovalLevel(stepCount); + notice.setApproverAccounts(null); + notice.setApproverTeaIds(null); + saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), null, null); + if (isImmediatePublish(notice)) { + doRealPublish(notice); + } else { + noticeRepository.save(notice); + } + log.info("[approve] 通知 {} 全部步骤通过, 真实发布", notice.getId()); } @Override @@ -508,21 +631,53 @@ public class NoticeService implements INoticeService { throw new BizException(ErrorCode.PARAM_ERROR, "请填写驳回意见"); } Notice notice = loadNotice(serid); - Integer me = currentTeaId(); - requireApprover(notice, me); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { throw alreadyHandledError(serid); } + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + if (steps.isEmpty()) { + // 存量单步数据按旧语义驳回 + requireApprover(notice, meTeaId); + if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) { + throw alreadyHandledError(serid); + } + notice.setStatus(REJECTED_CODE); + noticeRepository.save(notice); + saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, meAccount, currentName(), opinion, null); + log.info("[reject] 通知 {} 审批驳回(旧单步), 意见: {}", serid, opinion); + return; + } + int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); + NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1)); + if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { + throw alreadyHandledError(serid); + } + requireStepApprover(current, meAccount, meTeaId); + int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT); if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) { throw alreadyHandledError(serid); } + saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion); + current.setStatus(NoticeApprovalStep.STATUS_REJECTED); + current.setHandleTime(LocalDateTime.now()); + approvalStepRepository.save(current); notice.setStatus(REJECTED_CODE); noticeRepository.save(notice); - saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, currentAccount(), currentName(), opinion, null); - log.info("[reject] 通知 {} 审批驳回, 审批教师 {}, 意见: {}", serid, me, opinion); + log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion); } - /** 仅该轮审批教师快照含当前教师ID可处理 */ + /** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */ + private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) { + boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount)) + || (meTeaId != null && parseTeaIds(step.getApproverTeaIds()).contains(meTeaId)); + if (!hit) { + throw new BizException(ErrorCode.FORBIDDEN, "您不是本步骤的审批人"); + } + } + + /** 仅存量单步快照含当前教师ID可处理(旧链路兜底) */ private void requireApprover(Notice notice, Integer teaId) { if (teaId == null) { throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话已过期"); @@ -547,6 +702,227 @@ public class NoticeService implements INoticeService { return new BizException(ErrorCode.FORBIDDEN, "该通知已离开待审批状态,无需重复处理"); } + /** + * 归一化请求里的审批流:steps 优先;缺省时旧 approverTeaIds 包装为单步或签 + * (教师绑定兼容链路)。每步账号(approvers)与教师ID(approverTeaIds)二选一, + * 两者皆空的步丢弃;整体为空返回空列表。 + */ + private List resolveRequestFlow(List steps, List legacyTeaIds) { + List result = new ArrayList<>(); + if (steps != null) { + for (FlowStepItem step : steps) { + if (step == null) { + continue; + } + List accounts = new ArrayList<>(); + if (step.getApprovers() != null) { + for (String account : step.getApprovers()) { + if (StringUtils.hasText(account) && !accounts.contains(account.trim())) { + accounts.add(account.trim()); + } + } + } + List teaIds = step.getApproverTeaIds() == null ? new ArrayList<>() + : step.getApproverTeaIds().stream().filter(Objects::nonNull).collect(Collectors.toList()); + if (accounts.isEmpty() && teaIds.isEmpty()) { + continue; + } + FlowStepItem item = new FlowStepItem(); + item.setName(StringUtils.hasText(step.getName()) ? step.getName().trim() : null); + item.setVote("all".equalsIgnoreCase(step.getVote()) ? "all" : "any"); + item.setApprovers(accounts); + item.setApproverTeaIds(teaIds); + result.add(item); + } + } + if (result.isEmpty() && legacyTeaIds != null && !legacyTeaIds.isEmpty()) { + FlowStepItem legacy = new FlowStepItem(); + legacy.setVote("any"); + result.add(legacy); + } + return result; + } + + /** 发布入参的审批流解析:flow 多步优先 → approvers+vote 单步简写 → 旧 approverTeaIds 兼容包装 */ + private List resolveNoticeFlow(NoticeRequest request) { + if (request.getFlow() != null && !request.getFlow().isEmpty()) { + return resolveRequestFlow(request.getFlow(), null); + } + if (request.getApprovers() != null && !request.getApprovers().isEmpty()) { + FlowStepItem single = new FlowStepItem(); + single.setVote(request.getVote()); + single.setApprovers(request.getApprovers()); + return resolveRequestFlow(Collections.singletonList(single), null); + } + return resolveRequestFlow(null, request.getApproverTeaIds()); + } + + /** + * 保存审批流定义(草稿/编辑态):全步骤置「待流转」,提交审批时激活第 1 步。 + * 全量替换语义:flow 非空重建;flow 空且 legacyTeaIds 非 null 时仅清空旧定义; + * 两者皆无(未携带流程字段)时不改动。 + */ + private void saveFlowDefinition(Notice entity, List flow, List legacyTeaIds) { + boolean flowCarried = flow != null && !flow.isEmpty(); + if (!flowCarried && legacyTeaIds == null) { + return; + } + approvalStepRepository.deleteByNoticeId(entity.getId()); + approvalStepRepository.flush(); + if (!flowCarried) { + return; + } + List steps = buildSteps(entity, flow, legacyTeaIds); + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + step.setNoticeId(entity.getId()); + step.setStepNo(i + 1); + step.setStatus(NoticeApprovalStep.STATUS_WAITING); + approvalStepRepository.save(step); + } + } + + /** + * 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照); + * 旧 teaId 路径按学校教师名单校验。每步至少一人,整体至少一步。 + * 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。 + */ + private List buildSteps(Notice entity, List flowItems, List legacyTeaIds) { + if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) { + List existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId()); + // 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建 + boolean hasDefinition = !existing.isEmpty() && existing.stream() + .allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING); + if (hasDefinition) { + return existing; + } + } + if (flowItems == null || flowItems.isEmpty()) { + return Collections.emptyList(); + } + List steps = new ArrayList<>(); + for (int i = 0; i < flowItems.size(); i++) { + FlowStepItem item = flowItems.get(i); + NoticeApprovalStep step = new NoticeApprovalStep(); + step.setStepName(StringUtils.hasText(item.getName()) ? item.getName() + : "第" + (i + 1) + "步"); + step.setVote("all".equals(item.getVote()) ? "all" : "any"); + if (item.getApprovers() != null && !item.getApprovers().isEmpty()) { + // 账号路径:按本校启用审批人池绑定 + LinkedHashMap nameByAccount = bindAccounts(entity.getSchId(), item.getApprovers()); + List accounts = new ArrayList<>(nameByAccount.keySet()); + step.setApproverAccounts(toJsonStringList(accounts)); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameByAccount.values()))); + List teaIds = new ArrayList<>(); + for (String account : accounts) { + sysApproverRepository.findBySchIdAndAccount(entity.getSchId(), account) + .filter(a -> a.getTeaId() != null) + .ifPresent(a -> teaIds.add(a.getTeaId())); + } + if (!teaIds.isEmpty()) { + step.setApproverTeaIds(toJsonTeaIds(teaIds)); + } + } else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) { + // 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定 + LinkedHashMap nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds()); + step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + } else { + // 请求级旧 approverTeaIds 兼容包装步(单步或签) + LinkedHashMap nameById = bindApprovers(entity.getSchId(), legacyTeaIds); + step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + } + steps.add(step); + } + return steps; + } + + /** + * 按本校启用审批人池绑定账号:账号必须存在于池(多租户隔离)且启用, + * 保持选择顺序去重;姓名取池快照(池未维护姓名时回退账号展示)。 + */ + private LinkedHashMap bindAccounts(Integer schId, List accounts) { + if (accounts == null || accounts.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + Map poolByAccount = new LinkedHashMap<>(); + for (SysApprover approver : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) { + poolByAccount.putIfAbsent(approver.getAccount(), approver); + } + LinkedHashMap result = new LinkedHashMap<>(); + List missing = new ArrayList<>(); + for (String account : accounts) { + if (result.containsKey(account)) { + continue; + } + SysApprover pool = poolByAccount.get(account); + if (pool == null) { + missing.add(account); + continue; + } + result.put(account, StringUtils.hasText(pool.getName()) ? pool.getName() : account); + } + if (!missing.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "审批人不在本校审批人名单或已停用(" + String.join("、", missing) + "),请在「审核设置」中维护"); + } + return result; + } + + /** 当前审批人姓名:上下文 displayName 优先,缺省回退登录账号 */ + private String meName(String meAccount) { + String name = currentName(); + return StringUtils.hasText(name) ? name : meAccount; + } + + /** 审核记录落行(步骤名/表决方式随行快照,历史轮次永久保留) */ + private void saveRecordRow(Integer noticeId, int round, NoticeApprovalStep step, + String account, String name, int action, String opinion) { + NoticeApprovalRecord row = new NoticeApprovalRecord(); + row.setNoticeId(noticeId); + row.setRound(round); + row.setStepNo(step.getStepNo()); + row.setStepName(step.getStepName()); + row.setVote(step.getVote()); + row.setApproverAccount(account); + row.setApproverName(name); + row.setAction(action); + row.setOpinion(opinion); + row.setActionTime(LocalDateTime.now()); + approvalRecordRepository.save(row); + } + + /** 账号 JSON 解析(["a","b"]),空/非法返回空列表 */ + private List parseAccounts(String json) { + if (!StringUtils.hasText(json)) { + return Collections.emptyList(); + } + try { + String[] arr = objectMapper.readValue(json, String[].class); + return arr == null ? Collections.emptyList() : new ArrayList<>(Arrays.asList(arr)); + } catch (Exception e) { + log.warn("[parseAccounts] 审批人账号快照解析失败: {}", json, e); + return Collections.emptyList(); + } + } + + /** 字符串列表转 JSON 数组串 */ + private String toJsonStringList(List values) { + if (values == null || values.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(values); + } catch (Exception e) { + log.warn("[toJsonStringList] 序列化失败: {}", values, e); + return null; + } + } + /** * 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重); * 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时 @@ -617,108 +993,275 @@ public class NoticeService implements INoticeService { } @Override - public BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable) { - Integer me = currentTeaId(); - if (me == null) { - return PageUtils.toResult(new PageImpl<>(Collections.emptyList(), pageable, 0)); - } - boolean reviewed = "reviewed".equalsIgnoreCase(auditState); - List statuses = reviewed - ? Arrays.asList(PUBLISHED_CODE, REJECTED_CODE) - : Collections.singletonList(PENDING_CODE); - // 审批教师快照含我:JSON 整数数组按 [n / [n, / ,n, / ,n] 四段边界匹配(101 不会命中 1101,改造方案 4.4) - String idStr = String.valueOf(me); + public BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable) { + String state = StringUtils.hasText(auditState) ? auditState.trim().toLowerCase() : "pending"; + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); Specification spec = (root, cq, cb) -> { List predicates = new ArrayList<>(); - predicates.add(cb.or( - cb.like(root.get("approverTeaIds"), "[" + idStr + "]"), - cb.like(root.get("approverTeaIds"), "[" + idStr + ",%"), - cb.like(root.get("approverTeaIds"), "%," + idStr + ",%"), - cb.like(root.get("approverTeaIds"), "%," + idStr + "]"))); - predicates.add(root.get("status").in(statuses)); + switch (state) { + case "approved": + // 走审批流发布的(直接发布的 approvalLevel 为空,不算审批通过) + predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); + predicates.add(root.get("approvalLevel").isNotNull()); + break; + case "rejected": + predicates.add(cb.equal(root.get("status"), REJECTED_CODE)); + break; + case "all": + case "reviewed": + predicates.add(root.get("status").in(Arrays.asList(PUBLISHED_CODE, REJECTED_CODE))); + predicates.add(cb.or(root.get("approvalLevel").isNotNull(), + cb.equal(root.get("status"), REJECTED_CODE))); + break; + default: + predicates.add(cb.equal(root.get("status"), PENDING_CODE)); + predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId)); + break; + } appendCommonPredicates(query, root, cb, predicates); appendSchIdPredicate(root, cb, predicates); return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); }; Page page = noticeRepository.findAll(spec, pageable); - List notices = page.getContent(); - Map> recordsByNotice = notices.isEmpty() - ? Collections.emptyMap() - : approvalRepository.findByNoticeIds( - notices.stream().map(Notice::getId).collect(Collectors.toList())) - .stream().collect(Collectors.groupingBy(NoticeApproval::getNoticeId)); - // 审批教师姓名绑定:整页一次拉取学校教师,按ID映射(查询失败降级为ID展示) - Map teaNameById = loadSchTeaNames(currentSchId()); - String meAccount = currentAccount(); - List content = notices.stream() - .map(n -> toApprovalVo(n, recordsByNotice.getOrDefault(n.getId(), Collections.emptyList()), me, teaNameById, meAccount)) + List content = page.getContent().stream() + .map(n -> toVo(n, false)) .collect(Collectors.toList()); + fillApprovalFields(content); return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); } - /** 学校教师ID→姓名映射(审批列表姓名回显用);接口失败返回空映射降级为ID展示 */ - private Map loadSchTeaNames(Integer schId) { - if (schId == null) { - return Collections.emptyMap(); + @Override + public int approvalCount() { + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + if (!StringUtils.hasText(meAccount) && meTeaId == null) { + return 0; } - try { - Map names = new LinkedHashMap<>(); - for (SchTeaList tea : classService.getSchTeaList(schId)) { - if (tea.getUserTeaSerID() != null) { - names.putIfAbsent(tea.getUserTeaSerID(), - StringUtils.hasText(tea.getTeaName()) ? tea.getTeaName() : String.valueOf(tea.getUserTeaSerID())); - } + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("status"), PENDING_CODE)); + predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId)); + appendSchIdPredicate(root, cb, predicates); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + return (int) noticeRepository.count(spec); + } + + /** 「待我审批」匹配:当前步账号快照 / 旧教师ID快照任一命中(JSON 数组四段边界匹配,101 不会命中 1101) */ + private Predicate approverMatchPredicate(Root root, CriteriaBuilder cb, String meAccount, Integer meTeaId) { + List or = new ArrayList<>(); + if (StringUtils.hasText(meAccount)) { + String acc = meAccount.replace("%", "").replace("_", ""); + if (StringUtils.hasText(acc)) { + or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\"]")); + or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\",%")); + or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\",%")); + or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\"]")); + or.add(cb.like(root.get("approverAccounts"), "[" + acc + "]")); + or.add(cb.like(root.get("approverAccounts"), "[" + acc + ",%")); + or.add(cb.like(root.get("approverAccounts"), "%," + acc + ",%")); + or.add(cb.like(root.get("approverAccounts"), "%," + acc + "]")); } - return names; - } catch (Exception e) { - log.warn("[loadSchTeaNames] 拉取学校教师失败, schId={}", schId, e); - return Collections.emptyMap(); + } + if (meTeaId != null) { + String idStr = String.valueOf(meTeaId); + or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + "]")); + or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + ",%")); + or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + ",%")); + or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + "]")); + } + return cb.or(or.toArray(new Predicate[0])); + } + + /** + * 列表行批量装配多步审批字段:审批状态(none/pending/approved/rejected)、 + * 进度文本(第x/y步 名·会签 done/total)、当前步下标、当前用户可否审批。 + */ + private void fillApprovalFields(List vos) { + if (vos == null || vos.isEmpty()) { + return; + } + List ids = vos.stream().map(NoticeVo::getId).collect(Collectors.toList()); + Map> stepsByNotice = approvalStepRepository.findByNoticeIds(ids).stream() + .collect(Collectors.groupingBy(NoticeApprovalStep::getNoticeId)); + Map> recordsByNotice = approvalRecordRepository.findByNoticeIds(ids).stream() + .collect(Collectors.groupingBy(NoticeApprovalRecord::getNoticeId)); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + for (NoticeVo vo : vos) { + List steps = stepsByNotice.getOrDefault(vo.getId(), Collections.emptyList()); + if (steps.isEmpty()) { + boolean pending = vo.getStatus() != null && vo.getStatus() == PENDING_CODE; + vo.setApprovalStatus(pending ? "pending" : "none"); + vo.setApprovalStatusDesc(vo.getStatusDesc()); + vo.setApprovalLevel(0); + vo.setCanApprove(false); + continue; + } + List records = recordsByNotice.getOrDefault(vo.getId(), Collections.emptyList()); + vo.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); + vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps)); + vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records)); + vo.setCanApprove(canApproveNow(vo.getStatus(), steps, records, meAccount, meTeaId)); } } - /** 审批列表行装配:审核人姓名按提交行 assignee_tea_ids 绑定学校教师;已审行带最新处理信息(含他人已处理) */ - private ApprovalListVo toApprovalVo(Notice notice, List records, - Integer meTeaId, Map teaNameById, String meAccount) { - ApprovalListVo vo = new ApprovalListVo(); - vo.setId(notice.getId()); - vo.setTitle(notice.getTitle()); - vo.setType(notice.getType()); - vo.setTypeDesc(NoticeTypeEnum.of(notice.getType()).getDesc()); - vo.setStatus(notice.getStatus()); - vo.setStatusDesc(NoticeStatusEnum.of(notice.getStatus()).getDesc()); - vo.setPublisherAccount(notice.getPublisherAccount()); - vo.setPublisherName(notice.getPublisherName()); - vo.setReceiptFlag(notice.getReceiptFlag()); - vo.setTimingSendTime(notice.getTimingSendTime()); - // records 按 id asc,顺序覆盖即取最新 - NoticeApproval submit = null; - NoticeApproval handled = null; - for (NoticeApproval record : records) { - if (record.getAction() == NoticeApproval.ACTION_SUBMIT) { - submit = record; - } else if (record.getAction() == NoticeApproval.ACTION_APPROVE - || record.getAction() == NoticeApproval.ACTION_REJECT) { - handled = record; + /** 前端审批状态枚举映射:none 免审 / pending / approved(走流程发布)/ rejected */ + private String approvalStatusOf(Integer status, List steps) { + if (status == null) { + return "none"; + } + if (status == PENDING_CODE) { + return "pending"; + } + if (status == REJECTED_CODE) { + return "rejected"; + } + if (status == PUBLISHED_CODE) { + return steps.isEmpty() ? "none" : "approved"; + } + return "none"; + } + + /** 当前步下标(0 起):待审批=首个审批中步骤;已驳回=驳回发生步骤;其余=步数(全部走完) */ + private int stepLevelOf(Integer status, List steps) { + if (status != null && status == PENDING_CODE) { + for (int i = 0; i < steps.size(); i++) { + Integer st = steps.get(i).getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_ACTIVE) { + return i; + } + } + return 0; + } + if (status != null && status == REJECTED_CODE) { + for (int i = 0; i < steps.size(); i++) { + Integer st = steps.get(i).getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) { + return i; + } } } - if (submit != null) { - vo.setSubmitTime(submit.getActionTime()); - for (Integer teaId : parseTeaIds(submit.getAssigneeTeaIds())) { - vo.getApproverNames().add(teaNameById.getOrDefault(teaId, "ID " + teaId)); + return steps.size(); + } + + /** 进度文本(对齐前端展示):待审批「第1/2步 校级审核·会签 0/2」,或签不带计数 */ + private String progressDesc(Integer status, List steps, List records) { + if (status == null) { + return ""; + } + if (status == REJECTED_CODE) { + return "已驳回"; + } + if (status != PENDING_CODE) { + return "已发布"; + } + int level = stepLevelOf(status, steps); + NoticeApprovalStep current = steps.get(level); + String name = StringUtils.hasText(current.getStepName()) ? current.getStepName() : "第" + (level + 1) + "步"; + String prefix = "第" + (level + 1) + "/" + steps.size() + "步 " + name; + if ("all".equals(current.getVote())) { + int round = currentRound(records); + long done = records.stream() + .filter(r -> r.getRound() != null && r.getRound() == round + && current.getStepNo() != null && current.getStepNo().equals(r.getStepNo()) + && r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_APPROVE) + .count(); + return prefix + "·会签 " + done + "/" + parseAccounts(current.getApproverAccounts()).size(); + } + return prefix + "·或签"; + } + + /** 当前用户是否本单当前步待签审批人(在步内且本轮本步未处理过) */ + private boolean canApproveNow(Integer status, List steps, + List records, String meAccount, Integer meTeaId) { + if (status == null || status != PENDING_CODE || steps.isEmpty()) { + return false; + } + int level = stepLevelOf(status, steps); + NoticeApprovalStep current = steps.get(level); + boolean inStep = (meAccount != null && parseAccounts(current.getApproverAccounts()).contains(meAccount)) + || (meTeaId != null && parseTeaIds(current.getApproverTeaIds()).contains(meTeaId)); + if (!inStep) { + return false; + } + int round = currentRound(records); + return records.stream().noneMatch(r -> r.getRound() != null && r.getRound() == round + && current.getStepNo() != null && current.getStepNo().equals(r.getStepNo()) + && meAccount != null && meAccount.equals(r.getApproverAccount())); + } + + /** 最新送审轮次(无记录按 1) */ + private int currentRound(List records) { + return records.stream().mapToInt(r -> r.getRound() == null ? 1 : r.getRound()).max().orElse(1); + } + + /** + * 详情多步审批流装配:每步含审批人账号/姓名、步骤状态(approved/pending/rejected/waiting)、 + * 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。 + */ + private List toFlowVos(Integer status, List steps, + List records) { + if (steps.isEmpty()) { + return Collections.emptyList(); + } + int level = stepLevelOf(status, steps); + List flow = new ArrayList<>(); + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + ApprovalFlowVo vo = new ApprovalFlowVo(); + vo.setName(StringUtils.hasText(step.getStepName()) ? step.getStepName() : "第" + (i + 1) + "步"); + vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any"); + List accounts = parseAccounts(step.getApproverAccounts()); + List names = parseAccounts(step.getApproverNames()); + vo.setApprovers(accounts); + vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds())); + // 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照 + if (!accounts.isEmpty()) { + vo.setApproverNames(names.size() == accounts.size() ? names : accounts); + } else { + vo.setApproverNames(names); } + List stepRecords = new ArrayList<>(); + for (NoticeApprovalRecord r : records) { + if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) { + continue; + } + ApprovalFlowVo.Record item = new ApprovalFlowVo.Record(); + item.setAccount(r.getApproverAccount()); + item.setName(r.getApproverName()); + item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT + ? "rejected" : "approved"); + item.setTime(r.getActionTime()); + item.setRemark(r.getOpinion()); + stepRecords.add(item); + } + vo.setRecords(stepRecords); + Integer st = step.getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_APPROVED) { + vo.setStatus("approved"); + } else if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) { + vo.setStatus("rejected"); + } else if (status != null && status == PENDING_CODE && i == level) { + vo.setStatus("pending"); + } else { + vo.setStatus("waiting"); + } + if ("pending".equals(vo.getStatus())) { + Set acted = stepRecords.stream() + .map(ApprovalFlowVo.Record::getAccount) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + for (int j = 0; j < accounts.size(); j++) { + if (!acted.contains(accounts.get(j))) { + vo.getPending().add(j < vo.getApproverNames().size() ? vo.getApproverNames().get(j) : accounts.get(j)); + } + } + } + flow.add(vo); } - if (handled != null && notice.getStatus() != null && notice.getStatus() != PENDING_CODE) { - vo.setHandled(true); - vo.setHandledByMe(meAccount.equals(handled.getOperatorAccount())); - vo.setHandledAccount(handled.getOperatorAccount()); - vo.setHandledName(handled.getOperatorName()); - vo.setHandledAction(handled.getAction()); - ApprovalActionEnum action = ApprovalActionEnum.of(handled.getAction()); - vo.setHandledActionDesc(action != null ? action.getDesc() : null); - vo.setHandledTime(handled.getActionTime()); - vo.setHandledOpinion(handled.getOpinion()); - } - return vo; + return flow; } @Override @@ -923,6 +1466,11 @@ public class NoticeService implements INoticeService { entity.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); } } + // 审批流定义(草稿/已驳回重编辑):flow 或 approvers 字段携带即全量替换(空数组=清空); + // 已发布不可变更流程(与内容同理),待审批已被上方拦截 + if (status != PUBLISHED_CODE && (request.getFlow() != null || request.getApprovers() != null)) { + saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds()); + } // 推送范围全量替换(草稿/已驳回重编辑场景;null 不改动)。 // 已发布(旧链路)不允许变更范围:改范围而不重新圈人会造成可见性口径错乱 if (request.getPushScopes() != null && status != PUBLISHED_CODE) { @@ -987,6 +1535,8 @@ public class NoticeService implements INoticeService { noticeReceiverRepository.deleteByNoticeId(id); pushScopeRepository.deleteByNoticeId(id); approvalRepository.deleteByNoticeId(id); + approvalStepRepository.deleteByNoticeId(id); + approvalRecordRepository.deleteByNoticeId(id); upFileRepository.deleteByTopicTypeAndTopicId("notice", id); noticeRepository.deleteById(id); } @@ -1035,13 +1585,19 @@ public class NoticeService implements INoticeService { entity.setId(null); entity.setDeptName(resolveDeptName(request)); normalizeReceiveList(entity, request.getReceiveList()); - entity.setStatus(PUBLISHED_CODE); entity.setPublisherAccount(currentAccount()); entity.setPublisherName(currentName()); entity.setParentId(sourceId); entity.setIsPulled(0); entity.setReadCount(0); entity.setSchId(currentSchId()); + // 转发同发布分流:带审批流(或显式 approve)先送审,否则直接发布 + List flow = resolveNoticeFlow(request); + if ("approve".equalsIgnoreCase(String.valueOf(request.getPublishType())) || !flow.isEmpty()) { + doSubmitApproval(entity, flow, null, null); + return entity.getId(); + } + entity.setStatus(PUBLISHED_CODE); noticeRepository.save(entity); saveReceivers(entity); if (isImmediatePublish(entity)) { @@ -1158,6 +1714,8 @@ public class NoticeService implements INoticeService { vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(","))); } } + // 批量填充多步审批字段(审批状态/进度文本/当前步下标/可否审批) + fillApprovalFields(content); return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); } @@ -1259,7 +1817,14 @@ public class NoticeService implements INoticeService { if (account.equals(notice.getPublisherAccount())) { return; } - if (parseTeaIds(notice.getApproverTeaIds()).contains(currentTeaId())) { + // 审批人放行:当前步账号快照(多步流程)或教师ID快照(旧链路)命中 + if (parseAccounts(notice.getApproverAccounts()).contains(account) + || parseTeaIds(notice.getApproverTeaIds()).contains(currentTeaId())) { + return; + } + // 走过流程的审批人(历史轮次/已办步骤)也可查看 + if (approvalRecordRepository.findByNoticeIdOrderByIdAsc(notice.getId()).stream() + .anyMatch(r -> account.equals(r.getApproverAccount()))) { return; } } @@ -1792,6 +2357,15 @@ public class NoticeService implements INoticeService { data.setApprovalRecords(loadApprovalRecords(serid)); // 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定) data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds())); + // 多步审批流(步骤表 + 审核记录表) + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + List flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid); + data.setApprovalEnabled(steps.isEmpty() ? 0 : 1); + data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); + data.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, flowRecords)); + data.setApprovalLevel(stepLevelOf(vo.getStatus(), steps)); + data.setCanApprove(canApproveNow(vo.getStatus(), steps, flowRecords, currentAccount(), currentTeaId())); + data.setApprovalFlow(toFlowVos(vo.getStatus(), steps, flowRecords)); } List files = upFileRepository.findByTopicTypeAndTopicId("notice", serid); List fileList = files.stream().map(f -> { diff --git a/src/main/resources/static/api-test.html b/src/main/resources/static/api-test.html index 58dd29b..938ca69 100644 --- a/src/main/resources/static/api-test.html +++ b/src/main/resources/static/api-test.html @@ -343,12 +343,25 @@

@@ -359,6 +372,7 @@
推送范围(学员)
+
加载中…