From e5b08e4728bfbec71ab935dbe64877963cd21a06 Mon Sep 17 00:00:00 2001 From: dk <1260500659@qq.com> Date: Sun, 28 Jun 2026 14:02:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=B7=A5=E4=BD=9C=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E3=80=81=E6=88=91=E7=9A=84=E7=BB=A9=E6=95=88=E3=80=81=E4=BA=A7?= =?UTF-8?q?=E5=93=81/=E9=A1=B9=E7=9B=AE=E3=80=81=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E9=80=9A=E7=9F=A5):=20=E4=BF=AE=E5=A4=8D=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/vo/TeamPerformanceSummaryRespVO.java | 4 +- .../team/vo/TeamReportRemindReqVO.java | 3 + .../team/vo/TeamReportRemindRespVO.java | 2 +- .../team/vo/TeamReportSummaryRespVO.java | 38 +- .../dal/dataobject/product/ProductDO.java | 3 + .../dal/dataobject/project/ProjectDO.java | 3 + .../notify/NotifyTemplateCodeConstants.java | 4 + .../team/TeamPerformanceServiceImpl.java | 374 +++++++--- .../team/TeamWorkReportServiceImpl.java | 675 ++++++++++++------ 9 files changed, 764 insertions(+), 342 deletions(-) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/performance/team/vo/TeamPerformanceSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/performance/team/vo/TeamPerformanceSummaryRespVO.java index 863be2a..966534f 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/performance/team/vo/TeamPerformanceSummaryRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/performance/team/vo/TeamPerformanceSummaryRespVO.java @@ -13,7 +13,7 @@ public class TeamPerformanceSummaryRespVO { private String periodMonthStart; private String periodMonthEnd; - private Integer totalSheetCount; + private Integer expectedSheetCount; private Integer pendingSendCount; private Integer pendingConfirmCount; private BigDecimal confirmedRate; @@ -25,6 +25,7 @@ public class TeamPerformanceSummaryRespVO { public static class PendingSendUser { private Long userId; private String userNickname; + private String periodMonth; private Long managerUserId; private String managerName; private Long sheetId; @@ -35,6 +36,7 @@ public class TeamPerformanceSummaryRespVO { public static class PendingConfirmUser { private Long userId; private String userNickname; + private String periodMonth; private Long sheetId; private LocalDateTime sentTime; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindReqVO.java index dbd3c82..4984725 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindReqVO.java @@ -20,4 +20,7 @@ public class TeamReportRemindReqVO { @Schema(description = "催办用户 ID 列表;不传则催办全部待提交用户") private List userIds; + + @Schema(description = "项目 ID,仅项目半月报按单个项目槽位催办时传入", example = "2042074259501088770") + private Long projectId; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindRespVO.java index 5a8c50e..1cdfb4f 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportRemindRespVO.java @@ -7,6 +7,6 @@ import lombok.Data; @Data public class TeamReportRemindRespVO { - @Schema(description = "实际催办人数", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + @Schema(description = "实际催办数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") private Integer remindedCount; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportSummaryRespVO.java index e55a135..1ecdda2 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportSummaryRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/workreport/team/vo/TeamReportSummaryRespVO.java @@ -17,24 +17,24 @@ public class TeamReportSummaryRespVO { @Schema(description = "实际查询周期结束日期") private LocalDate periodEndDate; - @Schema(description = "应填人数", requiredMode = Schema.RequiredMode.REQUIRED, example = "18") - private Integer totalShouldSubmit; + @Schema(description = "应填报告数", requiredMode = Schema.RequiredMode.REQUIRED, example = "18") + private Integer expectedReportCount; - @Schema(description = "已提交人数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") - private Integer submittedCount; + @Schema(description = "已提交报告数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Integer submittedReportCount; - @Schema(description = "未提交人数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") - private Integer unsubmittedCount; + @Schema(description = "未提交报告数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Integer unsubmittedReportCount; - @Schema(description = "待审批人数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") - private Integer pendingApprovalCount; + @Schema(description = "待审批报告数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer pendingApprovalReportCount; - @Schema(description = "未提交人员列表") - private List unsubmittedUsers; + @Schema(description = "未提交报告槽位列表") + private List unsubmittedReports; - @Schema(description = "未提交人员") + @Schema(description = "未提交报告槽位") @Data - public static class PendingUser { + public static class UnsubmittedReport { @Schema(description = "用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2042074259501088770") @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = ToStringSerializer.class) @@ -42,5 +42,19 @@ public class TeamReportSummaryRespVO { @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "戴坤") private String userNickname; + + @Schema(description = "周期主键", requiredMode = Schema.RequiredMode.REQUIRED, + example = "weekly-2026-06-22-2026-06-28") + private String periodKey; + + @Schema(description = "周期名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-22 至 2026-06-28") + private String periodLabel; + + @Schema(description = "项目 ID,仅项目半月报返回", example = "2042074259501088770") + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = ToStringSerializer.class) + private Long projectId; + + @Schema(description = "项目名称,仅项目半月报返回", example = "RDMS") + private String projectName; } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java index 6fbfa07..8920d11 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java @@ -1,5 +1,7 @@ package com.njcn.rdms.module.project.dal.dataobject.product; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; @@ -42,6 +44,7 @@ public class ProductDO extends BaseDO { /** * 产品描述 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String description; /** * 最近一次状态动作原因 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java index 698a5d9..5f1f451 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/ProjectDO.java @@ -1,5 +1,7 @@ package com.njcn.rdms.module.project.dal.dataobject.project; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; @@ -81,6 +83,7 @@ public class ProjectDO extends BaseDO { /** * 项目说明 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String projectDesc; /** * 最近一次状态动作原因 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java index 4974a0d..50f8a9b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java @@ -28,6 +28,10 @@ public class NotifyTemplateCodeConstants { /** 工作报告团队催办:主管催办下属提交指定周期工作报告 */ public static final String WORK_REPORT_TEAM_REMIND = "work_report_team_remind"; + /** 项目半月报团队催办:主管按项目槽位催办下属提交项目半月报 */ + public static final String WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND = + "work_report_project_half_month_team_remind"; + /** 执行指派:创建执行后通知负责人 + 协办人 */ public static final String EXECUTION_ASSIGNED = "execution_assigned"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/performance/team/TeamPerformanceServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/performance/team/TeamPerformanceServiceImpl.java index 8444966..b284aa1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/performance/team/TeamPerformanceServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/performance/team/TeamPerformanceServiceImpl.java @@ -1,7 +1,6 @@ package com.njcn.rdms.module.project.service.performance.team; import com.njcn.rdms.framework.common.pojo.CommonResult; -import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.PerformanceConstants; import com.njcn.rdms.module.project.controller.admin.performance.team.vo.TeamPerformanceRemindReqVO; import com.njcn.rdms.module.project.controller.admin.performance.team.vo.TeamPerformanceRemindRespVO; @@ -12,6 +11,8 @@ import com.njcn.rdms.module.project.dal.mysql.performance.PerformanceSheetMapper import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.service.team.TeamDashboardAccessService; +import com.njcn.rdms.module.system.api.dept.DeptApi; +import com.njcn.rdms.module.system.api.dept.dto.DeptRespDTO; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.UserManagementRelationApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; @@ -48,6 +49,8 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { @Resource private AdminUserApi adminUserApi; @Resource + private DeptApi deptApi; + @Resource private UserManagementRelationApi userManagementRelationApi; @Resource private ApplicationEventPublisher applicationEventPublisher; @@ -58,23 +61,59 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { String[] range = normalizePeriodMonthRange( reqVO == null ? null : reqVO.getPeriodMonthStart(), reqVO == null ? null : reqVO.getPeriodMonthEnd()); + List periodMonths = buildPeriodMonths(range[0], range[1]); List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); + Map userMap = loadUserMap(subordinateIds); + Map deptMap = loadDeptMap(userMap.values()); List sheets = performanceSheetMapper .selectListByEmployeeIdsAndPeriodMonthRange(subordinateIds, range[0], range[1]); - Map> sheetsByEmployee = sheets.stream() - .collect(Collectors.groupingBy(PerformanceSheetDO::getEmployeeId)); - Map userMap = loadUserMap(subordinateIds); + Map sheetBySlot = buildSheetBySlot(sheets); + Map directManagerCache = new HashMap<>(); + List pendingSendUsers = new ArrayList<>(); + List pendingConfirmUsers = new ArrayList<>(); + Map deptAverageMap = new LinkedHashMap<>(); + int confirmedCount = 0; + for (Long subordinateId : subordinateIds) { + AdminUserRespDTO user = userMap.get(subordinateId); + for (String periodMonth : periodMonths) { + PerformanceSheetDO sheet = sheetBySlot.get(slotKey(subordinateId, periodMonth)); + DeptBucket deptBucket = resolveDeptBucket(sheet, user, deptMap); + DeptAverageAccumulator accumulator = touchDeptAverageAccumulator(deptAverageMap, deptBucket); + if (accumulator != null) { + accumulator.incrementExpectedCount(); + } + if (isPendingSendStatus(sheet == null ? null : sheet.getStatusCode())) { + pendingSendUsers.add(buildPendingSendUser( + subordinateId, periodMonth, sheet, user, directManagerCache)); + continue; + } + if (PerformanceConstants.STATUS_SENT.equals(sheet.getStatusCode())) { + pendingConfirmUsers.add(buildPendingConfirmUser(sheet, user, periodMonth)); + continue; + } + if (PerformanceConstants.STATUS_CONFIRMED.equals(sheet.getStatusCode())) { + confirmedCount++; + if (accumulator != null) { + accumulator.addConfirmedScore(sheet.getActualScoreTotal()); + } + } + } + } + + int expectedSheetCount = subordinateIds.size() * periodMonths.size(); TeamPerformanceSummaryRespVO respVO = new TeamPerformanceSummaryRespVO(); respVO.setPeriodMonthStart(range[0]); respVO.setPeriodMonthEnd(range[1]); - respVO.setTotalSheetCount(sheets.size()); - respVO.setPendingSendUsers(buildPendingSendUsers(subordinateIds, sheetsByEmployee, userMap)); - respVO.setPendingConfirmUsers(buildPendingConfirmUsers(sheets, userMap)); - respVO.setPendingSendCount(respVO.getPendingSendUsers().size()); - respVO.setPendingConfirmCount(respVO.getPendingConfirmUsers().size()); - respVO.setConfirmedRate(calculateConfirmedRate(sheets)); - respVO.setDeptOrgAverages(buildDeptOrgAverages(sheets)); + respVO.setExpectedSheetCount(expectedSheetCount); + respVO.setPendingSendUsers(pendingSendUsers); + respVO.setPendingConfirmUsers(pendingConfirmUsers); + respVO.setPendingSendCount(pendingSendUsers.size()); + respVO.setPendingConfirmCount(pendingConfirmUsers.size()); + respVO.setConfirmedRate(calculateConfirmedRate(expectedSheetCount, confirmedCount)); + respVO.setDeptOrgAverages(deptAverageMap.values().stream() + .map(DeptAverageAccumulator::toRespVO) + .toList()); return respVO; } @@ -83,6 +122,7 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { public TeamPerformanceRemindRespVO remind(TeamPerformanceRemindReqVO reqVO) { teamDashboardAccessService.validateTeamDashboardPermission(PerformanceConstants.PERMISSION_TEAM_DASHBOARD); String[] range = normalizePeriodMonthRange(reqVO.getPeriodMonthStart(), reqVO.getPeriodMonthEnd()); + List periodMonths = buildPeriodMonths(range[0], range[1]); List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); List targetEmployeeIds = reqVO.getUserIds() == null ? subordinateIds @@ -90,15 +130,14 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { reqVO.getUserIds(), PerformanceConstants.PERMISSION_TEAM_DASHBOARD); List sheets = performanceSheetMapper .selectListByEmployeeIdsAndPeriodMonthRange(targetEmployeeIds, range[0], range[1]); - Map> sheetsByEmployee = sheets.stream() - .collect(Collectors.groupingBy(PerformanceSheetDO::getEmployeeId)); + Map sheetBySlot = buildSheetBySlot(sheets); Map userMap = loadUserMap(targetEmployeeIds); int remindedCount; if (PerformanceConstants.REMIND_TYPE_PENDING_CONFIRM.equals(reqVO.getRemindType())) { remindedCount = remindPendingConfirm(sheets); } else if (PerformanceConstants.REMIND_TYPE_PENDING_SEND.equals(reqVO.getRemindType())) { - remindedCount = remindPendingSend(targetEmployeeIds, sheetsByEmployee, userMap, range[0]); + remindedCount = remindPendingSend(targetEmployeeIds, periodMonths, sheetBySlot, userMap); } else { throw invalidParamException("催办类型不合法"); } @@ -107,78 +146,12 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { return respVO; } - private List buildPendingSendUsers( - List subordinateIds, Map> sheetsByEmployee, - Map userMap) { - List result = new ArrayList<>(); - for (Long subordinateId : subordinateIds) { - List employeeSheets = sheetsByEmployee.getOrDefault(subordinateId, List.of()); - if (!hasPendingSend(employeeSheets)) { - continue; - } - AdminUserRespDTO user = userMap.get(subordinateId); - PerformanceSheetDO firstPending = employeeSheets.isEmpty() ? null : employeeSheets.get(0); - AdminUserRespDTO manager = firstPending == null ? loadDirectManagerQuietly(subordinateId) : null; - TeamPerformanceSummaryRespVO.PendingSendUser item = new TeamPerformanceSummaryRespVO.PendingSendUser(); - item.setUserId(subordinateId); - item.setUserNickname(user == null ? "" : defaultText(user.getNickname())); - item.setManagerUserId( - firstPending == null ? (manager == null ? null : manager.getId()) : firstPending.getManagerId()); - item.setManagerName(firstPending == null ? (manager == null ? "" : defaultText(manager.getNickname())) - : firstPending.getManagerName()); - item.setSheetId(firstPending == null ? null : firstPending.getId()); - item.setStatusCode(firstPending == null ? null : firstPending.getStatusCode()); - result.add(item); - } - return result; - } - - private List buildPendingConfirmUsers( - List sheets, Map userMap) { - return sheets.stream() - .filter(sheet -> PerformanceConstants.STATUS_SENT.equals(sheet.getStatusCode())) - .map(sheet -> { - AdminUserRespDTO user = userMap.get(sheet.getEmployeeId()); - TeamPerformanceSummaryRespVO.PendingConfirmUser item = new TeamPerformanceSummaryRespVO.PendingConfirmUser(); - item.setUserId(sheet.getEmployeeId()); - item.setUserNickname( - user == null ? defaultText(sheet.getEmployeeName()) : defaultText(user.getNickname())); - item.setSheetId(sheet.getId()); - item.setSentTime(sheet.getSentTime()); - return item; - }) - .toList(); - } - - private List buildDeptOrgAverages(List sheets) { - Map averages = new LinkedHashMap<>(); - for (PerformanceSheetDO sheet : sheets) { - if (!PerformanceConstants.STATUS_CONFIRMED.equals(sheet.getStatusCode()) - || sheet.getActualScoreTotal() == null - || sheet.getEmployeeDeptId() == null - || (!PerformanceConstants.ORG_TYPE_DIRECTION.equals(sheet.getDeptOrgType()) - && !PerformanceConstants.ORG_TYPE_FUNCTION.equals(sheet.getDeptOrgType()))) { - continue; - } - String key = sheet.getEmployeeDeptId() + ":" + sheet.getDeptOrgType(); - averages.computeIfAbsent(key, ignored -> new DeptAverageAccumulator( - sheet.getEmployeeDeptId(), sheet.getEmployeeDeptName(), sheet.getDeptOrgType())) - .add(sheet.getActualScoreTotal()); - } - return averages.values().stream() - .map(DeptAverageAccumulator::toRespVO) - .toList(); - } - - private BigDecimal calculateConfirmedRate(List sheets) { - if (sheets.isEmpty()) { + private BigDecimal calculateConfirmedRate(int expectedSheetCount, int confirmedCount) { + if (expectedSheetCount <= 0) { return BigDecimal.ZERO.setScale(2); } - long confirmed = sheets.stream() - .filter(sheet -> PerformanceConstants.STATUS_CONFIRMED.equals(sheet.getStatusCode())) - .count(); - return BigDecimal.valueOf(confirmed * 100) - .divide(BigDecimal.valueOf(sheets.size()), 2, RoundingMode.HALF_UP); + return BigDecimal.valueOf(confirmedCount * 100L) + .divide(BigDecimal.valueOf(expectedSheetCount), 2, RoundingMode.HALF_UP); } private int remindPendingConfirm(List sheets) { @@ -194,27 +167,31 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { return count; } - private int remindPendingSend(List targetEmployeeIds, Map> sheetsByEmployee, - Map userMap, String defaultPeriodMonth) { + private int remindPendingSend(List targetEmployeeIds, List periodMonths, + Map sheetBySlot, Map userMap) { int count = 0; + Map directManagerCache = new HashMap<>(); for (Long employeeId : targetEmployeeIds) { - List employeeSheets = sheetsByEmployee.getOrDefault(employeeId, List.of()); - if (!hasPendingSend(employeeSheets)) { + PendingSendContext pendingContext = findPendingSendContext(employeeId, periodMonths, sheetBySlot); + if (pendingContext == null) { continue; } - PerformanceSheetDO firstPending = employeeSheets.isEmpty() ? null : employeeSheets.get(0); - AdminUserRespDTO manager = firstPending == null ? loadDirectManagerQuietly(employeeId) : null; - Long managerId = firstPending == null ? (manager == null ? null : manager.getId()) - : firstPending.getManagerId(); + PerformanceSheetDO pendingSheet = pendingContext.sheet(); + AdminUserRespDTO manager = pendingSheet == null + ? directManagerCache.computeIfAbsent(employeeId, this::loadDirectManagerQuietly) + : null; + Long managerId = pendingSheet != null && pendingSheet.getManagerId() != null + ? pendingSheet.getManagerId() + : (manager == null ? null : manager.getId()); if (managerId == null) { continue; } - String employeeName = firstPending == null + String employeeName = pendingSheet == null ? defaultText(userMap.get(employeeId) == null ? null : userMap.get(employeeId).getNickname()) - : firstPending.getEmployeeName(); - PerformanceSheetDO noticeSheet = firstPending == null - ? buildVirtualSheet(defaultPeriodMonth, employeeId, employeeName, manager) - : firstPending; + : defaultText(pendingSheet.getEmployeeName()); + PerformanceSheetDO noticeSheet = pendingSheet == null + ? buildVirtualSheet(pendingContext.periodMonth(), employeeId, employeeName, manager) + : pendingSheet; publishNotice(List.of(managerId), NotifyTemplateCodeConstants.PERFORMANCE_PENDING_SEND_REMIND, noticeSheet, employeeName); count++; @@ -248,6 +225,21 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { templateCode, params, NotifyMessageLevelConstants.REMIND)); } + private Map loadDeptMap(Collection users) { + if (users == null || users.isEmpty()) { + return Map.of(); + } + LinkedHashSet deptIds = users.stream() + .filter(Objects::nonNull) + .map(AdminUserRespDTO::getDeptId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (deptIds.isEmpty()) { + return Map.of(); + } + return deptApi.getDeptMap(deptIds); + } + private Map loadUserMap(Collection userIds) { if (userIds == null || userIds.isEmpty()) { return Map.of(); @@ -271,9 +263,142 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { } } - /** - * 校验并归一化月份区间,两者均为空时默认当前月。 - */ + private Map buildSheetBySlot(List sheets) { + if (sheets == null || sheets.isEmpty()) { + return Map.of(); + } + return sheets.stream() + .filter(Objects::nonNull) + .filter(sheet -> sheet.getEmployeeId() != null) + .filter(sheet -> StringUtils.hasText(sheet.getPeriodMonth())) + .collect(Collectors.toMap( + sheet -> slotKey(sheet.getEmployeeId(), sheet.getPeriodMonth()), + sheet -> sheet, + this::preferLatestSheet, + LinkedHashMap::new)); + } + + private PerformanceSheetDO preferLatestSheet(PerformanceSheetDO left, PerformanceSheetDO right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + if (left.getUpdateTime() != null && right.getUpdateTime() != null) { + return left.getUpdateTime().isAfter(right.getUpdateTime()) ? left : right; + } + if (left.getCreateTime() != null && right.getCreateTime() != null) { + return left.getCreateTime().isAfter(right.getCreateTime()) ? left : right; + } + if (left.getId() != null && right.getId() != null) { + return left.getId() >= right.getId() ? left : right; + } + return left; + } + + private List buildPeriodMonths(String periodMonthStart, String periodMonthEnd) { + YearMonth start = YearMonth.parse(periodMonthStart); + YearMonth end = YearMonth.parse(periodMonthEnd); + List result = new ArrayList<>(); + for (YearMonth current = start; !current.isAfter(end); current = current.plusMonths(1)) { + result.add(current.toString()); + } + return result; + } + + private String slotKey(Long employeeId, String periodMonth) { + return employeeId + ":" + periodMonth; + } + + private PendingSendContext findPendingSendContext(Long employeeId, List periodMonths, + Map sheetBySlot) { + for (String periodMonth : periodMonths) { + PerformanceSheetDO sheet = sheetBySlot.get(slotKey(employeeId, periodMonth)); + if (isPendingSendStatus(sheet == null ? null : sheet.getStatusCode())) { + return new PendingSendContext(periodMonth, sheet); + } + } + return null; + } + + private boolean isPendingSendStatus(String statusCode) { + return statusCode == null + || PerformanceConstants.STATUS_DRAFT.equals(statusCode) + || PerformanceConstants.STATUS_REJECTED.equals(statusCode); + } + + private TeamPerformanceSummaryRespVO.PendingSendUser buildPendingSendUser(Long userId, String periodMonth, + PerformanceSheetDO sheet, AdminUserRespDTO user, Map directManagerCache) { + AdminUserRespDTO manager = null; + if (sheet == null || sheet.getManagerId() == null || !StringUtils.hasText(sheet.getManagerName())) { + manager = directManagerCache.computeIfAbsent(userId, this::loadDirectManagerQuietly); + } + TeamPerformanceSummaryRespVO.PendingSendUser item = new TeamPerformanceSummaryRespVO.PendingSendUser(); + item.setUserId(userId); + item.setUserNickname(resolveUserNickname(user, sheet)); + item.setPeriodMonth(periodMonth); + item.setManagerUserId(sheet != null && sheet.getManagerId() != null + ? sheet.getManagerId() + : (manager == null ? null : manager.getId())); + item.setManagerName(sheet != null && StringUtils.hasText(sheet.getManagerName()) + ? defaultText(sheet.getManagerName()) + : (manager == null ? "" : defaultText(manager.getNickname()))); + item.setSheetId(sheet == null ? null : sheet.getId()); + item.setStatusCode(sheet == null ? null : sheet.getStatusCode()); + return item; + } + + private TeamPerformanceSummaryRespVO.PendingConfirmUser buildPendingConfirmUser(PerformanceSheetDO sheet, + AdminUserRespDTO user, String periodMonth) { + TeamPerformanceSummaryRespVO.PendingConfirmUser item = new TeamPerformanceSummaryRespVO.PendingConfirmUser(); + item.setUserId(sheet.getEmployeeId()); + item.setUserNickname(resolveUserNickname(user, sheet)); + item.setPeriodMonth(periodMonth); + item.setSheetId(sheet.getId()); + item.setSentTime(sheet.getSentTime()); + return item; + } + + private String resolveUserNickname(AdminUserRespDTO user, PerformanceSheetDO sheet) { + if (user != null && StringUtils.hasText(user.getNickname())) { + return defaultText(user.getNickname()); + } + return sheet == null ? "" : defaultText(sheet.getEmployeeName()); + } + + private DeptAverageAccumulator touchDeptAverageAccumulator(Map averageMap, + DeptBucket deptBucket) { + if (deptBucket == null) { + return null; + } + return averageMap.computeIfAbsent(deptBucket.key(), ignored -> + new DeptAverageAccumulator(deptBucket.deptId(), deptBucket.deptName(), deptBucket.deptOrgType())); + } + + private DeptBucket resolveDeptBucket(PerformanceSheetDO sheet, AdminUserRespDTO user, + Map deptMap) { + if (sheet != null + && sheet.getEmployeeDeptId() != null + && isDeptAverageOrgType(sheet.getDeptOrgType())) { + return new DeptBucket(sheet.getEmployeeDeptId(), + defaultText(sheet.getEmployeeDeptName()), sheet.getDeptOrgType()); + } + if (user == null || user.getDeptId() == null) { + return null; + } + DeptRespDTO dept = deptMap.get(user.getDeptId()); + if (dept == null || !isDeptAverageOrgType(dept.getOrgType())) { + return null; + } + return new DeptBucket(dept.getId(), defaultText(dept.getName()), dept.getOrgType()); + } + + private boolean isDeptAverageOrgType(String orgType) { + return PerformanceConstants.ORG_TYPE_DIRECTION.equals(orgType) + || PerformanceConstants.ORG_TYPE_FUNCTION.equals(orgType); + } + private String[] normalizePeriodMonthRange(String start, String end) { String normalizedStart = StringUtils.hasText(start) ? normalizeSinglePeriodMonth(start) : null; String normalizedEnd = StringUtils.hasText(end) ? normalizeSinglePeriodMonth(end) : null; @@ -281,7 +406,13 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { String currentMonth = YearMonth.now().toString(); return new String[] { currentMonth, currentMonth }; } - if (normalizedStart != null && normalizedEnd != null && normalizedStart.compareTo(normalizedEnd) > 0) { + if (normalizedStart == null) { + normalizedStart = normalizedEnd; + } + if (normalizedEnd == null) { + normalizedEnd = normalizedStart; + } + if (normalizedStart.compareTo(normalizedEnd) > 0) { throw invalidParamException("绩效月份起始不能晚于结束月份"); } return new String[] { normalizedStart, normalizedEnd }; @@ -295,17 +426,6 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { } } - /** - * 判断员工在区间内的绩效表是否存在“待发送”状态(无记录 / 含 draft / 含 rejected)。 - */ - private boolean hasPendingSend(List sheets) { - if (sheets == null || sheets.isEmpty()) { - return true; - } - return sheets.stream().anyMatch(sheet -> PerformanceConstants.STATUS_DRAFT.equals(sheet.getStatusCode()) - || PerformanceConstants.STATUS_REJECTED.equals(sheet.getStatusCode())); - } - private String statusName(String statusCode) { if (statusCode == null) { return "未创建"; @@ -329,7 +449,8 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { private final String deptName; private final String deptOrgType; private BigDecimal total = BigDecimal.ZERO; - private int count = 0; + private int expectedCount = 0; + private int confirmedCount = 0; DeptAverageAccumulator(Long deptId, String deptName, String deptOrgType) { this.deptId = deptId; @@ -337,9 +458,15 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { this.deptOrgType = deptOrgType; } - void add(BigDecimal score) { - total = total.add(score); - count++; + void incrementExpectedCount() { + expectedCount++; + } + + void addConfirmedScore(BigDecimal score) { + confirmedCount++; + if (score != null) { + total = total.add(score); + } } TeamPerformanceSummaryRespVO.DeptOrgAverage toRespVO() { @@ -347,11 +474,20 @@ public class TeamPerformanceServiceImpl implements TeamPerformanceService { respVO.setDeptId(deptId); respVO.setDeptName(deptName); respVO.setDeptOrgType(deptOrgType); - respVO.setConfirmedCount(count); - respVO.setAverageScore(count == 0 + respVO.setConfirmedCount(confirmedCount); + respVO.setAverageScore(expectedCount == 0 ? BigDecimal.ZERO.setScale(2) - : total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP)); + : total.divide(BigDecimal.valueOf(expectedCount), 2, RoundingMode.HALF_UP)); return respVO; } } + + private record PendingSendContext(String periodMonth, PerformanceSheetDO sheet) { + } + + private record DeptBucket(Long deptId, String deptName, String deptOrgType) { + private String key() { + return deptId + ":" + deptOrgType; + } + } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/team/TeamWorkReportServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/team/TeamWorkReportServiceImpl.java index 77dd993..9cd743b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/team/TeamWorkReportServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/workreport/team/TeamWorkReportServiceImpl.java @@ -28,7 +28,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -72,29 +76,23 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService { public TeamReportSummaryRespVO getSummary(TeamReportSummaryReqVO reqVO) { teamDashboardAccessService.validateTeamDashboardPermission(WorkReportConstants.PERMISSION_TEAM_DASHBOARD); String reportType = normalizeReportType(reqVO.getReportType()); - boolean useDateRange = reqVO.getPeriodStartDate() != null || reqVO.getPeriodEndDate() != null; + DateRange dateRange = normalizeDateRange(reqVO.getPeriodStartDate(), reqVO.getPeriodEndDate()); ReportContext context; - LocalDate respStartDate; - LocalDate respEndDate; - if (useDateRange) { - respStartDate = reqVO.getPeriodStartDate(); - respEndDate = reqVO.getPeriodEndDate(); - context = buildReportContextByDateRange(reportType, respStartDate, respEndDate); + if (dateRange != null) { + context = buildReportContextByDateRange(reportType, dateRange.startDate(), dateRange.endDate()); } else { context = buildReportContext(reportType, reqVO.getPeriodKey()); - respStartDate = null; - respEndDate = null; } + + List unsubmittedReports = buildUnsubmittedReports(context); TeamReportSummaryRespVO respVO = new TeamReportSummaryRespVO(); - respVO.setPeriodStartDate(respStartDate); - respVO.setPeriodEndDate(respEndDate); - respVO.setTotalShouldSubmit(context.expectedUserIds().size()); - respVO.setSubmittedCount(context.submittedUserIds().size()); - respVO.setPendingApprovalCount(context.pendingApprovalUserIds().size()); - List unsubmittedUsers = buildPendingUsers(context.expectedUserIds(), - context.submittedUserIds()); - respVO.setUnsubmittedUsers(unsubmittedUsers); - respVO.setUnsubmittedCount(unsubmittedUsers.size()); + respVO.setPeriodStartDate(dateRange == null ? null : dateRange.startDate()); + respVO.setPeriodEndDate(dateRange == null ? null : dateRange.endDate()); + respVO.setExpectedReportCount(context.expectedSlots().size()); + respVO.setSubmittedReportCount(context.submittedSlotKeys().size()); + respVO.setUnsubmittedReportCount(unsubmittedReports.size()); + respVO.setPendingApprovalReportCount(context.pendingApprovalSlotKeys().size()); + respVO.setUnsubmittedReports(unsubmittedReports); return respVO; } @@ -104,117 +102,193 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService { teamDashboardAccessService.validateTeamDashboardPermission(WorkReportConstants.PERMISSION_TEAM_DASHBOARD); String reportType = normalizeReportType(reqVO.getReportType()); ReportContext context = buildReportContext(reportType, reqVO.getPeriodKey()); - List remindUserIds = resolveRemindUserIds(reqVO.getUserIds(), context); - - if (!remindUserIds.isEmpty()) { - Map params = new HashMap<>(); - params.put("reportTypeName", reportTypeDisplayName(reportType)); - params.put("periodKey", reqVO.getPeriodKey()); - params.put("managerName", defaultText(SecurityFrameworkUtils.getLoginUserNickname())); - applicationEventPublisher.publishEvent(NotifySendEvent.of(remindUserIds, - NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, params, NotifyMessageLevelConstants.REMIND)); - } + int remindedCount = WorkReportConstants.REPORT_TYPE_PROJECT.equals(reportType) + ? remindProjectReport(reqVO, context) + : remindPersonalReport(reportType, reqVO, context); TeamReportRemindRespVO respVO = new TeamReportRemindRespVO(); - respVO.setRemindedCount(remindUserIds.size()); + respVO.setRemindedCount(remindedCount); return respVO; } + private int remindPersonalReport(String reportType, TeamReportRemindReqVO reqVO, ReportContext context) { + List remindUserIds = resolveRemindUserIds(reqVO.getUserIds(), context); + if (remindUserIds.isEmpty()) { + return 0; + } + Map params = new HashMap<>(); + params.put("reportTypeName", reportTypeDisplayName(reportType)); + params.put("periodKey", formatPeriodKeyForRemind(reportType, reqVO.getPeriodKey())); + params.put("managerName", defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + applicationEventPublisher.publishEvent(NotifySendEvent.of(remindUserIds, + NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND, params, NotifyMessageLevelConstants.REMIND)); + return remindUserIds.size(); + } + + private int remindProjectReport(TeamReportRemindReqVO reqVO, ReportContext context) { + List remindSlots = resolveProjectRemindSlots(reqVO, context); + if (remindSlots.isEmpty()) { + return 0; + } + String managerName = defaultText(SecurityFrameworkUtils.getLoginUserNickname()); + for (ExpectedReportSlot slot : remindSlots) { + Map params = new HashMap<>(); + params.put("projectName", slot.projectName()); + params.put("periodLabel", slot.periodLabel()); + params.put("managerName", managerName); + applicationEventPublisher.publishEvent(NotifySendEvent.of(List.of(slot.userId()), + NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND, + params, NotifyMessageLevelConstants.REMIND)); + } + return remindSlots.size(); + } + private ReportContext buildReportContext(String reportType, String periodKey) { if (!StringUtils.hasText(periodKey)) { throw invalidParamException("周期主键不能为空"); } + ReportPeriod period = parseRequiredPeriod(reportType, periodKey); + return buildReportContextByPeriods(reportType, List.of(period)); + } + + private ReportContext buildReportContextByDateRange(String reportType, LocalDate startDate, LocalDate endDate) { + return buildReportContextByPeriods(reportType, buildPeriodsByDateRange(reportType, startDate, endDate)); + } + + private ReportContext buildReportContextByPeriods(String reportType, List periods) { if (WorkReportConstants.REPORT_TYPE_PROJECT.equals(reportType)) { - return buildProjectContext(periodKey); + return buildProjectContext(periods); } List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); - if (subordinateIds.isEmpty()) { - return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); + if (subordinateIds.isEmpty() || periods.isEmpty()) { + return ReportContext.empty(); } if (WorkReportConstants.REPORT_TYPE_WEEKLY.equals(reportType)) { - return buildWeeklyContext(periodKey, subordinateIds); + return buildWeeklyContext(periods, subordinateIds); } - return buildMonthlyContext(periodKey, subordinateIds); + return buildMonthlyContext(periods, subordinateIds); } - private ReportContext buildWeeklyContext(String periodKey, List subordinateIds) { - List reports = weeklyReportMapper.selectListByReporterIdsAndPeriodKey( - subordinateIds, periodKey, SUBMITTED_STATUS_CODES); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); - for (WeeklyReportDO report : reports) { - if (report == null || report.getReporterId() == null) { - continue; - } - submittedUserIds.add(report.getReporterId()); - if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(report.getReporterId()); - } - } - return new ReportContext(subordinateIds, submittedUserIds, pendingApprovalUserIds); + private ReportContext buildWeeklyContext(List periods, List subordinateIds) { + Map expectedSlotMap = buildPersonalExpectedSlotMap(subordinateIds, periods); + List reports = weeklyReportMapper.selectListByReporterIdsAndPeriodDateRange( + subordinateIds, periods.get(0).startDate(), periods.get(periods.size() - 1).startDate(), + SUBMITTED_STATUS_CODES); + return buildPersonalReportContext(expectedSlotMap, reports.stream() + .filter(Objects::nonNull) + .filter(report -> report.getReporterId() != null) + .map(report -> new ActualReportSlot( + personalSlotKey(report.getReporterId(), report.getPeriodKey()), + report.getStatusCode())) + .toList()); } - private ReportContext buildMonthlyContext(String periodKey, List subordinateIds) { - List reports = monthlyReportMapper.selectListByReporterIdsAndPeriodKey( - subordinateIds, periodKey, SUBMITTED_STATUS_CODES); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); - for (MonthlyReportDO report : reports) { - if (report == null || report.getReporterId() == null) { - continue; - } - submittedUserIds.add(report.getReporterId()); - if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(report.getReporterId()); - } - } - return new ReportContext(subordinateIds, submittedUserIds, pendingApprovalUserIds); + private ReportContext buildMonthlyContext(List periods, List subordinateIds) { + Map expectedSlotMap = buildPersonalExpectedSlotMap(subordinateIds, periods); + List reports = monthlyReportMapper.selectListByReporterIdsAndPeriodDateRange( + subordinateIds, periods.get(0).startDate(), periods.get(periods.size() - 1).startDate(), + SUBMITTED_STATUS_CODES); + return buildPersonalReportContext(expectedSlotMap, reports.stream() + .filter(Objects::nonNull) + .filter(report -> report.getReporterId() != null) + .map(report -> new ActualReportSlot( + personalSlotKey(report.getReporterId(), report.getPeriodKey()), + report.getStatusCode())) + .toList()); } - private ReportContext buildProjectContext(String periodKey) { + private ReportContext buildProjectContext(List periods) { List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); - if (subordinateIds.isEmpty()) { - return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); + if (subordinateIds.isEmpty() || periods.isEmpty()) { + return ReportContext.empty(); } List activeProjects = loadActiveProjectsForSubordinates(subordinateIds); if (activeProjects.isEmpty()) { - return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); + return ReportContext.empty(); } - Map> projectsByOwner = activeProjects.stream() + Map expectedSlotMap = buildProjectExpectedSlotMap(activeProjects, periods); + LinkedHashSet ownerIds = activeProjects.stream() + .map(ProjectDO::getManagerUserId) .filter(Objects::nonNull) - .filter(project -> project.getManagerUserId() != null) - .collect(Collectors.groupingBy(ProjectDO::getManagerUserId, LinkedHashMap::new, Collectors.toList())); - LinkedHashSet expectedUserIds = new LinkedHashSet<>(projectsByOwner.keySet()); - List reports = projectReportMapper.selectListByProjectOwnerIdsAndPeriodKey( - expectedUserIds, periodKey, SUBMITTED_STATUS_CODES); - Map> submittedProjectsByOwner = new HashMap<>(); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (ownerIds.isEmpty()) { + return ReportContext.empty(); + } + List reports = projectReportMapper.selectListByProjectOwnerIdsAndPeriodDateRange( + ownerIds, periods.get(0).startDate(), periods.get(periods.size() - 1).startDate(), + SUBMITTED_STATUS_CODES); + Set submittedSlotKeys = new LinkedHashSet<>(); + Set pendingApprovalSlotKeys = new LinkedHashSet<>(); for (ProjectReportDO report : reports) { if (report == null || report.getProjectOwnerId() == null || report.getProjectId() == null) { continue; } - Long ownerId = report.getProjectOwnerId(); - if (!expectedUserIds.contains(ownerId)) { + String slotKey = projectSlotKey(report.getProjectOwnerId(), report.getProjectId(), report.getPeriodKey()); + if (!expectedSlotMap.containsKey(slotKey)) { continue; } + submittedSlotKeys.add(slotKey); if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(ownerId); - } - submittedProjectsByOwner.computeIfAbsent(ownerId, key -> new LinkedHashSet<>()).add(report.getProjectId()); - } - for (Map.Entry> entry : projectsByOwner.entrySet()) { - Long ownerId = entry.getKey(); - Set submittedProjectIds = submittedProjectsByOwner.getOrDefault(ownerId, Collections.emptySet()); - boolean allSubmitted = entry.getValue().stream() - .map(ProjectDO::getId) - .filter(Objects::nonNull) - .allMatch(submittedProjectIds::contains); - if (allSubmitted) { - submittedUserIds.add(ownerId); + pendingApprovalSlotKeys.add(slotKey); } } - return new ReportContext(new ArrayList<>(expectedUserIds), submittedUserIds, pendingApprovalUserIds); + return new ReportContext(new ArrayList<>(expectedSlotMap.values()), submittedSlotKeys, pendingApprovalSlotKeys); + } + + private ReportContext buildPersonalReportContext(Map expectedSlotMap, + List actualSlots) { + if (expectedSlotMap.isEmpty()) { + return ReportContext.empty(); + } + Set submittedSlotKeys = new LinkedHashSet<>(); + Set pendingApprovalSlotKeys = new LinkedHashSet<>(); + for (ActualReportSlot actualSlot : actualSlots) { + if (actualSlot == null || !expectedSlotMap.containsKey(actualSlot.slotKey())) { + continue; + } + submittedSlotKeys.add(actualSlot.slotKey()); + if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(actualSlot.statusCode())) { + pendingApprovalSlotKeys.add(actualSlot.slotKey()); + } + } + return new ReportContext(new ArrayList<>(expectedSlotMap.values()), submittedSlotKeys, pendingApprovalSlotKeys); + } + + private Map buildPersonalExpectedSlotMap(List subordinateIds, + List periods) { + LinkedHashMap result = new LinkedHashMap<>(); + for (Long subordinateId : subordinateIds) { + if (subordinateId == null) { + continue; + } + for (ReportPeriod period : periods) { + String slotKey = personalSlotKey(subordinateId, period.periodKey()); + result.put(slotKey, new ExpectedReportSlot( + slotKey, subordinateId, period.periodKey(), period.periodLabel(), null, null)); + } + } + return result; + } + + private Map buildProjectExpectedSlotMap(List activeProjects, + List periods) { + LinkedHashMap result = new LinkedHashMap<>(); + for (ProjectDO project : activeProjects) { + if (project == null || project.getManagerUserId() == null || project.getId() == null) { + continue; + } + for (ReportPeriod period : periods) { + String slotKey = projectSlotKey(project.getManagerUserId(), project.getId(), period.periodKey()); + result.put(slotKey, new ExpectedReportSlot( + slotKey, + project.getManagerUserId(), + period.periodKey(), + period.periodLabel(), + project.getId(), + defaultText(project.getProjectName()))); + } + } + return result; } private List loadActiveProjectsForSubordinates(Collection subordinateIds) { @@ -236,8 +310,11 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService { } private List resolveRemindUserIds(List requestedUserIds, ReportContext context) { - LinkedHashSet unsubmittedUserIds = new LinkedHashSet<>(context.expectedUserIds()); - unsubmittedUserIds.removeAll(context.submittedUserIds()); + LinkedHashSet unsubmittedUserIds = context.expectedSlots().stream() + .filter(slot -> !context.submittedSlotKeys().contains(slot.slotKey())) + .map(ExpectedReportSlot::userId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); if (requestedUserIds == null) { return new ArrayList<>(unsubmittedUserIds); } @@ -248,34 +325,216 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService { .collect(Collectors.toList()); } - private List buildPendingUsers(List expectedUserIds, - Set submittedUserIds) { - LinkedHashSet pendingIds = new LinkedHashSet<>(expectedUserIds); - pendingIds.removeAll(submittedUserIds); - if (pendingIds.isEmpty()) { + private List resolveProjectRemindSlots(TeamReportRemindReqVO reqVO, ReportContext context) { + List pendingSlots = context.expectedSlots().stream() + .filter(slot -> !context.submittedSlotKeys().contains(slot.slotKey())) + .toList(); + if (pendingSlots.isEmpty()) { return Collections.emptyList(); } - CommonResult> result = adminUserApi.getUserList(pendingIds); - List users = result == null ? null : result.getCheckedData(); - if (users == null || users.isEmpty()) { - users = Collections.emptyList(); + + Set validatedUserIds = reqVO.getUserIds() == null + ? null + : new LinkedHashSet<>(teamDashboardAccessService.resolveRequestedSubordinateUserIds( + reqVO.getUserIds(), WorkReportConstants.PERMISSION_TEAM_DASHBOARD)); + + Long requestedProjectId = reqVO.getProjectId(); + return pendingSlots.stream() + .filter(slot -> validatedUserIds == null || validatedUserIds.contains(slot.userId())) + .filter(slot -> requestedProjectId == null || Objects.equals(slot.projectId(), requestedProjectId)) + .toList(); + } + + private List buildUnsubmittedReports(ReportContext context) { + List pendingSlots = context.expectedSlots().stream() + .filter(slot -> !context.submittedSlotKeys().contains(slot.slotKey())) + .toList(); + if (pendingSlots.isEmpty()) { + return Collections.emptyList(); } - Map userMap = users.stream() + LinkedHashSet userIds = pendingSlots.stream() + .map(ExpectedReportSlot::userId) .filter(Objects::nonNull) - .filter(user -> user.getId() != null) - .collect(Collectors.toMap(AdminUserRespDTO::getId, user -> user, (left, right) -> left, - LinkedHashMap::new)); - List respList = new ArrayList<>(); - for (Long pendingId : pendingIds) { - AdminUserRespDTO user = userMap.get(pendingId); - TeamReportSummaryRespVO.PendingUser item = new TeamReportSummaryRespVO.PendingUser(); - item.setUserId(pendingId); + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = loadUserMap(userIds); + + List respList = new ArrayList<>(); + for (ExpectedReportSlot pendingSlot : pendingSlots) { + AdminUserRespDTO user = userMap.get(pendingSlot.userId()); + TeamReportSummaryRespVO.UnsubmittedReport item = new TeamReportSummaryRespVO.UnsubmittedReport(); + item.setUserId(pendingSlot.userId()); item.setUserNickname(user == null ? "" : defaultText(user.getNickname())); + item.setPeriodKey(pendingSlot.periodKey()); + item.setPeriodLabel(pendingSlot.periodLabel()); + item.setProjectId(pendingSlot.projectId()); + item.setProjectName(pendingSlot.projectName()); respList.add(item); } return respList; } + private Map loadUserMap(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Map.of(); + } + CommonResult> result = adminUserApi.getUserList(userIds); + List users = result == null ? null : result.getCheckedData(); + if (users == null || users.isEmpty()) { + return Map.of(); + } + return users.stream() + .filter(Objects::nonNull) + .filter(user -> user.getId() != null) + .collect(Collectors.toMap( + AdminUserRespDTO::getId, user -> user, (left, right) -> left, LinkedHashMap::new)); + } + + private List buildPeriodsByDateRange(String reportType, LocalDate startDate, LocalDate endDate) { + return switch (reportType) { + case WorkReportConstants.REPORT_TYPE_WEEKLY -> buildWeeklyPeriodsByDateRange(startDate, endDate); + case WorkReportConstants.REPORT_TYPE_MONTHLY -> buildMonthlyPeriodsByDateRange(startDate, endDate); + case WorkReportConstants.REPORT_TYPE_PROJECT -> buildProjectPeriodsByDateRange(startDate, endDate); + default -> throw invalidParamException("报告类型不合法"); + }; + } + + private List buildWeeklyPeriodsByDateRange(LocalDate startDate, LocalDate endDate) { + LocalDate firstStart = startDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)); + if (firstStart.isAfter(endDate)) { + return Collections.emptyList(); + } + List periods = new ArrayList<>(); + for (LocalDate currentStart = firstStart; !currentStart.isAfter(endDate); currentStart = currentStart.plusWeeks(1)) { + periods.add(buildWeeklyPeriod(currentStart, currentStart.plusDays(6))); + } + return periods; + } + + private List buildMonthlyPeriodsByDateRange(LocalDate startDate, LocalDate endDate) { + LocalDate firstStart = startDate.withDayOfMonth(1); + if (firstStart.isBefore(startDate)) { + firstStart = firstStart.plusMonths(1); + } + if (firstStart.isAfter(endDate)) { + return Collections.emptyList(); + } + List periods = new ArrayList<>(); + for (LocalDate currentStart = firstStart; !currentStart.isAfter(endDate); currentStart = currentStart.plusMonths(1)) { + periods.add(buildMonthlyPeriod(currentStart)); + } + return periods; + } + + private List buildProjectPeriodsByDateRange(LocalDate startDate, LocalDate endDate) { + LocalDate firstStart; + int day = startDate.getDayOfMonth(); + if (day <= 1) { + firstStart = startDate.withDayOfMonth(1); + } else if (day <= 16) { + firstStart = startDate.withDayOfMonth(16); + } else { + firstStart = startDate.plusMonths(1).withDayOfMonth(1); + } + if (firstStart.isAfter(endDate)) { + return Collections.emptyList(); + } + List periods = new ArrayList<>(); + for (LocalDate currentStart = firstStart; !currentStart.isAfter(endDate); currentStart = nextHalfMonthStart(currentStart)) { + periods.add(buildProjectPeriod(currentStart)); + } + return periods; + } + + private LocalDate nextHalfMonthStart(LocalDate currentStart) { + if (currentStart.getDayOfMonth() == 1) { + return currentStart.withDayOfMonth(16); + } + return currentStart.plusMonths(1).withDayOfMonth(1); + } + + private ReportPeriod parseRequiredPeriod(String reportType, String periodKey) { + return switch (reportType) { + case WorkReportConstants.REPORT_TYPE_WEEKLY -> parseWeeklyPeriod(periodKey); + case WorkReportConstants.REPORT_TYPE_MONTHLY -> parseMonthlyPeriod(periodKey); + case WorkReportConstants.REPORT_TYPE_PROJECT -> parseProjectPeriod(periodKey); + default -> throw invalidParamException("报告类型不合法"); + }; + } + + private ReportPeriod parseWeeklyPeriod(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 7 || !WorkReportConstants.REPORT_TYPE_WEEKLY.equals(parts[0])) { + throw invalidParamException("周报周期主键不合法"); + } + LocalDate startDate = parsePeriodDate(parts, 1); + LocalDate endDate = parsePeriodDate(parts, 4); + return buildWeeklyPeriod(startDate, endDate); + } + + private ReportPeriod parseMonthlyPeriod(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 7 || !WorkReportConstants.REPORT_TYPE_MONTHLY.equals(parts[0])) { + throw invalidParamException("月报周期主键不合法"); + } + LocalDate startDate = parsePeriodDate(parts, 1); + return buildMonthlyPeriod(startDate); + } + + private ReportPeriod parseProjectPeriod(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 8 || !WorkReportConstants.REPORT_TYPE_PROJECT.equals(parts[0])) { + throw invalidParamException("项目半月报周期主键不合法"); + } + LocalDate startDate = parsePeriodDate(parts, 1); + return buildProjectPeriod(startDate); + } + + private ReportPeriod buildWeeklyPeriod(LocalDate startDate, LocalDate endDate) { + return new ReportPeriod( + String.format("weekly-%s-%s", startDate, endDate), + String.format("%s 至 %s", startDate, endDate), + startDate, + endDate); + } + + private ReportPeriod buildMonthlyPeriod(LocalDate startDate) { + LocalDate normalizedStartDate = startDate.withDayOfMonth(1); + LocalDate endDate = normalizedStartDate.withDayOfMonth(normalizedStartDate.lengthOfMonth()); + return new ReportPeriod( + String.format("monthly-%s-%s", normalizedStartDate, endDate), + String.format("%d-%02d", normalizedStartDate.getYear(), normalizedStartDate.getMonthValue()), + normalizedStartDate, + endDate); + } + + private ReportPeriod buildProjectPeriod(LocalDate startDate) { + LocalDate normalizedStartDate = startDate.getDayOfMonth() <= 15 + ? startDate.withDayOfMonth(1) + : startDate.withDayOfMonth(16); + boolean firstHalf = normalizedStartDate.getDayOfMonth() == 1; + LocalDate endDate = firstHalf + ? normalizedStartDate.withDayOfMonth(15) + : normalizedStartDate.withDayOfMonth(normalizedStartDate.lengthOfMonth()); + String half = firstHalf ? "上半月" : "下半月"; + return new ReportPeriod( + String.format("project-%s-%s-%d", normalizedStartDate, endDate, firstHalf ? 1 : 2), + String.format("%d-%02d %s", normalizedStartDate.getYear(), normalizedStartDate.getMonthValue(), half), + normalizedStartDate, + endDate); + } + + private DateRange normalizeDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + LocalDate normalizedStartDate = startDate == null ? endDate : startDate; + LocalDate normalizedEndDate = endDate == null ? startDate : endDate; + if (normalizedStartDate.isAfter(normalizedEndDate)) { + throw invalidParamException("周期起始日期不能晚于结束日期"); + } + return new DateRange(normalizedStartDate, normalizedEndDate); + } + private String normalizeReportType(String reportType) { if (WorkReportConstants.REPORT_TYPE_WEEKLY.equals(reportType) || WorkReportConstants.REPORT_TYPE_MONTHLY.equals(reportType) @@ -294,107 +553,105 @@ public class TeamWorkReportServiceImpl implements TeamWorkReportService { }; } + private String formatPeriodKeyForRemind(String reportType, String periodKey) { + if (!StringUtils.hasText(periodKey)) { + return ""; + } + try { + return switch (reportType) { + case WorkReportConstants.REPORT_TYPE_WEEKLY -> formatWeeklyPeriodKey(periodKey); + case WorkReportConstants.REPORT_TYPE_MONTHLY -> formatMonthlyPeriodKey(periodKey); + case WorkReportConstants.REPORT_TYPE_PROJECT -> formatProjectPeriodKey(periodKey); + default -> periodKey; + }; + } catch (RuntimeException ex) { + return periodKey; + } + } + + private String formatWeeklyPeriodKey(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 7 || !WorkReportConstants.REPORT_TYPE_WEEKLY.equals(parts[0])) { + return periodKey; + } + LocalDate startDate = parsePeriodDate(parts, 1); + LocalDate endDate = parsePeriodDate(parts, 4); + int weekOfYear = startDate.get(ChronoField.ALIGNED_WEEK_OF_YEAR); + return startDate.getYear() + "年" + weekOfYear + "周(" + formatMonthDayRange(startDate, endDate) + ")"; + } + + private String formatMonthlyPeriodKey(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 7 || !WorkReportConstants.REPORT_TYPE_MONTHLY.equals(parts[0])) { + return periodKey; + } + LocalDate startDate = parsePeriodDate(parts, 1); + return startDate.getYear() + "年" + startDate.getMonthValue() + "月"; + } + + private String formatProjectPeriodKey(String periodKey) { + String[] parts = periodKey.split("-"); + if (parts.length != 8 || !WorkReportConstants.REPORT_TYPE_PROJECT.equals(parts[0])) { + return periodKey; + } + LocalDate startDate = parsePeriodDate(parts, 1); + String half = switch (parts[7]) { + case "1" -> "上半月"; + case "2" -> "下半月"; + default -> startDate.getDayOfMonth() <= 15 ? "上半月" : "下半月"; + }; + return startDate.getYear() + "年" + startDate.getMonthValue() + "月" + half; + } + + private LocalDate parsePeriodDate(String[] parts, int startIndex) { + try { + return LocalDate.parse(parts[startIndex] + "-" + parts[startIndex + 1] + "-" + parts[startIndex + 2]); + } catch (DateTimeParseException ex) { + throw new IllegalArgumentException("Invalid period date", ex); + } + } + + private String formatMonthDayRange(LocalDate startDate, LocalDate endDate) { + return startDate.getMonthValue() + "月" + startDate.getDayOfMonth() + + "日-" + endDate.getMonthValue() + "月" + endDate.getDayOfMonth() + "日"; + } + + private String personalSlotKey(Long userId, String periodKey) { + return userId + ":" + periodKey; + } + + private String projectSlotKey(Long userId, Long projectId, String periodKey) { + return userId + ":" + projectId + ":" + periodKey; + } + private String defaultText(String text) { return StringUtils.hasText(text) ? text.trim() : ""; } - private ReportContext buildReportContextByDateRange(String reportType, LocalDate startDate, LocalDate endDate) { - if (WorkReportConstants.REPORT_TYPE_PROJECT.equals(reportType)) { - return buildProjectContextByDateRange(startDate, endDate); - } - List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); - if (subordinateIds.isEmpty()) { + private record DateRange(LocalDate startDate, LocalDate endDate) { + } + + private record ReportPeriod(String periodKey, String periodLabel, LocalDate startDate, LocalDate endDate) { + } + + private record ExpectedReportSlot( + String slotKey, + Long userId, + String periodKey, + String periodLabel, + Long projectId, + String projectName) { + } + + private record ActualReportSlot(String slotKey, String statusCode) { + } + + private record ReportContext( + List expectedSlots, + Set submittedSlotKeys, + Set pendingApprovalSlotKeys) { + private static ReportContext empty() { return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); } - if (WorkReportConstants.REPORT_TYPE_WEEKLY.equals(reportType)) { - return buildWeeklyContextByDateRange(startDate, endDate, subordinateIds); - } - return buildMonthlyContextByDateRange(startDate, endDate, subordinateIds); - } - - private ReportContext buildWeeklyContextByDateRange(LocalDate startDate, LocalDate endDate, - List subordinateIds) { - List reports = weeklyReportMapper.selectListByReporterIdsAndPeriodDateRange( - subordinateIds, startDate, endDate, SUBMITTED_STATUS_CODES); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); - for (WeeklyReportDO report : reports) { - if (report == null || report.getReporterId() == null) { - continue; - } - submittedUserIds.add(report.getReporterId()); - if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(report.getReporterId()); - } - } - return new ReportContext(subordinateIds, submittedUserIds, pendingApprovalUserIds); - } - - private ReportContext buildMonthlyContextByDateRange(LocalDate startDate, LocalDate endDate, - List subordinateIds) { - List reports = monthlyReportMapper.selectListByReporterIdsAndPeriodDateRange( - subordinateIds, startDate, endDate, SUBMITTED_STATUS_CODES); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); - for (MonthlyReportDO report : reports) { - if (report == null || report.getReporterId() == null) { - continue; - } - submittedUserIds.add(report.getReporterId()); - if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(report.getReporterId()); - } - } - return new ReportContext(subordinateIds, submittedUserIds, pendingApprovalUserIds); - } - - private ReportContext buildProjectContextByDateRange(LocalDate startDate, LocalDate endDate) { - List subordinateIds = teamDashboardAccessService.getAllSubordinateUserIds(); - if (subordinateIds.isEmpty()) { - return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); - } - List activeProjects = loadActiveProjectsForSubordinates(subordinateIds); - if (activeProjects.isEmpty()) { - return new ReportContext(Collections.emptyList(), Collections.emptySet(), Collections.emptySet()); - } - Map> projectsByOwner = activeProjects.stream() - .filter(Objects::nonNull) - .filter(project -> project.getManagerUserId() != null) - .collect(Collectors.groupingBy(ProjectDO::getManagerUserId, LinkedHashMap::new, Collectors.toList())); - LinkedHashSet expectedUserIds = new LinkedHashSet<>(projectsByOwner.keySet()); - List reports = projectReportMapper.selectListByProjectOwnerIdsAndPeriodDateRange( - expectedUserIds, startDate, endDate, SUBMITTED_STATUS_CODES); - Map> submittedProjectsByOwner = new HashMap<>(); - Set submittedUserIds = new LinkedHashSet<>(); - Set pendingApprovalUserIds = new LinkedHashSet<>(); - for (ProjectReportDO report : reports) { - if (report == null || report.getProjectOwnerId() == null || report.getProjectId() == null) { - continue; - } - Long ownerId = report.getProjectOwnerId(); - if (!expectedUserIds.contains(ownerId)) { - continue; - } - if (WorkReportConstants.STATUS_PENDING_APPROVAL.equals(report.getStatusCode())) { - pendingApprovalUserIds.add(ownerId); - } - submittedProjectsByOwner.computeIfAbsent(ownerId, key -> new LinkedHashSet<>()).add(report.getProjectId()); - } - for (Map.Entry> entry : projectsByOwner.entrySet()) { - Long ownerId = entry.getKey(); - Set submittedProjectIds = submittedProjectsByOwner.getOrDefault(ownerId, Collections.emptySet()); - boolean allSubmitted = entry.getValue().stream() - .map(ProjectDO::getId) - .filter(Objects::nonNull) - .allMatch(submittedProjectIds::contains); - if (allSubmitted) { - submittedUserIds.add(ownerId); - } - } - return new ReportContext(new ArrayList<>(expectedUserIds), submittedUserIds, pendingApprovalUserIds); - } - - private record ReportContext(List expectedUserIds, Set submittedUserIds, - Set pendingApprovalUserIds) { } }