feat: 实施工作时间配置系统

- 添加工作时间配置数据库迁移(V12__add_work_hours_config.sql)
- 创建WorkHoursCalculator工具类用于工作时间计算
- 添加工作时间配置API接口(SystemConfigController)
- 修改后端服务使用动态工作时间(TaskService、TaskItemService、TaskApprovalService、AnalyticsService)
- 在Settings.vue中添加工作时间配置UI
- 修改前端时效格式化函数使用动态工作时长(TaskHall.vue、CreateTask.vue)
- 所有时间计算(截止时间、延误扣分、时效格式化)现在使用配置的工作时间,替换硬编码的8小时
This commit is contained in:
Your Name 2026-01-14 11:40:43 +08:00
parent 51264aef06
commit 124e18ab78
13 changed files with 900 additions and 160 deletions

View File

@ -3,11 +3,15 @@ package com.designhub.controller;
import com.designhub.common.Result; import com.designhub.common.Result;
import com.designhub.entity.SystemConfig; import com.designhub.entity.SystemConfig;
import com.designhub.service.SystemConfigService; import com.designhub.service.SystemConfigService;
import com.designhub.util.WorkHoursCalculator;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -21,6 +25,64 @@ import java.util.Map;
public class SystemConfigController { public class SystemConfigController {
private final SystemConfigService systemConfigService; 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<WorkHoursConfigDTO> 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<Boolean> 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<String, String> 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 = "获取所有配置列表") @Operation(summary = "获取所有配置列表")
@GetMapping @GetMapping

View File

@ -41,6 +41,7 @@ public class AnalyticsService {
private final DesignerTargetScoreMapper designerTargetScoreMapper; private final DesignerTargetScoreMapper designerTargetScoreMapper;
private final DemandTypeMapper demandTypeMapper; private final DemandTypeMapper demandTypeMapper;
private final ProgressDeductionLogMapper progressDeductionLogMapper; private final ProgressDeductionLogMapper progressDeductionLogMapper;
private final com.designhub.util.WorkHoursCalculator workHoursCalculator;
/** /**
* 获取需求统计数据(根据角色返回不同维度的数据) * 获取需求统计数据(根据角色返回不同维度的数据)
@ -81,13 +82,13 @@ public class AnalyticsService {
.le(Task::getCreateTime, endTime); .le(Task::getCreateTime, endTime);
// 如果指定了部门ID,则按部门过滤 // 如果指定了部门ID,则按部门过滤
if (deptId != null) { if (deptId != null) {
queryWrapper.eq(Task::getPublisherDeptId, deptId); queryWrapper.eq(Task::getPublisherDeptId, deptId);
log.info("按部门过滤: deptId={}", deptId); log.info("按部门过滤: deptId={}", deptId);
} }
// 如果指定了用户ID,则按用户过滤 // 如果指定了用户ID,则按用户过滤
if (userId != null) { if (userId != null) {
queryWrapper.eq(Task::getPublisherId, userId); queryWrapper.eq(Task::getPublisherId, userId);
log.info("按用户过滤: userId={}", userId); log.info("按用户过滤: userId={}", userId);
} }
@ -690,12 +691,12 @@ public class AnalyticsService {
// 计算延误扣分(如果超时完成) // 计算延误扣分(如果超时完成)
if (task.getDeadline() != null && task.getActualEndTime() != null if (task.getDeadline() != null && task.getActualEndTime() != null
&& task.getActualEndTime().isAfter(task.getDeadline())) { && task.getActualEndTime().isAfter(task.getDeadline())) {
// 计算延误小时数 // 计算延误小时数(只计算工作时间段内的延误)
long overdueHours = Duration.between(task.getDeadline(), task.getActualEndTime()).toHours(); BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), task.getActualEndTime());
if (overdueHours > 0 && taskScore.compareTo(BigDecimal.ZERO) > 0) { if (overdueHours.compareTo(BigDecimal.ZERO) > 0 && taskScore.compareTo(BigDecimal.ZERO) > 0) {
// 扣分 = 任务积分 * 延误小时数 * 扣分比例 // 扣分 = 任务积分 * 延误小时数 * 扣分比例
BigDecimal taskDeduction = taskScore BigDecimal taskDeduction = taskScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
// 扣分不能超过任务积分 // 扣分不能超过任务积分
@ -711,12 +712,12 @@ public class AnalyticsService {
&& task.getDeadline().isBefore(endTime) // deadline在查询月份范围内 && task.getDeadline().isBefore(endTime) // deadline在查询月份范围内
&& now.isAfter(task.getDeadline()) // 当前时间已超过deadline && now.isAfter(task.getDeadline()) // 当前时间已超过deadline
&& taskScore.compareTo(BigDecimal.ZERO) > 0) { && taskScore.compareTo(BigDecimal.ZERO) > 0) {
// 计算延误小时数(当前时间 - deadline) // 计算延误小时数(只计算工作时间段内的延误)
long overdueHours = Duration.between(task.getDeadline(), now).toHours(); BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(task.getDeadline(), now);
if (overdueHours > 0) { if (overdueHours.compareTo(BigDecimal.ZERO) > 0) {
// 扣分 = 任务积分 * 延误小时数 * 扣分比例 // 扣分 = 任务积分 * 延误小时数 * 扣分比例
BigDecimal taskDeduction = taskScore BigDecimal taskDeduction = taskScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .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 if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0
&& item.getCreateTime() != null && item.getCompleteTime() != null) { && item.getCreateTime() != null && item.getCompleteTime() != null) {
// 计算子任务的预期deadline:createTime + estimatedHours // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算)
long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours());
LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes);
// 如果完成时间超过预期deadline,计算延误扣分 // 如果完成时间超过预期deadline,计算延误扣分
if (item.getCompleteTime().isAfter(expectedDeadline)) { if (expectedDeadline != null && item.getCompleteTime().isAfter(expectedDeadline)) {
BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO;
if (itemScore.compareTo(BigDecimal.ZERO) > 0) { 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 BigDecimal itemDeduction = itemScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .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 if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0
&& item.getCreateTime() != null) { && item.getCreateTime() != null) {
// 计算子任务的预期deadline:createTime + estimatedHours // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算)
long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours());
LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes);
// 如果当前时间超过预期deadline,计算延误扣分 // 如果当前时间超过预期deadline,计算延误扣分
if (now.isAfter(expectedDeadline)) { if (expectedDeadline != null && now.isAfter(expectedDeadline)) {
BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO;
if (itemScore.compareTo(BigDecimal.ZERO) > 0) { 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 BigDecimal itemDeduction = itemScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
@ -1497,13 +1496,13 @@ public class AnalyticsService {
} }
if (taskScore.compareTo(BigDecimal.ZERO) > 0) { 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 BigDecimal taskDeduction = taskScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
@ -1514,8 +1513,9 @@ public class AnalyticsService {
totalDelayDeduction = totalDelayDeduction.add(taskDeduction); totalDelayDeduction = totalDelayDeduction.add(taskDeduction);
long overdueHoursLong = overdueHours.longValue();
log.info("发现已完成延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}", log.info("发现已完成延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}",
task.getTaskNo(), taskScore, overdueHours, taskDeduction); task.getTaskNo(), taskScore, overdueHoursLong, taskDeduction);
delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder()
.taskId(task.getId()) .taskId(task.getId())
@ -1524,9 +1524,9 @@ public class AnalyticsService {
.taskScore(taskScore) .taskScore(taskScore)
.deadline(task.getDeadline()) .deadline(task.getDeadline())
.actualEndTime(task.getActualEndTime()) .actualEndTime(task.getActualEndTime())
.overdueHours(overdueHours) .overdueHours(overdueHoursLong)
.deductionScore(taskDeduction) .deductionScore(taskDeduction)
.deductionReason("任务延误 " + overdueHours + " 小时") .deductionReason("任务延误 " + overdueHoursLong + " 小时")
.build()); .build());
} }
} }
@ -1550,16 +1550,16 @@ public class AnalyticsService {
log.info("进行中任务积分计算: taskNo={}, taskScore={}", task.getTaskNo(), taskScore); log.info("进行中任务积分计算: taskNo={}, taskScore={}", task.getTaskNo(), taskScore);
if (taskScore.compareTo(BigDecimal.ZERO) > 0) { 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={}", log.info("进行中任务延误计算: taskNo={}, deadline={}, now={}, overdueHours={}",
task.getTaskNo(), task.getDeadline(), now, overdueHours); task.getTaskNo(), task.getDeadline(), now, overdueHours);
if (overdueHours > 0) { if (overdueHours.compareTo(BigDecimal.ZERO) > 0) {
// 计算扣分 // 计算扣分
BigDecimal taskDeduction = taskScore BigDecimal taskDeduction = taskScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
@ -1570,8 +1570,9 @@ public class AnalyticsService {
totalDelayDeduction = totalDelayDeduction.add(taskDeduction); totalDelayDeduction = totalDelayDeduction.add(taskDeduction);
long overdueHoursLong = overdueHours.longValue();
log.info("发现进行中延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}", log.info("发现进行中延误任务: taskNo={}, 任务积分={}, 延误小时={}, 扣分={}",
task.getTaskNo(), taskScore, overdueHours, taskDeduction); task.getTaskNo(), taskScore, overdueHoursLong, taskDeduction);
delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder()
.taskId(task.getId()) .taskId(task.getId())
@ -1580,9 +1581,9 @@ public class AnalyticsService {
.taskScore(taskScore) .taskScore(taskScore)
.deadline(task.getDeadline()) .deadline(task.getDeadline())
.actualEndTime(null) // 进行中任务没有完成时间 .actualEndTime(null) // 进行中任务没有完成时间
.overdueHours(overdueHours) .overdueHours(overdueHoursLong)
.deductionScore(taskDeduction) .deductionScore(taskDeduction)
.deductionReason("任务进行中,已延误 " + overdueHours + " 小时") .deductionReason("任务进行中,已延误 " + overdueHoursLong + " 小时")
.build()); .build());
} }
} }
@ -1616,22 +1617,21 @@ public class AnalyticsService {
if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0
&& item.getCreateTime() != null && item.getCompleteTime() != null) { && item.getCreateTime() != null && item.getCompleteTime() != null) {
// 计算子任务的预期deadline:createTime + estimatedHours // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算)
long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours());
LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes);
// 如果完成时间超过预期deadline,计算延误扣分 // 如果完成时间超过预期deadline,计算延误扣分
if (item.getCompleteTime().isAfter(expectedDeadline)) { if (expectedDeadline != null && item.getCompleteTime().isAfter(expectedDeadline)) {
BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO;
if (itemScore.compareTo(BigDecimal.ZERO) > 0) { 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 BigDecimal itemDeduction = itemScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
@ -1647,8 +1647,9 @@ public class AnalyticsService {
String taskNo = item.getItemNo() != null ? item.getItemNo() : ""; String taskNo = item.getItemNo() != null ? item.getItemNo() : "";
String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务"; String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务";
long overdueHoursLong = overdueHours.longValue();
log.info("发现已完成延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}", log.info("发现已完成延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}",
taskNo, itemScore, overdueHours, itemDeduction); taskNo, itemScore, overdueHoursLong, itemDeduction);
delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder()
.taskId(item.getTaskId()) // 使用主任务ID .taskId(item.getTaskId()) // 使用主任务ID
@ -1657,9 +1658,9 @@ public class AnalyticsService {
.taskScore(itemScore) .taskScore(itemScore)
.deadline(expectedDeadline) .deadline(expectedDeadline)
.actualEndTime(item.getCompleteTime()) .actualEndTime(item.getCompleteTime())
.overdueHours(overdueHours) .overdueHours(overdueHoursLong)
.deductionScore(itemDeduction) .deductionScore(itemDeduction)
.deductionReason("子任务延误 " + overdueHours + " 小时") .deductionReason("子任务延误 " + overdueHoursLong + " 小时")
.build()); .build());
} }
} }
@ -1672,22 +1673,21 @@ public class AnalyticsService {
if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0
&& item.getCreateTime() != null) { && item.getCreateTime() != null) {
// 计算子任务的预期deadline:createTime + estimatedHours // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算)
long estimatedMinutes = item.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours());
LocalDateTime expectedDeadline = item.getCreateTime().plusMinutes(estimatedMinutes);
// 如果当前时间超过预期deadline,计算延误扣分 // 如果当前时间超过预期deadline,计算延误扣分
if (now.isAfter(expectedDeadline)) { if (expectedDeadline != null && now.isAfter(expectedDeadline)) {
BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO; BigDecimal itemScore = item.getTotalScore() != null ? BigDecimal.valueOf(item.getTotalScore()) : BigDecimal.ZERO;
if (itemScore.compareTo(BigDecimal.ZERO) > 0) { 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 BigDecimal itemDeduction = itemScore
.multiply(BigDecimal.valueOf(overdueHours)) .multiply(overdueHours)
.multiply(deductionRate) .multiply(deductionRate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
@ -1703,8 +1703,9 @@ public class AnalyticsService {
String taskNo = item.getItemNo() != null ? item.getItemNo() : ""; String taskNo = item.getItemNo() != null ? item.getItemNo() : "";
String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务"; String taskTitle = mainTask != null ? mainTask.getTitle() : "子任务";
long overdueHoursLong = overdueHours.longValue();
log.info("发现进行中延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}", log.info("发现进行中延误子任务: itemNo={}, 任务积分={}, 延误小时={}, 扣分={}",
taskNo, itemScore, overdueHours, itemDeduction); taskNo, itemScore, overdueHoursLong, itemDeduction);
delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder() delayDeductions.add(DeductionDetailVO.DelayDeductionItem.builder()
.taskId(item.getTaskId()) // 使用主任务ID .taskId(item.getTaskId()) // 使用主任务ID
@ -1713,9 +1714,9 @@ public class AnalyticsService {
.taskScore(itemScore) .taskScore(itemScore)
.deadline(expectedDeadline) .deadline(expectedDeadline)
.actualEndTime(null) // 进行中子任务没有完成时间 .actualEndTime(null) // 进行中子任务没有完成时间
.overdueHours(overdueHours) .overdueHours(overdueHoursLong)
.deductionScore(itemDeduction) .deductionScore(itemDeduction)
.deductionReason("子任务进行中,已延误 " + overdueHours + " 小时") .deductionReason("子任务进行中,已延误 " + overdueHoursLong + " 小时")
.build()); .build());
} }
} }

View File

@ -43,6 +43,7 @@ public class TaskApprovalService {
private final ConfigService configService; private final ConfigService configService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DingTalkService dingTalkService; private final DingTalkService dingTalkService;
private final com.designhub.util.WorkHoursCalculator workHoursCalculator;
/** /**
* 需求部门负责人审核通过 * 需求部门负责人审核通过
@ -227,14 +228,11 @@ public class TaskApprovalService {
task.setStartTime(now); task.setStartTime(now);
task.setActualStartTime(now); task.setActualStartTime(now);
// 重新计算deadline:从接单时间开始计算 // 重新计算deadline:从接单时间开始计算(使用工作时间计算)
if (task.getEstimatedHours() != null) { if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(java.math.BigDecimal.ZERO) > 0) {
int hours = task.getEstimatedHours().intValue(); task.setDeadline(workHoursCalculator.calculateDeadline(now, task.getEstimatedHours()));
if (hours > 0) { log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}",
task.setDeadline(now.plusHours(hours)); now, task.getEstimatedHours(), task.getDeadline());
log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}",
now, hours, task.getDeadline());
}
} }
} else { } else {
task.setStatus("pending"); // 未指定则发布到大厅 task.setStatus("pending"); // 未指定则发布到大厅
@ -855,12 +853,9 @@ public class TaskApprovalService {
task.setStartTime(now); task.setStartTime(now);
task.setActualStartTime(now); task.setActualStartTime(now);
// 重新计算deadline:从接单时间开始计算 // 重新计算deadline:从接单时间开始计算(使用工作时间计算)
if (task.getEstimatedHours() != null) { if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(java.math.BigDecimal.ZERO) > 0) {
int hours = task.getEstimatedHours().intValue(); task.setDeadline(workHoursCalculator.calculateDeadline(now, task.getEstimatedHours()));
if (hours > 0) {
task.setDeadline(now.plusHours(hours));
}
} }
// 更新子任务状态为in_progress(第一组或全部,取决于流水线模式) // 更新子任务状态为in_progress(第一组或全部,取决于流水线模式)
@ -901,24 +896,14 @@ public class TaskApprovalService {
} }
/** /**
* 格式化预估工时显示 * 格式化预估工时显示(基于工作时间配置)
* 将小时数转换为 "X天X小时" 格式(8小时=1天)
*/ */
private String formatEstimatedHours(java.math.BigDecimal hours) { private String formatEstimatedHours(java.math.BigDecimal hours) {
if (hours == null) { if (hours == null) {
return "未设置"; return "未设置";
} }
int totalHours = hours.intValue(); String formatted = workHoursCalculator.formatWorkHours(hours);
int days = totalHours / 8; // WorkHoursCalculator 返回 "--" 表示无效,这里保持 "未设置" 的语义
int remainingHours = totalHours % 8; return "--".equals(formatted) ? "未设置" : formatted;
StringBuilder sb = new StringBuilder();
if (days > 0) {
sb.append(days).append("天");
}
if (remainingHours > 0 || days == 0) {
sb.append(remainingHours).append("小时");
}
return sb.toString();
} }
} }

