2026-04-23 0005d49a697ce934c6fc2a61ecb75d881b9a76f2
feat(production): 完善生产订单管理功能

- 新增生产订单备料相关表映射和实体类
- 实现生产订单保存时自动填充来源单据、计划和工艺信息
- 添加生产计划下发状态同步机制,防止重复转单
- 完善生产订单快照生成逻辑,确保工艺参数一致性
- 实现生产订单状态流转控制(待开始-进行中-已完成)
- 优化生产计划合并转单功能,支持多计划批量处理
- 添加生产计划核心字段变更校验,防止已下发计划被修改
- 实现销售台账自动同步生产计划功能
- 完善报工流程状态管理,支持工单进度回滚
- 添加BOM需求量计算和快照生成功能
- 实现生产订单删除前的状态检查机制
已修改4个文件
540 ■■■■■ 文件已修改
src/main/java/com/ruoyi/production/controller/ProductionPlanController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 277 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionPlanController.java
@@ -82,7 +82,7 @@
    @Log(title = "删除生产计划", businessType = BusinessType.DELETE)
    @ApiOperation("删除生产计划")
    public R delete(@RequestBody List<Long> ids) {
        return R.ok(productionPlanService.removeByIds(ids));
        return R.ok(productionPlanService.delete(ids));
    }
    @PostMapping("/downloadTemplate")
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -12,10 +12,15 @@
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderBomMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderPickMapper;
import com.ruoyi.production.mapper.ProductionOrderPickRecordMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationParamMapper;
import com.ruoyi.production.mapper.ProductionPlanMapper;
import com.ruoyi.production.mapper.ProductionProductMainMapper;
import com.ruoyi.production.pojo.ProductionOrderPick;
import com.ruoyi.production.pojo.ProductionOrderPickRecord;
import com.ruoyi.production.pojo.ProductionBomStructure;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
@@ -23,7 +28,13 @@
import com.ruoyi.production.pojo.ProductionOrderRouting;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperationParam;
import com.ruoyi.production.pojo.ProductionPlan;
import com.ruoyi.production.pojo.ProductionProductMain;
import com.ruoyi.production.enums.ProductOrderStatusEnum;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.technology.mapper.TechnologyBomMapper;
import com.ruoyi.technology.mapper.TechnologyBomStructureMapper;
@@ -41,11 +52,16 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@@ -60,6 +76,11 @@
    private final ProductionOrderBomMapper productionOrderBomMapper;
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionProductMainMapper productionProductMainMapper;
    private final ProductionOrderPickMapper productionOrderPickMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final ProductionPlanMapper productionPlanMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final TechnologyRoutingMapper technologyRoutingMapper;
    private final TechnologyRoutingOperationMapper technologyRoutingOperationMapper;
    private final TechnologyRoutingOperationParamMapper technologyRoutingOperationParamMapper;
@@ -86,23 +107,35 @@
    @Override
    public boolean saveProductionOrder(ProductionOrder productionOrder) {
        ProductionOrder oldOrder = productionOrder.getId() == null ? null : this.getById(productionOrder.getId());
        // 下单入口统一补齐来源单据、计划和工艺信息,避免前端分别传多套字段。
        validateAndFillOrder(productionOrder, oldOrder);
        if (productionOrder.getNpsNo() == null || productionOrder.getNpsNo().trim().isEmpty()) {
            productionOrder.setNpsNo(generateNextOrderNo());
        }
        if (productionOrder.getCompleteQuantity() == null) {
            productionOrder.setCompleteQuantity(BigDecimal.ZERO);
        }
        if (productionOrder.getStatus() == null) {
            productionOrder.setStatus(ProductOrderStatusEnum.WAIT.getCode());
        }
        boolean saved = this.saveOrUpdate(productionOrder);
        if (!saved) {
            return false;
        }
        syncProductionPlanIssueStatus(oldOrder, productionOrder);
        boolean needSync = productionOrder.getTechnologyRoutingId() != null
                && (oldOrder == null
                || !Objects.equals(oldOrder.getTechnologyRoutingId(), productionOrder.getTechnologyRoutingId())
                || !Objects.equals(oldOrder.getProductModelId(), productionOrder.getProductModelId())
                || compareDecimal(oldOrder.getQuantity(), productionOrder.getQuantity()) != 0
                || productionOrderRoutingMapper.selectCount(Wrappers.<ProductionOrderRouting>lambdaQuery()
                        .eq(ProductionOrderRouting::getProductionOrderId, productionOrder.getId())) == 0);
        if (needSync) {
            // 工艺、产品或数量变化后,订单快照必须和当前下单数据重新对齐。
            syncProductionOrderSnapshot(productionOrder.getId());
        } else {
            // 未重建快照时,也要确保备料主单和订单数量保持同步。
            upsertOrderPick(productionOrder);
        }
        return true;
    }
