fix(工作台-工时、周报、日志管理):
- 修改工时周聚合逻辑,从仅统计周一到周五改为周一到周日 - 修复周末工时分配逻辑,周末工时不参与工作日均摊而是保留在周末 - 修复头像上传时旧头像删除逻辑,通过URL查找文件路径进行删除 - 添加加班工时统计功能,在团队工时周报表中显示批准的加班时间 - 优化未提交报告提醒功能,避免重复发送未读消息的提醒 - 修改周报告提醒的cron表达式,从每周五改为每周日执行
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -39,7 +39,7 @@ public class MyWorkbenchController {
|
||||
}
|
||||
|
||||
@GetMapping("/worklog-week")
|
||||
@Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)")
|
||||
@Operation(summary = "我的工时周聚合(返回周一~周日;按周填报只均摊到工作日,周末单天工时保留在周末)")
|
||||
public CommonResult<MyWorklogWeekRespVO> getMyWorklogWeek(
|
||||
@RequestParam("weekStart")
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) {
|
||||
|
||||
@@ -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<BigDecimal> dailyHours;
|
||||
@Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<DistributionItemVO> distribution;
|
||||
|
||||
@@ -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<MemberVO> 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<MyWorklogWeekRespVO.DistributionItemVO> items;
|
||||
|
||||
@Schema(description = "Approved overtime hours in selected week",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "9.00")
|
||||
private BigDecimal overtimeHours;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<OvertimeApplicationDO> {
|
||||
@@ -53,6 +54,20 @@ public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicati
|
||||
.eq(OvertimeApplicationDO::getApplicantId, applicantId));
|
||||
}
|
||||
|
||||
default List<OvertimeApplicationDO> selectApprovedListByApplicantIdsAndDateRange(Collection<Long> 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<OvertimeApplicationDO>()
|
||||
.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<OvertimeApplicationDO>()
|
||||
.eq(OvertimeApplicationDO::getId, id)
|
||||
|
||||
@@ -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<TaskWorklogDO> worklogs = taskWorklogMapper
|
||||
.selectListByUserIdAndPeriod(me, monday, monday.plusDays(6));
|
||||
// 逐日累计(scale=4)与每任务份额累计
|
||||
Map<LocalDate, BigDecimal> daily = new LinkedHashMap<>();
|
||||
Map<Long, BigDecimal> hoursByTask = new HashMap<>();
|
||||
for (TaskWorklogDO w : worklogs) {
|
||||
for (TaskWorklogDO worklog : worklogs) {
|
||||
Map<LocalDate, BigDecimal> shares = MyWorklogWeekSupport
|
||||
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday);
|
||||
for (Map.Entry<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> 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<BigDecimal> dailyHours = new ArrayList<>(5);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
List<BigDecimal> 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<MyTeamService.TeamMember> members = myTeamService.resolveTeamMembers();
|
||||
List<Long> userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList());
|
||||
List<TaskWorklogDO> worklogs = taskWorklogMapper
|
||||
.selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6));
|
||||
// 每成员每任务的本周份额合计
|
||||
List<TaskWorklogDO> worklogs = taskWorklogMapper.selectListByUserIdsAndPeriod(userIds, monday, sunday);
|
||||
|
||||
Map<Long, Map<Long, BigDecimal>> 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<Long, BigDecimal> 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<Long, BigDecimal> buildApprovedOvertimeHoursByUser(List<Long> userIds,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<OvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedListByApplicantIdsAndDateRange(
|
||||
userIds, startDate, endDate, OvertimeApplicationConstants.STATUS_APPROVED);
|
||||
if (applications.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, BigDecimal> 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<MyWorklogWeekRespVO.DistributionItemVO> buildDistribution(Map<Long, BigDecimal> hoursByTask) {
|
||||
if (hoursByTask.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
@@ -117,37 +165,41 @@ public class MyWorklogServiceImpl implements MyWorklogService {
|
||||
Map<Long, ProjectTaskDO> taskMap = projectTaskMapper.selectBatchIds(taskIds).stream()
|
||||
.collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a));
|
||||
Set<Long> personalIds = taskIds.stream()
|
||||
.filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet());
|
||||
.filter(id -> !taskMap.containsKey(id))
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> 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<Long, BigDecimal> hoursByProject = new LinkedHashMap<>();
|
||||
BigDecimal personalHours = BigDecimal.ZERO;
|
||||
BigDecimal otherHours = BigDecimal.ZERO;
|
||||
for (Map.Entry<Long, BigDecimal> e : hoursByTask.entrySet()) {
|
||||
ProjectTaskDO task = taskMap.get(e.getKey());
|
||||
for (Map.Entry<Long, BigDecimal> 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<Long, ProjectDO> 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<MyWorklogWeekRespVO.DistributionItemVO> 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) {
|
||||
|
||||
@@ -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。
|
||||
*
|
||||
* <p>前置条件: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<LocalDate> 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<LocalDate> 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;
|
||||
}
|
||||
|
||||
@@ -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<String> 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<WeeklyReportDO> reports = weeklyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
|
||||
Set<String> 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<String, Object> 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<MonthlyReportDO> reports = monthlyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
|
||||
Set<String> 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<String, Object> 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<ProjectReportDO> reports = projectReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
|
||||
Set<String> 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<String, Object> 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<String> loadUnreadReminderKeys(Collection<Long> userIds, List<String> templateCodes) {
|
||||
if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
List<Long> filteredUserIds = userIds.stream().filter(java.util.Objects::nonNull).distinct().toList();
|
||||
if (filteredUserIds.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
try {
|
||||
CommonResult<List<NotifyUnreadMessageRespDTO>> result = notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(
|
||||
new NotifyUnreadMessageListReqDTO()
|
||||
.setUserIds(new ArrayList<>(filteredUserIds))
|
||||
.setTemplateCodes(templateCodes));
|
||||
List<NotifyUnreadMessageRespDTO> messages = result == null ? null : result.getCheckedData();
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> 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<String> 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<String, Object> 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;
|
||||
}
|
||||
|
||||
@@ -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 * ?"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user