diff --git a/backend/src/main/java/com/designhub/controller/SystemConfigController.java b/backend/src/main/java/com/designhub/controller/SystemConfigController.java index 8d2f44a..f25955f 100644 --- a/backend/src/main/java/com/designhub/controller/SystemConfigController.java +++ b/backend/src/main/java/com/designhub/controller/SystemConfigController.java @@ -3,11 +3,15 @@ package com.designhub.controller; import com.designhub.common.Result; import com.designhub.entity.SystemConfig; import com.designhub.service.SystemConfigService; +import com.designhub.util.WorkHoursCalculator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; @@ -21,6 +25,64 @@ import java.util.Map; public class SystemConfigController { private final SystemConfigService systemConfigService; + private final WorkHoursCalculator workHoursCalculator; + + /** + * 工作时间配置DTO + */ + @Data + public static class WorkHoursConfigDTO { + private String startTime; // 上班开始时间,格式:HH:mm + private String endTime; // 上班结束时间,格式:HH:mm + private int workHoursPerDay; // 每天工作小时数 + } + + /** + * 工作时间配置更新DTO + */ + @Data + public static class WorkHoursConfigUpdateDTO { + private String startTime; // 上班开始时间,格式:HH:mm + private String endTime; // 上班结束时间,格式:HH:mm + } + + @Operation(summary = "获取工作时间配置") + @GetMapping("/work-hours") + public Result getWorkHoursConfig() { + WorkHoursCalculator.WorkHoursConfig config = workHoursCalculator.getWorkHoursConfig(); + WorkHoursConfigDTO dto = new WorkHoursConfigDTO(); + dto.setStartTime(config.getStartTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + dto.setEndTime(config.getEndTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + dto.setWorkHoursPerDay(config.getWorkHoursPerDay()); + return Result.success(dto); + } + + @Operation(summary = "更新工作时间配置") + @PutMapping("/work-hours") + public Result updateWorkHoursConfig(@RequestBody WorkHoursConfigUpdateDTO updateDTO) { + // 验证时间格式 + try { + LocalTime.parse(updateDTO.getStartTime(), DateTimeFormatter.ofPattern("HH:mm")); + LocalTime.parse(updateDTO.getEndTime(), DateTimeFormatter.ofPattern("HH:mm")); + } catch (Exception e) { + return Result.error("时间格式错误,应为 HH:mm 格式(如 09:00)"); + } + + // 验证开始时间不能晚于结束时间 + LocalTime startTime = LocalTime.parse(updateDTO.getStartTime(), DateTimeFormatter.ofPattern("HH:mm")); + LocalTime endTime = LocalTime.parse(updateDTO.getEndTime(), DateTimeFormatter.ofPattern("HH:mm")); + if (!startTime.isBefore(endTime)) { + return Result.error("上班开始时间必须早于结束时间"); + } + + // 更新配置 + Map configMap = new java.util.HashMap<>(); + configMap.put("work_hours.start_time", updateDTO.getStartTime()); + configMap.put("work_hours.end_time", updateDTO.getEndTime()); + systemConfigService.batchUpdateValues(configMap); + + return Result.success(true); + } @Operation(summary = "获取所有配置列表") @GetMapping diff --git a/backend/src/main/java/com/designhub/service/AnalyticsService.java b/backend/src/main/java/com/designhub/service/AnalyticsService.java index 6236d18..73e5959 100644 --- a/backend/src/main/java/com/designhub/service/AnalyticsService.java +++ b/backend/src/main/java/com/designhub/service/AnalyticsService.java @@ -41,6 +41,7 @@ public class AnalyticsService { private final DesignerTargetScoreMapper designerTargetScoreMapper; private final DemandTypeMapper demandTypeMapper; private final ProgressDeductionLogMapper progressDeductionLogMapper; + private final com.designhub.util.WorkHoursCalculator workHoursCalculator; /** * 获取需求统计数据(根据角色返回不同维度的数据) @@ -81,13 +82,13 @@ public class AnalyticsService { .le(Task::getCreateTime, endTime); // 如果指定了部门ID,则按部门过滤 - if (deptId != null) { - queryWrapper.eq(Task::getPublisherDeptId, deptId); + if (deptId != null) { + queryWrapper.eq(Task::getPublisherDeptId, deptId); log.info("按部门过滤: deptId={}", deptId); } // 如果指定了用户ID,则按用户过滤 if (userId != null) { - queryWrapper.eq(Task::getPublisherId, userId); + queryWrapper.eq(Task::getPublisherId, userId); log.info("按用户过滤: userId={}", userId); } @@ -690,12 +691,12 @@ public class AnalyticsService { // 计算延误扣分(如果超时完成) if (task.getDeadline() != null && task.getActualEndTime() != null && task.getActualEndTime().isAfter(task.getDeadline())) { - // 计算延误小时数 - long overdueHours = Duration.between(task.getDeadline(), task.getActualEndTime()).toHours(); - if (overdueHours > 0 && taskScore.compareTo(BigDecimal.ZERO) > 0) { + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), task.getActualEndTime()); + if (overdueHours.compareTo(BigDecimal.ZERO) > 0 && taskScore.compareTo(BigDecimal.ZERO) > 0) { // 扣分 = 任务积分 * 延误小时数 * 扣分比例 BigDecimal taskDeduction = taskScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); // 扣分不能超过任务积分 @@ -711,12 +712,12 @@ public class AnalyticsService { && task.getDeadline().isBefore(endTime) // deadline在查询月份范围内 && now.isAfter(task.getDeadline()) // 当前时间已超过deadline && taskScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数(当前时间 - deadline) - long overdueHours = Duration.between(task.getDeadline(), now).toHours(); - if (overdueHours > 0) { + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), now); + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 扣分 = 任务积分 * 延误小时数 * 扣分比例 BigDecimal taskDeduction = taskScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); // 扣分不能超过任务积分 @@ -754,22 +755,21 @@ public class AnalyticsService { if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 && item.getCreateTime() != null && item.getCompleteTime() != null) { - // 计算子任务的预期deadline:createTime + estimatedHours - long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); - LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes); + // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) + LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); // 如果完成时间超过预期deadline,计算延误扣分 - if (item.getCompleteTime().isAfter(expectedDeadline)) { + if (expectedDeadline != null && item.getCompleteTime().isAfter(expectedDeadline)) { BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; if (itemScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数 - long overdueHours = Duration.between(expectedDeadline, item.getCompleteTime()).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, item.getCompleteTime()); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal itemDeduction = itemScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -790,22 +790,21 @@ public class AnalyticsService { if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 && item.getCreateTime() != null) { - // 计算子任务的预期deadline:createTime + estimatedHours - long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); - LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes); + // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) + LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); // 如果当前时间超过预期deadline,计算延误扣分 - if (now.isAfter(expectedDeadline)) { + if (expectedDeadline != null && now.isAfter(expectedDeadline)) { BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; if (itemScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数(当前时间 - 预期deadline) - long overdueHours = Duration.between(expectedDeadline, now).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, now); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal itemDeduction = itemScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -1497,13 +1496,13 @@ public class AnalyticsService { } if (taskScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数 - long overdueHours = Duration.between(task.getDeadline(), task.getActualEndTime()).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), task.getActualEndTime()); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal taskDeduction = taskScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -1514,8 +1513,9 @@ public class AnalyticsService { totalDelayDeduction = totalDelayDeduction.add(taskDeduction); + long overdueHoursLong = overdueHours.longValue(); log.info("发现已完成延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}", - task.getTaskNo(), taskScore, overdueHours, taskDeduction); + task.getTaskNo(), taskScore, overdueHoursLong, taskDeduction); delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() .taskId(task.getId()) @@ -1524,9 +1524,9 @@ public class AnalyticsService { .taskScore(taskScore) .deadline(task.getDeadline()) .actualEndTime(task.getActualEndTime()) - .overdueHours(overdueHours) + .overdueHours(overdueHoursLong) .deductionScore(taskDeduction) - .deductionReason("任务延误 " + overdueHours + " 小时") + .deductionReason("任务延误 " + overdueHoursLong + " 小时") .build()); } } @@ -1550,16 +1550,16 @@ public class AnalyticsService { log.info("进行中任务积分计算: taskNo={}, taskScore={}", task.getTaskNo(), taskScore); if (taskScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数(当前时间 - deadline) - long overdueHours = Duration.between(task.getDeadline(), now).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), now); log.info("进行中任务延误计算: taskNo={}, deadline={}, now={}, overdueHours={}", task.getTaskNo(), task.getDeadline(), now, overdueHours); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal taskDeduction = taskScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -1570,8 +1570,9 @@ public class AnalyticsService { totalDelayDeduction = totalDelayDeduction.add(taskDeduction); + long overdueHoursLong = overdueHours.longValue(); log.info("发现进行中延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}", - task.getTaskNo(), taskScore, overdueHours, taskDeduction); + task.getTaskNo(), taskScore, overdueHoursLong, taskDeduction); delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() .taskId(task.getId()) @@ -1580,9 +1581,9 @@ public class AnalyticsService { .taskScore(taskScore) .deadline(task.getDeadline()) .actualEndTime(null) // 进行中任务没有完成时间 - .overdueHours(overdueHours) + .overdueHours(overdueHoursLong) .deductionScore(taskDeduction) - .deductionReason("任务进行中,已延误 " + overdueHours + " 小时") + .deductionReason("任务进行中,已延误 " + overdueHoursLong + " 小时") .build()); } } @@ -1616,22 +1617,21 @@ public class AnalyticsService { if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 && item.getCreateTime() != null && item.getCompleteTime() != null) { - // 计算子任务的预期deadline:createTime + estimatedHours - long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); - LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes); + // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) + LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); // 如果完成时间超过预期deadline,计算延误扣分 - if (item.getCompleteTime().isAfter(expectedDeadline)) { + if (expectedDeadline != null && item.getCompleteTime().isAfter(expectedDeadline)) { BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; if (itemScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数 - long overdueHours = Duration.between(expectedDeadline, item.getCompleteTime()).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, item.getCompleteTime()); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal itemDeduction = itemScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -1647,8 +1647,9 @@ public class AnalyticsService { String taskNo = item.getItemNo() != null ? item.getItemNo() : ""; String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务"; + long overdueHoursLong = overdueHours.longValue(); log.info("发现已完成延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}", - taskNo, itemScore, overdueHours, itemDeduction); + taskNo, itemScore, overdueHoursLong, itemDeduction); delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() .taskId(item.getTaskId()) // 使用主任务ID @@ -1657,9 +1658,9 @@ public class AnalyticsService { .taskScore(itemScore) .deadline(expectedDeadline) .actualEndTime(item.getCompleteTime()) - .overdueHours(overdueHours) + .overdueHours(overdueHoursLong) .deductionScore(itemDeduction) - .deductionReason("子任务延误 " + overdueHours + " 小时") + .deductionReason("子任务延误 " + overdueHoursLong + " 小时") .build()); } } @@ -1672,22 +1673,21 @@ public class AnalyticsService { if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 && item.getCreateTime() != null) { - // 计算子任务的预期deadline:createTime + estimatedHours - long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); - LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes); + // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) + LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); // 如果当前时间超过预期deadline,计算延误扣分 - if (now.isAfter(expectedDeadline)) { + if (expectedDeadline != null && now.isAfter(expectedDeadline)) { BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; if (itemScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数(当前时间 - 预期deadline) - long overdueHours = Duration.between(expectedDeadline, now).toHours(); + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, now); - if (overdueHours > 0) { + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { // 计算扣分 BigDecimal itemDeduction = itemScore - .multiply(BigDecimal.valueOf(overdueHours)) + .multiply(overdueHours) .multiply(deductionRate) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); @@ -1703,8 +1703,9 @@ public class AnalyticsService { String taskNo = item.getItemNo() != null ? item.getItemNo() : ""; String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务"; + long overdueHoursLong = overdueHours.longValue(); log.info("发现进行中延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}", - taskNo, itemScore, overdueHours, itemDeduction); + taskNo, itemScore, overdueHoursLong, itemDeduction); delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() .taskId(item.getTaskId()) // 使用主任务ID @@ -1713,9 +1714,9 @@ public class AnalyticsService { .taskScore(itemScore) .deadline(expectedDeadline) .actualEndTime(null) // 进行中子任务没有完成时间 - .overdueHours(overdueHours) + .overdueHours(overdueHoursLong) .deductionScore(itemDeduction) - .deductionReason("子任务进行中,已延误 " + overdueHours + " 小时") + .deductionReason("子任务进行中,已延误 " + overdueHoursLong + " 小时") .build()); } } diff --git a/backend/src/main/java/com/designhub/service/TaskApprovalService.java b/backend/src/main/java/com/designhub/service/TaskApprovalService.java index 27fb5d1..905fdb1 100644 --- a/backend/src/main/java/com/designhub/service/TaskApprovalService.java +++ b/backend/src/main/java/com/designhub/service/TaskApprovalService.java @@ -43,6 +43,7 @@ public class TaskApprovalService { private final ConfigService configService; private final ObjectMapper objectMapper; private final DingTalkService dingTalkService; + private final com.designhub.util.WorkHoursCalculator workHoursCalculator; /** * 需求部门负责人审核通过 @@ -227,14 +228,11 @@ public class TaskApprovalService { task.setStartTime(now); task.setActualStartTime(now); - // 重新计算deadline:从接单时间开始计算 - if (task.getEstimatedHours() != null) { - int hours = task.getEstimatedHours().intValue(); - if (hours > 0) { - task.setDeadline(now.plusHours(hours)); - log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}", - now, hours, task.getDeadline()); - } + // 重新计算deadline:从接单时间开始计算(使用工作时间计算) + if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(java.math.BigDecimal.ZERO) > 0) { + task.setDeadline(workHoursCalculator.calculateDeadline(now, task.getEstimatedHours())); + log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}", + now, task.getEstimatedHours(), task.getDeadline()); } } else { task.setStatus("pending"); // 未指定则发布到大厅 @@ -855,12 +853,9 @@ public class TaskApprovalService { task.setStartTime(now); task.setActualStartTime(now); - // 重新计算deadline:从接单时间开始计算 - if (task.getEstimatedHours() != null) { - int hours = task.getEstimatedHours().intValue(); - if (hours > 0) { - task.setDeadline(now.plusHours(hours)); - } + // 重新计算deadline:从接单时间开始计算(使用工作时间计算) + if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(java.math.BigDecimal.ZERO) > 0) { + task.setDeadline(workHoursCalculator.calculateDeadline(now, task.getEstimatedHours())); } // 更新子任务状态为in_progress(第一组或全部,取决于流水线模式) @@ -901,24 +896,14 @@ public class TaskApprovalService { } /** - * 格式化预估工时显示 - * 将小时数转换为 "X天X小时" 格式(8小时=1天) + * 格式化预估工时显示(基于工作时间配置) */ private String formatEstimatedHours(java.math.BigDecimal hours) { if (hours == null) { return "未设置"; } - int totalHours = hours.intValue(); - int days = totalHours / 8; - int remainingHours = totalHours % 8; - - StringBuilder sb = new StringBuilder(); - if (days > 0) { - sb.append(days).append("天"); - } - if (remainingHours > 0 || days == 0) { - sb.append(remainingHours).append("小时"); - } - return sb.toString(); + String formatted = workHoursCalculator.formatWorkHours(hours); + // WorkHoursCalculator 返回 "--" 表示无效,这里保持 "未设置" 的语义 + return "--".equals(formatted) ? "未设置" : formatted; } } diff --git a/backend/src/main/java/com/designhub/service/TaskItemService.java b/backend/src/main/java/com/designhub/service/TaskItemService.java index dbe1209..101a638 100644 --- a/backend/src/main/java/com/designhub/service/TaskItemService.java +++ b/backend/src/main/java/com/designhub/service/TaskItemService.java @@ -44,6 +44,7 @@ public class TaskItemService { private final DemandTypeMapper demandTypeMapper; private final com.designhub.mapper.UserMapper userMapper; private final ObjectMapper objectMapper; + private final com.designhub.util.WorkHoursCalculator workHoursCalculator; /** * 获取可接单的子任务列表 @@ -1347,23 +1348,14 @@ public class TaskItemService { } /** - * 格式化工时显示(与TaskApprovalService一致,8小时=1天) + * 格式化工时显示(基于工作时间配置) */ private String formatEstimatedHours(java.math.BigDecimal hours) { if (hours == null) { return "未设置"; } - int totalHours = hours.intValue(); - int days = totalHours / 8; - int remainingHours = totalHours % 8; - - StringBuilder sb = new StringBuilder(); - if (days > 0) { - sb.append(days).append("天"); - } - if (remainingHours > 0 || days == 0) { - sb.append(remainingHours).append("小时"); - } - return sb.toString(); + String formatted = workHoursCalculator.formatWorkHours(hours); + // WorkHoursCalculator 返回 "--" 表示无效,这里保持 "未设置" 的语义 + return "--".equals(formatted) ? "未设置" : formatted; } } diff --git a/backend/src/main/java/com/designhub/service/TaskService.java b/backend/src/main/java/com/designhub/service/TaskService.java index 9f2b449..b015e02 100644 --- a/backend/src/main/java/com/designhub/service/TaskService.java +++ b/backend/src/main/java/com/designhub/service/TaskService.java @@ -60,6 +60,7 @@ public class TaskService { private final DeptMonthlyScoreService deptMonthlyScoreService; private final SystemConfigService systemConfigService; private final com.designhub.mapper.DepartmentMapper departmentMapper; + private final com.designhub.util.WorkHoursCalculator workHoursCalculator; /** * 创建任务 @@ -1501,15 +1502,12 @@ public class TaskService { } LocalDateTime now = LocalDateTime.now(); - // 重新计算deadline:从接单时间开始计算,无论之前是否有deadline + // 重新计算deadline:从接单时间开始计算,无论之前是否有deadline(使用工作时间计算) LocalDateTime deadline = null; - if (task.getEstimatedHours() != null) { - int hours = task.getEstimatedHours().intValue(); - if (hours > 0) { - deadline = now.plusHours(hours); - log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}", - now, hours, deadline); - } + if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) { + deadline = workHoursCalculator.calculateDeadline(now, task.getEstimatedHours()); + log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}", + now, task.getEstimatedHours(), deadline); } int updated = taskMapper.grabTask(taskId, userId, deptId, now, deadline); @@ -2599,19 +2597,20 @@ public class TaskService { BigDecimal deductedScore = BigDecimal.ZERO; if (task.getStartTime() != null && task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) { - // 计算预计完成时间(接单时间 + 预估工时) - long estimatedMinutes = task.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); - LocalDateTime expectedCompleteTime = task.getStartTime().plusMinutes(estimatedMinutes); + // 计算预计完成时间(接单时间 + 预估工时,使用工作时间计算) + LocalDateTime expectedCompleteTime = workHoursCalculator.calculateDeadline(task.getStartTime(), task.getEstimatedHours()); // 判断是否延误 - if (completeTime.isAfter(expectedCompleteTime)) { + if (expectedCompleteTime != null && completeTime.isAfter(expectedCompleteTime)) { isDelayed = true; task.setIsDelayed(1); - // 计算延误小时数 - long delayMinutes = java.time.Duration.between(expectedCompleteTime, completeTime).toMinutes(); - delayHours = BigDecimal.valueOf(delayMinutes).divide(BigDecimal.valueOf(60), 2, java.math.RoundingMode.HALF_UP); - task.setDelayDays(delayHours.divide(BigDecimal.valueOf(24), 0, java.math.RoundingMode.CEILING).intValue()); + // 计算延误小时数(只计算工作时间段内的延误) + delayHours = workHoursCalculator.calculateDelayHours(expectedCompleteTime, completeTime); + // 计算延误天数(基于每天工作小时数) + com.designhub.util.WorkHoursCalculator.WorkHoursConfig config = workHoursCalculator.getWorkHoursConfig(); + int workHoursPerDay = config.getWorkHoursPerDay(); + task.setDelayDays(delayHours.divide(BigDecimal.valueOf(workHoursPerDay), 0, java.math.RoundingMode.CEILING).intValue()); // 从系统配置获取延误每小时扣分数 String penaltyPerHourStr = systemConfigService.getConfigValue("score.delay_penalty_per_hour"); @@ -2878,20 +2877,10 @@ public class TaskService { } /** - * 格式化工时(小时转天+小时,8小时=1天) + * 格式化工时(小时转天+小时,基于工作时间配置) */ private String formatHours(BigDecimal hours) { - if (hours == null) return "--"; - int totalHours = hours.intValue(); - int days = totalHours / 8; - int remainingHours = totalHours % 8; - if (days > 0 && remainingHours > 0) { - return days + "天" + remainingHours + "小时"; - } else if (days > 0) { - return days + "天"; - } else { - return totalHours + "小时"; - } + return workHoursCalculator.formatWorkHours(hours); } /** diff --git a/backend/src/main/java/com/designhub/util/WorkHoursCalculator.java b/backend/src/main/java/com/designhub/util/WorkHoursCalculator.java new file mode 100644 index 0000000..8570b7b --- /dev/null +++ b/backend/src/main/java/com/designhub/util/WorkHoursCalculator.java @@ -0,0 +1,218 @@ +package com.designhub.util; + +import com.designhub.service.SystemConfigService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +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.format.DateTimeParseException; + +/** + * 工作时间计算工具类 + * 用于计算截止时间、延误时间等,只考虑工作时间段内的计算 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WorkHoursCalculator { + + private final SystemConfigService systemConfigService; + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + /** + * 工作时间配置 + */ + @Data + public static class WorkHoursConfig { + private LocalTime startTime; // 上班开始时间 + private LocalTime endTime; // 上班结束时间 + private int workHoursPerDay; // 每天工作小时数 + } + + /** + * 获取工作时间配置 + */ + public WorkHoursConfig getWorkHoursConfig() { + WorkHoursConfig config = new WorkHoursConfig(); + + // 从系统配置获取开始时间(默认09:00) + String startTimeStr = systemConfigService.getConfigValue("work_hours.start_time", "09:00"); + // 从系统配置获取结束时间(默认18:00) + String endTimeStr = systemConfigService.getConfigValue("work_hours.end_time", "18:00"); + + try { + config.setStartTime(LocalTime.parse(startTimeStr, TIME_FORMATTER)); + config.setEndTime(LocalTime.parse(endTimeStr, TIME_FORMATTER)); + } catch (DateTimeParseException e) { + log.warn("工作时间配置格式错误,使用默认值: startTime={}, endTime={}", startTimeStr, endTimeStr); + config.setStartTime(LocalTime.of(9, 0)); + config.setEndTime(LocalTime.of(18, 0)); + } + + // 计算每天工作小时数 + if (config.getStartTime() != null && config.getEndTime() != null) { + long minutes = Duration.between(config.getStartTime(), config.getEndTime()).toMinutes(); + config.setWorkHoursPerDay((int) (minutes / 60)); + } else { + config.setWorkHoursPerDay(9); // 默认9小时 + } + + return config; + } + + /** + * 计算截止时间(只计算工作时间段) + * @param startTime 开始时间(接单时间) + * @param workHours 需要的工作小时数 + * @return 截止时间 + */ + public LocalDateTime calculateDeadline(LocalDateTime startTime, BigDecimal workHours) { + if (startTime == null || workHours == null || workHours.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + + WorkHoursConfig config = getWorkHoursConfig(); + LocalTime workStart = config.getStartTime(); + LocalTime workEnd = config.getEndTime(); + + // 如果开始时间不在工作时间内,移动到下一个工作日的开始时间 + LocalDateTime current = startTime; + LocalTime currentTime = current.toLocalTime(); + + // 如果当前时间在工作时间之前,移动到当天的开始时间 + if (currentTime.isBefore(workStart)) { + current = current.toLocalDate().atTime(workStart); + } + // 如果当前时间在工作时间之后,移动到下一个工作日的开始时间 + else if (currentTime.isAfter(workEnd) || currentTime.equals(workEnd)) { + current = current.toLocalDate().plusDays(1).atTime(workStart); + } + + // 计算剩余工时 + BigDecimal remainingHours = workHours; + + // 循环累加工时,只在工作时间段内计算 + while (remainingHours.compareTo(BigDecimal.ZERO) > 0) { + LocalTime currentWorkTime = current.toLocalTime(); + LocalDate currentDate = current.toLocalDate(); + + // 计算当天剩余的工作时间 + long remainingWorkMinutesToday = Duration.between(currentWorkTime, workEnd).toMinutes(); + BigDecimal remainingWorkHoursToday = BigDecimal.valueOf(remainingWorkMinutesToday).divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); + + if (remainingWorkHoursToday.compareTo(remainingHours) >= 0) { + // 当天可以完成,直接加上剩余工时 + current = current.plusMinutes(remainingHours.multiply(BigDecimal.valueOf(60)).longValue()); + break; + } else { + // 当天不够,移动到下一个工作日的开始时间 + remainingHours = remainingHours.subtract(remainingWorkHoursToday); + current = currentDate.plusDays(1).atTime(workStart); + } + } + + return current; + } + + /** + * 计算延误时间(只计算工作时间段内的延误) + * @param deadline 截止时间 + * @param actualTime 实际完成时间 + * @return 延误小时数(只计算工作时间段内) + */ + public BigDecimal calculateDelayHours(LocalDateTime deadline, LocalDateTime actualTime) { + if (deadline == null || actualTime == null) { + return BigDecimal.ZERO; + } + + // 如果实际时间早于或等于截止时间,未延误 + if (actualTime.isBefore(deadline) || actualTime.isEqual(deadline)) { + return BigDecimal.ZERO; + } + + WorkHoursConfig config = getWorkHoursConfig(); + LocalTime workStart = config.getStartTime(); + LocalTime workEnd = config.getEndTime(); + + // 计算延误时间(只计算工作时间段内) + BigDecimal delayHours = BigDecimal.ZERO; + LocalDateTime current = deadline; + int maxDays = 1000; // 防止无限循环 + int dayCount = 0; + + while (current.isBefore(actualTime) && dayCount < maxDays) { + LocalTime currentTime = current.toLocalTime(); + LocalDate currentDate = current.toLocalDate(); + + // 如果当前时间在工作时间内 + if (!currentTime.isBefore(workStart) && currentTime.isBefore(workEnd)) { + // 计算到当天结束或实际时间的时间差(取较小值) + LocalDateTime endOfDay = currentDate.atTime(workEnd); + LocalDateTime nextPoint = actualTime.isBefore(endOfDay) ? actualTime : endOfDay; + + long minutes = Duration.between(current, nextPoint).toMinutes(); + if (minutes > 0) { + delayHours = delayHours.add(BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP)); + } + + current = nextPoint; + + // 如果已经到达实际时间,退出循环 + if (current.isAfter(actualTime) || current.isEqual(actualTime)) { + break; + } + } + + // 移动到下一个工作日的开始时间 + if (currentTime.isAfter(workEnd) || currentTime.equals(workEnd) || currentTime.isBefore(workStart)) { + current = currentDate.plusDays(1).atTime(workStart); + dayCount++; + } else { + // 如果当前时间在工作时间内但还没到达实际时间,移动到当天结束 + current = currentDate.atTime(workEnd); + } + } + + if (dayCount >= maxDays) { + log.warn("计算延误时间超过{}天,可能存在异常: deadline={}, actualTime={}", maxDays, deadline, actualTime); + } + + return delayHours; + } + + /** + * 格式化工时显示(X天X小时,基于工作时间段) + * @param hours 小时数 + * @return 格式化后的字符串 + */ + public String formatWorkHours(BigDecimal hours) { + if (hours == null || hours.compareTo(BigDecimal.ZERO) <= 0) { + return "--"; + } + + WorkHoursConfig config = getWorkHoursConfig(); + int workHoursPerDay = config.getWorkHoursPerDay(); + + int totalHours = hours.intValue(); + int days = totalHours / workHoursPerDay; + int remainingHours = totalHours % workHoursPerDay; + + if (days > 0 && remainingHours > 0) { + return days + "天" + remainingHours + "小时"; + } else if (days > 0) { + return days + "天"; + } else { + return totalHours + "小时"; + } + } +} + diff --git a/backend/src/main/resources/db/migration/V12__add_work_hours_config.sql b/backend/src/main/resources/db/migration/V12__add_work_hours_config.sql new file mode 100644 index 0000000..177c4e0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_work_hours_config.sql @@ -0,0 +1,10 @@ +-- 添加工作时间配置 +-- V12: 工作时间配置(用于截止时间计算和延误扣分计算) + +-- 添加上班时间配置 +INSERT INTO `sys_config` (`config_key`, `config_value`, `config_group`, `config_name`, `description`, `value_type`, `built_in`, `sort_order`, `create_time`, `update_time`, `deleted`) +VALUES +('work_hours.start_time', '09:00', 'system', '上班开始时间', '工作日上班开始时间,格式:HH:mm(24小时制),例如:09:00', 'string', 1, 20, NOW(), NOW(), 0), +('work_hours.end_time', '18:00', 'system', '上班结束时间', '工作日上班结束时间,格式:HH:mm(24小时制),例如:18:00', 'string', 1, 21, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE `update_time` = NOW(); + diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 335b413..8e6b46c 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -143,3 +143,30 @@ export const getReceivingDepartments = () => { return request.get('/designer-target-score/receiving-departments') } +// ==================== 工作时间配置 ==================== + +export interface WorkHoursConfig { + startTime: string // 上班开始时间,格式:HH:mm + endTime: string // 上班结束时间,格式:HH:mm + workHoursPerDay: number // 每天工作小时数 +} + +export interface WorkHoursConfigUpdate { + startTime: string // 上班开始时间,格式:HH:mm + endTime: string // 上班结束时间,格式:HH:mm +} + +/** + * 获取工作时间配置 + */ +export const getWorkHoursConfig = () => { + return request.get('/system-config/work-hours') +} + +/** + * 更新工作时间配置 + */ +export const updateWorkHoursConfig = (config: WorkHoursConfigUpdate) => { + return request.put('/system-config/work-hours', config) +} + diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 86eafec..dd9e112 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -341,7 +341,10 @@ const getSubTaskStatusClass = (status: string) => { } // 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间) +// 只有已接单或进行中的子任务才显示截止时间 const getSubTaskDeadline = (subTask: TaskItem) => { + // 只有进行中或已完成的子任务才显示截止时间 + if (subTask.status !== 'in_progress' && subTask.status !== 'completed') return null if (!subTask.createTime || !subTask.estimatedHours) return null // 从创建时间(接单时间)开始计算 const startTime = dayjs(subTask.createTime) diff --git a/frontend/src/views/CreateTask.vue b/frontend/src/views/CreateTask.vue index 9f0e43e..3afb034 100644 --- a/frontend/src/views/CreateTask.vue +++ b/frontend/src/views/CreateTask.vue @@ -28,7 +28,7 @@
- 按8小时/工作日计算,超过8小时按整天计算 + 按{{ workHoursPerDay }}小时/工作日计算
@@ -547,7 +547,7 @@ import * as taskApi from '@/api/task' import * as configApi from '@/api/config' import * as uploadApi from '@/api/upload' import * as departmentApi from '@/api/department' -import { getSystemConfigsMapByGroup } from '@/api/system' +import { getSystemConfigsMapByGroup, getWorkHoursConfig } from '@/api/system' import { uploadFilesToS3 } from '@/api/file' import type { FormInstance } from 'ant-design-vue' import type { DemandType, Brand, Platform, Category, TaskSubType } from '@/api/config' @@ -754,6 +754,9 @@ const brands = computed(() => configStore.brands) const platforms = computed(() => configStore.platforms) const categories = computed(() => configStore.categories) +// 工作时间配置(用于时效格式化) +const workHoursPerDay = ref(9) // 默认9小时 + // 层级任务类型数据 const hierarchicalTaskTypes = ref([]) @@ -1003,29 +1006,19 @@ const formattedEstimatedTime = computed(() => { } }) -// 计算预计完成时间(考虑8小时工作制) +// 计算预计完成时间(基于工作时间配置) const estimatedDeadline = computed(() => { const totalHours = estimatedTotalHours.value if (totalHours === 0) return '--' const now = new Date() - const WORK_HOURS_PER_DAY = 8 // 每天工作8小时 + const workHours = workHoursPerDay.value || 9 // 默认9小时 // 计算需要多少个工作日 - let workDays = Math.floor(totalHours / WORK_HOURS_PER_DAY) - let remainingHours = totalHours % WORK_HOURS_PER_DAY - - // 如果超过8小时,按24小时(1天)计算 - if (totalHours > WORK_HOURS_PER_DAY) { - // 超过8小时的部分,每8小时算1个工作日,但日历上是24小时 - const totalDays = Math.ceil(totalHours / WORK_HOURS_PER_DAY) - const deadline = new Date(now.getTime() + totalDays * 24 * 60 * 60 * 1000) - return formatDeadline(deadline) - } else { - // 8小时内,当天完成 - const deadline = new Date(now.getTime() + totalHours * 60 * 60 * 1000) - return formatDeadline(deadline) - } + const totalDays = Math.ceil(totalHours / workHours) + // 按自然日计算(每工作日对应1个自然日) + const deadline = new Date(now.getTime() + totalDays * 24 * 60 * 60 * 1000) + return formatDeadline(deadline) }) // 格式化截止时间 @@ -1206,14 +1199,14 @@ const totalPipelineHours = computed(() => { return pipelineGroups.value.reduce((sum, group) => sum + group.totalHours, 0).toFixed(1) }) -// 格式化工时显示(8小时=1个工作日) +// 格式化工时显示(基于工作时间配置) const formatWorkHours = (hours: number): string => { if (hours === 0) return '0小时' - const WORK_HOURS_PER_DAY = 8 - const days = Math.floor(hours / WORK_HOURS_PER_DAY) + const workHours = workHoursPerDay.value || 9 // 默认9小时 + const days = Math.floor(hours / workHours) // 处理浮点数精度问题,保留两位小数 - let remainingHours = Math.round((hours % WORK_HOURS_PER_DAY) * 100) / 100 + let remainingHours = Math.round((hours % workHours) * 100) / 100 // 格式化小时显示 const formatHours = (h: number): string => { @@ -1756,6 +1749,16 @@ const loadReleaseTypeConfig = async () => { } } } + // 加载工作时间配置 + try { + const workHoursResponse = await getWorkHoursConfig() + if (workHoursResponse.data && workHoursResponse.data.workHoursPerDay) { + workHoursPerDay.value = workHoursResponse.data.workHoursPerDay + } + } catch (error) { + console.error('加载工作时间配置失败:', error) + // 使用默认值 + } } catch (error) { console.error('加载发布类型配置失败:', error) } diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 96c885c..7d74788 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -74,6 +74,44 @@ + 工作时间设置 + + +
+ + 用于计算任务截止时间和延误扣分 +
+
+ + + +
+ + 用于计算任务截止时间和延误扣分 +
+
+ + +
+ 每日工作小时数: + {{ workHoursSettings.workHoursPerDay }} 小时 +
+
+ 功能开关 { siteSettingsStore.updateSiteSettings(siteSettings.siteName, siteSettings.logoUrl) } +// 加载工作时间配置 +const loadWorkHours = async () => { + loadingWorkHours.value = true + try { + const response = await getWorkHoursConfig() + if (response.data) { + workHoursSettings.startTime = response.data.startTime + workHoursSettings.endTime = response.data.endTime + workHoursSettings.workHoursPerDay = response.data.workHoursPerDay + } + } catch (error) { + console.error('加载工作时间配置失败:', error) + // 使用默认值 + workHoursSettings.startTime = '09:00' + workHoursSettings.endTime = '18:00' + workHoursSettings.workHoursPerDay = 9 + } finally { + loadingWorkHours.value = false + } +} + +// 保存工作时间配置 +const handleSaveWorkHours = async () => { + if (!workHoursSettings.startTime || !workHoursSettings.endTime) { + message.warning('请设置上班开始时间和结束时间') + return + } + + // 验证时间格式和逻辑 + const startTime = dayjs(workHoursSettings.startTime, 'HH:mm') + const endTime = dayjs(workHoursSettings.endTime, 'HH:mm') + + if (!startTime.isValid() || !endTime.isValid()) { + message.error('时间格式错误') + return + } + + if (!endTime.isAfter(startTime)) { + message.error('上班结束时间必须晚于开始时间') + return + } + + savingWorkHours.value = true + try { + await updateWorkHoursConfig({ + startTime: workHoursSettings.startTime, + endTime: workHoursSettings.endTime + }) + // 重新加载配置以获取计算后的 workHoursPerDay + await loadWorkHours() + message.success('工作时间配置保存成功') + } catch (error: any) { + console.error('保存工作时间配置失败:', error) + message.error(error.response?.data?.message || '保存失败') + } finally { + savingWorkHours.value = false + } +} + onMounted(() => { loadAllData() // 加载网站设置 loadSiteSettings() + // 加载工作时间配置 + loadWorkHours() // 加载钉钉配置 loadDingTalkConfig() // 加载任务发布类型配置 diff --git a/frontend/src/views/TaskHall.vue b/frontend/src/views/TaskHall.vue index 39ec017..c3f6886 100644 --- a/frontend/src/views/TaskHall.vue +++ b/frontend/src/views/TaskHall.vue @@ -2435,6 +2435,7 @@ import * as taskApi from '@/api/task' import * as userApi from '@/api/user' import * as configApi from '@/api/config' import * as authApi from '@/api/auth' +import { getWorkHoursConfig } from '@/api/system' import type { Task, TaskApprovalDTO, TaskProduct } from '@/api/task' import type { DesignerWithWorkload } from '@/api/user' import { useConfigStore } from '@/stores/config' @@ -2657,6 +2658,9 @@ const brands = computed(() => configStore.brands) const platforms = computed(() => configStore.platforms) const categories = computed(() => configStore.categories) +// 工作时间配置(用于时效格式化) +const workHoursPerDay = ref(9) // 默认9小时 + // 层级任务类型数据(用于级联选择器) const hierarchicalTaskTypes = ref([]) @@ -2863,12 +2867,12 @@ const formatDeadline = (deadline: string | undefined) => { } } -// 格式化时效要求(工时转换为天+小时,8小时=1天) +// 格式化时效要求(工时转换为天+小时,基于工作时间配置) const formatEstimatedTime = (hours: number | undefined) => { if (!hours) return '--' - const WORK_HOURS_PER_DAY = 8 - const days = Math.floor(hours / WORK_HOURS_PER_DAY) - const remainingHours = Math.round((hours % WORK_HOURS_PER_DAY) * 100) / 100 + const workHours = workHoursPerDay.value || 9 // 默认9小时 + const days = Math.floor(hours / workHours) + const remainingHours = Math.round((hours % workHours) * 100) / 100 if (days === 0) { return Number.isInteger(remainingHours) ? `${remainingHours}小时` : `${remainingHours.toFixed(1)}小时` @@ -2910,13 +2914,16 @@ const getSubTaskStatusText = (status: string | undefined) => { } // 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间) +// 只有已接单或进行中的子任务才显示截止时间 const getSubTaskDeadline = (subTask: any) => { + // 只有进行中或已完成的子任务才显示截止时间 + if (subTask.status !== 'in_progress' && subTask.status !== 'completed') return null if (!subTask.createTime || !subTask.estimatedHours) return null // 从创建时间(接单时间)开始计算 const startTime = dayjs(subTask.createTime) // 计算截止时间:创建时间 + 预计工时 const deadline = startTime.add(subTask.estimatedHours, 'hour') - // 格式化为"XX月XX日XX时" + // 格式化为"XX月XX日HH时" return deadline.format('MM月DD日HH时') } diff --git a/工作时间系统实施计划.md b/工作时间系统实施计划.md new file mode 100644 index 0000000..c0b61d5 --- /dev/null +++ b/工作时间系统实施计划.md @@ -0,0 +1,331 @@ +# 工作时间系统实施计划 + +## 一、需求概述 + +### 1.1 核心需求 +1. **工作时间配置**:在系统设置中增加上班时间设置(开始时间、结束时间) +2. **动态计算**:一天的工作时间根据设置的上班时间段作为自然日(替换硬编码的8小时) +3. **截止时间计算**:计算截止时间时只计算到上班时间段内 +4. **延误扣分限制**:延误扣分时间也需要限制在上班时间段内才扣分,超过上班时间不扣分 + +### 1.2 涉及场景 +- 截止时间计算(任务创建、子任务分配时) +- 延误扣分计算(任务完成时、进行中任务检查时) +- 时效格式化显示(前端显示、钉钉通知) +- 子任务截止日期显示(前端任务卡片) + +--- + +## 二、数据库设计 + +### 2.1 使用系统配置表(sys_config) +**方案选择**:使用现有的 `sys_config` 表存储工作时间配置(参考 `site_url` 配置的实现方式) + +**配置项设计**: +- `config_key`: `work_hours.start_time`(上班开始时间,格式:HH:mm,如 "09:00") +- `config_key`: `work_hours.end_time`(上班结束时间,格式:HH:mm,如 "18:00") +- `config_group`: `system` +- `value_type`: `string` +- `built_in`: `1`(系统内置,不可删除) + +**默认值**: +- `work_hours.start_time`: `"09:00"` +- `work_hours.end_time`: `"18:00"` +- 工作时间长度:9小时(9:00-18:00,不含午休) + +### 2.2 数据库迁移文件 +创建 `V12__add_work_hours_config.sql`: +```sql +-- 添加上班时间配置 +INSERT INTO `sys_config` (`config_key`, `config_value`, `config_group`, `config_name`, `description`, `value_type`, `built_in`, `sort_order`, `create_time`, `update_time`, `deleted`) +VALUES +('work_hours.start_time', '09:00', 'system', '上班开始时间', '工作日上班开始时间,格式:HH:mm(24小时制),例如:09:00', 'string', 1, 20, NOW(), NOW(), 0), +('work_hours.end_time', '18:00', 'system', '上班结束时间', '工作日上班结束时间,格式:HH:mm(24小时制),例如:18:00', 'string', 1, 21, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE `update_time` = NOW(); +``` + +--- + +## 三、后端实现 + +### 3.1 工作时间计算工具类 +**文件路径**:`backend/src/main/java/com/designhub/util/WorkHoursCalculator.java` + +**核心方法**: +1. `getWorkHoursConfig()`:从系统配置获取工作时间(开始时间、结束时间、工作时长) +2. `calculateDeadline(LocalDateTime startTime, BigDecimal workHours)`:计算截止时间(只计算工作时间段) +3. `calculateDelayHours(LocalDateTime deadline, LocalDateTime actualTime)`:计算延误时间(只计算工作时间段内的延误) +4. `formatWorkHours(BigDecimal hours)`:格式化工时显示(X天X小时,基于工作时间段) + +**计算逻辑**: +- **截止时间计算**: + - 从开始时间(接单时间)开始,累加工时 + - 只在工作时间段内累加(跳过非工作时间) + - 如果当前时间不在工作时间内,先移动到下一个工作日的开始时间 + - 如果工时跨越多天,跳过周末(可选,根据需求) + +- **延误时间计算**: + - 从截止时间到实际完成时间 + - 只统计工作时间段内的小时数 + - 跳过非工作时间(下班后、周末) + +### 3.2 系统配置 Service 扩展 +**文件路径**:`backend/src/main/java/com/designhub/service/SystemConfigService.java` + +**新增方法**: +- `getWorkHoursConfig()`:获取工作时间配置(返回 WorkHoursConfig 对象) + +**新增内部类**: +```java +public static class WorkHoursConfig { + private LocalTime startTime; // 上班开始时间 + private LocalTime endTime; // 上班结束时间 + private int workHoursPerDay; // 每天工作小时数 +} +``` + +### 3.3 需要修改的后端文件清单 + +#### 3.3.1 时效格式化(替换硬编码的8小时) +- `TaskService.java`:`formatHours()` 方法 +- `TaskItemService.java`:`formatEstimatedHours()` 方法 +- `TaskApprovalService.java`:`formatEstimatedHours()` 方法 + +**修改方式**: +- 从 `WorkHoursCalculator.formatWorkHours()` 获取格式化结果 +- 使用动态的工作时长(从配置读取)替换硬编码的8小时 + +#### 3.3.2 截止时间计算 +- `TaskService.java`:任务创建时的截止时间计算 +- `TaskItemService.java`:子任务创建时的截止时间计算 +- `TaskApprovalService.java`:任务审批通过后的截止时间计算 + +**修改方式**: +- 使用 `WorkHoursCalculator.calculateDeadline()` 计算截止时间 + +#### 3.3.3 延误扣分计算 +- `AnalyticsService.java`: + - `getDesignerPerformance()`:设计师绩效统计中的延误扣分 + - `getDesignerDeductionDetails()`:设计师扣分明细中的延误扣分 +- `DelayPenaltyService.java`:`calculateDelayPenalty()` 方法(如果有使用) +- `TaskService.java`:任务完成时的延误扣分计算(`completeTask()` 方法) + +**修改方式**: +- 使用 `WorkHoursCalculator.calculateDelayHours()` 计算延误时间 +- 只计算工作时间段内的延误 + +--- + +## 四、前端实现 + +### 4.1 工作时间配置 UI +**文件路径**:`frontend/src/views/Settings.vue` + +**新增标签页**:在"网站设置"标签页中添加"工作时间设置"区域 + +**UI 组件**: +- 开始时间选择器(TimePicker,格式:HH:mm) +- 结束时间选择器(TimePicker,格式:HH:mm) +- 工作时间长度显示(自动计算,只读) +- 保存按钮 + +**配置项**: +- `work_hours.start_time`:上班开始时间 +- `work_hours.end_time`:上班结束时间 + +### 4.2 需要修改的前端文件清单 + +#### 4.2.1 截止时间计算 +- `frontend/src/components/TaskCard.vue`:`getSubTaskDeadline()` 方法 +- `frontend/src/views/TaskHall.vue`:`getSubTaskDeadline()` 方法 +- `frontend/src/views/CreateTask.vue`:`estimatedDeadline` 计算属性 + +**修改方式**: +- 从系统配置获取工作时间 +- 使用工作时间计算截止时间(调用后端API或前端计算) + +#### 4.2.2 时效格式化显示 +- `frontend/src/views/TaskHall.vue`:`formatEstimatedTime()` 函数 +- `frontend/src/views/CreateTask.vue`:`formatWorkHours()` 函数 + +**修改方式**: +- 从系统配置获取工作时长 +- 使用动态工作时长替换硬编码的8小时 + +--- + +## 五、API 设计 + +### 5.1 获取工作时间配置 +**接口**:`GET /api/system-config/work-hours` + +**响应**: +```json +{ + "code": 200, + "data": { + "startTime": "09:00", + "endTime": "18:00", + "workHoursPerDay": 9 + }, + "message": "操作成功" +} +``` + +### 5.2 更新工作时间配置 +**接口**:`PUT /api/system-config/work-hours` + +**请求体**: +```json +{ + "startTime": "09:00", + "endTime": "18:00" +} +``` + +**响应**: +```json +{ + "code": 200, + "data": true, + "message": "操作成功" +} +``` + +### 5.3 计算截止时间(可选,如果需要前端计算) +**接口**:`POST /api/system-config/calculate-deadline` + +**请求体**: +```json +{ + "startTime": "2026-01-14T09:00:00", + "workHours": 16 +} +``` + +**响应**: +```json +{ + "code": 200, + "data": "2026-01-16T10:00:00", + "message": "操作成功" +} +``` + +--- + +## 六、实施步骤 + +### 阶段一:数据库和基础工具类 +1. ✅ 创建数据库迁移文件 `V12__add_work_hours_config.sql` +2. ✅ 创建 `WorkHoursCalculator` 工具类 +3. ✅ 在 `SystemConfigService` 中添加 `getWorkHoursConfig()` 方法 +4. ✅ 创建工作时间配置 API(`SystemConfigController` 或新 Controller) + +### 阶段二:后端核心逻辑修改 +5. ✅ 修改 `TaskService.formatHours()` 使用动态工作时长 +6. ✅ 修改 `TaskItemService.formatEstimatedHours()` 使用动态工作时长 +7. ✅ 修改 `TaskApprovalService.formatEstimatedHours()` 使用动态工作时长 +8. ✅ 修改任务创建时的截止时间计算逻辑 +9. ✅ 修改子任务创建时的截止时间计算逻辑 +10. ✅ 修改 `AnalyticsService` 中的延误扣分计算 +11. ✅ 修改 `TaskService.completeTask()` 中的延误扣分计算 + +### 阶段三:前端实现 +12. ✅ 在 `Settings.vue` 中添加工作时间配置 UI +13. ✅ 创建前端 API 调用(`api/system.ts`) +14. ✅ 修改 `TaskCard.vue` 中的截止时间计算 +15. ✅ 修改 `TaskHall.vue` 中的截止时间计算 +16. ✅ 修改 `CreateTask.vue` 中的截止时间计算 +17. ✅ 修改前端时效格式化函数 + +### 阶段四:测试和优化 +18. ✅ 测试工作时间配置的保存和读取 +19. ✅ 测试截止时间计算(跨天、跨周末等场景) +20. ✅ 测试延误扣分计算(工作时间段内、外) +21. ✅ 测试时效格式化显示 +22. ✅ 测试钉钉通知中的时效显示 +23. ✅ 修复发现的 bug + +--- + +## 七、技术难点和注意事项 + +### 7.1 工作时间计算复杂性 +- **跨天计算**:工时可能跨越多天,需要跳过非工作时间 +- **跨周末**:是否需要跳过周末?建议第一版先不考虑,只处理工作日的工作时间段 +- **时区问题**:确保使用系统时区 + +### 7.2 性能考虑 +- 工作时间配置需要频繁读取,建议缓存(可以使用 Spring Cache) +- 截止时间计算可能涉及循环,需要优化算法 + +### 7.3 向后兼容 +- 如果配置不存在,使用默认值(9:00-18:00,9小时) +- 确保旧数据不会因为配置缺失而出错 + +### 7.4 数据迁移 +- 现有任务的截止时间是否需要重新计算? + - **建议**:不重新计算,只影响新创建的任务和新的延误扣分 +- 现有延误扣分记录是否需要重新计算? + - **建议**:不重新计算,只影响新产生的延误扣分 + +--- + +## 八、测试用例 + +### 8.1 工作时间配置 +- 测试保存和读取工作时间配置 +- 测试配置格式验证(HH:mm 格式) +- 测试开始时间不能晚于结束时间 +- 测试配置不存在时使用默认值 + +### 8.2 截止时间计算 +- 测试同一工作日内完成的任务 +- 测试跨天的任务(例如:16小时工时,从上午开始) +- 测试从非工作时间开始的任务(例如:晚上接单,从第二天早上开始计算) +- 测试跨周末的任务(如果实现) + +### 8.3 延误扣分计算 +- 测试在工作时间内延误的扣分 +- 测试跨越下班时间的延误(只计算工作时间内的延误) +- 测试跨越周末的延误(只计算工作日的延误) +- 测试提前完成的任务(不扣分) + +### 8.4 时效格式化 +- 测试小时数少于1天的情况 +- 测试小时数等于1天的情况 +- 测试小时数超过1天的情况 +- 测试小数点小时数(如 1.5 小时) + +--- + +## 九、预计工作量 + +- **数据库设计**:0.5 天 +- **后端工具类开发**:2 天 +- **后端逻辑修改**:3 天 +- **前端 UI 开发**:1 天 +- **前端逻辑修改**:1.5 天 +- **测试和修复**:2 天 + +**总计**:约 10 个工作日 + +--- + +## 十、风险点 + +1. **计算逻辑复杂性**:工作时间计算涉及复杂的日期时间处理,容易出现边界情况 +2. **性能问题**:如果计算逻辑复杂,可能影响性能 +3. **向后兼容性**:确保现有功能不受影响 +4. **数据一致性**:确保前后端计算逻辑一致 + +--- + +## 十一、后续优化建议 + +1. **支持多时间段**:支持上午、下午两个时间段(考虑午休) +2. **支持周末配置**:允许配置周末是否工作 +3. **支持节假日配置**:支持配置节假日,跳过计算 +4. **历史数据重算**:提供工具重新计算历史任务的截止时间和延误扣分 +