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<ProductionPlanMapper, ProductionPlan> 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<ProductionPlanVo> listPage(Page<ProductionPlanDto> 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<Long> planIds = productionPlanDto.getIds().stream()
|
.filter(Objects::nonNull)
|
.distinct()
|
.collect(Collectors.toList());
|
if (planIds.isEmpty()) {
|
throw new ServiceException("下发失败,未选择生产计划");
|
}
|
|
// 查询并校验计划是否都存在
|
List<ProductionPlanDto> 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<Long> 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<ProductionPlan> 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<Long> ids) {
|
// 删除主生产计划
|
if (productionPlanMapper.selectList(Wrappers.<ProductionPlan>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.<ProductionOrder>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<ProductionPlanImportDto> excelUtil = new ExcelUtil<>(ProductionPlanImportDto.class);
|
List<ProductionPlanImportDto> 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<String> 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.<ProductionPlan>lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos));
|
if (existApplyNoCount > 0) {
|
List<String> existMpsNos = baseMapper.selectList(Wrappers.<ProductionPlan>lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos))
|
.stream()
|
.map(ProductionPlan::getMpsNo)
|
.collect(Collectors.toList());
|
throw new ServiceException("导入失败,主生产计划号已存在: " + String.join(", ", existMpsNos));
|
}
|
|
List<ProductModel> allModels = productModelMapper.selectList(Wrappers.<ProductModel>lambdaQuery());
|
Set<Long> productIds = allModels.stream()
|
.map(ProductModel::getProductId)
|
.filter(Objects::nonNull)
|
.collect(Collectors.toSet());
|
Map<Long, String> productNameById = productIds.isEmpty()
|
? Collections.emptyMap()
|
: productMapper.selectBatchIds(productIds).stream()
|
.collect(Collectors.toMap(Product::getId, Product::getProductName, (a, b) -> a));
|
|
LocalDateTime now = LocalDateTime.now();
|
List<ProductionPlan> 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<Long> ids = requestDto == null || requestDto.getIds() == null
|
? Collections.emptyList()
|
: requestDto.getIds().stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
|
List<ProductionPlanImportDto> exportList = new ArrayList<>();
|
if (!ids.isEmpty()) {
|
List<ProductionPlanDto> 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<ProductionPlanVo> 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<ProductionPlanImportDto> util = new ExcelUtil<>(ProductionPlanImportDto.class);
|
util.exportExcel(response, exportList, "主生产计划");
|
}
|
|
/**
|
* 校验主生产计划号唯一性,可通过 excludeId 排除当前记录。
|
*/
|
private void checkMpsNoUnique(String mpsNo, Long excludeId) {
|
// 按主生产计划号查询重复记录
|
LambdaQueryWrapper<ProductionPlan> 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<ProductModel> allModels,
|
Map<Long, String> productNameById) {
|
// 先按规格型号做第一轮过滤
|
String model = StringUtils.trim(dto.getModel());
|
if (StringUtils.isEmpty(model)) {
|
throw new ServiceException("导入失败:第" + rowNo + "行规格型号不能为空");
|
}
|
|
List<ProductModel> 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<ProductionPlan> 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<Long> planIds) {
|
// 去重并拼接为 [1,2,3] 形式的字符串
|
return planIds.stream()
|
.filter(Objects::nonNull)
|
.distinct()
|
.map(String::valueOf)
|
.collect(Collectors.joining(",", "[", "]"));
|
}
|
}
|