@@ -99,23 +99,28 @@ public class DictController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增审批人(JSON body:account/name/deptId)。
|
||||
* 新增审批人(JSON body:account/name/deptId/teaId;account 可空,空时后端按 teaId 经用户中心解析)。
|
||||
*/
|
||||
@PostMapping("AddApprover")
|
||||
public void addApprover(@RequestBody Map<String, String> body) {
|
||||
dictService.addApprover(body.get("account"), body.get("name"), body.get("deptId"));
|
||||
public void addApprover(@RequestBody Map<String, Object> body) {
|
||||
dictService.addApprover(
|
||||
asString(body.get("account")),
|
||||
asString(body.get("name")),
|
||||
asString(body.get("deptId")),
|
||||
toInteger(body.get("teaId")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新审批人(JSON body:id + 可选 name/deptId/enabled)。
|
||||
* 更新审批人(JSON body:id + 可选 name/deptId/enabled/teaId;teaId=0 清空教师绑定)。
|
||||
*/
|
||||
@PostMapping("UpdateApprover")
|
||||
public void updateApprover(@RequestBody Map<String, Object> body) {
|
||||
dictService.updateApprover(
|
||||
toInteger(body.get("id")),
|
||||
(String) body.get("name"),
|
||||
(String) body.get("deptId"),
|
||||
toInteger(body.get("enabled")));
|
||||
asString(body.get("name")),
|
||||
asString(body.get("deptId")),
|
||||
toInteger(body.get("enabled")),
|
||||
toInteger(body.get("teaId")));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +145,9 @@ public class DictController {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map 值转 String(null 返回 null,避免 String.valueOf(null) 产出 "null" 串) */
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,18 @@ public class NoticeController {
|
||||
return noticeService.userList(query, PageUtils.toPageable(query, sort));
|
||||
}
|
||||
|
||||
// ============================ 收件箱三 tab 统计 ============================
|
||||
|
||||
/**
|
||||
* 「已收到」全部 / 未读 / 已读 三个 tab 的条数(v17-B1)。
|
||||
* <p>入参与 List 相同(keyword/type/deptId/startTime/endTime;分页与 readType 字段被忽略),
|
||||
* 统计结果与对应 tab 下列表的 total 一一对应,恒满足 {@code all = unread + read}。
|
||||
*/
|
||||
@GetMapping("ReadTabCount")
|
||||
public ReadTabCountVo readTabCount(NoticeQuery query) {
|
||||
return noticeService.readTabCount(query);
|
||||
}
|
||||
|
||||
// ============================ 已发/全部通知列表 ============================
|
||||
|
||||
/**
|
||||
@@ -182,11 +194,22 @@ public class NoticeController {
|
||||
|
||||
/**
|
||||
* 上一篇/下一篇通知ID。
|
||||
* <p>v17:已收序列(type=1)沿用与 List 相同的 keyword / 部门 / 时间范围筛选,
|
||||
* <b>公告重要程度在本接口用 noticeType 传</b>——同一请求里 `type` 已被「序列类型」占用,
|
||||
* 若让 NoticeQuery 直接绑定同名 type 会把 "0/1" 误当成重要程度筛选。
|
||||
* 参数均可选,不传即整条可见已收序列,旧调用方行为不变。
|
||||
*/
|
||||
@GetMapping("GetNextPrev")
|
||||
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
|
||||
@RequestParam("type") String type) {
|
||||
return noticeService.getNextPrev(serid, type);
|
||||
@RequestParam("type") String type,
|
||||
@RequestParam(value = "noticeType", required = false) Integer noticeType,
|
||||
NoticeQuery query) {
|
||||
if (noticeType != null) {
|
||||
query.setType(noticeType);
|
||||
}
|
||||
// 已收序列刻意不受未读/已读影响(v17 决策 2);服务层也已固定按「全部」桶取序列
|
||||
query.setReadType(null);
|
||||
return noticeService.getNextPrev(serid, type, query);
|
||||
}
|
||||
|
||||
// ============================ 附件打包下载 ============================
|
||||
@@ -346,7 +369,7 @@ public class NoticeController {
|
||||
// ============================ 审批列表(待审/已审合一) ============================
|
||||
|
||||
/**
|
||||
* 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 /
|
||||
* 审批列表:auditState/status=pending(默认) 待审批(当前步审批人快照含我) / approved 走流程已发布 /
|
||||
* rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段:
|
||||
* approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。
|
||||
*/
|
||||
@@ -370,6 +393,17 @@ public class NoticeController {
|
||||
return noticeService.approvalCount();
|
||||
}
|
||||
|
||||
// ============================ 审批中心 tab 统计数字 ============================
|
||||
|
||||
/**
|
||||
* 审批中心四 tab 统计数字(v13-E3):{pending,approved,rejected,all},
|
||||
* 口径与 ApprovalList 各 tab 默认过滤一致(不含 keyword 搜索条件)。
|
||||
*/
|
||||
@GetMapping("ApprovalTabCount")
|
||||
public ApprovalTabCountVo approvalTabCount() {
|
||||
return noticeService.approvalTabCount();
|
||||
}
|
||||
|
||||
/** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */
|
||||
private List<PushScopeItem> parseScopes(String scopes) {
|
||||
if (scopes == null || scopes.trim().isEmpty()) {
|
||||
|
||||
@@ -45,6 +45,13 @@ public class NoticeApproval extends BaseEntity {
|
||||
@Column(name = "operator_name", length = 64)
|
||||
private String operatorName;
|
||||
|
||||
/**
|
||||
* 操作人同步教师ID(用户中心 teaId,与教师树叶子、{@code notice_approval_step.approver_tea_ids} 同一 ID 空间;
|
||||
* 非本表/池表自增 id)。v15 新增:审批轨迹按人名展示、按教师ID留痕,账号仅作登录标识。
|
||||
*/
|
||||
@Column(name = "operator_tea_id")
|
||||
private Integer operatorTeaId;
|
||||
|
||||
/** 审批意见 */
|
||||
@Column(name = "opinion", length = 1000)
|
||||
private String opinion;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yunhai.campusnotice.model.notice;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通知视图对象(列表/详情返回)。
|
||||
@@ -74,6 +75,12 @@ public class NoticeVo {
|
||||
/** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */
|
||||
private Boolean canApprove;
|
||||
|
||||
/** 当前步待签审批人姓名(审批列表「当前审批人」列;非待审批态为 null,对齐详情 approvalFlow[].pending) */
|
||||
private List<String> pendingApproverNames;
|
||||
|
||||
/** 最新轮末条处理意见(驳回行=驳回意见、通过行=末步通过意见;待审批/免审为 null,审批列表「审批意见」列) */
|
||||
private String lastOpinion;
|
||||
|
||||
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
|
||||
private String UpFile;
|
||||
|
||||
|
||||
@@ -84,19 +84,26 @@ public interface IDictService {
|
||||
/**
|
||||
* 审批人管理列表(含停用)。
|
||||
*
|
||||
* @return List<Map> 每项 {id, account, name, deptId, deptName, enabled}
|
||||
* @return List<Map> 每项 {id, account, name, deptId, deptName, enabled, teaId}
|
||||
*/
|
||||
List<Map<String, Object>> getApproverList();
|
||||
|
||||
/**
|
||||
* 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。
|
||||
* <p>v7:teaId 非空时落教师ID绑定;account 为空且 teaId 非空时按教师ID经用户中心解析登录账号,
|
||||
* 解析失败报错提示手填账号。
|
||||
*
|
||||
* @param account 登录账号(可空,空时按 teaId 自动解析)
|
||||
* @param name 姓名(可空,回退账号展示)
|
||||
* @param deptId 发文部门字典 key(可空)
|
||||
* @param teaId 教师树叶子ID(可空;审核设置「选择教师」带入)
|
||||
*/
|
||||
void addApprover(String account, String name, String deptId);
|
||||
void addApprover(String account, String name, String deptId, Integer teaId);
|
||||
|
||||
/**
|
||||
* 更新审批人(null 参数不改动;enabled 仅接受 0/1)。
|
||||
* 更新审批人(null 参数不改动;enabled 仅接受 0/1;teaId 传 0 表示清空教师绑定)。
|
||||
*/
|
||||
void updateApprover(Integer id, String name, String deptId, Integer enabled);
|
||||
void updateApprover(Integer id, String name, String deptId, Integer enabled, Integer teaId);
|
||||
|
||||
/**
|
||||
* 删除审批人(仅本校记录)。
|
||||
|
||||
@@ -54,7 +54,7 @@ public interface INoticeService {
|
||||
void reject(Integer serid, String opinion);
|
||||
|
||||
/**
|
||||
* 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回;
|
||||
* 审批列表:pending(默认)=待审批(当前步审批人快照含我)status=2;approved=走流程已发布;rejected=已驳回;
|
||||
* all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/
|
||||
* approvalLevel/canApprove)。
|
||||
*/
|
||||
@@ -65,6 +65,12 @@ public interface INoticeService {
|
||||
*/
|
||||
int approvalCount();
|
||||
|
||||
/**
|
||||
* 审批中心四 tab 统计数字(v13-E3):pending/approved/rejected/all,
|
||||
* 口径与 ApprovalList 各 tab 默认过滤一致(不含 keyword)。
|
||||
*/
|
||||
ApprovalTabCountVo approvalTabCount();
|
||||
|
||||
/**
|
||||
* 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。
|
||||
*/
|
||||
@@ -110,6 +116,13 @@ public interface INoticeService {
|
||||
*/
|
||||
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 「已收到」三 tab 统计(v17-B1):全部 / 未读 / 已读。
|
||||
* 口径与 {@link #userList} 同源(同一可见性谓词 + 同一筛选条件),
|
||||
* <b>readType 不作为入参</b>(它就是被统计的三个桶),故恒满足 {@code all = unread + read}。
|
||||
*/
|
||||
ReadTabCountVo readTabCount(NoticeQuery query);
|
||||
|
||||
/**
|
||||
* 管理端列表:按条件查询全部通知(管理员查看全部)。
|
||||
*/
|
||||
@@ -165,10 +178,14 @@ public interface INoticeService {
|
||||
* 上一篇/下一篇通知ID(SQL 相邻查询)。
|
||||
*
|
||||
* @param serid 当前通知ID
|
||||
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列)
|
||||
* @return {prevId, nextId}
|
||||
* @param type 0 已发(当前用户发布的序列,遗留 E1:仍无筛选且整表加载)/ 1 已收
|
||||
* @param query 已收序列的筛选条件(keyword/type/deptId/时间范围,与 List 同源,可为 null)。
|
||||
* 已收序列 v17 起为「当前用户可见的已收公告」(含未读),
|
||||
* <b>刻意忽略 query.readType</b>:未读公告点开即被标读,
|
||||
* 若序列带未读过滤会把当前条挤出集合、上下篇同时置灰。
|
||||
* @return {prevId, nextId},0 表示该方向没有可跳的公告
|
||||
*/
|
||||
Map<String, Object> getNextPrev(Integer serid, String type);
|
||||
Map<String, Object> getNextPrev(Integer serid, String type, NoticeQuery query);
|
||||
|
||||
/**
|
||||
* 获取某通知的阅读明细列表。
|
||||
@@ -195,6 +212,17 @@ public interface INoticeService {
|
||||
*/
|
||||
int timingPublishDue();
|
||||
|
||||
/**
|
||||
* 历史审批身份回填(v15):把「姓名=登录名」的历史留痕换成用户中心真实姓名,
|
||||
* 并补齐 {@code notice_approval.operator_tea_id}、{@code notice_approval_record.approver_tea_id}
|
||||
* 与 {@code notice_approval_step.approver_names}(按 teaId 对齐本校教师名单)。
|
||||
* <p>幂等:仅改「姓名为空/等于账号」与「teaId 为 NULL」的行,可重复执行。
|
||||
* 由启动开关 {@code campus-notice.backfill-approver-identity=true} 触发一次。
|
||||
*
|
||||
* @return 各表更新条数统计(trail/record/step/notice/unresolved)
|
||||
*/
|
||||
Map<String, Integer> backfillApproverIdentity();
|
||||
|
||||
/**
|
||||
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.slf4j.Logger;
|
||||
@@ -290,6 +291,8 @@ public class DictService implements IDictService {
|
||||
item.put("name", StringUtils.hasText(approver.getName()) ? approver.getName() : approver.getAccount());
|
||||
item.put("account", approver.getAccount());
|
||||
item.put("deptName", approver.getDeptName());
|
||||
// 教师ID:ApproverSuggest 记忆回显按 teaId 映射回池内账号(新版前端账号路径选人)
|
||||
item.put("teaId", approver.getTeaId());
|
||||
item.put("isParent", false);
|
||||
result.add(item);
|
||||
}
|
||||
@@ -308,6 +311,8 @@ public class DictService implements IDictService {
|
||||
item.put("deptId", approver.getDeptId());
|
||||
item.put("deptName", approver.getDeptName());
|
||||
item.put("enabled", approver.getEnabled());
|
||||
// v7:教师ID绑定(审核设置「选择教师」落库;白名单过滤与回显用)
|
||||
item.put("teaId", approver.getTeaId());
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
@@ -315,12 +320,19 @@ public class DictService implements IDictService {
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void addApprover(String account, String name, String deptId) {
|
||||
public void addApprover(String account, String name, String deptId, Integer teaId) {
|
||||
Integer schId = requireSchId();
|
||||
if (!StringUtils.hasText(account)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号");
|
||||
String acc = account == null ? "" : account.trim();
|
||||
if (!StringUtils.hasText(acc) && teaId != null && teaId > 0) {
|
||||
// v7:选择教师带入 teaId 但未填账号 → 按教师ID经用户中心解析登录账号
|
||||
acc = resolveAccountByTeaId(teaId);
|
||||
if (!StringUtils.hasText(acc)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无法自动获取教师登录账号(teaId=" + teaId + "),请手动填写账号");
|
||||
}
|
||||
}
|
||||
if (!StringUtils.hasText(acc)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号,或选择本校教师自动关联");
|
||||
}
|
||||
String acc = account.trim();
|
||||
if (sysApproverRepository.findBySchIdAndAccount(schId, acc).isPresent()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "审批人「" + acc + "」已存在");
|
||||
}
|
||||
@@ -330,13 +342,14 @@ public class DictService implements IDictService {
|
||||
approver.setName(StringUtils.hasText(name) ? name.trim() : acc);
|
||||
approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null);
|
||||
approver.setDeptName(resolveDeptName(approver.getDeptId()));
|
||||
approver.setTeaId(teaId != null && teaId > 0 ? teaId : null);
|
||||
approver.setEnabled(1);
|
||||
sysApproverRepository.save(approver);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateApprover(Integer id, String name, String deptId, Integer enabled) {
|
||||
public void updateApprover(Integer id, String name, String deptId, Integer enabled, Integer teaId) {
|
||||
Integer schId = requireSchId();
|
||||
SysApprover approver = requireOwnSchool(schId, id);
|
||||
if (StringUtils.hasText(name)) {
|
||||
@@ -349,9 +362,29 @@ public class DictService implements IDictService {
|
||||
if (enabled != null) {
|
||||
approver.setEnabled(enabled == 0 ? 0 : 1);
|
||||
}
|
||||
if (teaId != null) {
|
||||
// v7:teaId=0 语义为清空教师绑定(存量记录补绑/解绑)
|
||||
approver.setTeaId(teaId > 0 ? teaId : null);
|
||||
}
|
||||
sysApproverRepository.save(approver);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按教师ID经用户中心解析登录账号(v7)。
|
||||
* <p>bschclass 教师名单(SchTeaList)无登录账号字段,故走用户中心;取 {@code BoaUser.getAccount()}。解析失败返回 null。
|
||||
*/
|
||||
private String resolveAccountByTeaId(Integer teaId) {
|
||||
try {
|
||||
BoaUser user = YschBaseOpenApi.getUserById(String.valueOf(teaId));
|
||||
if (user != null && StringUtils.hasText(user.getAccount())) {
|
||||
return user.getAccount().trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[addApprover] 用户中心解析教师账号失败, teaId={}", teaId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delApprover(Integer id) {
|
||||
|
||||
@@ -54,6 +54,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -66,6 +67,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Expression;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.persistence.criteria.Subquery;
|
||||
@@ -73,6 +75,7 @@ import javax.servlet.http.HttpServletResponse;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -147,6 +150,14 @@ public class NoticeService implements INoticeService {
|
||||
private static final int PENDING_CODE = NoticeStatusEnum.PENDING.getCode();
|
||||
private static final int REJECTED_CODE = NoticeStatusEnum.REJECTED.getCode();
|
||||
|
||||
/**
|
||||
* 阅读桶({@link NoticeQuery#getReadType()} 的取值,v17 用于 tab 统计与已收相邻查询):
|
||||
* 0 全部 / 1 未读 / 2 已读。
|
||||
*/
|
||||
private static final int ALL = 0;
|
||||
private static final int UNREAD = 1;
|
||||
private static final int READ = 2;
|
||||
|
||||
/**
|
||||
* 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
|
||||
*/
|
||||
@@ -156,11 +167,132 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录人姓名(平台默认仅提供登录名,展示姓名后续可接用户中心补齐)。
|
||||
* 当前登录人姓名。
|
||||
* <p>平台 {@code DefaultLoginUserProvider} 用 token 串同时喂 loginName/displayName/userId,
|
||||
* 故 displayName 常等于登录名(历史数据里 {@code operator_name=lijing} 即由此落库)。
|
||||
* v15:此时回退用户中心真实姓名,保证发布人/审批轨迹/审批流程一律落人名;解析失败保持原值。
|
||||
*/
|
||||
private static String currentName() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getDisplayName();
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
String loginName = user.getLoginName();
|
||||
String display = user.getDisplayName();
|
||||
if (!StringUtils.hasText(display) || display.equals(loginName)) {
|
||||
return cachedUserName(loginName);
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
/** 账号→真实姓名缓存(v15):姓名属低频变更数据,进程内缓存避免同账号反复外呼用户中心 */
|
||||
private static final Map<String, String> ACCOUNT_NAME_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/** 按账号取用户中心姓名,查不到回退账号本身(结果含回退值一并缓存) */
|
||||
private static String cachedUserName(String account) {
|
||||
if (!StringUtils.hasText(account)) {
|
||||
return account;
|
||||
}
|
||||
if (ACCOUNT_NAME_CACHE.size() > 2000) {
|
||||
ACCOUNT_NAME_CACHE.clear();
|
||||
}
|
||||
return ACCOUNT_NAME_CACHE.computeIfAbsent(account, acc -> {
|
||||
try {
|
||||
// 先按登录账号反查(v15 原路径)
|
||||
BoaUser boa = YschBaseOpenApi.getUserInfo(acc);
|
||||
// 发布人落库值可能是数字型教师ID而非登录账号:byLoginName 查不到时回退 byId
|
||||
if ((boa == null || !StringUtils.hasText(boa.getName())) && isNumericId(acc)) {
|
||||
boa = YschBaseOpenApi.getUserById(acc);
|
||||
}
|
||||
if (boa != null && StringUtils.hasText(boa.getName())) {
|
||||
return boa.getName();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[cachedUserName] 用户中心姓名解析失败, account={}", acc);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
}
|
||||
|
||||
/** 值是否为纯数字(用于区分「教师ID」与「登录账号」两种落库形态) */
|
||||
private static boolean isNumericId(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (!Character.isDigit(value.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示用发布人姓名解析(v16):兼容三类落库形态——
|
||||
* ①正常人名快照直接返回;②快照等于账号(早期未解析);③快照为数字型教师ID。
|
||||
* 后两类经用户中心(byLoginName→byId 兜底)解析为真实姓名,解析失败保持原值。
|
||||
*/
|
||||
private static String displayPublisherName(String snapshot, String account) {
|
||||
boolean snapshotIsName = StringUtils.hasText(snapshot)
|
||||
&& !snapshot.equals(account) && !isNumericId(snapshot);
|
||||
if (snapshotIsName) {
|
||||
return snapshot;
|
||||
}
|
||||
String primary = StringUtils.hasText(account) ? account : snapshot;
|
||||
String resolved = cachedUserName(primary);
|
||||
if (StringUtils.hasText(resolved) && !resolved.equals(primary) && !isNumericId(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
if (StringUtils.hasText(snapshot) && !snapshot.equals(primary)) {
|
||||
resolved = cachedUserName(snapshot);
|
||||
if (StringUtils.hasText(resolved) && !resolved.equals(snapshot) && !isNumericId(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return StringUtils.hasText(snapshot) ? snapshot : account;
|
||||
}
|
||||
|
||||
/** 本校 teaId→姓名 map(一次教师名单外呼;失败返回空 map,调用方回退账号路径) */
|
||||
private Map<Integer, String> teaNameMap(Integer schId) {
|
||||
Map<Integer, String> map = new HashMap<>();
|
||||
if (schId == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (SchTeaList tea : classService.getSchTeaList(schId)) {
|
||||
if (tea.getUserTeaSerID() != null && StringUtils.hasText(tea.getTeaName())) {
|
||||
map.putIfAbsent(tea.getUserTeaSerID(), tea.getTeaName());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[teaNameMap] 教师名单拉取失败, schId={}", schId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示姓名兜底(v15):快照姓名缺失或等于账号时,先按同步教师ID查本校教师名单,
|
||||
* 再按账号查用户中心(带缓存),仍解析不到才退回快照/账号原值。
|
||||
*
|
||||
* @param teaNameById 调用方懒加载一次的本校 teaId→姓名 map(可为空 map)
|
||||
*/
|
||||
private String displayApproverName(String snapshot, String account, Integer teaId, Map<Integer, String> teaNameById) {
|
||||
if (StringUtils.hasText(snapshot) && !snapshot.equals(account)) {
|
||||
return snapshot;
|
||||
}
|
||||
if (teaId != null && teaNameById != null) {
|
||||
String name = teaNameById.get(teaId);
|
||||
if (StringUtils.hasText(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(account)) {
|
||||
String name = cachedUserName(account);
|
||||
if (StringUtils.hasText(name) && !name.equals(account)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return StringUtils.hasText(snapshot) ? snapshot : account;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,9 +304,18 @@ public class NoticeService implements INoticeService {
|
||||
if (user == null || !StringUtils.hasText(user.getUserId())) {
|
||||
return null;
|
||||
}
|
||||
String raw = user.getUserId().trim();
|
||||
try {
|
||||
return Integer.valueOf(user.getUserId().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return Integer.valueOf(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
// v13-E1:平台默认 DefaultLoginUserProvider 把登录名放进 userId(非数字),
|
||||
// 回退用户中心按账号反查用户中心ID(与教师树叶子 teaId 同 ID 空间)
|
||||
}
|
||||
try {
|
||||
BoaUser boa = YschBaseOpenApi.getUserInfo(raw);
|
||||
return boa == null ? null : boa.getId();
|
||||
} catch (Exception e) {
|
||||
log.warn("[currentTeaId] 用户中心按账号反查失败, account={}", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -538,14 +679,14 @@ public class NoticeService implements INoticeService {
|
||||
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
List<NoticeApprovalStep> steps = normalizeSteps(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));
|
||||
NoticeApprovalStep current = resolveCurrentStep(steps, level);
|
||||
if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
@@ -636,7 +777,7 @@ public class NoticeService implements INoticeService {
|
||||
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
List<NoticeApprovalStep> steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid));
|
||||
if (steps.isEmpty()) {
|
||||
// 存量单步数据按旧语义驳回
|
||||
requireApprover(notice, meTeaId);
|
||||
@@ -650,7 +791,7 @@ public class NoticeService implements INoticeService {
|
||||
return;
|
||||
}
|
||||
int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel();
|
||||
NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1));
|
||||
NoticeApprovalStep current = resolveCurrentStep(steps, level);
|
||||
if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
@@ -660,6 +801,9 @@ public class NoticeService implements INoticeService {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion);
|
||||
// 多步驳回同步写 notice_approval:前端「审批轨迹」= loadApprovalRecords 仅读该表,
|
||||
// 不补此行则驳回在轨迹里无痕,alreadyHandledError 也会误报「已离开待审批状态」
|
||||
saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, meAccount, meName(meAccount), opinion, null);
|
||||
current.setStatus(NoticeApprovalStep.STATUS_REJECTED);
|
||||
current.setHandleTime(LocalDateTime.now());
|
||||
approvalStepRepository.save(current);
|
||||
@@ -668,6 +812,34 @@ public class NoticeService implements INoticeService {
|
||||
log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤列表规范化:按 step_no 去重(每步仅保留 id 最大 = 最近一次提交写入的行,丢弃历史脏行),
|
||||
* 再按 step_no 升序返回。防御陈旧/重复步骤行使基于下标的 level/steps.size() 流转判断踩到同一步的脏行。
|
||||
*/
|
||||
private List<NoticeApprovalStep> normalizeSteps(List<NoticeApprovalStep> raw) {
|
||||
java.util.LinkedHashMap<Integer, NoticeApprovalStep> byStepNo = new java.util.LinkedHashMap<>();
|
||||
for (NoticeApprovalStep step : raw) {
|
||||
Integer no = step.getStepNo();
|
||||
NoticeApprovalStep prev = byStepNo.get(no);
|
||||
if (prev == null || (step.getId() != null && prev.getId() != null && step.getId() > prev.getId())) {
|
||||
byStepNo.put(no, step);
|
||||
}
|
||||
}
|
||||
List<NoticeApprovalStep> result = new ArrayList<>(byStepNo.values());
|
||||
result.sort(java.util.Comparator.comparingInt(s -> s.getStepNo() == null ? 0 : s.getStepNo()));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 当前审批步定位:优先取状态为「审批中」的步骤,规避陈旧/重复步骤行按下标错位;无则回退下标原逻辑 */
|
||||
private NoticeApprovalStep resolveCurrentStep(List<NoticeApprovalStep> steps, int level) {
|
||||
for (NoticeApprovalStep step : steps) {
|
||||
if (step.getStatus() != null && step.getStatus() == NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
return steps.get(Math.min(level, steps.size() - 1));
|
||||
}
|
||||
|
||||
/** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */
|
||||
private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) {
|
||||
boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount))
|
||||
@@ -783,11 +955,13 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照);
|
||||
* 旧 teaId 路径按学校教师名单校验。每步至少一人,整体至少一步。
|
||||
* 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照,遗留兼容);
|
||||
* 旧 teaId 路径按学校教师名单校验(v18:审核设置下线,不再附加审批人池白名单校验)。
|
||||
* 每步至少一人,整体至少一步。
|
||||
* 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。
|
||||
*/
|
||||
private List<NoticeApprovalStep> buildSteps(Notice entity, List<FlowStepItem> flowItems, List<Integer> legacyTeaIds) {
|
||||
private List<NoticeApprovalStep> buildSteps(Notice entity, List<FlowStepItem> flowItems,
|
||||
List<Integer> legacyTeaIds) {
|
||||
if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) {
|
||||
List<NoticeApprovalStep> existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId());
|
||||
// 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建
|
||||
@@ -823,21 +997,47 @@ public class NoticeService implements INoticeService {
|
||||
step.setApproverTeaIds(toJsonTeaIds(teaIds));
|
||||
}
|
||||
} else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) {
|
||||
// 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定
|
||||
// 教师ID路径(教师树选人):按学校教师名单绑定(v18 起不再附加审批人池校验)
|
||||
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds());
|
||||
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
|
||||
// v13-E2:补账号快照,主表当前步快照随首步带上后读侧可直接按登录名匹配
|
||||
bindAccountsByTeaIds(step, nameById.keySet());
|
||||
} else {
|
||||
// 请求级旧 approverTeaIds 兼容包装步(单步或签)
|
||||
|
||||
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), legacyTeaIds);
|
||||
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
|
||||
bindAccountsByTeaIds(step, nameById.keySet());
|
||||
}
|
||||
steps.add(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* v13-E2:教师ID路径补账号快照——逐个 teaId 经用户中心反查登录账号写入
|
||||
* {@code approverAccounts}(与读侧 {@code approverMatchPredicate} 的账号分支同源)。
|
||||
* 反查失败或无账号的个体跳过;全部落空时保持 null(读侧仍可走 teaId 匹配兜底)。
|
||||
*/
|
||||
private void bindAccountsByTeaIds(NoticeApprovalStep step, Collection<Integer> teaIds) {
|
||||
List<String> accounts = new ArrayList<>();
|
||||
for (Integer teaId : teaIds) {
|
||||
if (teaId == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
BoaUser user = YschBaseOpenApi.getUserById(String.valueOf(teaId));
|
||||
if (user != null && StringUtils.hasText(user.getAccount())) {
|
||||
accounts.add(user.getAccount());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[bindAccountsByTeaIds] 用户中心反查账号失败, teaId={}", teaId);
|
||||
}
|
||||
}
|
||||
step.setApproverAccounts(toJsonStringList(accounts));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本校启用审批人池绑定账号:账号必须存在于池(多租户隔离)且启用,
|
||||
* 保持选择顺序去重;姓名取池快照(池未维护姓名时回退账号展示)。
|
||||
@@ -890,6 +1090,7 @@ public class NoticeService implements INoticeService {
|
||||
row.setVote(step.getVote());
|
||||
row.setApproverAccount(account);
|
||||
row.setApproverName(name);
|
||||
row.setApproverTeaId(currentTeaId());
|
||||
row.setAction(action);
|
||||
row.setOpinion(opinion);
|
||||
row.setActionTime(LocalDateTime.now());
|
||||
@@ -927,6 +1128,7 @@ public class NoticeService implements INoticeService {
|
||||
* 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重);
|
||||
* 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时
|
||||
* 另经用户中心校验所属组。记忆回显仅是默认值,提交时仍全量校验,防止记忆中的教师离职后带病提交。
|
||||
* v18:审核设置功能下线,不再按审批人池(sys_approver)白名单校验。
|
||||
*/
|
||||
private LinkedHashMap<Integer, String> bindApprovers(Integer schId, List<Integer> teaIds) {
|
||||
if (teaIds == null || teaIds.isEmpty()) {
|
||||
@@ -998,6 +1200,26 @@ public class NoticeService implements INoticeService {
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
Specification<Notice> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = approvalStatePredicates(state, root, cb, meAccount, meTeaId);
|
||||
appendCommonPredicates(query, root, cb, predicates);
|
||||
appendSchIdPredicate(root, cb, predicates);
|
||||
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
Page<Notice> page = noticeRepository.findAll(spec, pageable);
|
||||
List<NoticeVo> content = page.getContent().stream()
|
||||
.map(n -> toVo(n, false))
|
||||
.collect(Collectors.toList());
|
||||
fillApprovalFields(content);
|
||||
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
|
||||
}
|
||||
|
||||
/**
|
||||
* v13-E3:审批中心四 tab 的状态谓词(approvalList 与 approvalTabCount 共用,保证数字口径一致)。
|
||||
* pending=待审批(当前步审批人快照含我)/ approved=走流程已发布 / rejected=已驳回 /
|
||||
* all|reviewed=已审合一。
|
||||
*/
|
||||
private List<Predicate> approvalStatePredicates(String state, Root<Notice> root, CriteriaBuilder cb,
|
||||
String meAccount, Integer meTeaId) {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
switch (state) {
|
||||
case "approved":
|
||||
@@ -1019,16 +1241,29 @@ public class NoticeService implements INoticeService {
|
||||
predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId));
|
||||
break;
|
||||
}
|
||||
appendCommonPredicates(query, root, cb, predicates);
|
||||
return predicates;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalTabCountVo approvalTabCount() {
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
ApprovalTabCountVo vo = new ApprovalTabCountVo();
|
||||
vo.setPending(countApprovalState("pending", meAccount, meTeaId));
|
||||
vo.setApproved(countApprovalState("approved", meAccount, meTeaId));
|
||||
vo.setRejected(countApprovalState("rejected", meAccount, meTeaId));
|
||||
vo.setAll(countApprovalState("all", meAccount, meTeaId));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 单 tab 计数:状态谓词 + 多租户过滤,不带 keyword 等列表查询条件(与列表明细各自成立) */
|
||||
private int countApprovalState(String state, String meAccount, Integer meTeaId) {
|
||||
Specification<Notice> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = approvalStatePredicates(state, root, cb, meAccount, meTeaId);
|
||||
appendSchIdPredicate(root, cb, predicates);
|
||||
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
Page<Notice> page = noticeRepository.findAll(spec, pageable);
|
||||
List<NoticeVo> content = page.getContent().stream()
|
||||
.map(n -> toVo(n, false))
|
||||
.collect(Collectors.toList());
|
||||
fillApprovalFields(content);
|
||||
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
|
||||
return (int) noticeRepository.count(spec);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1104,6 +1339,9 @@ public class NoticeService implements INoticeService {
|
||||
vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps));
|
||||
vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records));
|
||||
vo.setCanApprove(canApproveNow(vo.getStatus(), steps, records, meAccount, meTeaId));
|
||||
// v7:列表行直接带出当前步待签人姓名与最新轮末条处理意见(审批中心「当前审批人」「审批意见」列)
|
||||
vo.setPendingApproverNames(pendingNamesOf(vo.getStatus(), steps, records));
|
||||
vo.setLastOpinion(lastOpinionOf(vo.getStatus(), steps, records));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,6 +1435,83 @@ public class NoticeService implements INoticeService {
|
||||
return records.stream().mapToInt(r -> r.getRound() == null ? 1 : r.getRound()).max().orElse(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前步待签审批人姓名(v7,列表「当前审批人」列与详情 approvalFlow[].pending 同源)。
|
||||
* <p>账号路径按账号快照比对已处理人;教师ID路径无账号快照,按姓名快照比对(仅统计最新轮,
|
||||
* 驳回重提后旧轮记录不计入)。非待审批态返回 null。
|
||||
*/
|
||||
private List<String> pendingNamesOf(Integer status, List<NoticeApprovalStep> steps,
|
||||
List<NoticeApprovalRecord> records) {
|
||||
if (status == null || status != PENDING_CODE || steps.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int level = stepLevelOf(status, steps);
|
||||
if (level >= steps.size()) {
|
||||
return null;
|
||||
}
|
||||
NoticeApprovalStep current = steps.get(level);
|
||||
int round = currentRound(records);
|
||||
Set<String> actedAccounts = new HashSet<>();
|
||||
Set<String> actedNames = new HashSet<>();
|
||||
for (NoticeApprovalRecord r : records) {
|
||||
if (r.getRound() != null && r.getRound() == round
|
||||
&& current.getStepNo() != null && current.getStepNo().equals(r.getStepNo())) {
|
||||
if (r.getApproverAccount() != null) {
|
||||
actedAccounts.add(r.getApproverAccount());
|
||||
}
|
||||
if (r.getApproverName() != null) {
|
||||
actedNames.add(r.getApproverName());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> accounts = parseAccounts(current.getApproverAccounts());
|
||||
List<String> names = parseAccounts(current.getApproverNames());
|
||||
List<String> pending = new ArrayList<>();
|
||||
if (!accounts.isEmpty()) {
|
||||
for (int j = 0; j < accounts.size(); j++) {
|
||||
if (!actedAccounts.contains(accounts.get(j))) {
|
||||
pending.add(j < names.size() ? names.get(j) : accounts.get(j));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 教师ID路径:步骤仅姓名快照,按姓名比对已处理人
|
||||
for (String n : names) {
|
||||
if (!actedNames.contains(n)) {
|
||||
pending.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新轮末条处理意见(v7,列表「审批意见」列):驳回行=驳回意见、通过行=末步通过意见;
|
||||
* 待审批/免审/无步骤返回 null(前端显示「—」)。
|
||||
*/
|
||||
private String lastOpinionOf(Integer status, List<NoticeApprovalStep> steps,
|
||||
List<NoticeApprovalRecord> records) {
|
||||
if (status == null || steps.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (status != REJECTED_CODE && status != PUBLISHED_CODE) {
|
||||
return null;
|
||||
}
|
||||
int round = currentRound(records);
|
||||
NoticeApprovalRecord last = null;
|
||||
for (NoticeApprovalRecord r : records) {
|
||||
if (r.getRound() == null || r.getRound() != round || r.getAction() == null) {
|
||||
continue;
|
||||
}
|
||||
if (r.getAction() != NoticeApprovalRecord.ACTION_APPROVE && r.getAction() != NoticeApprovalRecord.ACTION_REJECT) {
|
||||
continue;
|
||||
}
|
||||
if (last == null || (r.getId() != null && (last.getId() == null || r.getId() > last.getId()))) {
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
return last == null ? null : last.getOpinion();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情多步审批流装配:每步含审批人账号/姓名、步骤状态(approved/pending/rejected/waiting)、
|
||||
* 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。
|
||||
@@ -1208,6 +1523,8 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
int level = stepLevelOf(status, steps);
|
||||
List<ApprovalFlowVo> flow = new ArrayList<>();
|
||||
// v15:出现姓名快照不可用的历史数据时,懒加载一次本校教师名单做批量补名
|
||||
Map<Integer, String> teaNameById = null;
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
NoticeApprovalStep step = steps.get(i);
|
||||
ApprovalFlowVo vo = new ApprovalFlowVo();
|
||||
@@ -1215,14 +1532,26 @@ public class NoticeService implements INoticeService {
|
||||
vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any");
|
||||
List<String> accounts = parseAccounts(step.getApproverAccounts());
|
||||
List<String> names = parseAccounts(step.getApproverNames());
|
||||
List<Integer> stepTeaIds = parseTeaIds(step.getApproverTeaIds());
|
||||
vo.setApprovers(accounts);
|
||||
vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds()));
|
||||
vo.setApproverTeaIds(stepTeaIds);
|
||||
// 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照
|
||||
if (!accounts.isEmpty()) {
|
||||
vo.setApproverNames(names.size() == accounts.size() ? names : accounts);
|
||||
} else {
|
||||
vo.setApproverNames(names);
|
||||
List<String> displayNames = new ArrayList<>();
|
||||
boolean alignedTeaId = stepTeaIds.size() == names.size();
|
||||
for (int j = 0; j < names.size(); j++) {
|
||||
String account = j < accounts.size() ? accounts.get(j) : null;
|
||||
if (nameMissing(names.get(j), account)) {
|
||||
if (teaNameById == null) {
|
||||
teaNameById = teaNameMap(currentSchId());
|
||||
}
|
||||
}
|
||||
displayNames.add(displayApproverName(names.get(j), account,
|
||||
alignedTeaId ? stepTeaIds.get(j) : null, teaNameById));
|
||||
}
|
||||
if (displayNames.isEmpty()) {
|
||||
displayNames = accounts;
|
||||
}
|
||||
vo.setApproverNames(displayNames);
|
||||
List<ApprovalFlowVo.Record> stepRecords = new ArrayList<>();
|
||||
for (NoticeApprovalRecord r : records) {
|
||||
if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) {
|
||||
@@ -1230,7 +1559,11 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
ApprovalFlowVo.Record item = new ApprovalFlowVo.Record();
|
||||
item.setAccount(r.getApproverAccount());
|
||||
item.setName(r.getApproverName());
|
||||
if (nameMissing(r.getApproverName(), r.getApproverAccount()) && teaNameById == null) {
|
||||
teaNameById = teaNameMap(currentSchId());
|
||||
}
|
||||
item.setName(displayApproverName(r.getApproverName(), r.getApproverAccount(),
|
||||
r.getApproverTeaId(), teaNameById));
|
||||
item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT
|
||||
? "rejected" : "approved");
|
||||
item.setTime(r.getActionTime());
|
||||
@@ -1249,14 +1582,10 @@ public class NoticeService implements INoticeService {
|
||||
vo.setStatus("waiting");
|
||||
}
|
||||
if ("pending".equals(vo.getStatus())) {
|
||||
Set<String> 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));
|
||||
}
|
||||
// v7:与列表「当前审批人」列同源;修复教师ID路径无账号快照待签恒空、旧轮记录计入已处理两缺陷
|
||||
List<String> pending = pendingNamesOf(status, steps, records);
|
||||
if (pending != null) {
|
||||
vo.getPending().addAll(pending);
|
||||
}
|
||||
}
|
||||
flow.add(vo);
|
||||
@@ -1343,6 +1672,7 @@ public class NoticeService implements INoticeService {
|
||||
row.setAction(action);
|
||||
row.setOperatorAccount(operatorAccount);
|
||||
row.setOperatorName(operatorName);
|
||||
row.setOperatorTeaId(currentTeaId());
|
||||
row.setOpinion(opinion);
|
||||
row.setAssigneeTeaIds(assigneeTeaIds);
|
||||
row.setActionTime(LocalDateTime.now());
|
||||
@@ -1624,17 +1954,46 @@ public class NoticeService implements INoticeService {
|
||||
content.forEach(vo -> vo.setApproverTeaIds(null));
|
||||
}
|
||||
|
||||
// 未读数与列表同口径(含范围可见性过滤),避免「数字与列表对不上」
|
||||
long unreadCount = noticeRepository.count(visibleSpec(account, true))
|
||||
- noticeReadRepository.countByReaderAccount(account);
|
||||
if (unreadCount < 0) {
|
||||
unreadCount = 0;
|
||||
}
|
||||
// 未读数与列表、tab 统计同一口径(v17:直接按 userSpec 的 readType=1 计数,
|
||||
// 不再用「可见总数 − 该账号全部阅读记录数」相减——那会把已删除/已撤回/跨校的已读记录算进减数)
|
||||
long unreadCount = countUnreadForCurrentUser();
|
||||
|
||||
return new NoticePageResult<>(content, (int) page.getTotalElements(),
|
||||
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户「可见且未读」的公告条数(v17 统一口径)。
|
||||
* <p>不带 keyword/type 等筛选(全局未读数,用于列表页顶部徽标与统计页);
|
||||
* 与 {@link #userList} 的差异仅在于筛选条件,谓词基座同为 {@link #userSpec}。
|
||||
*/
|
||||
private long countUnreadForCurrentUser() {
|
||||
String account = currentAccount();
|
||||
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
|
||||
Set<Integer> receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account));
|
||||
return noticeRepository.count(userSpec(new NoticeQuery(), readIds, receiverNoticeIds, UNREAD));
|
||||
}
|
||||
|
||||
/**
|
||||
* 已收到三 tab 统计(v17-B1):全部 / 未读 / 已读。
|
||||
* <p>口径 = {@code GET /api/notice/List} 在同一 keyword/type/deptId/时间范围过滤下
|
||||
* 分别取 readType=0/1/2 的总数;<b>readType 不作为入参</b>(它就是被统计的三个桶),
|
||||
* 因此恒满足 {@code all = unread + read}。
|
||||
*/
|
||||
@Override
|
||||
public ReadTabCountVo readTabCount(NoticeQuery query) {
|
||||
NoticeQuery filter = query == null ? new NoticeQuery() : query;
|
||||
String account = currentAccount();
|
||||
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
|
||||
Set<Integer> receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account));
|
||||
|
||||
ReadTabCountVo vo = new ReadTabCountVo();
|
||||
vo.setAll((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, ALL)));
|
||||
vo.setUnread((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, UNREAD)));
|
||||
vo.setRead((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, READ)));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户端可见规格:已发布 + 已到点(定时未到点不可见)+ 范围可见性。
|
||||
* 范围可见性(最小改动原则):无 push_scope 维持「全校广播」现状;
|
||||
@@ -1674,6 +2033,19 @@ public class NoticeService implements INoticeService {
|
||||
* 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。
|
||||
*/
|
||||
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds, Set<Integer> receiverNoticeIds) {
|
||||
Integer readType = query.getReadType() == null ? ALL : query.getReadType();
|
||||
return userSpec(query, readIds, receiverNoticeIds, readType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 {@link #userSpec(NoticeQuery, Set, Set)},但显式指定阅读桶(v17-B3①)。
|
||||
* <p>tab 统计与已收相邻查询需要在<b>同一套过滤条件</b>下分别取 0/1/2 三个桶,
|
||||
* 故把 readType 从 query 里提出来做参数,避免复制一份过滤逻辑造成口径漂移。
|
||||
*
|
||||
* @param readType {@link #ALL} 全部 / {@link #UNREAD} 未读 / {@link #READ} 已读
|
||||
*/
|
||||
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds, Set<Integer> receiverNoticeIds,
|
||||
int readType) {
|
||||
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE));
|
||||
@@ -1683,10 +2055,9 @@ public class NoticeService implements INoticeService {
|
||||
appendSchIdPredicate(root, cb, predicates);
|
||||
predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds));
|
||||
|
||||
Integer readType = query.getReadType() == null ? 0 : query.getReadType();
|
||||
if (readType == 1 && !readIds.isEmpty()) {
|
||||
if (readType == UNREAD && !readIds.isEmpty()) {
|
||||
predicates.add(cb.not(root.get("id").in(readIds)));
|
||||
} else if (readType == 2) {
|
||||
} else if (readType == READ) {
|
||||
if (readIds.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("id"), -1));
|
||||
} else {
|
||||
@@ -2058,8 +2429,17 @@ public class NoticeService implements INoticeService {
|
||||
|
||||
// ============================ 上一篇/下一篇(SQL 相邻查询) ============================
|
||||
|
||||
/**
|
||||
* 上一篇/下一篇通知ID。
|
||||
*
|
||||
* @param type "0"=已发序列(本人发布,遗留 E1 未改);其余=已收序列
|
||||
* @param query 已收序列的筛选条件(v17-B3④:keyword/type/deptId/时间范围,与 List 同源)。
|
||||
* <b>刻意忽略其中的 readType</b>——未读公告点开即被标读,若序列带 readType=1
|
||||
* 会把刚打开的这条挤出集合,导致上下篇同时置灰、用户卡在原地;
|
||||
* 因此未读/已读两个 tab 共用同一条「全部可见已收」序列。传 null 等价于无筛选。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getNextPrev(Integer serid, String type) {
|
||||
public Map<String, Object> getNextPrev(Integer serid, String type, NoticeQuery query) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
Notice current = noticeRepository.findById(serid).orElse(null);
|
||||
if (current == null) {
|
||||
@@ -2070,23 +2450,75 @@ public class NoticeService implements INoticeService {
|
||||
if ("0".equals(type)) {
|
||||
// 已发序列:按当前用户发布的通知,按 createTime 排序
|
||||
List<Notice> published = noticeRepository.findAll(
|
||||
(root, query, cb) -> cb.equal(root.get("publisherAccount"), currentAccount()),
|
||||
(root, q, cb) -> cb.equal(root.get("publisherAccount"), currentAccount()),
|
||||
Sort.by(Sort.Direction.DESC, "createTime"));
|
||||
List<Integer> ids = published.stream().map(Notice::getId).collect(Collectors.toList());
|
||||
int index = ids.indexOf(serid);
|
||||
data.put("prevId", index > 0 ? ids.get(index - 1) : 0);
|
||||
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0);
|
||||
} else {
|
||||
// 已收序列:按阅读记录
|
||||
List<Integer> ids = noticeReadRepository.findNoticeIdsByReaderAccount(currentAccount());
|
||||
Collections.reverse(ids);
|
||||
int index = ids.indexOf(serid);
|
||||
data.put("prevId", index > 0 ? ids.get(index - 1) : 0);
|
||||
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0);
|
||||
// 已收序列(v17):与 GET /api/notice/List 同一可见集合 + 同一筛选条件
|
||||
String account = currentAccount();
|
||||
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
|
||||
Set<Integer> receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account));
|
||||
Specification<Notice> base = userSpec(query == null ? new NoticeQuery() : query,
|
||||
readIds, receiverNoticeIds, ALL);
|
||||
data.put("prevId", adjacentReceivedId(base, current, true));
|
||||
data.put("nextId", adjacentReceivedId(base, current, false));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已收序列中与 current 相邻的一条(v17-B3④)。
|
||||
* <p>排序键与 List 的 {@code @SortDefault({"topmost","createTime"} DESC)} 对齐,
|
||||
* 并补 id 作稳定 tie-break(列表在同置顶+同创建时间下顺序本就不确定);
|
||||
* topmost 经 coalesce 归 0,使历史 NULL 行与 0 同档(两者在列表 ORDER BY 里的先后同样不确定,同源风险)。
|
||||
* <p>只取 1 行({@code PageRequest.of(0, 1, sort)}),不再像旧实现那样为找两个邻居把整表实体
|
||||
* (含 LONGTEXT 正文)读进内存。
|
||||
*
|
||||
* @param newer true=列表中位于当前<b>上一条</b>(排序键更大者里取最小);false=<b>下一条</b>(排序键更小者里取最大)
|
||||
* @return 相邻公告ID;当前条不在筛选集合内或该方向无邻居时返回 0
|
||||
*/
|
||||
private int adjacentReceivedId(Specification<Notice> base, Notice current, boolean newer) {
|
||||
if (current.getCreateTime() == null || current.getId() == null) {
|
||||
return 0;
|
||||
}
|
||||
// 当前条必须也落在同一筛选集合内,否则不给跳(如改范围后不再可见、或不属于当前关键字结果)
|
||||
long selfInSet = noticeRepository.count(
|
||||
base.and((root, cq, cb) -> cb.equal(root.<Integer>get("id"), current.getId())));
|
||||
if (selfInSet == 0) {
|
||||
return 0;
|
||||
}
|
||||
Specification<Notice> side = (root, cq, cb) -> noticeKeySidePredicate(root, cb, current, newer);
|
||||
Sort sort = newer
|
||||
? Sort.by(Sort.Order.asc("topmost"), Sort.Order.asc("createTime"), Sort.Order.asc("id"))
|
||||
: Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("createTime"), Sort.Order.desc("id"));
|
||||
Page<Notice> neighbors = noticeRepository.findAll(base.and(side), PageRequest.of(0, 1, sort));
|
||||
List<Notice> hits = neighbors.getContent();
|
||||
return hits.isEmpty() || hits.get(0).getId() == null ? 0 : hits.get(0).getId();
|
||||
}
|
||||
|
||||
/** 排序键 {@code (coalesce(topmost,0), createTime, id)} 与 current 的严格大小比较(字典序三档 OR)。 */
|
||||
private Predicate noticeKeySidePredicate(Root<Notice> root, CriteriaBuilder cb, Notice current, boolean greater) {
|
||||
Expression<Integer> top = cb.coalesce(root.<Integer>get("topmost"), 0);
|
||||
int curTop = current.getTopmost() == null ? 0 : current.getTopmost();
|
||||
Expression<LocalDateTime> time = root.<LocalDateTime>get("createTime");
|
||||
Expression<Integer> id = root.<Integer>get("id");
|
||||
LocalDateTime curTime = current.getCreateTime();
|
||||
Integer curId = current.getId();
|
||||
if (greater) {
|
||||
return cb.or(
|
||||
cb.greaterThan(top, curTop),
|
||||
cb.and(cb.equal(top, curTop), cb.greaterThan(time, curTime)),
|
||||
cb.and(cb.equal(top, curTop), cb.equal(time, curTime), cb.greaterThan(id, curId)));
|
||||
}
|
||||
return cb.or(
|
||||
cb.lessThan(top, curTop),
|
||||
cb.and(cb.equal(top, curTop), cb.lessThan(time, curTime)),
|
||||
cb.and(cb.equal(top, curTop), cb.equal(time, curTime), cb.lessThan(id, curId)));
|
||||
}
|
||||
|
||||
// ============================ 阅读明细 ============================
|
||||
|
||||
@Override
|
||||
@@ -2260,11 +2692,144 @@ public class NoticeService implements INoticeService {
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史审批身份回填(v15,见 INoticeService 注释)。
|
||||
* <p>数据量小(审批留痕行数有限),直接全表逐行处理;账号解析结果按进程缓存,避免重复外呼用户中心。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public Map<String, Integer> backfillApproverIdentity() {
|
||||
Map<String, Integer> stat = new LinkedHashMap<>();
|
||||
int trail = 0, record = 0, stepUpd = 0, noticeUpd = 0, unresolved = 0;
|
||||
Map<String, BoaUser> userByAccount = new HashMap<>();
|
||||
|
||||
// 1) 审批轨迹 notice_approval:姓名=登录名 → 真名;operator_tea_id 为空 → 补同步教师ID
|
||||
for (NoticeApproval row : approvalRepository.findAll()) {
|
||||
String acc = row.getOperatorAccount();
|
||||
if (!StringUtils.hasText(acc)) {
|
||||
continue;
|
||||
}
|
||||
BoaUser u = resolveUserOnce(userByAccount, acc);
|
||||
boolean changed = false;
|
||||
if (u != null) {
|
||||
if (nameMissing(row.getOperatorName(), acc) && StringUtils.hasText(u.getName())) {
|
||||
row.setOperatorName(u.getName());
|
||||
changed = true;
|
||||
}
|
||||
if (row.getOperatorTeaId() == null && u.getId() != null) {
|
||||
row.setOperatorTeaId(u.getId());
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
unresolved++;
|
||||
}
|
||||
if (changed) {
|
||||
approvalRepository.save(row);
|
||||
trail++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 每步审核记录 notice_approval_record:同 1) 口径
|
||||
for (NoticeApprovalRecord row : approvalRecordRepository.findAll()) {
|
||||
String acc = row.getApproverAccount();
|
||||
if (!StringUtils.hasText(acc)) {
|
||||
continue;
|
||||
}
|
||||
BoaUser u = resolveUserOnce(userByAccount, acc);
|
||||
boolean changed = false;
|
||||
if (u != null) {
|
||||
if (nameMissing(row.getApproverName(), acc) && StringUtils.hasText(u.getName())) {
|
||||
row.setApproverName(u.getName());
|
||||
changed = true;
|
||||
}
|
||||
if (row.getApproverTeaId() == null && u.getId() != null) {
|
||||
row.setApproverTeaId(u.getId());
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
unresolved++;
|
||||
}
|
||||
if (changed) {
|
||||
approvalRecordRepository.save(row);
|
||||
record++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 审批人姓名快照 notice_approval_step:按 teaId 用本校教师名单的权威姓名覆盖(教师名单缺失时保留原值)
|
||||
Map<Integer, Map<Integer, String>> teaNamesBySch = new HashMap<>();
|
||||
for (NoticeApprovalStep stepRow : approvalStepRepository.findAll()) {
|
||||
Notice owner = noticeRepository.findById(stepRow.getNoticeId()).orElse(null);
|
||||
if (owner == null || owner.getSchId() == null) {
|
||||
continue;
|
||||
}
|
||||
Map<Integer, String> namesById = teaNamesBySch.computeIfAbsent(owner.getSchId(), this::teaNameMap);
|
||||
List<Integer> teaIds = parseTeaIds(stepRow.getApproverTeaIds());
|
||||
List<String> names = parseAccounts(stepRow.getApproverNames());
|
||||
if (teaIds.isEmpty() || names.size() != teaIds.size()) {
|
||||
continue;
|
||||
}
|
||||
List<String> fixed = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (int i = 0; i < names.size(); i++) {
|
||||
String authoritative = namesById.get(teaIds.get(i));
|
||||
String nv = StringUtils.hasText(authoritative) ? authoritative : names.get(i);
|
||||
if (!nv.equals(names.get(i))) {
|
||||
changed = true;
|
||||
}
|
||||
fixed.add(nv);
|
||||
}
|
||||
if (changed) {
|
||||
try {
|
||||
stepRow.setApproverNames(objectMapper.writeValueAsString(fixed));
|
||||
approvalStepRepository.save(stepRow);
|
||||
stepUpd++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[backfillApproverIdentity] 步骤 {} 姓名快照序列化失败", stepRow.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 发布人姓名 notice.publisher_name:姓名=登录名 → 真名
|
||||
for (Notice n : noticeRepository.findAll()) {
|
||||
String acc = n.getPublisherAccount();
|
||||
if (!StringUtils.hasText(acc)) {
|
||||
continue;
|
||||
}
|
||||
BoaUser u = resolveUserOnce(userByAccount, acc);
|
||||
if (u != null && nameMissing(n.getPublisherName(), acc) && StringUtils.hasText(u.getName())) {
|
||||
n.setPublisherName(u.getName());
|
||||
noticeRepository.save(n);
|
||||
noticeUpd++;
|
||||
}
|
||||
}
|
||||
|
||||
stat.put("trail", trail);
|
||||
stat.put("record", record);
|
||||
stat.put("step", stepUpd);
|
||||
stat.put("notice", noticeUpd);
|
||||
stat.put("unresolved", unresolved);
|
||||
return stat;
|
||||
}
|
||||
|
||||
/** 回填用:按账号解析用户中心用户(BoaUser 含 id/name),失败缓存 null 避免同账号反复外呼 */
|
||||
private BoaUser resolveUserOnce(Map<String, BoaUser> cache, String account) {
|
||||
if (cache.containsKey(account)) {
|
||||
return cache.get(account);
|
||||
}
|
||||
BoaUser u = null;
|
||||
try {
|
||||
u = YschBaseOpenApi.getUserInfo(account);
|
||||
} catch (Exception e) {
|
||||
log.warn("[backfillApproverIdentity] 用户中心解析失败, account={}", account);
|
||||
}
|
||||
cache.put(account, u);
|
||||
return u;
|
||||
}
|
||||
|
||||
// ============================ 统计 ============================
|
||||
|
||||
@Override
|
||||
public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) {
|
||||
String account = currentAccount();
|
||||
Integer schId = currentSchId();
|
||||
LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start;
|
||||
LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end;
|
||||
@@ -2275,11 +2840,8 @@ public class NoticeService implements INoticeService {
|
||||
long publishedCount = schId != null ? noticeRepository.countBySchIdAndStatus(schId, PUBLISHED_CODE)
|
||||
: noticeRepository.countByStatus(PUBLISHED_CODE);
|
||||
long totalReadRecords = noticeReadRepository.count();
|
||||
long unreadForCurrentUser = noticeRepository.count(visibleSpec(account, true))
|
||||
- noticeReadRepository.countByReaderAccount(account);
|
||||
if (unreadForCurrentUser < 0) {
|
||||
unreadForCurrentUser = 0;
|
||||
}
|
||||
// v17:与「已收到」列表/tab 统计同一口径(原先也是「可见总数 − 全部已读记录」相减)
|
||||
long unreadForCurrentUser = countUnreadForCurrentUser();
|
||||
|
||||
List<LocalDateTime> createTimes = schId != null
|
||||
? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED_CODE, s, e)
|
||||
@@ -2302,7 +2864,8 @@ public class NoticeService implements INoticeService {
|
||||
: noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED_CODE, s, e);
|
||||
Map<String, Long> pubMap = new LinkedHashMap<>();
|
||||
for (String p : publishers) {
|
||||
pubMap.merge(p == null ? "未知" : p, 1L, Long::sum);
|
||||
String publisher = displayPublisherName(p, p);
|
||||
pubMap.merge(StringUtils.hasText(publisher) ? publisher : "未知", 1L, Long::sum);
|
||||
}
|
||||
List<NoticeStatisticsVo.PublisherStat> byPublisher = pubMap.entrySet().stream()
|
||||
.map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue()))
|
||||
@@ -2358,7 +2921,7 @@ public class NoticeService implements INoticeService {
|
||||
// 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定)
|
||||
data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds()));
|
||||
// 多步审批流(步骤表 + 审核记录表)
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
List<NoticeApprovalStep> steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid));
|
||||
List<NoticeApprovalRecord> flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid);
|
||||
data.setApprovalEnabled(steps.isEmpty() ? 0 : 1);
|
||||
data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps));
|
||||
@@ -2389,20 +2952,34 @@ public class NoticeService implements INoticeService {
|
||||
|
||||
/** 审批轨迹装配:全部行 + 提交行 assignees 快照(前端按提交行切分轮次) */
|
||||
private List<ApprovalRecordVo> loadApprovalRecords(Integer noticeId) {
|
||||
return approvalRepository.findByNoticeIdOrderByIdAsc(noticeId).stream()
|
||||
.map(record -> {
|
||||
List<NoticeApproval> rows = approvalRepository.findByNoticeIdOrderByIdAsc(noticeId);
|
||||
List<ApprovalRecordVo> list = new ArrayList<>(rows.size());
|
||||
// v15:历史行 operator_name 落成登录名(displayName==loginName 时代写入),出现脏数据时懒加载一次教师名单补名
|
||||
Map<Integer, String> teaNameById = Collections.emptyMap();
|
||||
boolean teaNameLoaded = false;
|
||||
for (NoticeApproval record : rows) {
|
||||
ApprovalRecordVo vo = new ApprovalRecordVo();
|
||||
vo.setAction(record.getAction());
|
||||
ApprovalActionEnum action = ApprovalActionEnum.of(record.getAction());
|
||||
vo.setActionDesc(action != null ? action.getDesc() : String.valueOf(record.getAction()));
|
||||
vo.setOperatorAccount(record.getOperatorAccount());
|
||||
vo.setOperatorName(record.getOperatorName());
|
||||
if (!teaNameLoaded && nameMissing(record.getOperatorName(), record.getOperatorAccount())) {
|
||||
teaNameById = teaNameMap(currentSchId());
|
||||
teaNameLoaded = true;
|
||||
}
|
||||
vo.setOperatorName(displayApproverName(record.getOperatorName(), record.getOperatorAccount(),
|
||||
record.getOperatorTeaId(), teaNameById));
|
||||
vo.setOpinion(record.getOpinion());
|
||||
vo.setAssigneeTeaIds(parseTeaIds(record.getAssigneeTeaIds()));
|
||||
vo.setActionTime(record.getActionTime());
|
||||
return vo;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
list.add(vo);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 姓名快照是否不可用(v15):为空或等于登录账号(历史上 displayName 恒等于 loginName) */
|
||||
private static boolean nameMissing(String snapshot, String account) {
|
||||
return !StringUtils.hasText(snapshot) || snapshot.equals(account);
|
||||
}
|
||||
|
||||
/** 学员端请求判定:平台路径 mobiapi 段(/{platform}/notice/...) */
|
||||
@@ -2475,6 +3052,8 @@ public class NoticeService implements INoticeService {
|
||||
if (!StringUtils.hasText(vo.getDeptName())) {
|
||||
vo.setDeptName(dictService.resolveDeptName(vo.getDeptId()));
|
||||
}
|
||||
// v16:发布人快照可能落成账号或教师ID,展示前统一解析为真实姓名(列表/详情共用 toVo)
|
||||
vo.setPublisherName(displayPublisherName(vo.getPublisherName(), vo.getPublisherAccount()));
|
||||
vo.setRead(read);
|
||||
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
|
||||
return vo;
|
||||
|
||||
@@ -62,8 +62,8 @@ yh:
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
app-key: 100006
|
||||
app-secret: e053f7d98518407ab0e69f0500820d17
|
||||
open:
|
||||
enabled: true
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
.seg { display: inline-flex; background: #e2e8f0; border-radius: 8px; padding: 3px; }
|
||||
.seg button { border: none; background: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text2); }
|
||||
.seg button.on { background: #fff; color: var(--primary); font-weight: 600; box-shadow: var(--shadow); }
|
||||
/* tab 条数(v17 ReadTabCount):空内容时不占位 */
|
||||
.seg .seg-cnt { margin-left: 5px; font-size: 12px; color: var(--text2); font-weight: 400; }
|
||||
.input { border: 1px solid var(--border); border-radius: 8px; padding: 7px 12px; font-size: 13px; background: #fff; color: var(--text); outline: none; }
|
||||
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
select.input { cursor: pointer; }
|
||||
@@ -270,9 +272,9 @@
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="seg" id="readSeg">
|
||||
<button class="on" data-v="0" onclick="inboxFilter('readType',0,this)">全部</button>
|
||||
<button data-v="1" onclick="inboxFilter('readType',1,this)">未读</button>
|
||||
<button data-v="2" onclick="inboxFilter('readType',2,this)">已读</button>
|
||||
<button class="on" data-v="0" onclick="inboxFilter('readType',0,this)">全部<span class="seg-cnt" id="cntAll"></span></button>
|
||||
<button data-v="1" onclick="inboxFilter('readType',1,this)">未读<span class="seg-cnt" id="cntUnread"></span></button>
|
||||
<button data-v="2" onclick="inboxFilter('readType',2,this)">已读<span class="seg-cnt" id="cntRead"></span></button>
|
||||
</div>
|
||||
<select class="input" id="inboxType" onchange="reloadInbox()">
|
||||
<option value="">全部类型</option>
|
||||
@@ -727,6 +729,17 @@ function renderUnreadBadge() {
|
||||
const n = inboxState.unreadCount;
|
||||
$('unreadBadge').textContent = n > 0 ? (n > 99 ? '99+' : n) : '';
|
||||
}
|
||||
/* 三 tab 条数(v17 ReadTabCount):与列表同一 keyword/type 过滤,readType 不参与 */
|
||||
async function loadInboxCounts() {
|
||||
const put = (id, n) => { const el = $(id); if (el) el.textContent = n > 0 ? '(' + n + ')' : ''; };
|
||||
try {
|
||||
const c = await api('/notice/ReadTabCount', {
|
||||
params: { type: $('inboxType').value, keyword: $('inboxKw').value.trim() }
|
||||
});
|
||||
if (!c) return;
|
||||
put('cntAll', c.all); put('cntUnread', c.unread); put('cntRead', c.read);
|
||||
} catch (e) { /* 统计失败不影响列表 */ }
|
||||
}
|
||||
async function loadInbox() {
|
||||
const box = $('inboxList');
|
||||
box.innerHTML = '<div class="empty">加载中…</div>';
|
||||
@@ -743,6 +756,7 @@ async function loadInbox() {
|
||||
const list = (r && r.content) || [];
|
||||
inboxState.unreadCount = (r && r.unreadCount) || inboxState.unreadCount;
|
||||
renderUnreadBadge();
|
||||
loadInboxCounts();
|
||||
$('inboxSub').textContent = r ? ('共 ' + r.total + ' 条 · 未读 ' + r.unreadCount + ' 条') : '';
|
||||
|
||||
if (!list.length) {
|
||||
@@ -819,9 +833,14 @@ async function openDetail(serid, from) {
|
||||
$('dFiles').style.display = 'none';
|
||||
}
|
||||
|
||||
// 上一篇/下一篇
|
||||
// 上一篇/下一篇(v17:已收序列沿用收件箱当前筛选;重要程度走 noticeType,`type` 已被序列类型占用)
|
||||
let np = {};
|
||||
try { np = await api('/notice/GetNextPrev', { params: { serid, type: 1 } }) || {}; } catch (e) { /* 忽略 */ }
|
||||
const navParams = { serid, type: 1 };
|
||||
if (from === 'inbox') {
|
||||
navParams.keyword = $('inboxKw').value.trim();
|
||||
if ($('inboxType').value) navParams.noticeType = $('inboxType').value;
|
||||
}
|
||||
try { np = await api('/notice/GetNextPrev', { params: navParams }) || {}; } catch (e) { /* 忽略 */ }
|
||||
setupNavBtn('dPrev', np.prev || np.preId || np.prevId, '上一篇');
|
||||
setupNavBtn('dNext', np.next || np.nextId, '下一篇');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user