diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/WorkReportConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/WorkReportConstants.java index da1299d..b40f10d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/WorkReportConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/WorkReportConstants.java @@ -31,6 +31,18 @@ public final class WorkReportConstants { public static final String ACTION_APPROVE = "approve"; public static final String ACTION_REJECT = "reject"; + /** + * 未提交提醒附加进消息模板参数的报告类型键。 + * 仅供“未读不重发”判定使用,不参与模板正文渲染。 + */ + public static final String PARAM_REMIND_REPORT_TYPE = "reportType"; + + /** + * 未提交提醒附加进消息模板参数的报告标识键。 + * 统一按字符串存,避免 JSON 反序列化后的数字类型差异。 + */ + public static final String PARAM_REMIND_REPORT_ID = "reportId"; + public static final String PERMISSION_QUERY = "project:work-report:query"; public static final String PERMISSION_CREATE = "project:work-report:create"; public static final String PERMISSION_UPDATE = "project:work-report:update"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java index 61e7283..f7fa1d6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyWorkbenchController.java @@ -39,7 +39,7 @@ public class MyWorkbenchController { } @GetMapping("/worklog-week") - @Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)") + @Operation(summary = "我的工时周聚合(返回周一~周日;按周填报只均摊到工作日,周末单天工时保留在周末)") public CommonResult getMyWorklogWeek( @RequestParam("weekStart") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java index 203199f..15411a2 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/MyWorklogWeekRespVO.java @@ -15,7 +15,7 @@ public class MyWorklogWeekRespVO { @Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") private LocalDate weekStart; - @Schema(description = "周一~周五逐日工时(固定 5 元素,均摊推算值,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "周一~周日逐日工时(固定 7 元素,按填报日期段推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED) private List dailyHours; @Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED) private List distribution; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java index 051dba2..83464a2 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/workbench/TeamWorklogWeekRespVO.java @@ -5,28 +5,37 @@ import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; -@Schema(description = "管理后台 - 工作台「团队工时周聚合」Response VO") +@Schema(description = "Admin - workbench team worklog week response") @Data public class TeamWorklogWeekRespVO { - @Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") + @Schema(description = "Week monday", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08") private LocalDate weekStart; - @Schema(description = "团队成员工时列表;members[0] 恒为当前用户;该周无填报的成员 items 为空数组", requiredMode = Schema.RequiredMode.REQUIRED) + + @Schema(description = "Team members", requiredMode = Schema.RequiredMode.REQUIRED) private List members; - @Schema(description = "单个成员的周工时") + @Schema(description = "Single member week worklog") @Data public static class MemberVO { - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + + @Schema(description = "User id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") @JsonSerialize(using = ToStringSerializer.class) private Long userId; - @Schema(description = "用户昵称", example = "张三") + + @Schema(description = "User nickname", example = "zhangsan") private String userNickname; - @Schema(description = "本周工时按归属分布", requiredMode = Schema.RequiredMode.REQUIRED) + + @Schema(description = "Weekly worklog distribution", requiredMode = Schema.RequiredMode.REQUIRED) private List items; + + @Schema(description = "Approved overtime hours in selected week", + requiredMode = Schema.RequiredMode.REQUIRED, example = "9.00") + private BigDecimal overtimeHours; } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/overtime/OvertimeApplicationMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/overtime/OvertimeApplicationMapper.java index 015b5a3..211834a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/overtime/OvertimeApplicationMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/overtime/OvertimeApplicationMapper.java @@ -11,6 +11,7 @@ import org.springframework.util.StringUtils; import java.util.Collection; import java.util.List; +import java.time.LocalDate; @Mapper public interface OvertimeApplicationMapper extends BaseMapperX { @@ -53,6 +54,20 @@ public interface OvertimeApplicationMapper extends BaseMapperX selectApprovedListByApplicantIdsAndDateRange(Collection applicantIds, + LocalDate startDate, + LocalDate endDate, + String approvedStatusCode) { + if (applicantIds == null || applicantIds.isEmpty() || startDate == null || endDate == null + || !StringUtils.hasText(approvedStatusCode)) { + return List.of(); + } + return selectList(new LambdaQueryWrapperX() + .in(OvertimeApplicationDO::getApplicantId, applicantIds) + .eq(OvertimeApplicationDO::getStatusCode, approvedStatusCode) + .between(OvertimeApplicationDO::getOvertimeDate, startDate, endDate)); + } + default int updateByIdAndStatus(OvertimeApplicationDO update, Long id, String fromStatus) { return update(update, new LambdaQueryWrapperX() .eq(OvertimeApplicationDO::getId, id) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java index 1958ad0..a091f60 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogServiceImpl.java @@ -1,18 +1,22 @@ package com.njcn.rdms.module.project.service.project; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.OvertimeApplicationConstants; import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.MyWorklogWeekRespVO; import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamWorklogWeekRespVO; +import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO; import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO; +import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationMapper; import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; @@ -31,6 +35,8 @@ import java.util.stream.Collectors; @Service public class MyWorklogServiceImpl implements MyWorklogService { + private static final BigDecimal OVERTIME_DAY_HOURS = new BigDecimal("6"); + @Resource private TaskWorklogMapper taskWorklogMapper; @Resource @@ -41,29 +47,29 @@ public class MyWorklogServiceImpl implements MyWorklogService { private ProjectMapper projectMapper; @Resource private MyTeamService myTeamService; + @Resource + private OvertimeApplicationMapper overtimeApplicationMapper; @Override public MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart) { Long me = SecurityFrameworkUtils.getLoginUserId(); LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); - // 查询区间含周末:周六/周日的段要兜底归周五,必须查回来 List worklogs = taskWorklogMapper .selectListByUserIdAndPeriod(me, monday, monday.plusDays(6)); - // 逐日累计(scale=4)与每任务份额累计 Map daily = new LinkedHashMap<>(); Map hoursByTask = new HashMap<>(); - for (TaskWorklogDO w : worklogs) { + for (TaskWorklogDO worklog : worklogs) { Map shares = MyWorklogWeekSupport - .apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday); - for (Map.Entry e : shares.entrySet()) { - daily.merge(e.getKey(), e.getValue(), BigDecimal::add); - hoursByTask.merge(w.getTaskId(), e.getValue(), BigDecimal::add); + .apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday); + for (Map.Entry entry : shares.entrySet()) { + daily.merge(entry.getKey(), entry.getValue(), BigDecimal::add); + hoursByTask.merge(worklog.getTaskId(), entry.getValue(), BigDecimal::add); } } MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO(); resp.setWeekStart(monday); - List dailyHours = new ArrayList<>(5); - for (int i = 0; i < 5; i++) { + List dailyHours = new ArrayList<>(7); + for (int i = 0; i < 7; i++) { dailyHours.add(daily.getOrDefault(monday.plusDays(i), BigDecimal.ZERO) .setScale(2, RoundingMode.HALF_UP)); } @@ -75,40 +81,82 @@ public class MyWorklogServiceImpl implements MyWorklogService { @Override public TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart) { LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart); + LocalDate sunday = monday.plusDays(6); List members = myTeamService.resolveTeamMembers(); List userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList()); - List worklogs = taskWorklogMapper - .selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6)); - // 每成员每任务的本周份额合计 + List worklogs = taskWorklogMapper.selectListByUserIdsAndPeriod(userIds, monday, sunday); + Map> hoursByUserAndTask = new LinkedHashMap<>(); - for (TaskWorklogDO w : worklogs) { + for (TaskWorklogDO worklog : worklogs) { BigDecimal weekTotal = MyWorklogWeekSupport - .apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday) + .apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday) .values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); if (weekTotal.signum() > 0) { - hoursByUserAndTask.computeIfAbsent(w.getUserId(), k -> new HashMap<>()) - .merge(w.getTaskId(), weekTotal, BigDecimal::add); + hoursByUserAndTask.computeIfAbsent(worklog.getUserId(), key -> new HashMap<>()) + .merge(worklog.getTaskId(), weekTotal, BigDecimal::add); } } + + Map overtimeHoursByUser = buildApprovedOvertimeHoursByUser(userIds, monday, sunday); TeamWorklogWeekRespVO resp = new TeamWorklogWeekRespVO(); resp.setWeekStart(monday); - resp.setMembers(members.stream().map(m -> { + resp.setMembers(members.stream().map(member -> { TeamWorklogWeekRespVO.MemberVO vo = new TeamWorklogWeekRespVO.MemberVO(); - vo.setUserId(m.userId()); - vo.setUserNickname(m.nickname()); + vo.setUserId(member.userId()); + vo.setUserNickname(member.nickname()); vo.setItems(buildDistribution( - hoursByUserAndTask.getOrDefault(m.userId(), Collections.emptyMap()))); + hoursByUserAndTask.getOrDefault(member.userId(), Collections.emptyMap()))); + vo.setOvertimeHours(overtimeHoursByUser.getOrDefault(member.userId(), BigDecimal.ZERO) + .setScale(2, RoundingMode.HALF_UP)); return vo; }).collect(Collectors.toList())); return resp; } - /** - * 把 taskId→本周工时 聚成归属分布:工时表的 task_id 既可能指向任务也可能指向个人事项 - * (个人事项工时复用 rdms_task_worklog),先按任务表归 project,再按个人事项表归 personal, - * 都对不上归 other。排序:project 行按 hours 降序,personal/other 殿后。 - * 被软删的任务/事项的残留工时归 other(两表主键同为雪花 id 全局不撞,不存在误归类)。 - */ + private Map buildApprovedOvertimeHoursByUser(List userIds, + LocalDate startDate, + LocalDate endDate) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + List applications = overtimeApplicationMapper.selectApprovedListByApplicantIdsAndDateRange( + userIds, startDate, endDate, OvertimeApplicationConstants.STATUS_APPROVED); + if (applications.isEmpty()) { + return Collections.emptyMap(); + } + Map overtimeHoursByUser = new HashMap<>(); + for (OvertimeApplicationDO application : applications) { + if (application == null || application.getApplicantId() == null) { + continue; + } + BigDecimal overtimeHours = parseOvertimeHours(application.getOvertimeDuration()); + if (overtimeHours.signum() <= 0) { + continue; + } + overtimeHoursByUser.merge(application.getApplicantId(), overtimeHours, BigDecimal::add); + } + return overtimeHoursByUser; + } + + private BigDecimal parseOvertimeHours(String overtimeDuration) { + if (!StringUtils.hasText(overtimeDuration)) { + return BigDecimal.ZERO; + } + String normalized = overtimeDuration.trim() + .replace("\u5929", "") + .replace("\u5c0f\u65f6", "") + .replace("h", "") + .trim(); + if (!StringUtils.hasText(normalized)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(normalized).multiply(OVERTIME_DAY_HOURS); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + private List buildDistribution(Map hoursByTask) { if (hoursByTask.isEmpty()) { return Collections.emptyList(); @@ -117,37 +165,41 @@ public class MyWorklogServiceImpl implements MyWorklogService { Map taskMap = projectTaskMapper.selectBatchIds(taskIds).stream() .collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a)); Set personalIds = taskIds.stream() - .filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet()); + .filter(id -> !taskMap.containsKey(id)) + .collect(Collectors.toSet()); Set personalHit = personalIds.isEmpty() ? Collections.emptySet() : personalItemMapper.selectBatchIds(personalIds).stream() - .map(PersonalItemDO::getId).collect(Collectors.toSet()); - // 聚合:projectId→hours / personal / other + .map(PersonalItemDO::getId) + .collect(Collectors.toSet()); + Map hoursByProject = new LinkedHashMap<>(); BigDecimal personalHours = BigDecimal.ZERO; BigDecimal otherHours = BigDecimal.ZERO; - for (Map.Entry e : hoursByTask.entrySet()) { - ProjectTaskDO task = taskMap.get(e.getKey()); + for (Map.Entry entry : hoursByTask.entrySet()) { + ProjectTaskDO task = taskMap.get(entry.getKey()); if (task != null) { - hoursByProject.merge(task.getProjectId(), e.getValue(), BigDecimal::add); - } else if (personalHit.contains(e.getKey())) { - personalHours = personalHours.add(e.getValue()); + hoursByProject.merge(task.getProjectId(), entry.getValue(), BigDecimal::add); + } else if (personalHit.contains(entry.getKey())) { + personalHours = personalHours.add(entry.getValue()); } else { - otherHours = otherHours.add(e.getValue()); + otherHours = otherHours.add(entry.getValue()); } } + Map projectMap = hoursByProject.isEmpty() ? Collections.emptyMap() : projectMapper.selectBatchIds(hoursByProject.keySet()).stream() - .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); + .collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a)); + List items = new ArrayList<>(); hoursByProject.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) - .forEach(e -> { + .forEach(entry -> { MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO(); - item.setProjectId(e.getKey()); - ProjectDO project = projectMap.get(e.getKey()); + item.setProjectId(entry.getKey()); + ProjectDO project = projectMap.get(entry.getKey()); item.setProjectName(project == null ? null : project.getProjectName()); item.setKind("project"); - item.setHours(e.getValue().setScale(2, RoundingMode.HALF_UP)); + item.setHours(entry.getValue().setScale(2, RoundingMode.HALF_UP)); items.add(item); }); if (personalHours.signum() > 0) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java index ce9fd78..207dfa6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyWorklogWeekSupport.java @@ -10,8 +10,8 @@ import java.util.Map; /** * 工时周聚合的纯函数支撑:周一归一 + 按段均摊。 - * 口径(2026-06-12 与用户确认):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数; - * 纯周末段全额兜底归段结束日前最近的工作日;份额 scale=4,最终展示由调用方聚合后 setScale(2)。 + * 口径(2026-07-14 更新):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数; + * 周六/周日不参与工作日段均摊;周末单天工时保留在当天展示;份额 scale=4,最终展示由调用方聚合后 setScale(2)。 */ final class MyWorklogWeekSupport { @@ -25,7 +25,7 @@ final class MyWorklogWeekSupport { /** * 把一笔按段填报的工时摊到所选周的工作日。 - * 返回 [weekMonday, weekMonday+4] 内各工作日的份额(scale=4);无份额的日不出现在 map。 + * 返回 [weekMonday, weekMonday+6] 内各自然日的份额(scale=4);无份额的日不出现在 map。 * *

前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。 * 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。 @@ -36,26 +36,30 @@ final class MyWorklogWeekSupport { if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) { return result; } - LocalDate weekFriday = weekMonday.plusDays(4); + LocalDate weekSunday = weekMonday.plusDays(6); List workdays = segStart.datesUntil(segEnd.plusDays(1)) .filter(d -> d.getDayOfWeek().getValue() <= 5) .toList(); - if (workdays.isEmpty()) { - // 纯周末段:全额归段结束日前最近的工作日(周六/周日 → 周五) - LocalDate fallback = segEnd; - while (fallback.getDayOfWeek().getValue() > 5) { - fallback = fallback.minusDays(1); - } - if (!fallback.isBefore(weekMonday) && !fallback.isAfter(weekFriday)) { - result.put(fallback, hours.setScale(4, RoundingMode.HALF_UP)); + if (!workdays.isEmpty()) { + BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP); + for (LocalDate d : workdays) { + if (!d.isBefore(weekMonday) && !d.isAfter(weekSunday)) { + result.put(d, share); + } } return result; } - BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP); - for (LocalDate d : workdays) { - if (!d.isBefore(weekMonday) && !d.isAfter(weekFriday)) { - result.put(d, share); - } + + List weekendDays = segStart.datesUntil(segEnd.plusDays(1)) + .filter(d -> d.getDayOfWeek().getValue() > 5) + .filter(d -> !d.isBefore(weekMonday) && !d.isAfter(weekSunday)) + .toList(); + if (weekendDays.isEmpty()) { + return result; + } + BigDecimal share = hours.divide(BigDecimal.valueOf(weekendDays.size()), 4, RoundingMode.HALF_UP); + for (LocalDate d : weekendDays) { + result.put(d, share); } return result; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/remind/WorkReportPendingSubmitRemindService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/remind/WorkReportPendingSubmitRemindService.java index ef5f16a..21741d5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/remind/WorkReportPendingSubmitRemindService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/remind/WorkReportPendingSubmitRemindService.java @@ -9,8 +9,13 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMa import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; +import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO; +import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,11 +24,16 @@ import org.springframework.util.StringUtils; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.IsoFields; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; @Service +@Slf4j public class WorkReportPendingSubmitRemindService { private static final List REMIND_STATUS_CODES = List.of( @@ -38,6 +48,8 @@ public class WorkReportPendingSubmitRemindService { private ProjectReportMapper projectReportMapper; @Resource private ApplicationEventPublisher applicationEventPublisher; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; @Transactional(rollbackFor = Exception.class) public void remindPendingSubmitReports() { @@ -49,14 +61,21 @@ public class WorkReportPendingSubmitRemindService { private void remindWeekly(LocalDateTime cutoffTime) { List reports = weeklyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); + Set unreadKeys = loadUnreadReminderKeys( + reports.stream().map(WeeklyReportDO::getReporterId).toList(), + List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND)); for (WeeklyReportDO report : reports) { if (report == null || report.getReporterId() == null) { continue; } + if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId(), unreadKeys)) { + continue; + } Map params = new HashMap<>(); params.put("reportTypeName", "周报"); params.put("periodKey", formatWeeklyPeriod(report.getPeriodKey(), report.getPeriodStartDate(), report.getPeriodEndDate())); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); + appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId()); applicationEventPublisher.publishEvent(NotifySendEvent.of( List.of(report.getReporterId()), NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, @@ -67,14 +86,21 @@ public class WorkReportPendingSubmitRemindService { private void remindMonthly(LocalDateTime cutoffTime) { List reports = monthlyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); + Set unreadKeys = loadUnreadReminderKeys( + reports.stream().map(MonthlyReportDO::getReporterId).toList(), + List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND)); for (MonthlyReportDO report : reports) { if (report == null || report.getReporterId() == null) { continue; } + if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId(), unreadKeys)) { + continue; + } Map params = new HashMap<>(); params.put("reportTypeName", "月报"); params.put("periodKey", formatMonthlyPeriod(report.getPeriodKey(), report.getPeriodStartDate())); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); + appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId()); applicationEventPublisher.publishEvent(NotifySendEvent.of( List.of(report.getReporterId()), NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, @@ -85,14 +111,21 @@ public class WorkReportPendingSubmitRemindService { private void remindProject(LocalDateTime cutoffTime) { List reports = projectReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES); + Set unreadKeys = loadUnreadReminderKeys( + reports.stream().map(ProjectReportDO::getProjectOwnerId).toList(), + List.of(NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND)); for (ProjectReportDO report : reports) { if (report == null || report.getProjectOwnerId() == null) { continue; } + if (hasUnreadReminder(report.getProjectOwnerId(), WorkReportConstants.REPORT_TYPE_PROJECT, report.getId(), unreadKeys)) { + continue; + } Map params = new HashMap<>(); params.put("projectName", defaultText(report.getProjectName(), "")); params.put("periodLabel", defaultText(report.getPeriodLabel(), report.getPeriodKey())); params.put("managerName", defaultText(report.getSupervisorName(), "系统通知")); + appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_PROJECT, report.getId()); applicationEventPublisher.publishEvent(NotifySendEvent.of( List.of(report.getProjectOwnerId()), NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND, @@ -119,6 +152,58 @@ public class WorkReportPendingSubmitRemindService { return startDate.getYear() + "年" + startDate.getMonthValue() + "月"; } + private Set loadUnreadReminderKeys(Collection userIds, List templateCodes) { + if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) { + return Set.of(); + } + List filteredUserIds = userIds.stream().filter(java.util.Objects::nonNull).distinct().toList(); + if (filteredUserIds.isEmpty()) { + return Set.of(); + } + try { + CommonResult> result = notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes( + new NotifyUnreadMessageListReqDTO() + .setUserIds(new ArrayList<>(filteredUserIds)) + .setTemplateCodes(templateCodes)); + List messages = result == null ? null : result.getCheckedData(); + if (messages == null || messages.isEmpty()) { + return Set.of(); + } + Set keys = new HashSet<>(); + for (NotifyUnreadMessageRespDTO message : messages) { + if (message == null || message.getUserId() == null || message.getTemplateParams() == null) { + continue; + } + Object reportType = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_TYPE); + Object reportId = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_ID); + if (reportType == null || reportId == null) { + continue; + } + keys.add(buildUnreadReminderKey(message.getUserId(), String.valueOf(reportType), String.valueOf(reportId))); + } + return keys; + } catch (Exception ex) { + log.warn("[loadUnreadReminderKeys][未读消息查询失败,本轮按无未读处理]", ex); + return Set.of(); + } + } + + private boolean hasUnreadReminder(Long userId, String reportType, Long reportId, Set unreadKeys) { + if (userId == null || reportId == null || unreadKeys == null || unreadKeys.isEmpty()) { + return false; + } + return unreadKeys.contains(buildUnreadReminderKey(userId, reportType, String.valueOf(reportId))); + } + + private String buildUnreadReminderKey(Long userId, String reportType, String reportId) { + return userId + "#" + reportType + "#" + reportId; + } + + private void appendRemindIdentity(Map params, String reportType, Long reportId) { + params.put(WorkReportConstants.PARAM_REMIND_REPORT_TYPE, reportType); + params.put(WorkReportConstants.PARAM_REMIND_REPORT_ID, reportId == null ? null : String.valueOf(reportId)); + } + private String defaultText(String text, String fallback) { return StringUtils.hasText(text) ? text.trim() : fallback; } diff --git a/rdms-project/rdms-project-boot/src/main/resources/application.yaml b/rdms-project/rdms-project-boot/src/main/resources/application.yaml index 5b8bd69..68391eb 100644 --- a/rdms-project/rdms-project-boot/src/main/resources/application.yaml +++ b/rdms-project/rdms-project-boot/src/main/resources/application.yaml @@ -123,7 +123,7 @@ rdms: enabled: true weekly: enabled: true - cron: "0 0 12 ? * FRI" + cron: "0 0 12 ? * SUN" monthly: enabled: true cron: "0 0 12 1-31 * ?" diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java index d8ece3f..d2d2e69 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java @@ -563,8 +563,8 @@ public class AdminUserServiceImpl implements AdminUserService { url = client.upload(content, path, type); - //删除旧的 - client.delete(user.getAvatar()); + // 旧头像删除失败不应影响新头像上传;而且 avatar 字段存的是 URL,不能直接当 path 删。 + deleteOldAvatarQuietly(client, user); // 3. 保存到数据库 fileDO = new FileDO().setConfigId(client.getId()) @@ -574,11 +574,31 @@ public class AdminUserServiceImpl implements AdminUserService { user.setAvatar(fileDO.getUrl()); this.userMapper.updateById(user); } catch (Exception e) { + log.warn("头像上传失败,userId={}, avatar={}, fileName={}, contentType={}", + user == null ? null : user.getId(), + user == null ? null : user.getAvatar(), + name, type, e); throw exception(USER_AVATAR_UPLOAD_FAILED); } return fileDO; } + private void deleteOldAvatarQuietly(FileClient client, AdminUserDO user) { + if (client == null || user == null || StrUtil.isBlank(user.getAvatar())) { + return; + } + try { + FileDO oldAvatar = fileMapper.selectByUrl(user.getAvatar()); + if (oldAvatar == null || StrUtil.isBlank(oldAvatar.getPath())) { + log.warn("删除旧头像时未找到文件记录,userId={}, avatar={}", user.getId(), user.getAvatar()); + return; + } + client.delete(oldAvatar.getPath()); + } catch (Exception ex) { + log.warn("删除旧头像失败,userId={}, avatar={}", user.getId(), user.getAvatar(), ex); + } + } + private void validateImageFile(MultipartFile file) { // 检查文件类型 String contentType = file.getContentType();