diff --git a/backend/src/main/java/com/designhub/service/TaskApprovalService.java b/backend/src/main/java/com/designhub/service/TaskApprovalService.java index 81e23de..96b08cf 100644 --- a/backend/src/main/java/com/designhub/service/TaskApprovalService.java +++ b/backend/src/main/java/com/designhub/service/TaskApprovalService.java @@ -1067,11 +1067,32 @@ public class TaskApprovalService { .collect(Collectors.joining(",")); task.setTaskTypeIds(taskTypeIds); - // 如果只有一个任务类型,更新quantity字段 - if (updates.getTaskTypes().size() == 1) { - task.setQuantity(updates.getTaskTypes().get(0).getQuantity()); + // 🔥 兼容:更新taskTypeId为第一个任务类型(用于筛选/展示) + if (updates.getTaskTypes().get(0) != null && updates.getTaskTypes().get(0).getTaskTypeId() != null) { + task.setTaskTypeId(updates.getTaskTypes().get(0).getTaskTypeId()); } + // 🔥 重新计算总数量与总工时(多任务类型也要更新quantity,否则后续节点/页面会出现数量不一致) + int totalQuantity = 0; + java.math.BigDecimal totalEstimatedHours = java.math.BigDecimal.ZERO; + for (TaskUpdateDTO.TaskTypeUpdateDTO typeUpdate : updates.getTaskTypes()) { + if (typeUpdate == null || typeUpdate.getTaskTypeId() == null) { + continue; + } + Integer qty = typeUpdate.getQuantity(); + int q = qty != null ? qty : 0; + totalQuantity += q; + + DemandType demandType = configService.getDemandTypeById(typeUpdate.getTaskTypeId()); + if (demandType != null && demandType.getEstimatedHours() != null && q > 0) { + totalEstimatedHours = totalEstimatedHours.add( + demandType.getEstimatedHours().multiply(new java.math.BigDecimal(q)) + ); + } + } + task.setQuantity(totalQuantity); + task.setEstimatedHours(totalEstimatedHours.setScale(2, java.math.RoundingMode.HALF_UP)); + // 重新计算积分 java.math.BigDecimal totalScore = taskService.calculateTotalScoreDecimal(updates.getTaskTypes()); task.setScore(totalScore); @@ -1087,10 +1108,12 @@ public class TaskApprovalService { * 当审核人调整任务明细数量时,同步更新对应的子任务 */ private void updateTaskItemsQuantityAndScore(Long taskId, List taskTypes) { - // 获取该任务的所有子任务 + // 获取该任务的所有子任务(按sortOrder排序,确保与前端明细顺序一致) List taskItems = taskItemMapper.selectList( new LambdaQueryWrapper() .eq(TaskItem::getTaskId, taskId) + .orderByAsc(TaskItem::getSortOrder) + .orderByAsc(TaskItem::getId) ); if (taskItems == null || taskItems.isEmpty()) { @@ -1098,79 +1121,253 @@ public class TaskApprovalService { return; } - // 构建 taskTypeId -> (quantity, unitScore) 的映射 - java.util.Map typeUpdateMap = new java.util.HashMap<>(); + // 构建更新明细的数量队列(支持同一typeId出现多次的情况) + java.util.Map> updateQtyQueues = new java.util.HashMap<>(); + java.util.Map updateTypeCounts = new java.util.HashMap<>(); for (TaskUpdateDTO.TaskTypeUpdateDTO typeUpdate : taskTypes) { - typeUpdateMap.put(typeUpdate.getTaskTypeId(), typeUpdate); + if (typeUpdate == null || typeUpdate.getTaskTypeId() == null) { + continue; + } + int q = typeUpdate.getQuantity() != null ? typeUpdate.getQuantity() : 0; + updateQtyQueues.computeIfAbsent(typeUpdate.getTaskTypeId(), k -> new java.util.ArrayDeque<>()).add(q); + updateTypeCounts.merge(typeUpdate.getTaskTypeId(), 1, Integer::sum); } - // 遍历子任务,更新数量和积分 + // 解析现有 typeDetails 并统计明细条目数量(用于校验:仅允许改数量,不允许增删类型/条目) + java.util.Map existingTypeCounts = new java.util.HashMap<>(); + java.util.Map parsedDetailsByItemId = new java.util.HashMap<>(); + boolean hasAnyTypeDetails = false; + for (TaskItem item : taskItems) { - boolean needUpdate = false; - - // 🔥 先更新 typeDetails JSON 字段(如果存在) - // 这样后面可以从更新后的 typeDetails 中正确计算总积分 - if (item.getTypeDetails() != null && !item.getTypeDetails().isEmpty()) { - String updatedTypeDetails = updateTypeDetailsJson(item.getTypeDetails(), typeUpdateMap); - if (!item.getTypeDetails().equals(updatedTypeDetails)) { - item.setTypeDetails(updatedTypeDetails); - item.setUpdateTime(LocalDateTime.now()); - needUpdate = true; - log.info("更新子任务typeDetails: itemId={}, newTypeDetails={}", item.getId(), updatedTypeDetails); - - // 🔥 从更新后的 typeDetails 中重新计算总积分和总数量 - // 这是修复多任务类型子任务积分计算错误的关键 - java.math.BigDecimal newTotalScore = calculateTotalScoreFromTypeDetails(updatedTypeDetails); - int newTotalQuantity = calculateTotalQuantityFromTypeDetails(updatedTypeDetails); - - if (newTotalScore != null) { - item.setTotalScore(newTotalScore); - log.info("从typeDetails重新计算子任务总积分: itemId={}, newTotalScore={}", item.getId(), newTotalScore); - } - if (newTotalQuantity > 0) { - item.setQuantity(newTotalQuantity); - // 重新计算平均单价 - if (newTotalScore != null && newTotalQuantity > 0) { - item.setUnitScore(newTotalScore.divide(new java.math.BigDecimal(newTotalQuantity), 2, java.math.RoundingMode.HALF_UP)); - } - log.info("从typeDetails重新计算子任务总数量: itemId={}, newTotalQuantity={}", item.getId(), newTotalQuantity); + if (item.getTypeDetails() == null || item.getTypeDetails().isEmpty()) { + continue; + } + hasAnyTypeDetails = true; + try { + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(item.getTypeDetails()); + if (!rootNode.isArray()) { + continue; + } + com.fasterxml.jackson.databind.node.ArrayNode arrayNode = (com.fasterxml.jackson.databind.node.ArrayNode) rootNode; + parsedDetailsByItemId.put(item.getId(), arrayNode); + + for (int i = 0; i < arrayNode.size(); i++) { + com.fasterxml.jackson.databind.JsonNode detailNode = arrayNode.get(i); + if (detailNode != null && detailNode.has("typeId")) { + Long typeId = detailNode.get("typeId").asLong(); + existingTypeCounts.merge(typeId, 1, Integer::sum); } } - } else { - // 如果没有 typeDetails,使用原来的单类型逻辑 - if (item.getTaskTypeId() != null && typeUpdateMap.containsKey(item.getTaskTypeId())) { - TaskUpdateDTO.TaskTypeUpdateDTO typeUpdate = typeUpdateMap.get(item.getTaskTypeId()); - Integer newQuantity = typeUpdate.getQuantity(); - - if (newQuantity != null && !newQuantity.equals(item.getQuantity())) { - // 获取单价积分 - java.math.BigDecimal unitScore = item.getUnitScore(); - if (unitScore == null) { - DemandType demandType = configService.getDemandTypeById(item.getTaskTypeId()); - if (demandType != null && demandType.getUnitScore() != null) { - unitScore = demandType.getUnitScore(); - } else { - unitScore = java.math.BigDecimal.ZERO; - } - } - - item.setQuantity(newQuantity); - item.setTotalScore(unitScore.multiply(new java.math.BigDecimal(newQuantity))); - item.setUpdateTime(LocalDateTime.now()); - needUpdate = true; - - log.info("更新单类型子任务数量和积分: itemId={}, taskTypeId={}, newQuantity={}, newTotalScore={}", - item.getId(), item.getTaskTypeId(), newQuantity, item.getTotalScore()); + } catch (Exception e) { + log.warn("解析子任务 {} 的 typeDetails 失败,跳过该子任务更新: {}", item.getId(), e.getMessage()); + } + } + + if (hasAnyTypeDetails) { + // 🔥 校验:避免前端增删/重排明细导致后端无法安全同步,出现“看似保存后又恢复”的问题 + if (!existingTypeCounts.equals(updateTypeCounts)) { + log.warn("任务 {} 明细条目不匹配,拒绝更新。existingCounts={}, updateCounts={}", + taskId, existingTypeCounts, updateTypeCounts); + throw new BusinessException(ResultCode.PARAM_ERROR, + "任务明细调整仅支持修改数量(不支持新增/删除/合并明细条目)。如需增删任务类型,请前往“编辑任务”页面调整后再审核。"); + } + + // 缓存 DemandType(避免重复查询) + java.util.Map demandTypeCache = new java.util.HashMap<>(); + + // 遍历子任务,按明细条目顺序逐条消费队列,更新 quantity/totalScore,并重算子任务总量/总分/工时 + for (TaskItem item : taskItems) { + com.fasterxml.jackson.databind.node.ArrayNode arrayNode = parsedDetailsByItemId.get(item.getId()); + if (arrayNode == null) { + continue; + } + + boolean needUpdate = false; + int newTotalQuantity = 0; + java.math.BigDecimal newTotalScore = java.math.BigDecimal.ZERO; + java.math.BigDecimal newEstimatedHours = java.math.BigDecimal.ZERO; + + for (int i = 0; i < arrayNode.size(); i++) { + com.fasterxml.jackson.databind.JsonNode detailNode = arrayNode.get(i); + if (detailNode == null || !detailNode.has("typeId")) { + continue; } + + Long typeId = detailNode.get("typeId").asLong(); + java.util.Deque queue = updateQtyQueues.get(typeId); + if (queue == null || queue.isEmpty()) { + // counts已校验一致,这里理论上不会发生 + continue; + } + + Integer updatedQty = queue.pollFirst(); + int qty = updatedQty != null ? updatedQty : 0; + + com.fasterxml.jackson.databind.node.ObjectNode obj = (com.fasterxml.jackson.databind.node.ObjectNode) detailNode; + int oldQty = obj.has("quantity") ? obj.get("quantity").asInt() : 0; + if (oldQty != qty) { + obj.put("quantity", qty); + needUpdate = true; + log.info("更新子任务明细数量: itemId={}, typeId={}, oldQty={}, newQty={}", item.getId(), typeId, oldQty, qty); + } + + // unitScore:优先使用明细内的unitScore,缺失则从配置补齐 + java.math.BigDecimal unitScore = null; + if (obj.has("unitScore") && !obj.get("unitScore").isNull()) { + try { + unitScore = new java.math.BigDecimal(obj.get("unitScore").asText()); + } catch (Exception ignore) { + unitScore = null; + } + } + DemandType demandType = demandTypeCache.computeIfAbsent(typeId, id -> configService.getDemandTypeById(id)); + if (unitScore == null) { + unitScore = (demandType != null && demandType.getUnitScore() != null) ? demandType.getUnitScore() : java.math.BigDecimal.ZERO; + obj.put("unitScore", unitScore); + needUpdate = true; + } + + // 重新计算并写回 totalScore(保留2位小数) + java.math.BigDecimal lineTotalScore = unitScore.multiply(new java.math.BigDecimal(qty)) + .setScale(2, java.math.RoundingMode.HALF_UP); + + java.math.BigDecimal oldLineTotalScore = null; + if (obj.has("totalScore") && !obj.get("totalScore").isNull()) { + try { + oldLineTotalScore = new java.math.BigDecimal(obj.get("totalScore").asText()); + } catch (Exception ignore) { + oldLineTotalScore = null; + } + } + if (oldLineTotalScore == null || oldLineTotalScore.compareTo(lineTotalScore) != 0) { + obj.put("totalScore", lineTotalScore.doubleValue()); + needUpdate = true; + } + + newTotalQuantity += qty; + newTotalScore = newTotalScore.add(lineTotalScore); + + // 重新计算子任务工时 + if (demandType != null && demandType.getEstimatedHours() != null && qty > 0) { + newEstimatedHours = newEstimatedHours.add( + demandType.getEstimatedHours().multiply(new java.math.BigDecimal(qty)) + ); + } + } + + // 校验:确保每个typeId的队列都被完全消费 + // (如果出现异常未消费完全,会在方法末尾统一检测) + + // 更新子任务汇总字段(即使明细没变,也要修复历史不一致) + java.math.BigDecimal scoreScaled = newTotalScore.setScale(2, java.math.RoundingMode.HALF_UP); + java.math.BigDecimal hoursScaled = newEstimatedHours.setScale(2, java.math.RoundingMode.HALF_UP); + + if (item.getQuantity() == null || item.getQuantity() != newTotalQuantity) { + item.setQuantity(newTotalQuantity); + needUpdate = true; + } + if (item.getTotalScore() == null || item.getTotalScore().compareTo(scoreScaled) != 0) { + item.setTotalScore(scoreScaled); + needUpdate = true; + } + java.math.BigDecimal avgUnitScore = newTotalQuantity > 0 + ? scoreScaled.divide(new java.math.BigDecimal(newTotalQuantity), 2, java.math.RoundingMode.HALF_UP) + : java.math.BigDecimal.ZERO; + if (item.getUnitScore() == null || item.getUnitScore().compareTo(avgUnitScore) != 0) { + item.setUnitScore(avgUnitScore); + needUpdate = true; + } + if (item.getEstimatedHours() == null || item.getEstimatedHours().compareTo(hoursScaled) != 0) { + item.setEstimatedHours(hoursScaled); + needUpdate = true; + } + + if (needUpdate) { + try { + String updatedTypeDetails = objectMapper.writeValueAsString(arrayNode); + if (item.getTypeDetails() == null || !item.getTypeDetails().equals(updatedTypeDetails)) { + item.setTypeDetails(updatedTypeDetails); + } + } catch (Exception e) { + log.warn("序列化子任务 {} typeDetails 失败: {}", item.getId(), e.getMessage()); + } + + item.setUpdateTime(LocalDateTime.now()); + taskItemMapper.updateById(item); } } + // 检查是否仍有未消费的更新队列(理论上不应有) + boolean hasRemaining = updateQtyQueues.values().stream().anyMatch(q -> q != null && !q.isEmpty()); + if (hasRemaining) { + log.warn("任务 {} 明细更新存在未消费的更新队列: {}", taskId, updateQtyQueues); + throw new BusinessException(ResultCode.PARAM_ERROR, + "任务明细更新失败:明细条目不一致,请刷新页面后重试。"); + } + + log.info("完成子任务数量和积分更新(含重复typeId支持): taskId={}, 子任务数量={}", taskId, taskItems.size()); + return; + } + + // 没有typeDetails的旧数据:按 taskTypeId 顺序消费数量队列,更新 quantity/totalScore + java.util.Map demandTypeCache = new java.util.HashMap<>(); + for (TaskItem item : taskItems) { + if (item.getTaskTypeId() == null) { + continue; + } + java.util.Deque queue = updateQtyQueues.get(item.getTaskTypeId()); + if (queue == null || queue.isEmpty()) { + continue; + } + + Integer newQty = queue.pollFirst(); + int qty = newQty != null ? newQty : 0; + + DemandType demandType = demandTypeCache.computeIfAbsent(item.getTaskTypeId(), id -> configService.getDemandTypeById(id)); + java.math.BigDecimal unitScore = item.getUnitScore(); + if (unitScore == null) { + unitScore = (demandType != null && demandType.getUnitScore() != null) ? demandType.getUnitScore() : java.math.BigDecimal.ZERO; + } + + java.math.BigDecimal newTotalScore = unitScore.multiply(new java.math.BigDecimal(qty)).setScale(2, java.math.RoundingMode.HALF_UP); + java.math.BigDecimal newHours = java.math.BigDecimal.ZERO; + if (demandType != null && demandType.getEstimatedHours() != null && qty > 0) { + newHours = demandType.getEstimatedHours().multiply(new java.math.BigDecimal(qty)).setScale(2, java.math.RoundingMode.HALF_UP); + } + + boolean needUpdate = false; + if (item.getQuantity() == null || item.getQuantity() != qty) { + item.setQuantity(qty); + needUpdate = true; + } + if (item.getTotalScore() == null || item.getTotalScore().compareTo(newTotalScore) != 0) { + item.setTotalScore(newTotalScore); + needUpdate = true; + } + if (item.getUnitScore() == null || item.getUnitScore().compareTo(unitScore) != 0) { + item.setUnitScore(unitScore); + needUpdate = true; + } + if (item.getEstimatedHours() == null || item.getEstimatedHours().compareTo(newHours) != 0) { + item.setEstimatedHours(newHours); + needUpdate = true; + } + if (needUpdate) { + item.setUpdateTime(LocalDateTime.now()); taskItemMapper.updateById(item); } } - log.info("完成子任务数量和积分更新: taskId={}, 子任务数量={}", taskId, taskItems.size()); + // 如果还有未消费队列,说明更新类型与子任务不一致,拒绝更新避免出现数据看似回滚 + boolean hasRemaining = updateQtyQueues.values().stream().anyMatch(q -> q != null && !q.isEmpty()); + if (hasRemaining) { + log.warn("任务 {} 明细更新存在未消费的更新队列(旧数据模式): {}", taskId, updateQtyQueues); + throw new BusinessException(ResultCode.PARAM_ERROR, + "任务明细更新失败:子任务与任务明细不一致,请前往“编辑任务”页面调整。"); + } + + log.info("完成子任务数量和积分更新(旧数据模式): taskId={}, 子任务数量={}", taskId, taskItems.size()); } /**