tuqin
4 天以前 f785e619ff7cbe99aa80fc7679a244224ab5024f
feat(erp): 生产车间可从销售订单直接生成生产计划(结合库存判断)

补齐需求「销售看不到库存、不知道是否需生产,改到生产车间」的生产车间接收端。
之前只把销售端的判断去掉了,车间仍要两个页面来回切、手抄销售订单号。

新增两个接口(挂在销售订单下,因为数据源是销售订单):
- GET /erp/sale-order/pending-production-list
返回已审核且尚未安排有效 MPS 的销售订单明细,附带
当前可用库存(MesWmStockApi.getStockQuantity)
与建议生产数量(订购数量 - 可用库存,不足 0 取 0),
按建议生产数量降序,急需的排前面
- POST /erp/sale-order/generate-mps
按车间填写数量批量生成 MPS

设计要点:
- 幂等保护:已有未取消 MPS 的明细会被排除/跳过,避免重复安排生产
- 只处理已审核(status=20)的销售订单
- 生成数量由车间自行指定(默认建议生产数量),不再直接取订单数量
- 单条失败不影响其余,返回成功条数
- 库存、MDM 物料、CRM 客户查询失败时降级处理(记 warn、不中断)

权限沿用 erp:sale-order:query / :update,无需新增菜单。

注:本期只提交代码,未部署。
已添加2个文件
已修改3个文件
345 ■■■■■ 文件已修改
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderGenerateMpsReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderPendingProductionRespVO.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java
@@ -334,4 +334,29 @@
        item.setTotalPrice(productAmount.add(tax));
    }
    /**
     * èŽ·å¾—å¾…å®‰æŽ’ç”Ÿäº§çš„é”€å”®è®¢å•æ˜Žç»†
     *
     * ä¾›ç”Ÿäº§è½¦é—´ã€Œä»Žé”€å”®è®¢å•生成生产计划」使用:
     * è¿”回已审核且尚未安排有效 MPS çš„æ˜Žç»†ï¼Œå¸¦å½“前可用库存与建议生产数量
     */
    @GetMapping("/pending-production-list")
    @Operation(summary = "获得待安排生产的销售订单明细")
    @PreAuthorize("@ss.hasPermission('erp:sale-order:query')")
    public CommonResult<List<cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO>>
            getPendingProductionList() {
        return success(saleOrderService.getPendingProductionList());
    }
    /**
     * ä»Žé”€å”®è®¢å•批量生成生产计划(MPS)
     */
    @PostMapping("/generate-mps")
    @Operation(summary = "从销售订单批量生成生产计划")
    @PreAuthorize("@ss.hasPermission('erp:sale-order:update')")
    public CommonResult<Integer> generateMpsFromSaleOrder(
            @Valid @RequestBody cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO reqVO) {
        return success(saleOrderService.generateMpsFromSaleOrder(reqVO));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderGenerateMpsReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "管理后台 - ä»Žé”€å”®è®¢å•批量生成生产计划 Request VO")