@@ -113,7 +146,9 @@
            return false;
        }
        for (Long id : ids) {
            ProductionOrder productionOrder = this.getById(id);
            clearProductionSnapshot(id);
            releaseProductionPlanIssueStatus(productionOrder);
        }
        return this.removeByIds(ids);
    }
@@ -131,6 +166,7 @@
        if (technologyRouting == null) {
            throw new ServiceException("Technology routing not found");
        }
        // 订单快照按“先清后建”处理,保证工艺路线、工序、参数、BOM 全部来自同一版本。
        clearProductionSnapshot(productionOrderId);
        ProductionOrderRouting orderRouting = new ProductionOrderRouting();
@@ -149,6 +185,7 @@
                        .orderByAsc(TechnologyRoutingOperation::getDragSort)
                        .orderByAsc(TechnologyRoutingOperation::getId));
        for (TechnologyRoutingOperation sourceOperation : routingOperations) {
            // 订单工序保存的是工艺工序快照,后续报工只依赖快照,不再直接引用工艺主数据。
            ProductionOrderRoutingOperation targetOperation = new ProductionOrderRoutingOperation();
            targetOperation.setProductionOrderId(productionOrder.getId());
            targetOperation.setTechnologyRoutingOperationId(sourceOperation.getId());
@@ -164,7 +201,7 @@
            task.setPlanQuantity(defaultDecimal(productionOrder.getQuantity()));
            task.setCompleteQuantity(BigDecimal.ZERO);
            task.setWorkOrderNo(generateNextTaskNo());
            task.setStatus(1);
            task.setStatus(2);
            productionOperationTaskMapper.insert(task);
            List<TechnologyRoutingOperationParam> sourceParams = technologyRoutingOperationParamMapper.selectList(
@@ -172,6 +209,7 @@
                            .eq(TechnologyRoutingOperationParam::getTechnologyRoutingOperationId, sourceOperation.getId())
                            .orderByAsc(TechnologyRoutingOperationParam::getId));
            for (TechnologyRoutingOperationParam sourceParam : sourceParams) {
                // 工序执行参数同样做快照,避免工艺参数调整影响已下达订单。
                ProductionOrderRoutingOperationParam targetParam = new ProductionOrderRoutingOperationParam();
                targetParam.setProductionOrderId(productionOrder.getId());
                targetParam.setTechnologyRoutingOperationId(targetOperation.getId());
@@ -193,6 +231,7 @@
        }
        syncProductionOrderBomSnapshot(productionOrder, technologyRouting);
        upsertOrderPick(productionOrder);
        return syncedParamCount;
    }
@@ -209,6 +248,7 @@
                        .eq(TechnologyBomStructure::getBomId, technologyBom.getId())
                        .orderByAsc(TechnologyBomStructure::getId));
        TechnologyBomStructure root = structureList.stream().filter(item -> item.getParentId() == null).findFirst().orElse(null);
        BigDecimal orderQuantity = defaultDecimal(productionOrder.getQuantity());
        ProductionOrderBom orderBom = new ProductionOrderBom();
        orderBom.setProductionOrderId(productionOrder.getId());
