package com.ruoyi.production.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.basic.mapper.ProductMapper; import com.ruoyi.basic.mapper.ProductModelMapper; import com.ruoyi.basic.pojo.Product; import com.ruoyi.basic.pojo.ProductModel; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.base.BaseException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.bean.BeanUtils; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.production.bean.dto.ProductionPlanDto; import com.ruoyi.production.bean.dto.ProductionPlanImportDto; import com.ruoyi.production.bean.vo.ProductionPlanVo; import com.ruoyi.production.mapper.ProductionOrderMapper; import com.ruoyi.production.mapper.ProductionPlanMapper; import com.ruoyi.production.pojo.ProductionOrder; import com.ruoyi.production.pojo.ProductionPlan; import com.ruoyi.production.service.ProductionOrderService; import com.ruoyi.production.service.ProductionPlanService; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class ProductionPlanServiceImpl extends ServiceImpl implements ProductionPlanService { private static final int PLAN_STATUS_WAIT = 0; private static final int PLAN_STATUS_PARTIAL = 1; private static final int PLAN_STATUS_ISSUED = 2; private final ProductionPlanMapper productionPlanMapper; private final ProductionOrderMapper productionOrderMapper; private final ProductionOrderService productionOrderService; private final ProductModelMapper productModelMapper; private final ProductMapper productMapper; @Override public IPage listPage(Page page, ProductionPlanDto productionPlanDto) { // 分页查询主生产计划列表 return productionPlanMapper.listPage(page, productionPlanDto); } /** * 合并生产计划并下发生产订单。 * 约束: * 1. 仅允许同一产品型号的计划合并; * 2. 已下发或部分下发的计划不允许再次合并; * 3. 下发数量不能大于所选计划剩余需求总量; * 4. 下发时统一调用 ProductionOrderService.saveProductionOrder,确保后续工艺/BOM/领料逻辑一致。 */ @Override @Transactional(rollbackFor = Exception.class) public boolean combine(ProductionPlanDto productionPlanDto) { // 基础入参校验:没有可下发计划则直接返回 false if (productionPlanDto == null || productionPlanDto.getIds() == null || productionPlanDto.getIds().isEmpty()) { return false; } // 去空、去重,得到本次参与合并的计划 ID List planIds = productionPlanDto.getIds().stream() .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); if (planIds.isEmpty()) { throw new ServiceException("下发失败,未选择生产计划"); } // 查询并校验计划是否都存在 List planLists = productionPlanMapper.selectWithMaterialByIds(planIds); if (planLists == null || planLists.isEmpty() || planLists.size() != planIds.size()) { throw new ServiceException("下发失败,生产计划不存在或已被删除"); } // 以第一条计划作为型号基准 ProductionPlanDto firstPlan = planLists.getFirst(); if (firstPlan.getProductModelId() == null) { throw new ServiceException("下发失败,生产计划缺少产品型号"); } // 仅允许同型号计划合并下发 boolean hasDifferentModel = planLists.stream() .anyMatch(item -> !Objects.equals(item.getProductModelId(), firstPlan.getProductModelId())); if (hasDifferentModel) { throw new BaseException("合并失败,所选生产计划的产品型号不一致"); } // 已下发或部分下发的计划不允许再次合并 boolean hasIssuedPlan = planLists.stream() .anyMatch(item -> item.getStatus() != null && (item.getStatus() == PLAN_STATUS_PARTIAL || item.getStatus() == PLAN_STATUS_ISSUED)); if (hasIssuedPlan) { throw new BaseException("合并失败,所选生产计划存在已下发或部分下发的数据"); } // 计算本次可下发的剩余需求总量 BigDecimal totalRequiredQuantity = planLists.stream() .map(this::resolveRemainingQuantity) .reduce(BigDecimal.ZERO, BigDecimal::add); if (totalRequiredQuantity.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("下发失败,所选生产计划剩余需求总量必须大于0"); } // 校验下发数量 BigDecimal assignedQuantity = productionPlanDto.getTotalAssignedQuantity(); if (assignedQuantity == null || assignedQuantity.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("下发失败,下发数量必须大于0"); } if (assignedQuantity.compareTo(totalRequiredQuantity) > 0) { throw new ServiceException("下发失败,下发数量不能大于计划剩余需求总量"); } // 按计划顺序分摊下发数量,收集实际参与下发的计划 ID BigDecimal remainingForOrderBind = assignedQuantity; List issuedPlanIds = new ArrayList<>(); for (ProductionPlanDto plan : planLists) { BigDecimal remainingQuantity = resolveRemainingQuantity(plan); if (remainingForOrderBind.compareTo(BigDecimal.ZERO) <= 0 || remainingQuantity.compareTo(BigDecimal.ZERO) <= 0) { continue; } BigDecimal issueForThisPlan = remainingForOrderBind.min(remainingQuantity); remainingForOrderBind = remainingForOrderBind.subtract(issueForThisPlan); if (issueForThisPlan.compareTo(BigDecimal.ZERO) > 0) { issuedPlanIds.add(plan.getId()); } } if (issuedPlanIds.isEmpty()) { throw new ServiceException("下发失败,无可下发数量"); } // 生成生产订单主单,并绑定本次下发关联的计划 ProductionOrder productionOrder = new ProductionOrder(); productionOrder.setProductionPlanIds(formatPlanIds(issuedPlanIds)); productionOrder.setProductModelId(firstPlan.getProductModelId()); productionOrder.setQuantity(assignedQuantity); productionOrder.setPlanCompleteTime(productionPlanDto.getPlanCompleteTime()); if (!productionOrderService.saveProductionOrder(productionOrder)) { throw new ServiceException("下发失败,生产订单保存失败"); } // 回写每条计划的累计下发量和状态 BigDecimal remainingAssignedQuantity = assignedQuantity; List updates = new ArrayList<>(); for (ProductionPlanDto plan : planLists) { BigDecimal requiredQuantity = Optional.ofNullable(plan.getQtyRequired()).orElse(BigDecimal.ZERO); if (requiredQuantity.compareTo(BigDecimal.ZERO) < 0) { requiredQuantity = BigDecimal.ZERO; } BigDecimal remainingQuantity = resolveRemainingQuantity(plan); BigDecimal historicalIssuedQuantity = requiredQuantity.subtract(remainingQuantity); BigDecimal issuedQuantity = BigDecimal.ZERO; if (remainingAssignedQuantity.compareTo(BigDecimal.ZERO) > 0 && remainingQuantity.compareTo(BigDecimal.ZERO) > 0) { issuedQuantity = remainingAssignedQuantity.min(remainingQuantity); remainingAssignedQuantity = remainingAssignedQuantity.subtract(issuedQuantity); } BigDecimal totalIssuedQuantity = historicalIssuedQuantity.add(issuedQuantity); int planStatus = resolvePlanStatus(requiredQuantity, totalIssuedQuantity); ProductionPlan update = new ProductionPlan(); update.setId(plan.getId()); update.setStatus(planStatus); update.setQuantityIssued(totalIssuedQuantity); updates.add(update); } if (!updates.isEmpty()) { // 批量更新计划状态与数量 this.updateBatchById(updates); } return true; } @Override @Transactional(rollbackFor = Exception.class) public boolean add(ProductionPlanDto dto) { // 新增主生产计划 if (StringUtils.isBlank(dto.getMpsNo())) { String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); dto.setMpsNo(buildPlanNo(datePrefix, resolveNextPlanSequence(datePrefix))); } else { checkMpsNoUnique(dto.getMpsNo(), null); } dto.setStatus(PLAN_STATUS_WAIT); if (StringUtils.isBlank(dto.getSource())) { dto.setSource("内部"); } return productionPlanMapper.insert(dto) > 0; } @Override @Transactional(rollbackFor = Exception.class) public boolean update(ProductionPlanDto dto) { // 更新主生产计划 if (dto == null || dto.getId() == null) { throw new ServiceException("编辑失败,数据不能为空"); } ProductionPlan old = getById(dto.getId()); if (old == null) { throw new ServiceException("编辑失败,生产计划不存在"); } if (old.getStatus() != PLAN_STATUS_WAIT) { throw new BaseException("编辑失败,该生产计划已下发或部分下发,禁止编辑"); } if (StringUtils.isNotBlank(dto.getMpsNo()) && !dto.getMpsNo().equals(old.getMpsNo())) { checkMpsNoUnique(dto.getMpsNo(), dto.getId()); } return productionPlanMapper.updateById(dto) > 0; } @Override @Transactional(rollbackFor = Exception.class) public boolean delete(List ids) { // 删除主生产计划 if (productionPlanMapper.selectList(Wrappers.lambdaQuery().in(ProductionPlan::getId, ids)) .stream() .anyMatch(p -> p.getStatus() == PLAN_STATUS_PARTIAL || p.getStatus() == PLAN_STATUS_ISSUED)) { throw new BaseException("删除失败,存在已下发或部分下发的计划"); } if (productionOrderMapper.selectList(Wrappers.lambdaQuery().in(ProductionOrder::getProductionPlanIds, ids)) .stream() .anyMatch(p -> p.getId() != null)) { throw new BaseException("删除失败,存在关联生产订单"); } return productionPlanMapper.deleteBatchIds(ids) > 0; } @Override @Transactional(rollbackFor = Exception.class) public void importProdData(MultipartFile file) { // 参数与前置条件校验 if (file == null || file.isEmpty()) { throw new ServiceException("导入数据不能为空"); } ExcelUtil excelUtil = new ExcelUtil<>(ProductionPlanImportDto.class); List list; try { list = excelUtil.importExcel(file.getInputStream()); } catch (Exception e) { throw new ServiceException("Excel解析失败"); } if (list == null || list.isEmpty()) { throw new ServiceException("Excel没有数据"); } String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); int nextSequence = resolveNextPlanSequence(datePrefix); Set mpsNos = new HashSet<>(); // 遍历处理数据并组装结果 for (int i = 0; i < list.size(); i++) { ProductionPlanImportDto dto = list.get(i); String mpsNo = StringUtils.trim(dto.getMpsNo()); if (StringUtils.isEmpty(mpsNo)) { mpsNo = buildPlanNo(datePrefix, nextSequence++); } dto.setMpsNo(mpsNo); if (!mpsNos.add(mpsNo)) { throw new ServiceException("导入失败,Excel中存在重复的主生产计划号 " + mpsNo); } if (dto.getQtyRequired() == null || dto.getQtyRequired().compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("导入失败:第" + (i + 2) + "行需求数量必须大于0"); } } // 查询并准备业务数据 Long existApplyNoCount = baseMapper.selectCount(Wrappers.lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos)); if (existApplyNoCount > 0) { List existMpsNos = baseMapper.selectList(Wrappers.lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos)) .stream() .map(ProductionPlan::getMpsNo) .collect(Collectors.toList()); throw new ServiceException("导入失败,主生产计划号已存在: " + String.join(", ", existMpsNos)); } List allModels = productModelMapper.selectList(Wrappers.lambdaQuery()); Set productIds = allModels.stream() .map(ProductModel::getProductId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map productNameById = productIds.isEmpty() ? Collections.emptyMap() : productMapper.selectBatchIds(productIds).stream() .collect(Collectors.toMap(Product::getId, Product::getProductName, (a, b) -> a)); LocalDateTime now = LocalDateTime.now(); List entityList = new ArrayList<>(); for (int i = 0; i < list.size(); i++) { ProductionPlanImportDto dto = list.get(i); ProductionPlan entity = new ProductionPlan(); BeanUtils.copyProperties(dto, entity); entity.setProductModelId(resolveProductModelId(dto, i + 2, allModels, productNameById)); entity.setStatus(PLAN_STATUS_WAIT); entity.setSource(StringUtils.isNotEmpty(dto.getSource()) ? StringUtils.trim(dto.getSource()) : "内部"); entity.setQuantityIssued(BigDecimal.ZERO); entity.setCreateTime(now); entity.setUpdateTime(now); entityList.add(entity); } // 持久化或输出处理结果 if (!this.saveBatch(entityList)) { throw new ServiceException("导入失败,保存生产计划数据失败"); } } @Override public void exportProdData(HttpServletResponse response, ProductionPlanDto requestDto) { // 导出主生产计划数据 List ids = requestDto == null || requestDto.getIds() == null ? Collections.emptyList() : requestDto.getIds().stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()); List exportList = new ArrayList<>(); if (!ids.isEmpty()) { List list = productionPlanMapper.selectWithMaterialByIds(ids); for (ProductionPlanDto item : list) { ProductionPlanImportDto dto = new ProductionPlanImportDto(); BeanUtils.copyProperties(item, dto); dto.setAssignedQuantity(item.getQuantityIssued()); exportList.add(dto); } } else { ProductionPlanDto query = new ProductionPlanDto(); if (requestDto != null) { BeanUtils.copyProperties(requestDto, query); } IPage page = productionPlanMapper.listPage(new Page<>(1, -1), query); if (page != null && page.getRecords() != null) { for (ProductionPlanVo item : page.getRecords()) { ProductionPlanImportDto dto = new ProductionPlanImportDto(); BeanUtils.copyProperties(item, dto); dto.setAssignedQuantity(item.getQuantityIssued()); exportList.add(dto); } } } ExcelUtil util = new ExcelUtil<>(ProductionPlanImportDto.class); util.exportExcel(response, exportList, "主生产计划"); } /** * 校验主生产计划号唯一性,可通过 excludeId 排除当前记录。 */ private void checkMpsNoUnique(String mpsNo, Long excludeId) { // 按主生产计划号查询重复记录 LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); wrapper.eq(ProductionPlan::getMpsNo, mpsNo); if (excludeId != null) { // 更新时排除当前记录本身 wrapper.ne(ProductionPlan::getId, excludeId); } if (productionPlanMapper.selectCount(wrapper) > 0) { // 存在重复计划号,直接拦截 throw new ServiceException("生产计划号 " + mpsNo + " 已存在"); } } /** * 根据导入行的型号、产品名称、单位定位唯一的产品型号 ID。 */ private Long resolveProductModelId(ProductionPlanImportDto dto, int rowNo, List allModels, Map productNameById) { // 先按规格型号做第一轮过滤 String model = StringUtils.trim(dto.getModel()); if (StringUtils.isEmpty(model)) { throw new ServiceException("导入失败:第" + rowNo + "行规格型号不能为空"); } List candidates = allModels.stream() .filter(item -> model.equals(StringUtils.trim(item.getModel()))) .collect(Collectors.toList()); if (candidates.isEmpty()) { throw new ServiceException("导入失败:第" + rowNo + "行规格型号不存在,型号:" + model); } // 若传了产品名称,再做第二轮过滤 String productName = StringUtils.trim(dto.getProductName()); if (StringUtils.isNotEmpty(productName)) { candidates = candidates.stream() .filter(item -> productName.equals(StringUtils.trim(productNameById.get(item.getProductId())))) .collect(Collectors.toList()); if (candidates.isEmpty()) { throw new ServiceException("导入失败:第" + rowNo + "行产品名称与规格型号不匹配"); } } // 若传了单位,再做第三轮过滤 String unit = StringUtils.trim(dto.getUnit()); if (StringUtils.isNotEmpty(unit)) { candidates = candidates.stream() .filter(item -> unit.equals(StringUtils.trim(item.getUnit()))) .collect(Collectors.toList()); if (candidates.isEmpty()) { throw new ServiceException("导入失败:第" + rowNo + "行单位与规格型号不匹配"); } } // 仍然多条说明信息不足以唯一定位 if (candidates.size() > 1) { throw new ServiceException("导入失败:第" + rowNo + "行规格型号匹配到多个产品,请补充产品名称或单位"); } return candidates.get(0).getId(); } /** * 生成主生产计划号,格式:JH + yyyyMMdd + 4位流水号。 */ private String buildPlanNo(String datePrefix, int sequence) { // 统一计划号格式:JH + 日期 + 4位流水号 return "JH" + datePrefix + String.format("%04d", sequence); } /** * 查询当日已存在的最大流水号,并返回下一个可用流水号。 */ private int resolveNextPlanSequence(String datePrefix) { // 查询当日最新一条计划号 QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.likeRight("mps_no", "JH" + datePrefix); queryWrapper.orderByDesc("mps_no"); queryWrapper.last("LIMIT 1"); ProductionPlan latestPlan = productionPlanMapper.selectOne(queryWrapper); // 默认从 0001 开始 int sequence = 1; if (latestPlan != null && StringUtils.isNotEmpty(latestPlan.getMpsNo())) { // 截取末尾流水号并递增 String sequenceStr = latestPlan.getMpsNo().substring(("JH" + datePrefix).length()); try { sequence = Integer.parseInt(sequenceStr) + 1; } catch (NumberFormatException ignored) { // 历史数据格式异常时回退到 0001 sequence = 1; } } return sequence; } /** * 计算生产计划的剩余未下发数量(需求量 - 已下发量,最小为 0)。 */ private BigDecimal resolveRemainingQuantity(ProductionPlan plan) { // 空对象按 0 处理 if (plan == null) { return BigDecimal.ZERO; } // 需求量为空或小于等于 0,视为无剩余 BigDecimal requiredQuantity = Optional.ofNullable(plan.getQtyRequired()).orElse(BigDecimal.ZERO); if (requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } // 已下发量为空或小于等于 0,剩余即需求量 BigDecimal issuedQuantity = Optional.ofNullable(plan.getQuantityIssued()).orElse(BigDecimal.ZERO); if (issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) { return requiredQuantity; } // 已下发量大于等于需求量,剩余归零 if (issuedQuantity.compareTo(requiredQuantity) >= 0) { return BigDecimal.ZERO; } // 正常场景返回差值 return requiredQuantity.subtract(issuedQuantity); } /** * 按需求量与累计下发量推导计划状态。 */ private int resolvePlanStatus(BigDecimal requiredQuantity, BigDecimal issuedQuantity) { // 无有效需求量时,状态保持待下发 if (requiredQuantity == null || requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) { return PLAN_STATUS_WAIT; } // 有需求但未下发,状态仍为待下发 if (issuedQuantity == null || issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) { return PLAN_STATUS_WAIT; } // 已下发量小于需求量为部分下发,否则为已下发 return issuedQuantity.compareTo(requiredQuantity) < 0 ? PLAN_STATUS_PARTIAL : PLAN_STATUS_ISSUED; } /** * 将计划 ID 集合转成 [1,2,3] 形式,写入生产订单关联字段。 */ private String formatPlanIds(List planIds) { // 去重并拼接为 [1,2,3] 形式的字符串 return planIds.stream() .filter(Objects::nonNull) .distinct() .map(String::valueOf) .collect(Collectors.joining(",", "[", "]")); } }