@Data
public class ErpSaleOrderGenerateMpsReqVO {
    @Schema(description = "待生成的明细列表", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "请至少选择一条销售订单明细")
    @Valid
    private List<Item> items;
    @Schema(description = "生成方式:true=按选择的明细逐条建 MPS;false æˆ–不传同 true(预留合并下发)",
            example = "true")
    private Boolean merge;
    @Schema(description = "明细项")
    @Data
    public static class Item {
        @Schema(description = "销售订单明细编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "11")
        @NotNull(message = "销售订单明细编号不能为空")
        private Long saleOrderItemId;
        @Schema(description = "计划生产数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "380")
        @NotNull(message = "计划生产数量不能为空")
        @DecimalMin(value = "0", inclusive = false, message = "计划生产数量必须大于 0")
        private BigDecimal quantity;
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderPendingProductionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å¾…安排生产的销售订单明细 Response VO
 *
 * ä¾›ç”Ÿäº§è½¦é—´ã€Œä»Žé”€å”®è®¢å•生成生产计划」使用:
 * å±•示已审核、且尚未安排 MPS çš„销售订单明细,并带上当前可用库存,
 * ç”±è½¦é—´æ®æ­¤åˆ¤æ–­æ˜¯å¦éœ€è¦ç”Ÿäº§ã€ç”Ÿäº§å¤šå°‘。
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å¾…安排生产的销售订单明细 Response VO")
@Data
public class ErpSaleOrderPendingProductionRespVO {
    @Schema(description = "销售订单编号", example = "1300")
    private Long saleOrderId;
    @Schema(description = "销售订单号", example = "XSDD20261102001")
    private String saleOrderNo;
    @Schema(description = "销售订单明细编号", example = "11")
    private Long saleOrderItemId;
    @Schema(description = "客户编号", example = "1001")
    private Long customerId;
    @Schema(description = "客户名称", example = "XX çº¸ä¸š")
    private String customerName;
    @Schema(description = "产品编号", example = "201")
    private Long productId;
    @Schema(description = "产品编码", example = "YL-001")
    private String productCode;
    @Schema(description = "产品名称", example = "原纸")
    private String productName;
    @Schema(description = "规格型号", example = "A4")
    private String productSpecification;
    @Schema(description = "单位", example = "吨")
    private String productUnitName;
    @Schema(description = "订购数量", example = "500")
    private BigDecimal orderCount;
    @Schema(description = "订单日期", example = "2026-11-02 10:00:00")
    private LocalDateTime orderTime;
    @Schema(description = "当前可用库存", example = "120")
    private BigDecimal stockQuantity;
    @Schema(description = "建议生产数量 = è®¢è´­æ•°é‡ - å¯ç”¨åº“存(小于 0 æ—¶ä¸º 0)", example = "380")
    private BigDecimal suggestQuantity;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderService.java
@@ -5,6 +5,8 @@
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderImportRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderItemDO;
import jakarta.validation.Valid;
@@ -153,4 +155,21 @@
     */
    void updateSaleOrderOutStatus(Long orderId);
    /**
     * èŽ·å¾—å¾…å®‰æŽ’ç”Ÿäº§çš„é”€å”®è®¢å•æ˜Žç»†
     *
     * ä¾›ç”Ÿäº§è½¦é—´ã€Œä»Žé”€å”®è®¢å•生成生产计划」使用:
     * è¿”回已审核(status=20)、且尚未安排有效 MPS çš„销售订单明细,
     * å¹¶å¸¦ä¸Šå½“前可用库存与建议生产数量
     */
    List<ErpSaleOrderPendingProductionRespVO> getPendingProductionList();
    /**
     * ä»Žé”€å”®è®¢å•批量生成生产计划(MPS)
     *
     * @param reqVO æ˜Žç»†ä¸Žè®¡åˆ’数量
     * @return æˆåŠŸç”Ÿæˆæ¡æ•°
     */
    int generateMpsFromSaleOrder(ErpSaleOrderGenerateMpsReqVO reqVO);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java
@@ -52,9 +52,13 @@
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -98,6 +102,9 @@
    private CrmContractApi contractApi;
    @Resource
    private ErpAccountService accountService;
    @Resource
    private cn.iocoder.yudao.module.mes.api.wms.MesWmStockApi mesWmStockApi;
    @Resource
    private AdminUserApi adminUserApi;
@@ -561,6 +568,195 @@
        return mpsId;
    }
    // ==================== ä»Žé”€å”®è®¢å•生成生产计划(生产车间视角) ====================
    @Override
    public List<cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO>
            getPendingProductionList() {
        // 1. å–全部已审核的销售订单
        List<ErpSaleOrderDO> orders = saleOrderMapper.selectList(
                new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<ErpSaleOrderDO>()
                        .eq(ErpSaleOrderDO::getStatus, ErpAuditStatus.APPROVE.getStatus())
                        .orderByDesc(ErpSaleOrderDO::getId));
        if (CollUtil.isEmpty(orders)) {
            return Collections.emptyList();
        }
        Map<Long, ErpSaleOrderDO> orderMap = orders.stream()
                .collect(Collectors.toMap(ErpSaleOrderDO::getId, o -> o, (a, b) -> a));
        // 2. å–订单明细
        List<ErpSaleOrderItemDO> items = saleOrderItemMapper.selectListByOrderIds(orderMap.keySet());
        if (CollUtil.isEmpty(items)) {
            return Collections.emptyList();
        }
        // 3. å·²æœ‰æœ‰æ•ˆ MPS çš„æ˜Žç»†è¦æŽ’除,避免重复安排生产
        Set<Long> plannedItemIds = new HashSet<>();
        List<Long> allItemIds = items.stream().map(ErpSaleOrderItemDO::getId).collect(Collectors.toList());
        try {
            // æ³¨æ„ï¼šè¯¥ API ç›´æŽ¥è¿”回 List,不是 CommonResult
            List<cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO> existMps =
                    mpsApi.getMpsListBySaleOrderItemIds(allItemIds);
            if (CollUtil.isNotEmpty(existMps)) {
                existMps.stream()
                        .filter(m -> ObjectUtil.notEqual(m.getStatus(), MPS_STATUS_CANCELED))
                        .map(cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO::getSaleOrderItemId)
                        .filter(Objects::nonNull)
                        .forEach(plannedItemIds::add);
            }
        } catch (Exception e) {
            log.warn("[getPendingProductionList] æŸ¥è¯¢å·²æœ‰ MPS å¤±è´¥ï¼ŒæŒ‰å…¨éƒ¨å¯å®‰æŽ’处理", e);
        }
        // 4. ç»„装:带出可用库存与建议生产数量
        List<cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO> result
                = new ArrayList<>();
        for (ErpSaleOrderItemDO item : items) {
            if (plannedItemIds.contains(item.getId())) {
                continue;
            }
            ErpSaleOrderDO order = orderMap.get(item.getOrderId());
            if (order == null) {
                continue;
            }
            cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO vo
                    = new cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO();
            vo.setSaleOrderId(order.getId());
            vo.setSaleOrderNo(order.getNo());
            vo.setSaleOrderItemId(item.getId());
            vo.setCustomerId(order.getCustomerId());
            vo.setCustomerName(getCustomerName(order.getCustomerId()));
            vo.setProductId(item.getProductId());
            // æ˜Žç»†è¡¨åªå­˜ productId,产品编码/名称/规格从 MDM å–
            try {
                MdmItemRespDTO mdmItem = mdmItemApi.getItem(item.getProductId()).getCheckedData();
                if (mdmItem != null) {
                    vo.setProductCode(mdmItem.getCode());
                    vo.setProductName(mdmItem.getName());
                    vo.setProductSpecification(mdmItem.getSpecification());
                }
            } catch (Exception e) {
                log.warn("[getPendingProductionList] èŽ·å–ç‰©æ–™ä¿¡æ¯å¤±è´¥ï¼ŒproductId={}", item.getProductId(), e);
            }
            vo.setOrderCount(item.getCount());
            vo.setOrderTime(order.getOrderTime());
            // å½“前可用库存
            BigDecimal stock = BigDecimal.ZERO;
            try {
                BigDecimal qty = mesWmStockApi.getStockQuantity(item.getProductId());
                if (qty != null) {
                    stock = qty;
                }
            } catch (Exception e) {
                log.warn("[getPendingProductionList] æŸ¥è¯¢åº“存失败,productId={}", item.getProductId(), e);
            }
            vo.setStockQuantity(stock);
            // å»ºè®®ç”Ÿäº§æ•°é‡ = è®¢è´­æ•°é‡ - å¯ç”¨åº“存,不足 0 åˆ™å– 0
            BigDecimal count = item.getCount() == null ? BigDecimal.ZERO : item.getCount();
            BigDecimal suggest = count.subtract(stock);
            vo.setSuggestQuantity(suggest.compareTo(BigDecimal.ZERO) > 0 ? suggest : BigDecimal.ZERO);
            result.add(vo);
        }
        // æŒ‰å»ºè®®ç”Ÿäº§æ•°é‡é™åºï¼Œæ€¥éœ€ç”Ÿäº§çš„æŽ’前面
        result.sort((a, b) -> b.getSuggestQuantity().compareTo(a.getSuggestQuantity()));
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int generateMpsFromSaleOrder(
            cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO reqVO) {
        if (reqVO.getItems() == null || reqVO.getItems().isEmpty()) {
            return 0;
        }
        int success = 0;
        for (cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO.Item reqItem
                : reqVO.getItems()) {
            ErpSaleOrderItemDO item = saleOrderItemMapper.selectById(reqItem.getSaleOrderItemId());
            if (item == null) {
                log.warn("[generateMpsFromSaleOrder] é”€å”®è®¢å•明细不存在,id={}", reqItem.getSaleOrderItemId());
                continue;
            }
            ErpSaleOrderDO order = saleOrderMapper.selectById(item.getOrderId());
            if (order == null || !ErpAuditStatus.APPROVE.getStatus().equals(order.getStatus())) {
                log.warn("[generateMpsFromSaleOrder] é”€å”®è®¢å•未审核,跳过,itemId={}", item.getId());
                continue;
            }
            // å¹‚等保护:已有有效 MPS åˆ™è·³è¿‡ï¼Œé¿å…é‡å¤å®‰æŽ’
            try {
                List<cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO> existMps =
                        mpsApi.getMpsListBySaleOrderItemIds(Collections.singletonList(item.getId()));
                if (CollUtil.isNotEmpty(existMps)
                        && existMps.stream().anyMatch(m -> ObjectUtil.notEqual(m.getStatus(), MPS_STATUS_CANCELED))) {
                    log.info("[generateMpsFromSaleOrder] æ˜Žç»†({}) å·²å­˜åœ¨æœ‰æ•ˆ MPS,跳过", item.getId());
                    continue;
                }
            } catch (Exception e) {
                log.warn("[generateMpsFromSaleOrder] å¹‚等校验失败,继续生成,itemId={}", item.getId(), e);
            }
            try {
                createMpsForSaleOrderItemWithQuantity(order, item, reqItem.getQuantity());
                success++;
            } catch (Exception e) {
                log.error("[generateMpsFromSaleOrder] ç”Ÿæˆ MPS å¤±è´¥ï¼ŒitemId={}", item.getId(), e);
            }
        }
        log.info("[generateMpsFromSaleOrder] å…±ç”Ÿæˆ MPS {} æ¡", success);
        return success;
    }
    /**
     * æŒ‰æŒ‡å®šæ•°é‡ä¸ºé”€å”®è®¢å•明细创建 MPS
     *
     * ä¸Ž createMpsForSaleOrderItem çš„差别:数量由车间自行指定(通常是 è®¢è´­æ•°é‡ - å¯ç”¨åº“存),
     * è€Œä¸æ˜¯ç›´æŽ¥ç”¨è®¢å•数量
     */
    private Long createMpsForSaleOrderItemWithQuantity(ErpSaleOrderDO saleOrder, ErpSaleOrderItemDO item,
                                                       BigDecimal quantity) {
        String productName = null;
        try {
            MdmItemRespDTO mdmItem = mdmItemApi.getItem(item.getProductId()).getCheckedData();
            if (mdmItem != null) {
                productName = mdmItem.getName();
            }
        } catch (Exception e) {
            log.warn("[createMpsForSaleOrderItemWithQuantity] èŽ·å–ç‰©æ–™ä¿¡æ¯å¤±è´¥ï¼ŒproductId={}", item.getProductId(), e);
        }
        MesProMpsCreateReqDTO createReqDTO = new MesProMpsCreateReqDTO();
        createReqDTO.setSaleOrderId(saleOrder.getId());
        createReqDTO.setSaleOrderNo(saleOrder.getNo());
        createReqDTO.setSaleOrderItemId(item.getId());
        createReqDTO.setMdmItemId(item.getProductId());
        createReqDTO.setProductName(productName);
        createReqDTO.setQuantity(quantity);
        createReqDTO.setClientId(saleOrder.getCustomerId());
        createReqDTO.setRequestDate(formatRequestDate(saleOrder.getOrderTime()));
        createReqDTO.setRemark("由生产车间从销售订单生成:" + saleOrder.getNo());
        return mpsApi.createMps(createReqDTO);
    }
    /**
     * å–客户名称,失败时返回 null ä¸å½±å“ä¸»æµç¨‹
     */
    private String getCustomerName(Long customerId) {
        if (customerId == null) {
            return null;
        }
        try {
            // è¯¥ API ç›´æŽ¥è¿”回 DTO,不是 CommonResult
            cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO customer =
                    customerApi.getCustomer(customerId);
            return customer == null ? null : customer.getName();
        } catch (Exception e) {
            log.warn("[getCustomerName] èŽ·å–å®¢æˆ·å¤±è´¥ï¼ŒcustomerId={}", customerId, e);
            return null;
        }
    }
    /**
     * æ ¼å¼åŒ–需求日期
     */