View File

@ -44,6 +44,7 @@ public class TaskItemService {
private final DemandTypeMapper demandTypeMapper; private final DemandTypeMapper demandTypeMapper;
private final com.designhub.mapper.UserMapper userMapper; private final com.designhub.mapper.UserMapper userMapper;
private final ObjectMapper objectMapper; 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) { private String formatEstimatedHours(java.math.BigDecimal hours) {
if (hours == null) { if (hours == null) {
return "未设置"; return "未设置";
} }
int totalHours = hours.intValue(); String formatted = workHoursCalculator.formatWorkHours(hours);
int days = totalHours / 8; // WorkHoursCalculator 返回 "--" 表示无效,这里保持 "未设置" 的语义
int remainingHours = totalHours % 8; return "--".equals(formatted) ? "未设置" : formatted;
StringBuilder sb = new StringBuilder();
if (days > 0) {
sb.append(days).append("天");
}
if (remainingHours > 0 || days == 0) {
sb.append(remainingHours).append("小时");
}
return sb.toString();
} }
} }

View File

@ -60,6 +60,7 @@ public class TaskService {
private final DeptMonthlyScoreService deptMonthlyScoreService; private final DeptMonthlyScoreService deptMonthlyScoreService;
private final SystemConfigService systemConfigService; private final SystemConfigService systemConfigService;
private final com.designhub.mapper.DepartmentMapper departmentMapper; 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(); LocalDateTime now = LocalDateTime.now();
// 重新计算deadline:从接单时间开始计算,无论之前是否有deadline // 重新计算deadline:从接单时间开始计算,无论之前是否有deadline(使用工作时间计算)
LocalDateTime deadline = null; LocalDateTime deadline = null;
if (task.getEstimatedHours() != null) { if (task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) {
int hours = task.getEstimatedHours().intValue(); deadline = workHoursCalculator.calculateDeadline(now, task.getEstimatedHours());
if (hours > 0) { log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}",
deadline = now.plusHours(hours); now, task.getEstimatedHours(), deadline);
log.info("任务deadline已重新计算:从接单时间 {} 开始,预估 {} 小时,截止时间 {}",
now, hours, deadline);
}
} }
int updated = taskMapper.grabTask(taskId, userId, deptId, now, deadline); int updated = taskMapper.grabTask(taskId, userId, deptId, now, deadline);
@ -2599,19 +2597,20 @@ public class TaskService {
BigDecimal deductedScore = BigDecimal.ZERO; BigDecimal deductedScore = BigDecimal.ZERO;
if (task.getStartTime() != null && task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) { if (task.getStartTime() != null && task.getEstimatedHours() != null && task.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) {
// 计算预计完成时间(接单时间 + 预估工时) // 计算预计完成时间(接单时间 + 预估工时,使用工作时间计算)
long estimatedMinutes = task.getEstimatedHours().multiply(BigDecimal.valueOf(60)).longValue(); LocalDateTime expectedCompleteTime = workHoursCalculator.calculateDeadline(task.getStartTime(), task.getEstimatedHours());
LocalDateTime expectedCompleteTime = task.getStartTime().plusMinutes(estimatedMinutes);
// 判断是否延误 // 判断是否延误
if (completeTime.isAfter(expectedCompleteTime)) { if (expectedCompleteTime != null && completeTime.isAfter(expectedCompleteTime)) {
isDelayed = true; isDelayed = true;
task.setIsDelayed(1); task.setIsDelayed(1);
// 计算延误小时数 // 计算延误小时数(只计算工作时间段内的延误)
long delayMinutes = java.time.Duration.between(expectedCompleteTime, completeTime).toMinutes(); delayHours = workHoursCalculator.calculateDelayHours(expectedCompleteTime, completeTime);
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()); 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"); String penaltyPerHourStr = systemConfigService.getConfigValue("score.delay_penalty_per_hour");
@ -2878,20 +2877,10 @@ public class TaskService {
} }
/** /**
* 格式化工时(小时转天+小时,8小时=1天) * 格式化工时(小时转天+小时,基于工作时间配置)
*/ */
private String formatHours(BigDecimal hours) { private String formatHours(BigDecimal hours) {
if (hours == null) return "--"; return workHoursCalculator.formatWorkHours(hours);
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 + "小时";
}
} }
/** /**

View File

@ -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 + "小时";
}
}
}

View File

@ -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();

View File

@ -143,3 +143,30 @@ export const getReceivingDepartments = () => {
return request.get<any>('/designer-target-score/receiving-departments') return request.get<any>('/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<WorkHoursConfig>('/system-config/work-hours')
}
/**
* 更新工作时间配置
*/
export const updateWorkHoursConfig = (config: WorkHoursConfigUpdate) => {
return request.put<boolean>('/system-config/work-hours', config)
}

View File

@ -341,7 +341,10 @@ const getSubTaskStatusClass = (status: string) => {
} }
// 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间) // 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间)
// 只有已接单或进行中的子任务才显示截止时间
const getSubTaskDeadline = (subTask: TaskItem) => { const getSubTaskDeadline = (subTask: TaskItem) => {
// 只有进行中或已完成的子任务才显示截止时间
if (subTask.status !== 'in_progress' && subTask.status !== 'completed') return null
if (!subTask.createTime || !subTask.estimatedHours) return null if (!subTask.createTime || !subTask.estimatedHours) return null
// 从创建时间(接单时间)开始计算 // 从创建时间(接单时间)开始计算
const startTime = dayjs(subTask.createTime) const startTime = dayjs(subTask.createTime)

View File

@ -28,7 +28,7 @@
</div> </div>
<div class="estimate-tip"> <div class="estimate-tip">
<Icon name="info" size="xs" style="margin-right: 4px; opacity: 0.6;" /> <Icon name="info" size="xs" style="margin-right: 4px; opacity: 0.6;" />
<span>按8小时/工作日计算,超过8小时按整天计算</span> <span>按{{ workHoursPerDay }}小时/工作日计算</span>
</div> </div>
</div> </div>
</div> </div>
@ -547,7 +547,7 @@ import * as taskApi from '@/api/task'
import * as configApi from '@/api/config' import * as configApi from '@/api/config'
import * as uploadApi from '@/api/upload' import * as uploadApi from '@/api/upload'
import * as departmentApi from '@/api/department' import * as departmentApi from '@/api/department'
import { getSystemConfigsMapByGroup } from '@/api/system' import { getSystemConfigsMapByGroup, getWorkHoursConfig } from '@/api/system'
import { uploadFilesToS3 } from '@/api/file' import { uploadFilesToS3 } from '@/api/file'
import type { FormInstance } from 'ant-design-vue' import type { FormInstance } from 'ant-design-vue'
import type { DemandType, Brand, Platform, Category, TaskSubType } from '@/api/config' 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 platforms = computed(() => configStore.platforms)
const categories = computed(() => configStore.categories) const categories = computed(() => configStore.categories)
// 工作时间配置(用于时效格式化)
const workHoursPerDay = ref(9) // 默认9小时
// 层级任务类型数据 // 层级任务类型数据
const hierarchicalTaskTypes = ref<any[]>([]) const hierarchicalTaskTypes = ref<any[]>([])
@ -1003,29 +1006,19 @@ const formattedEstimatedTime = computed(() => {
} }
}) })
// 计算预计完成时间(考虑8小时工作制) // 计算预计完成时间(基于工作时间配置)
const estimatedDeadline = computed(() => { const estimatedDeadline = computed(() => {
const totalHours = estimatedTotalHours.value const totalHours = estimatedTotalHours.value
if (totalHours === 0) return '--' if (totalHours === 0) return '--'
const now = new Date() 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) const totalDays = Math.ceil(totalHours / workHours)
let remainingHours = totalHours % WORK_HOURS_PER_DAY // 按自然日计算(每工作日对应1个自然日)
const deadline = new Date(now.getTime() + totalDays * 24 * 60 * 60 * 1000)
// 如果超过8小时,按24小时(1天)计算 return formatDeadline(deadline)
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)
}
}) })
// 格式化截止时间 // 格式化截止时间
@ -1206,14 +1199,14 @@ const totalPipelineHours = computed(() => {
return pipelineGroups.value.reduce((sum, group) => sum + group.totalHours, 0).toFixed(1) return pipelineGroups.value.reduce((sum, group) => sum + group.totalHours, 0).toFixed(1)
}) })
// 格式化工时显示(8小时=1个工作日) // 格式化工时显示(基于工作时间配置)
const formatWorkHours = (hours: number): string => { const formatWorkHours = (hours: number): string => {
if (hours === 0) return '0小时' if (hours === 0) return '0小时'
const WORK_HOURS_PER_DAY = 8 const workHours = workHoursPerDay.value || 9 // 默认9小时
const days = Math.floor(hours / WORK_HOURS_PER_DAY) 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 => { 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) { } catch (error) {
console.error('加载发布类型配置失败:', error) console.error('加载发布类型配置失败:', error)
} }

View File

@ -74,6 +74,44 @@
</div> </div>
</a-form-item> </a-form-item>
<a-divider orientation="left">工作时间设置</a-divider>
<a-form-item label="上班开始时间">
<a-time-picker
v-model:value="workHoursSettings.startTime"
format="HH:mm"
value-format="HH:mm"
placeholder="请选择上班开始时间"
size="large"
style="width: 100%;"
/>
<div style="margin-top: 8px; font-size: 12px; color: #888;">
<Icon name="info" size="sm" color="#888" style="margin-right: 4px;" />
用于计算任务截止时间和延误扣分
</div>
</a-form-item>
<a-form-item label="上班结束时间">
<a-time-picker
v-model:value="workHoursSettings.endTime"
format="HH:mm"
value-format="HH:mm"
placeholder="请选择上班结束时间"
size="large"
style="width: 100%;"
/>
<div style="margin-top: 8px; font-size: 12px; color: #888;">
<Icon name="info" size="sm" color="#888" style="margin-right: 4px;" />
用于计算任务截止时间和延误扣分
</div>
</a-form-item>
<a-form-item v-if="workHoursSettings.workHoursPerDay > 0">
<div style="padding: 8px 12px; background: #f5f5f5; border-radius: 4px;">
<span style="font-size: 14px; color: #666;">每日工作小时数:</span>
<span style="font-size: 14px; font-weight: 500; color: #333;">{{ workHoursSettings.workHoursPerDay }} 小时</span>
</div>
</a-form-item>
<a-divider orientation="left">功能开关</a-divider> <a-divider orientation="left">功能开关</a-divider>
<a-form-item label="注册功能"> <a-form-item label="注册功能">
<a-switch <a-switch
@ -2367,8 +2405,11 @@ import {
saveDesignerTargetScore, saveDesignerTargetScore,
deleteDesignerTargetScore, deleteDesignerTargetScore,
getReceivingDepartments, getReceivingDepartments,
getWorkHoursConfig,
updateWorkHoursConfig,
type SystemConfig, type SystemConfig,
type DesignerTargetScore type DesignerTargetScore,
type WorkHoursConfig
} from '@/api/system' } from '@/api/system'
import { import {
getAllUsers, getAllUsers,
@ -2439,6 +2480,16 @@ const siteSettings = reactive({
registerEnabledConfigId: null as number | null registerEnabledConfigId: null as number | null
}) })
// 工作时间设置
const workHoursSettings = reactive({
startTime: '09:00' as string | null,
endTime: '18:00' as string | null,
workHoursPerDay: 0
})
const savingWorkHours = ref(false)
const loadingWorkHours = ref(false)
const loading = reactive({ const loading = reactive({
demandTypes: false, demandTypes: false,
taskSubTypes: false, taskSubTypes: false,
@ -4733,10 +4784,71 @@ const updateSiteInfo = () => {
siteSettingsStore.updateSiteSettings(siteSettings.siteName, siteSettings.logoUrl) 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(() => { onMounted(() => {
loadAllData() loadAllData()
// 加载网站设置 // 加载网站设置
loadSiteSettings() loadSiteSettings()
// 加载工作时间配置
loadWorkHours()
// 加载钉钉配置 // 加载钉钉配置
loadDingTalkConfig() loadDingTalkConfig()
// 加载任务发布类型配置 // 加载任务发布类型配置

View File

@ -2435,6 +2435,7 @@ import * as taskApi from '@/api/task'
import * as userApi from '@/api/user' import * as userApi from '@/api/user'
import * as configApi from '@/api/config' import * as configApi from '@/api/config'
import * as authApi from '@/api/auth' import * as authApi from '@/api/auth'
import { getWorkHoursConfig } from '@/api/system'
import type { Task, TaskApprovalDTO, TaskProduct } from '@/api/task' import type { Task, TaskApprovalDTO, TaskProduct } from '@/api/task'
import type { DesignerWithWorkload } from '@/api/user' import type { DesignerWithWorkload } from '@/api/user'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
@ -2657,6 +2658,9 @@ const brands = computed(() => configStore.brands)
const platforms = computed(() => configStore.platforms) const platforms = computed(() => configStore.platforms)
const categories = computed(() => configStore.categories) const categories = computed(() => configStore.categories)
// 工作时间配置(用于时效格式化)
const workHoursPerDay = ref(9) // 默认9小时
// 层级任务类型数据(用于级联选择器) // 层级任务类型数据(用于级联选择器)
const hierarchicalTaskTypes = ref<any[]>([]) const hierarchicalTaskTypes = ref<any[]>([])
@ -2863,12 +2867,12 @@ const formatDeadline = (deadline: string | undefined) => {
} }
} }
// 格式化时效要求(工时转换为天+小时,8小时=1天) // 格式化时效要求(工时转换为天+小时,基于工作时间配置)
const formatEstimatedTime = (hours: number | undefined) => { const formatEstimatedTime = (hours: number | undefined) => {
if (!hours) return '--' if (!hours) return '--'
const WORK_HOURS_PER_DAY = 8 const workHours = workHoursPerDay.value || 9 // 默认9小时
const days = Math.floor(hours / WORK_HOURS_PER_DAY) const days = Math.floor(hours / workHours)
const remainingHours = Math.round((hours % WORK_HOURS_PER_DAY) * 100) / 100 const remainingHours = Math.round((hours % workHours) * 100) / 100
if (days === 0) { if (days === 0) {
return Number.isInteger(remainingHours) ? `${remainingHours}小时` : `${remainingHours.toFixed(1)}小时` return Number.isInteger(remainingHours) ? `${remainingHours}小时` : `${remainingHours.toFixed(1)}小时`
@ -2910,13 +2914,16 @@ const getSubTaskStatusText = (status: string | undefined) => {
} }
// 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间) // 计算子任务完成截止日期(从接单开始计算+预计完成需要的时间)
// 只有已接单或进行中的子任务才显示截止时间
const getSubTaskDeadline = (subTask: any) => { const getSubTaskDeadline = (subTask: any) => {
// 只有进行中或已完成的子任务才显示截止时间
if (subTask.status !== 'in_progress' && subTask.status !== 'completed') return null
if (!subTask.createTime || !subTask.estimatedHours) return null if (!subTask.createTime || !subTask.estimatedHours) return null
// 从创建时间(接单时间)开始计算 // 从创建时间(接单时间)开始计算
const startTime = dayjs(subTask.createTime) const startTime = dayjs(subTask.createTime)
// 计算截止时间:创建时间 + 预计工时 // 计算截止时间:创建时间 + 预计工时
const deadline = startTime.add(subTask.estimatedHours, 'hour') const deadline = startTime.add(subTask.estimatedHours, 'hour')
// 格式化为"XX月XX日XX时" // 格式化为"XX月XX日HH时"
return deadline.format('MM月DD日HH时') return deadline.format('MM月DD日HH时')
} }

View File

@ -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. **历史数据重算**:提供工具重新计算历史任务的截止时间和延误扣分