@@ -216,12 +256,13 @@
        orderBom.setProductModelId(root != null ? root.getProductModelId() : productionOrder.getProductModelId());
        orderBom.setTechnologyOperationId(root == null ? null : root.getOperationId());
        orderBom.setUnitQuantity(root != null && root.getUnitQuantity() != null ? root.getUnitQuantity() : BigDecimal.ONE);
        orderBom.setDemandedQuantity(root != null && root.getDemandedQuantity() != null ? root.getDemandedQuantity() : defaultDecimal(productionOrder.getQuantity()));
        orderBom.setDemandedQuantity(orderQuantity);
        orderBom.setUnit(root == null ? null : root.getUnit());
        productionOrderBomMapper.insert(orderBom);
        Map<Long, Long> idMap = new HashMap<>();
        for (TechnologyBomStructure source : structureList) {
            // 子节点 parentId 需要映射成新快照节点 id,才能保留原始 BOM 层级。
            ProductionBomStructure target = new ProductionBomStructure();
            target.setProductionOrderId(productionOrder.getId());
            target.setProductionOrderBomId(orderBom.getId());
@@ -229,7 +270,7 @@
            target.setProductModelId(source.getProductModelId());
            target.setTechnologyOperationId(source.getOperationId());
            target.setUnitQuantity(source.getUnitQuantity());
            target.setDemandedQuantity(source.getDemandedQuantity());
            target.setDemandedQuantity(resolveBomDemandQuantity(source, orderQuantity));
            target.setUnit(source.getUnit());
            productionBomStructureMapper.insert(target);
            idMap.put(source.getId(), target.getId());
@@ -237,11 +278,19 @@
    }
    private void clearProductionSnapshot(Long productionOrderId) {
        // 已产生领料记录后禁止重建,避免备料/投料依据与订单快照脱节。
        boolean hasPickRecord = productionOrderPickRecordMapper.selectCount(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getProductionOrderId, productionOrderId)) > 0;
        if (hasPickRecord) {
            throw new ServiceException("Production order pick records already exist, snapshot cannot be regenerated");
        }
        List<Long> taskIds = productionOperationTaskMapper.selectList(
                        Wrappers.<ProductionOperationTask>lambdaQuery()
                                .eq(ProductionOperationTask::getProductionOrderId, productionOrderId))
                .stream().map(ProductionOperationTask::getId).collect(Collectors.toList());
        if (!taskIds.isEmpty()) {
            // 已有报工记录说明订单已开工,此时不允许再重建快照。
            boolean started = productionProductMainMapper.selectCount(
                    Wrappers.<ProductionProductMain>lambdaQuery()
                            .in(ProductionProductMain::getProductionOperationTaskId, taskIds)) > 0;
@@ -261,6 +310,8 @@
                .eq(ProductionBomStructure::getProductionOrderId, productionOrderId));
        productionOrderBomMapper.delete(Wrappers.<ProductionOrderBom>lambdaQuery()
                .eq(ProductionOrderBom::getProductionOrderId, productionOrderId));
        productionOrderPickMapper.delete(Wrappers.<ProductionOrderPick>lambdaQuery()
                .eq(ProductionOrderPick::getProductionOrderId, productionOrderId));
    }
    private LambdaQueryWrapper<ProductionOrder> buildQueryWrapper(ProductionOrderDto dto) {
@@ -314,4 +365,224 @@
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private void validateAndFillOrder(ProductionOrder productionOrder, ProductionOrder oldOrder) {
        if (productionOrder == null) {
            throw new ServiceException("Production order is required");
        }
        fillFromSalesLedgerProduct(productionOrder);
        fillFromProductionPlans(productionOrder);
        if (productionOrder.getProductModelId() == null) {
            throw new ServiceException("productModelId is required");
        }
        if (defaultDecimal(productionOrder.getQuantity()).compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("quantity must be greater than 0");
        }
        if (productionOrder.getTechnologyRoutingId() == null) {
            // 未显式指定工艺路线时,按产品规格选最新一条工艺作为默认路线。
            TechnologyRouting technologyRouting = technologyRoutingMapper.selectOne(
                    Wrappers.<TechnologyRouting>lambdaQuery()
                            .eq(TechnologyRouting::getProductModelId, productionOrder.getProductModelId())
                            .orderByDesc(TechnologyRouting::getId)
                            .last("limit 1"));
            if (technologyRouting == null) {
                throw new ServiceException("No technology routing found for the product model");
            }
            productionOrder.setTechnologyRoutingId(technologyRouting.getId());
        }
        if (oldOrder != null && ProductOrderStatusEnum.isStarted(oldOrder.getStatus())) {
            // 开工后只允许修正非核心字段,核心生产依据锁定。
            if (!Objects.equals(oldOrder.getProductModelId(), productionOrder.getProductModelId())
                    || !Objects.equals(oldOrder.getTechnologyRoutingId(), productionOrder.getTechnologyRoutingId())
                    || compareDecimal(oldOrder.getQuantity(), productionOrder.getQuantity()) != 0) {
                throw new ServiceException("Started production orders cannot modify product, routing or quantity");
            }
        }
    }
    private void fillFromSalesLedgerProduct(ProductionOrder productionOrder) {
        if (productionOrder.getSaleLedgerProductId() == null) {
            return;
        }
        // 销售明细是订单来源时,以销售明细为准回填销售台账、产品规格和默认数量。
        SalesLedgerProduct salesLedgerProduct = salesLedgerProductMapper.selectById(productionOrder.getSaleLedgerProductId().longValue());
        if (salesLedgerProduct == null) {
            throw new ServiceException("Sales ledger product not found");
        }
        if (productionOrder.getSalesLedgerId() == null) {
            productionOrder.setSalesLedgerId(salesLedgerProduct.getSalesLedgerId());
        } else if (!Objects.equals(productionOrder.getSalesLedgerId(), salesLedgerProduct.getSalesLedgerId())) {
            throw new ServiceException("salesLedgerId does not match the sales ledger product");
        }
        if (productionOrder.getProductModelId() == null) {
            productionOrder.setProductModelId(salesLedgerProduct.getProductModelId());
        } else if (!Objects.equals(productionOrder.getProductModelId(), salesLedgerProduct.getProductModelId())) {
            throw new ServiceException("productModelId does not match the sales ledger product");
        }
        if (productionOrder.getQuantity() == null || productionOrder.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            productionOrder.setQuantity(salesLedgerProduct.getQuantity());
        }
        if (productionOrder.getPlanCompleteTime() == null && productionOrder.getSalesLedgerId() != null) {
            SalesLedger salesLedger = salesLedgerMapper.selectById(productionOrder.getSalesLedgerId());
            if (salesLedger != null && salesLedger.getDeliveryDate() != null) {
                productionOrder.setPlanCompleteTime(salesLedger.getDeliveryDate());
            }
        }
    }
    private void fillFromProductionPlans(ProductionOrder productionOrder) {
        List<Long> planIds = parsePlanIds(productionOrder.getProductionPlanIds());
        if (planIds.isEmpty()) {
            return;
        }
        // 多计划合并转单时,所有计划必须属于同一规格,且只能下发一次。
        List<ProductionPlan> productionPlans = productionPlanMapper.selectBatchIds(planIds);
        if (productionPlans.size() != planIds.size()) {
            throw new ServiceException("Some production plans do not exist");
        }
        Set<Long> productModelIds = productionPlans.stream()
                .map(ProductionPlan::getProductModelId)
                .collect(Collectors.toSet());
        if (productModelIds.size() > 1) {
            throw new ServiceException("Selected production plans must belong to the same product model");
        }
        if (Boolean.TRUE.equals(productionPlans.stream().anyMatch(item -> Boolean.TRUE.equals(item.getIssued())))) {
            throw new ServiceException("Selected production plans already issued");
        }
        ProductionPlan firstPlan = productionPlans.get(0);
        if (productionOrder.getProductModelId() == null) {
            productionOrder.setProductModelId(firstPlan.getProductModelId());
        } else if (!Objects.equals(productionOrder.getProductModelId(), firstPlan.getProductModelId())) {
            throw new ServiceException("productModelId does not match the production plans");
        }
        if (productionOrder.getQuantity() == null || productionOrder.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
            productionOrder.setQuantity(productionPlans.stream()
                    .map(ProductionPlan::getQtyRequired)
                    .reduce(BigDecimal.ZERO, BigDecimal::add));
        }
        if (productionOrder.getPlanCompleteTime() == null) {
            LocalDate planCompleteTime = productionPlans.stream()
                    .map(this::resolvePlanCompleteDate)
                    .filter(Objects::nonNull)
                    .min(Comparator.naturalOrder())
                    .orElse(null);
            productionOrder.setPlanCompleteTime(planCompleteTime);
        }
        productionOrder.setProductionPlanIds(formatPlanIds(planIds));
    }
    private void syncProductionPlanIssueStatus(ProductionOrder oldOrder, ProductionOrder newOrder) {
        // 只处理本次增量变化,避免无关计划被重复写状态。
        Set<Long> oldIds = new LinkedHashSet<>(parsePlanIds(oldOrder == null ? null : oldOrder.getProductionPlanIds()));
        Set<Long> newIds = new LinkedHashSet<>(parsePlanIds(newOrder == null ? null : newOrder.getProductionPlanIds()));
        Set<Long> toRelease = new LinkedHashSet<>(oldIds);
        toRelease.removeAll(newIds);
        Set<Long> toIssue = new LinkedHashSet<>(newIds);
        toIssue.removeAll(oldIds);
        if (!toRelease.isEmpty()) {
            updatePlanIssuedFlag(new ArrayList<>(toRelease), false);
        }
        if (!toIssue.isEmpty()) {
            updatePlanIssuedFlag(new ArrayList<>(toIssue), true);
        }
    }
    private void releaseProductionPlanIssueStatus(ProductionOrder productionOrder) {
        if (productionOrder == null) {
            return;
        }
        List<Long> planIds = parsePlanIds(productionOrder.getProductionPlanIds());
        if (!planIds.isEmpty()) {
            updatePlanIssuedFlag(planIds, false);
        }
    }
    private void updatePlanIssuedFlag(List<Long> planIds, boolean issued) {
        if (planIds == null || planIds.isEmpty()) {
            return;
        }
        List<ProductionPlan> plans = productionPlanMapper.selectBatchIds(planIds);
        for (ProductionPlan plan : plans) {
            ProductionPlan update = new ProductionPlan();
            update.setId(plan.getId());
            update.setIssued(issued);
            productionPlanMapper.updateById(update);
        }
    }
    private void upsertOrderPick(ProductionOrder productionOrder) {
        if (productionOrder == null || productionOrder.getId() == null) {
            return;
        }
        // 订单下达后自动生成一张备料主单,后续领料记录都挂在这张单上。
        ProductionOrderPick orderPick = productionOrderPickMapper.selectOne(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOrderId, productionOrder.getId())
                        .last("limit 1"));
        if (orderPick == null) {
            orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(productionOrder.getId());
        }
        orderPick.setProductModelId(productionOrder.getProductModelId() == null ? null : Math.toIntExact(productionOrder.getProductModelId()));
        orderPick.setQuantity(defaultDecimal(productionOrder.getQuantity()));
        orderPick.setRemark("下单自动生成");
        if (orderPick.getId() == null) {
            productionOrderPickMapper.insert(orderPick);
        } else {
            productionOrderPickMapper.updateById(orderPick);
        }
    }
    private BigDecimal resolveBomDemandQuantity(TechnologyBomStructure source, BigDecimal orderQuantity) {
        // 工艺 BOM 中的需求量按“单件需求 * 订单数量”展开成订单级需求。
        BigDecimal baseQuantity = source.getDemandedQuantity() != null ? source.getDemandedQuantity() : source.getUnitQuantity();
        baseQuantity = baseQuantity == null ? BigDecimal.ZERO : baseQuantity;
        if (baseQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        }
        return baseQuantity.multiply(orderQuantity);
    }
    private List<Long> parsePlanIds(String productionPlanIds) {
        if (productionPlanIds == null || productionPlanIds.trim().isEmpty()) {
            return new ArrayList<>();
        }
        String normalized = productionPlanIds.replace("[", "").replace("]", "").trim();
        if (normalized.isEmpty()) {
            return new ArrayList<>();
        }
        return java.util.Arrays.stream(normalized.split(","))
                .map(String::trim)
                .filter(item -> !item.isEmpty())
                .map(Long::valueOf)
                .distinct()
                .collect(Collectors.toList());
    }
    private String formatPlanIds(List<Long> planIds) {
        if (planIds == null || planIds.isEmpty()) {
            return null;
        }
        return planIds.stream()
                .distinct()
                .map(String::valueOf)
                .collect(Collectors.joining(",", "[", "]"));
    }
    private LocalDate resolvePlanCompleteDate(ProductionPlan productionPlan) {
        if (productionPlan == null) {
            return null;
        }
        if (productionPlan.getPromisedDeliveryDate() != null) {
            return productionPlan.getPromisedDeliveryDate().toLocalDate();
        }
        if (productionPlan.getRequiredDate() != null) {
            return productionPlan.getRequiredDate().toLocalDate();
        }
        return null;
    }
    private int compareDecimal(BigDecimal left, BigDecimal right) {
        return defaultDecimal(left).compareTo(defaultDecimal(right));
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java
@@ -1,56 +1,176 @@
package com.ruoyi.production.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.production.bean.dto.ProductionPlanDto;
import com.ruoyi.production.bean.dto.ProductionOrderDto;
import com.ruoyi.production.bean.vo.ProductionPlanVo;
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 com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
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.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(rollbackFor = Exception.class)
public class ProductionPlanServiceImpl extends ServiceImpl<ProductionPlanMapper, ProductionPlan> implements ProductionPlanService {
    private final ProductionOrderService productionOrderService;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    @Override
    public IPage<ProductionPlanVo> listPage(Page<ProductionPlanDto> page, ProductionPlanDto productionPlanDto) {
        return null;
        Page<ProductionPlan> entityPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        return this.page(entityPage, buildQueryWrapper(productionPlanDto))
                .convert(item -> BeanUtil.copyProperties(item, ProductionPlanVo.class));
    }
    @Override
    public void loadProdData() {
        // 用销售明细作为来源同步生产计划,source 字段承担幂等去重作用。
        List<SalesLedgerProduct> salesLedgerProducts = salesLedgerProductMapper.selectList(
                Wrappers.<SalesLedgerProduct>lambdaQuery()
                        .isNotNull(SalesLedgerProduct::getProductModelId)
                        .gt(SalesLedgerProduct::getQuantity, BigDecimal.ZERO));
        for (SalesLedgerProduct salesLedgerProduct : salesLedgerProducts) {
            String source = buildSalesSource(salesLedgerProduct.getId());
            long exists = this.count(Wrappers.<ProductionPlan>lambdaQuery().eq(ProductionPlan::getSource, source));
            if (exists > 0) {
                continue;
            }
            SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerProduct.getSalesLedgerId());
            ProductionPlan productionPlan = new ProductionPlan();
            productionPlan.setMpsNo(generateNextPlanNo());
            productionPlan.setProductModelId(salesLedgerProduct.getProductModelId());
            productionPlan.setQtyRequired(defaultDecimal(salesLedgerProduct.getQuantity()));
            productionPlan.setRequiredDate(resolveDeliveryTime(salesLedger));
            productionPlan.setPromisedDeliveryDate(resolveDeliveryTime(salesLedger));
            productionPlan.setSource(source);
            productionPlan.setIssued(Boolean.FALSE);
            productionPlan.setState("1");
            this.save(productionPlan);
        }
    }
    @Override
    public void syncProdDataJob() {
        loadProdData();
    }
    @Override
    public boolean combine(ProductionPlanDto productionPlanDto) {
        return false;
        // 多个计划合并转单后,仍统一走生产订单保存逻辑,避免两套下单流程不一致。
        if (productionPlanDto == null || productionPlanDto.getIds() == null || productionPlanDto.getIds().isEmpty()) {
            throw new ServiceException("请选择生产计划");
        }
        List<ProductionPlan> productionPlans = this.listByIds(productionPlanDto.getIds());
        if (productionPlans.size() != productionPlanDto.getIds().size()) {
            throw new ServiceException("部分生产计划不存在");
        }
        if (productionPlans.stream().anyMatch(item -> Boolean.TRUE.equals(item.getIssued()))) {
            throw new ServiceException("已下发的生产计划不能重复转单");
        }
        Long productModelId = productionPlans.stream()
                .map(ProductionPlan::getProductModelId)
                .distinct()
                .reduce((left, right) -> {
                    throw new ServiceException("仅支持相同产品规格的生产计划合并转单");
                })
                .orElseThrow(() -> new ServiceException("生产计划缺少产品规格"));
        BigDecimal totalRequiredQuantity = productionPlans.stream()
                .map(ProductionPlan::getQtyRequired)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal assignedQuantity = defaultDecimal(productionPlanDto.getTotalAssignedQuantity());
        if (assignedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            assignedQuantity = totalRequiredQuantity;
        }
        if (assignedQuantity.compareTo(totalRequiredQuantity) > 0) {
            throw new ServiceException("下发数量不能大于计划总需求数量");
        }
        ProductionOrder productionOrder = new ProductionOrder();
        productionOrder.setProductModelId(productModelId);
        productionOrder.setQuantity(assignedQuantity);
        productionOrder.setProductionPlanIds(formatPlanIds(productionPlans));
        productionOrder.setPlanCompleteTime(productionPlanDto.getPlanCompleteTime() != null
                ? productionPlanDto.getPlanCompleteTime()
                : resolveEarliestPlanDate(productionPlans));
        productionOrder.setStrength(productionPlanDto.getStrength());
        return productionOrderService.saveProductionOrder(productionOrder);
    }
    @Override
    public boolean add(ProductionPlanDto productionPlanDto) {
        return false;
        // 手工建计划时补齐默认编号和状态,保证后续可以直接下发转单。
        ProductionPlan productionPlan = BeanUtil.copyProperties(productionPlanDto, ProductionPlan.class);
        validateProductionPlan(productionPlan, false);
        if (productionPlan.getMpsNo() == null || productionPlan.getMpsNo().trim().isEmpty()) {
            productionPlan.setMpsNo(generateNextPlanNo());
        }
        if (productionPlan.getIssued() == null) {
            productionPlan.setIssued(Boolean.FALSE);
        }
        if (productionPlan.getState() == null || productionPlan.getState().trim().isEmpty()) {
            productionPlan.setState("1");
        }
        return this.save(productionPlan);
    }
    @Override
    public boolean update(ProductionPlanDto productionPlanDto) {
        return false;
        // 已下发计划的核心字段不能再变更,否则会和已生成订单脱节。
        if (productionPlanDto == null || productionPlanDto.getId() == null) {
            throw new ServiceException("生产计划ID不能为空");
        }
        ProductionPlan current = this.getById(productionPlanDto.getId());
        if (current == null) {
            throw new ServiceException("生产计划不存在");
        }
        if (Boolean.TRUE.equals(current.getIssued()) && hasPlanCoreChanges(current, productionPlanDto)) {
            throw new ServiceException("已下发的生产计划不允许修改产品、数量和交期");
        }
        ProductionPlan update = BeanUtil.copyProperties(productionPlanDto, ProductionPlan.class);
        validateProductionPlan(update, true);
        return this.updateById(update);
    }
    @Override
    public boolean delete(List<Long> ids) {
        return false;
        // 删除前校验 issued,避免把已转单计划直接从源头删掉。
        if (ids == null || ids.isEmpty()) {
            return false;
        }
        List<ProductionPlan> productionPlans = this.listByIds(ids);
        if (productionPlans.stream().anyMatch(item -> Boolean.TRUE.equals(item.getIssued()))) {
            throw new ServiceException("已下发的生产计划不允许删除");
        }
        return this.removeByIds(ids);
    }
    @Override
@@ -62,4 +182,104 @@
    public void exportProdData(HttpServletResponse response, List<Long> ids) {
    }
    private LambdaQueryWrapper<ProductionPlan> buildQueryWrapper(ProductionPlanDto dto) {
        ProductionPlan query = dto == null ? new ProductionPlan() : dto;
        return Wrappers.<ProductionPlan>lambdaQuery()
                .eq(query.getId() != null, ProductionPlan::getId, query.getId())
                .eq(query.getProductModelId() != null, ProductionPlan::getProductModelId, query.getProductModelId())
                .eq(query.getIssued() != null, ProductionPlan::getIssued, query.getIssued())
                .eq(query.getState() != null && !query.getState().trim().isEmpty(), ProductionPlan::getState, query.getState())
                .eq(query.getIsAudit() != null && !query.getIsAudit().trim().isEmpty(), ProductionPlan::getIsAudit, query.getIsAudit())
                .like(query.getMpsNo() != null && !query.getMpsNo().trim().isEmpty(), ProductionPlan::getMpsNo, query.getMpsNo())
                .like(query.getSource() != null && !query.getSource().trim().isEmpty(), ProductionPlan::getSource, query.getSource())
                .orderByDesc(ProductionPlan::getId);
    }
    private void validateProductionPlan(ProductionPlan productionPlan, boolean update) {
        if (productionPlan == null) {
            throw new ServiceException("生产计划不能为空");
        }
        if (update && productionPlan.getId() == null) {
            throw new ServiceException("生产计划ID不能为空");
        }
        if (productionPlan.getProductModelId() == null) {
            throw new ServiceException("productModelId不能为空");
        }
        if (defaultDecimal(productionPlan.getQtyRequired()).compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("qtyRequired必须大于0");
        }
    }
    private boolean hasPlanCoreChanges(ProductionPlan current, ProductionPlanDto update) {
        // 这些字段会直接影响转单结果,用来判断是否属于核心变更。
        return !Objects.equals(current.getProductModelId(), update.getProductModelId())
                || defaultDecimal(current.getQtyRequired()).compareTo(defaultDecimal(update.getQtyRequired())) != 0
                || !Objects.equals(toLocalDate(current.getPromisedDeliveryDate()), update.getPlanCompleteTime())
                || !Objects.equals(toLocalDate(current.getRequiredDate()), toLocalDate(update.getRequiredDate()));
    }
    private String generateNextPlanNo() {
        // 编号按日期递增生成,方便和订单、工单统一追踪。
        String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        String prefix = "MPS" + datePrefix;
        ProductionPlan latestPlan = this.getOne(Wrappers.<ProductionPlan>lambdaQuery()
                .likeRight(ProductionPlan::getMpsNo, prefix)
                .orderByDesc(ProductionPlan::getMpsNo)
                .last("limit 1"));
        int sequence = 1;
        if (latestPlan != null && latestPlan.getMpsNo() != null && latestPlan.getMpsNo().startsWith(prefix)) {
            try {
                sequence = Integer.parseInt(latestPlan.getMpsNo().substring(prefix.length())) + 1;
            } catch (NumberFormatException ignored) {
                sequence = 1;
            }
        }
        return prefix + String.format("%04d", sequence);
    }
    private String formatPlanIds(List<ProductionPlan> productionPlans) {
        return productionPlans.stream()
                .map(ProductionPlan::getId)
                .distinct()
                .map(String::valueOf)
                .collect(Collectors.joining(",", "[", "]"));
    }
    private LocalDate resolveEarliestPlanDate(List<ProductionPlan> productionPlans) {
        return productionPlans.stream()
                .map(this::resolvePlanDate)
                .filter(Objects::nonNull)
                .min(Comparator.naturalOrder())
                .orElse(null);
    }
    private LocalDate resolvePlanDate(ProductionPlan productionPlan) {
        if (productionPlan.getPromisedDeliveryDate() != null) {
            return productionPlan.getPromisedDeliveryDate().toLocalDate();
        }
        if (productionPlan.getRequiredDate() != null) {
            return productionPlan.getRequiredDate().toLocalDate();
        }
        return null;
    }
    private LocalDateTime resolveDeliveryTime(SalesLedger salesLedger) {
        if (salesLedger == null || salesLedger.getDeliveryDate() == null) {
            return null;
        }
        return salesLedger.getDeliveryDate().atStartOfDay();
    }
    private String buildSalesSource(Long salesLedgerProductId) {
        return "salesLedgerProduct:" + salesLedgerProductId;
    }
    private LocalDate toLocalDate(LocalDateTime value) {
        return value == null ? null : value.toLocalDate();
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java
@@ -19,6 +19,7 @@
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.production.bean.dto.ProductStructureDto;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.enums.ProductOrderStatusEnum;
import com.ruoyi.production.mapper.*;
import com.ruoyi.production.pojo.*;
import com.ruoyi.production.service.ProductionProductMainService;
@@ -107,6 +108,7 @@
    }
    private Boolean addProductMainByProductionTask(ProductionProductMainDto dto) {
        // 报工以订单工序快照为准,避免工艺主数据变更后影响历史工单执行。
        SysUser user = userMapper.selectUserById(dto.getUserId());
        ProductionOperationTask productionOperationTask = productionOperationTaskMapper.selectById(dto.getProductionOperationTaskId());
        if (productionOperationTask == null) {
@@ -144,6 +146,7 @@
        productStructureDto.setUnitQuantity(BigDecimal.ONE);
        productStructureDtos.add(productStructureDto);
        for (ProductStructureDto item : productStructureDtos) {
            // 当前实现按工序成品直接作为投入,后续若接入领料记录可在这里替换来源。
            ProductionProductInput productionProductInput = new ProductionProductInput();
            productionProductInput.setProductionProductMainId(productionProductMain.getId());
            productionProductInput.setProductMainId(productionProductMain.getId());
@@ -171,6 +174,7 @@
        boolean isLastOperation = routingOperation.getDragSort() != null && routingOperation.getDragSort().equals(routingOperationList.size());
        if (productQty.compareTo(BigDecimal.ZERO) > 0) {
            if (Boolean.TRUE.equals(routingOperation.getIsQuality())) {
                // 质检工序先生成检验单,非质检工序直接入合格品库存。
                int inspectType = isLastOperation ? 2 : 1;
                String process = isLastOperation ? null : technologyOperation == null ? null : technologyOperation.getName();
                Product product = productMapper.selectById(productModel.getProductId());
@@ -209,20 +213,26 @@
            if (ObjectUtils.isNull(productionOperationTask.getActualStartTime())) {
                productionOperationTask.setActualStartTime(LocalDate.now());
            }
            // 报工驱动工单状态流转:有产出即进行中,达到计划量即完工。
            productionOperationTask.setStatus(3);
            if (productionOperationTask.getPlanQuantity() != null
                    && productionOperationTask.getCompleteQuantity().compareTo(productionOperationTask.getPlanQuantity()) >= 0) {
                productionOperationTask.setActualEndTime(LocalDate.now());
                productionOperationTask.setStatus(4);
            }
            productionOperationTaskMapper.updateById(productionOperationTask);
            if (ObjectUtils.isNull(productionOrder.getStartTime())) {
                productionOrder.setStartTime(LocalDateTime.now());
            }
            // 订单状态由最后一道工序的合格产出推动,避免中间工序提前完工。
            productionOrder.setStatus(ProductOrderStatusEnum.RUNNING.getCode());
            if (isLastOperation) {
                productionOrder.setCompleteQuantity(defaultDecimal(productionOrder.getCompleteQuantity()).add(productQty));
                if (productionOrder.getQuantity() != null
                        && productionOrder.getCompleteQuantity().compareTo(productionOrder.getQuantity()) >= 0) {
                    productionOrder.setEndTime(LocalDateTime.now());
                    productionOrder.setStatus(ProductOrderStatusEnum.FINISHED.getCode());
                }
            }
            productionOrderMapper.updateById(productionOrder);
@@ -253,6 +263,7 @@
    }
    private Boolean removeProductMainByProductionTask(ProductionProductMain productionProductMain) {
        // 删除报工需要同步回滚质检、库存、工时核算和订单/工单进度。
        List<QualityInspect> qualityInspects = qualityInspectMapper.selectList(
                Wrappers.<QualityInspect>lambdaQuery().eq(QualityInspect::getProductMainId, productionProductMain.getId()));
        if (qualityInspects.size() > 0) {
@@ -275,11 +286,19 @@
            BigDecimal validQuantity = defaultDecimal(productionProductOutput.getQuantity()).subtract(defaultDecimal(productionProductOutput.getScrapQty()));
            productionOperationTask.setCompleteQuantity(defaultDecimal(productionOperationTask.getCompleteQuantity()).subtract(validQuantity));
            productionOperationTask.setActualEndTime(null);
            if (defaultDecimal(productionOperationTask.getCompleteQuantity()).compareTo(BigDecimal.ZERO) <= 0) {
                productionOperationTask.setCompleteQuantity(BigDecimal.ZERO);
                productionOperationTask.setActualStartTime(null);
                productionOperationTask.setStatus(2);
            } else {
                productionOperationTask.setStatus(3);
            }
            productionOperationTaskMapper.updateById(productionOperationTask);
            ProductionOrder productionOrder = productionOrderMapper.selectById(productionOperationTask.getProductionOrderId());
            ProductionOrderRoutingOperation routingOperation = productionOrderRoutingOperationMapper.selectById(productionOperationTask.getTechnologyRoutingOperationId());
            if (productionOrder != null && routingOperation != null) {
                // 只有最后一道工序的报工才会影响生产订单完工数量。
                List<ProductionOrderRoutingOperation> routingOperationList = productionOrderRoutingOperationMapper.selectList(
                        Wrappers.<ProductionOrderRoutingOperation>lambdaQuery()
                                .eq(ProductionOrderRoutingOperation::getTechnologyRoutingId, routingOperation.getTechnologyRoutingId())
@@ -289,8 +308,14 @@
                    BigDecimal newCompleteQty = defaultDecimal(productionOrder.getCompleteQuantity()).subtract(validQuantity);
                    productionOrder.setCompleteQuantity(newCompleteQty.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : newCompleteQty);
                    productionOrder.setEndTime(null);
                    productionOrderMapper.updateById(productionOrder);
                }
                if (defaultDecimal(productionOrder.getCompleteQuantity()).compareTo(BigDecimal.ZERO) <= 0) {
                    productionOrder.setStartTime(null);
                    productionOrder.setStatus(ProductOrderStatusEnum.WAIT.getCode());
                } else {
                    productionOrder.setStatus(ProductOrderStatusEnum.RUNNING.getCode());
                }
                productionOrderMapper.updateById(productionOrder);
            }
        }