package com.designhub.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.designhub.entity.Department; import com.designhub.entity.Task; import com.designhub.entity.TaskProgressSnapshot; import com.designhub.entity.User; import com.designhub.mapper.DepartmentMapper; import com.designhub.mapper.TaskMapper; import com.designhub.mapper.TaskProgressSnapshotMapper; import com.designhub.mapper.UserMapper; import com.designhub.vo.TaskProgressOverviewVO; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @Service @Slf4j @RequiredArgsConstructor public class TaskProgressSnapshotService { private final TaskMapper taskMapper; private final UserMapper userMapper; private final DepartmentMapper departmentMapper; private final TaskProgressSnapshotMapper snapshotMapper; private final ObjectMapper objectMapper; @Value("${analytics.task-progress.snapshot.enabled:true}") private boolean snapshotEnabled; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @Scheduled(cron = "0 0/10 * * * ?") public void scheduledSnapshot() { if (!snapshotEnabled) { return; } try { buildAndPersistSnapshot(); } catch (Exception ex) { log.error("构建任务进度快照失败", ex); } } public TaskProgressOverviewVO getOverview(String dateRange, String month, Long deptId) { TaskProgressOverviewVO overview = loadLatestSnapshot() .orElseGet(this::buildAndPersistSnapshot); return applyFilters(overview, dateRange, month, deptId); } private synchronized TaskProgressOverviewVO buildAndPersistSnapshot() { List tasks = loadTasks(); Map userMap = loadUsers(tasks); Map deptMap = loadDepartments(tasks); TaskProgressOverviewVO overview = buildOverview(tasks, userMap, deptMap); persistSnapshot(overview); return overview; } private List loadTasks() { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .select( Task::getId, Task::getTaskNo, Task::getTitle, Task::getStatus, Task::getProgress, Task::getPriority, Task::getScore, Task::getPublisherId, Task::getPublisherDeptId, Task::getReceiverId, Task::getReceiverDeptId, Task::getCreateTime, Task::getActualStartTime, Task::getStartTime, Task::getSubmitTime, Task::getActualEndTime, Task::getCompleteTime, Task::getDeadline, Task::getAuditTime, Task::getEstimatedHours, Task::getDeleted ) .eq(Task::getDeleted, 0) .ne(Task::getStatus, "draft"); return taskMapper.selectList(wrapper); } private Map loadUsers(List tasks) { Set userIds = tasks.stream() .flatMap(task -> java.util.stream.Stream.of(task.getPublisherId(), task.getReceiverId())) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (CollectionUtils.isEmpty(userIds)) { return new HashMap<>(); } return userMapper.selectBatchIds(userIds).stream() .collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a)); } private Map loadDepartments(List tasks) { Set deptIds = tasks.stream() .flatMap(task -> java.util.stream.Stream.of(task.getReceiverDeptId(), task.getPublisherDeptId())) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (CollectionUtils.isEmpty(deptIds)) { return new HashMap<>(); } return departmentMapper.selectBatchIds(deptIds).stream() .collect(Collectors.toMap(Department::getId, dept -> dept, (a, b) -> a)); } private TaskProgressOverviewVO buildOverview(List tasks, Map userMap, Map deptMap) { LocalDateTime now = LocalDateTime.now(); Map statusDistribution = tasks.stream() .collect(Collectors.groupingBy(task -> Optional.ofNullable(task.getStatus()).orElse("unknown"), Collectors.counting())); long total = tasks.size(); long pending = tasks.stream().filter(task -> TaskStatusGroups.PENDING.contains(task.getStatus())).count(); long inProgress = tasks.stream().filter(task -> TaskStatusGroups.IN_PROGRESS.contains(task.getStatus())).count(); long review = tasks.stream().filter(task -> TaskStatusGroups.REVIEW.contains(task.getStatus())).count(); long completed = tasks.stream().filter(task -> TaskStatusGroups.COMPLETED.contains(task.getStatus())).count(); long overdue = tasks.stream().filter(task -> isOverdue(task, now)).count(); long onTimeCompleted = tasks.stream().filter(task -> isCompletedOnTime(task)).count(); double completionRate = total > 0 ? completed * 100.0 / total : 0d; double onTimeRate = completed > 0 ? onTimeCompleted * 100.0 / completed : 0d; double avgCycle = tasks.stream() .filter(task -> task.getCompleteTime() != null) .mapToDouble(task -> Duration.between(task.getCreateTime(), task.getCompleteTime()).toMinutes() / 60.0) .average() .orElse(0d); TaskProgressOverviewVO.KpiMetrics metrics = TaskProgressOverviewVO.KpiMetrics.builder() .totalTasks(total) .pendingTasks(pending) .inProgressTasks(inProgress) .reviewTasks(review) .completedTasks(completed) .overdueTasks(overdue) .completionRate(round(completionRate)) .onTimeRate(round(onTimeRate)) .avgCycleHours(round(avgCycle)) .build(); List trend = buildTrend(tasks); List stageSegments = buildStageSegments(tasks); List departmentLoads = buildDepartmentLoads(tasks, deptMap); List taskPoints = buildTaskPoints(tasks, userMap, deptMap, now); List riskTasks = buildRiskTasks(taskPoints, now); metrics.setRiskTaskCount(riskTasks.size()); return TaskProgressOverviewVO.builder() .snapshotTime(now) .metrics(metrics) .statusDistribution(statusDistribution.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new))) .trend(trend) .stageSegments(stageSegments) .departmentLoads(departmentLoads) .riskTasks(riskTasks) .taskPoints(taskPoints) .build(); } private List buildTrend(List tasks) { LocalDate today = LocalDate.now(); LocalDate start = today.minusDays(29); Map map = new LinkedHashMap<>(); for (int i = 0; i < 30; i++) { LocalDate date = start.plusDays(i); map.put(date.format(DATE_FORMATTER), TaskProgressOverviewVO.TrendPoint.builder() .date(date.format(DATE_FORMATTER)) .createdCount(0) .completedCount(0) .build()); } for (Task task : tasks) { if (task.getCreateTime() != null) { LocalDate date = task.getCreateTime().toLocalDate(); if (!date.isBefore(start)) { map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> { point.setCreatedCount(point.getCreatedCount() + 1); return point; }); } } if (task.getCompleteTime() != null) { LocalDate date = task.getCompleteTime().toLocalDate(); if (!date.isBefore(start)) { map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> { point.setCompletedCount(point.getCompletedCount() + 1); return point; }); } } } return new ArrayList<>(map.values()); } private List buildStageSegments(List tasks) { AtomicLong approvalMinutes = new AtomicLong(); AtomicLong executionMinutes = new AtomicLong(); AtomicLong reviewMinutes = new AtomicLong(); AtomicLong approvalCount = new AtomicLong(); AtomicLong executionCount = new AtomicLong(); AtomicLong reviewCount = new AtomicLong(); tasks.forEach(task -> { if (task.getAuditTime() != null) { approvalMinutes.addAndGet(Duration.between(task.getCreateTime(), task.getAuditTime()).toMinutes()); approvalCount.incrementAndGet(); } LocalDateTime start = task.getActualStartTime() != null ? task.getActualStartTime() : task.getStartTime(); LocalDateTime end = task.getActualEndTime() != null ? task.getActualEndTime() : task.getCompleteTime(); if (start != null && end != null) { executionMinutes.addAndGet(Duration.between(start, end).toMinutes()); executionCount.incrementAndGet(); } if (task.getSubmitTime() != null && task.getCompleteTime() != null) { reviewMinutes.addAndGet(Duration.between(task.getSubmitTime(), task.getCompleteTime()).toMinutes()); reviewCount.incrementAndGet(); } }); double approvalAvg = approvalCount.get() == 0 ? 0d : approvalMinutes.get() / 60.0 / approvalCount.get(); double executionAvg = executionCount.get() == 0 ? 0d : executionMinutes.get() / 60.0 / executionCount.get(); double reviewAvg = reviewCount.get() == 0 ? 0d : reviewMinutes.get() / 60.0 / reviewCount.get(); double total = approvalAvg + executionAvg + reviewAvg; List segments = new ArrayList<>(); segments.add(buildStage("审批环节", approvalAvg, total)); segments.add(buildStage("制作环节", executionAvg, total)); segments.add(buildStage("评审环节", reviewAvg, total)); return segments; } private TaskProgressOverviewVO.StageSegment buildStage(String stage, double avg, double total) { double percentage = total > 0 ? avg / total * 100 : 0; return TaskProgressOverviewVO.StageSegment.builder() .stage(stage) .avgHours(round(avg)) .percentage(round(percentage)) .build(); } private List buildDepartmentLoads(List tasks, Map deptMap) { Map loads = new HashMap<>(); for (Task task : tasks) { Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId(); if (deptId == null) { deptId = -1L; } TaskProgressOverviewVO.DepartmentLoad load = loads.computeIfAbsent(deptId, id -> { String name = id == -1L ? "未分配" : deptMap.getOrDefault(id, new Department()).getDeptName(); return TaskProgressOverviewVO.DepartmentLoad.builder() .deptId(id) .deptName(name != null ? name : "未知部门") .build(); }); String status = Optional.ofNullable(task.getStatus()).orElse("unknown"); if (TaskStatusGroups.PENDING.contains(status)) { load.setPendingCount(load.getPendingCount() + 1); } else if (TaskStatusGroups.IN_PROGRESS.contains(status)) { load.setInProgressCount(load.getInProgressCount() + 1); } else if (TaskStatusGroups.REVIEW.contains(status)) { load.setReviewCount(load.getReviewCount() + 1); } else if (TaskStatusGroups.COMPLETED.contains(status)) { load.setCompletedCount(load.getCompletedCount() + 1); } if (isOverdue(task, LocalDateTime.now())) { load.setOverdueCount(load.getOverdueCount() + 1); } } loads.values().forEach(load -> { double workloadIndex = load.getPendingCount() * 0.5 + load.getInProgressCount() + load.getReviewCount() * 0.8 + load.getOverdueCount() * 1.2; load.setWorkloadIndex(round(workloadIndex)); }); return loads.values().stream() .sorted(Comparator.comparingDouble(TaskProgressOverviewVO.DepartmentLoad::getWorkloadIndex).reversed()) .collect(Collectors.toList()); } private List buildTaskPoints(List tasks, Map userMap, Map deptMap, LocalDateTime now) { return tasks.stream() .sorted(Comparator.comparing(Task::getCreateTime).reversed()) .limit(200) .map(task -> { User publisher = task.getPublisherId() != null ? userMap.get(task.getPublisherId()) : null; User receiver = task.getReceiverId() != null ? userMap.get(task.getReceiverId()) : null; Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId(); Department dept = deptId != null ? deptMap.get(deptId) : null; return TaskProgressOverviewVO.TaskPoint.builder() .taskId(task.getId()) .deptId(deptId) .taskNo(task.getTaskNo()) .title(task.getTitle()) .status(task.getStatus()) .progress(task.getProgress() != null ? task.getProgress() : 0) .priority(task.getPriority()) .score(task.getScore()) .publisherName(publisher != null ? publisher.getRealName() : "") .receiverName(receiver != null ? receiver.getRealName() : "") .deptName(dept != null ? dept.getDeptName() : "未分配") .createTime(task.getCreateTime()) .deadline(task.getDeadline()) .overdue(isOverdue(task, now)) .nearDeadline(isNearDeadline(task, now)) .build(); }) .collect(Collectors.toList()); } private List buildRiskTasks(List taskPoints, LocalDateTime now) { List risks = new LinkedList<>(); for (TaskProgressOverviewVO.TaskPoint point : taskPoints) { if (point.isOverdue() || point.isNearDeadline()) { TaskProgressOverviewVO.RiskTask risk = TaskProgressOverviewVO.RiskTask.builder() .taskId(point.getTaskId()) .deptId(point.getDeptId()) .taskNo(point.getTaskNo()) .title(point.getTitle()) .status(point.getStatus()) .deptName(point.getDeptName()) .ownerName(point.getReceiverName()) .deadline(point.getDeadline()) .createTime(point.getCreateTime()) .warningType(point.isOverdue() ? "overdue" : "near_deadline") .warningReason(point.isOverdue() ? "任务已超时" : "任务即将超时") .severity(point.isOverdue() ? "danger" : "warning") .build(); risks.add(risk); } } return risks.stream() .sorted(Comparator.comparing(TaskProgressOverviewVO.RiskTask::getSeverity) .thenComparing(risk -> Optional.ofNullable(risk.getDeadline()).orElse(now))) .limit(20) .collect(Collectors.toList()); } private void persistSnapshot(TaskProgressOverviewVO overview) { try { TaskProgressSnapshot snapshot = new TaskProgressSnapshot(); snapshot.setSnapshotTime(LocalDateTime.now()); snapshot.setMetricsJson(objectMapper.writeValueAsString(overview.getMetrics())); snapshot.setStatusDistributionJson(objectMapper.writeValueAsString(overview.getStatusDistribution())); snapshot.setTrendJson(objectMapper.writeValueAsString(overview.getTrend())); snapshot.setStageSegmentsJson(objectMapper.writeValueAsString(overview.getStageSegments())); snapshot.setDepartmentLoadJson(objectMapper.writeValueAsString(overview.getDepartmentLoads())); snapshot.setRiskTasksJson(objectMapper.writeValueAsString(overview.getRiskTasks())); snapshot.setTaskPointsJson(objectMapper.writeValueAsString(overview.getTaskPoints())); snapshotMapper.insert(snapshot); } catch (Exception ex) { log.error("写入任务进度快照失败", ex); } } private Optional loadLatestSnapshot() { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .orderByDesc(TaskProgressSnapshot::getSnapshotTime) .last("LIMIT 1"); TaskProgressSnapshot snapshot = snapshotMapper.selectOne(wrapper); if (snapshot == null) { return Optional.empty(); } try { return Optional.of(TaskProgressOverviewVO.builder() .snapshotTime(snapshot.getSnapshotTime()) .metrics(read(snapshot.getMetricsJson(), TaskProgressOverviewVO.KpiMetrics.class)) .statusDistribution(read(snapshot.getStatusDistributionJson(), new TypeReference>() {})) .trend(read(snapshot.getTrendJson(), new TypeReference>() {})) .stageSegments(read(snapshot.getStageSegmentsJson(), new TypeReference>() {})) .departmentLoads(read(snapshot.getDepartmentLoadJson(), new TypeReference>() {})) .riskTasks(read(snapshot.getRiskTasksJson(), new TypeReference>() {})) .taskPoints(read(snapshot.getTaskPointsJson(), new TypeReference>() {})) .build()); } catch (Exception ex) { log.error("解析任务进度快照失败", ex); return Optional.empty(); } } private T read(String json, TypeReference type) throws Exception { if (json == null) { return null; } return objectMapper.readValue(json, type); } private T read(String json, Class type) throws Exception { if (json == null) { return null; } return objectMapper.readValue(json, type); } private TaskProgressOverviewVO applyFilters(TaskProgressOverviewVO overview, String dateRange, String month, Long deptId) { if (overview == null) { return null; } LocalDateTime start = resolveStart(dateRange, month); LocalDateTime end = resolveEnd(month); List filteredTrend = Optional.ofNullable(overview.getTrend()) .map(ArrayList::new) .orElseGet(ArrayList::new); if (start != null) { filteredTrend = filteredTrend.stream() .filter(point -> { LocalDate date = LocalDate.parse(point.getDate(), DATE_FORMATTER); return !date.atStartOfDay().isBefore(start); }) .collect(Collectors.toList()); } List filteredTaskPoints = Optional.ofNullable(overview.getTaskPoints()) .map(ArrayList::new) .orElseGet(ArrayList::new); if (start != null) { filteredTaskPoints = filteredTaskPoints.stream() .filter(point -> point.getCreateTime() == null || !point.getCreateTime().isBefore(start)) .collect(Collectors.toList()); } if (end != null) { filteredTaskPoints = filteredTaskPoints.stream() .filter(point -> point.getCreateTime() == null || !point.getCreateTime().isAfter(end)) .collect(Collectors.toList()); } TaskProgressOverviewVO.KpiMetrics metrics = overview.getMetrics(); List deptLoads = Optional.ofNullable(overview.getDepartmentLoads()) .map(ArrayList::new) .orElseGet(ArrayList::new); List filteredRisks = Optional.ofNullable(overview.getRiskTasks()) .map(ArrayList::new) .orElseGet(ArrayList::new); if (deptId != null) { deptLoads = deptLoads.stream() .filter(load -> load.getDeptId() != null && load.getDeptId().equals(deptId)) .collect(Collectors.toList()); if (!deptLoads.isEmpty()) { TaskProgressOverviewVO.DepartmentLoad load = deptLoads.get(0); long totalTasks = load.getPendingCount() + load.getInProgressCount() + load.getReviewCount() + load.getCompletedCount(); double completionRate = totalTasks == 0 ? 0 : round((double) load.getCompletedCount() * 100 / totalTasks); metrics = TaskProgressOverviewVO.KpiMetrics.builder() .totalTasks(totalTasks) .pendingTasks(load.getPendingCount()) .inProgressTasks(load.getInProgressCount()) .reviewTasks(load.getReviewCount()) .completedTasks(load.getCompletedCount()) .overdueTasks(load.getOverdueCount()) .completionRate(completionRate) .onTimeRate(overview.getMetrics().getOnTimeRate()) .avgCycleHours(overview.getMetrics().getAvgCycleHours()) .riskTaskCount(filteredRisks.stream() .filter(risk -> deptId.equals(risk.getDeptId())) .count()) .build(); } Long targetDeptId = deptId; filteredTaskPoints = filteredTaskPoints.stream() .filter(point -> Objects.equals(point.getDeptId(), targetDeptId)) .collect(Collectors.toList()); filteredRisks = filteredRisks.stream() .filter(risk -> Objects.equals(risk.getDeptId(), targetDeptId)) .collect(Collectors.toList()); } if (metrics != null) { metrics.setRiskTaskCount(filteredRisks != null ? filteredRisks.size() : 0); } return TaskProgressOverviewVO.builder() .snapshotTime(overview.getSnapshotTime()) .metrics(metrics) .statusDistribution(overview.getStatusDistribution()) .trend(filteredTrend) .stageSegments(overview.getStageSegments()) .departmentLoads(deptLoads) .riskTasks(filteredRisks) .taskPoints(filteredTaskPoints) .build(); } private LocalDateTime resolveStart(String dateRange, String month) { if (month != null && !month.isEmpty()) { LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd")); return date.atStartOfDay(); } LocalDateTime now = LocalDateTime.now(); if (dateRange == null) { return null; } switch (dateRange) { case "week": return now.minusWeeks(1); case "month": return now.minusMonths(1); case "quarter": return now.minusMonths(3); case "year": return now.minusYears(1); default: return null; } } private LocalDateTime resolveEnd(String month) { if (month == null || month.isEmpty()) { return null; } LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd")); return date.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX); } private boolean isOverdue(Task task, LocalDateTime now) { return task.getDeadline() != null && task.getDeadline().isBefore(now) && !TaskStatusGroups.COMPLETED.contains(task.getStatus()); } private boolean isNearDeadline(Task task, LocalDateTime now) { return task.getDeadline() != null && !TaskStatusGroups.COMPLETED.contains(task.getStatus()) && Duration.between(now, task.getDeadline()).toHours() <= 12 && Duration.between(now, task.getDeadline()).toMinutes() >= 0; } private boolean isCompletedOnTime(Task task) { if (!TaskStatusGroups.COMPLETED.contains(task.getStatus())) { return false; } if (task.getDeadline() == null || task.getCompleteTime() == null) { return true; } return !task.getCompleteTime().isAfter(task.getDeadline()); } private double round(double value) { return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue(); } private static class TaskStatusGroups { private static final Set PENDING = Set.of("pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval"); private static final Set IN_PROGRESS = Set.of("in_progress"); private static final Set REVIEW = Set.of("review", "submitted"); private static final Set COMPLETED = Set.of("completed"); } }