gaoluyang
6 天以前 7bd10d34e8a8dc2437ec8551eb511e37634d7306
feat(mes): 生产计划审批改走协同办公,修复工单领料与产成品入库校验

- MPS 提交审核改为启动 BPM 流程(mes-pro-mps-audit),页面内「审核」接口与按钮废弃,审批结果由监听器回写;支持全局免审批直接通过,流程未部署时给出可执行提示
- 工单辅料预览:无工序或投料不在末道工序时回退按全部投料行汇总单耗,避免有投料却显示「暂无辅料需求」
- 产成品入库执行上架前校验每行上架明细合计与收货数量一致,提示具体行、双方数量与下一步动作
- 物料需求补品牌信息;SQL 脚本增加 process_instance_id 字段并清理旧「审核」按钮权限

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加1个文件
已删除1个文件
已修改9个文件
413 ■■■■■ 文件已修改
sql/mysql/pro_mps_audit_20261030.sql 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/mps/MesProMpsController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/mps/vo/MesProMpsAuditReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderBomController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/mps/MesProMpsDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/material/MesProMaterialRequirementServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/listener/MesProMpsStatusListener.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptServiceImpl.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/pro_mps_audit_20261030.sql
@@ -1,8 +1,9 @@
-- =============================================
-- è®¡åˆ’排程(MPS)审核流 + è¾…料采购 å‡çº§è„šæœ¬
-- 1) mes_pro_mps å¢žåŠ ï¼šè®¡åˆ’å¼€å§‹æ—¥æœŸã€å®¡æ ¸ä¿¡æ¯ã€é‡‡è´­ç”³è¯·å•å…³è”
-- 1) mes_pro_mps å¢žåŠ ï¼šè®¡åˆ’å¼€å§‹æ—¥æœŸã€å®¡æ ¸ä¿¡æ¯ã€é‡‡è´­ç”³è¯·å•å…³è”ã€å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–å·
-- 2) å­—å…¸ mes_pro_mps_status å¢žåŠ ï¼š4-审核中、5-已审核
-- 3) èœå•/按钮:提交审核、审核、生成采购申请单
-- 3) èœå•/按钮:提交审核、生成采购申请单(页面内「审核」按钮已废弃,审批统一在协同办公完成)
-- 4) å®¡æ‰¹æµç¨‹ï¼šéœ€éƒ¨ç½²æµç¨‹å®šä¹‰ mes-pro-mps-audit(见 docs/sql/mes_pro_mps_audit_20261102.bpmn)
-- å¯é‡å¤æ‰§è¡Œï¼Œä¸åŒ…含 tenant_id(项目不使用多租户)
-- =============================================
START TRANSACTION;
@@ -45,6 +46,14 @@
              'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号:提交审核后进入协同办公审批,审批结果回写本表
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'mes_pro_mps' AND column_name = 'process_instance_id');
SET @sql = IF(@exists = 0,
              'ALTER TABLE mes_pro_mps ADD COLUMN process_instance_id varchar(64) DEFAULT NULL COMMENT ''BPM æµç¨‹å®žä¾‹ç¼–号'' AFTER purchase_request_id',
              'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- =============================================
-- 2. å­—典数据:MPS çŠ¶æ€å¢žåŠ  å®¡æ ¸ä¸­(4)、已审核(5)
-- =============================================
@@ -63,9 +72,14 @@
WHERE NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_type = 'mes_pro_mps_status' AND value = '5' AND deleted = b'0');
-- =============================================
-- 3. æŒ‰é’®æƒé™ï¼šæäº¤å®¡æ ¸ / å®¡æ ¸ / ç”Ÿæˆé‡‡è´­ç”³è¯·å•
-- 3. æŒ‰é’®æƒé™ï¼šæäº¤å®¡æ ¸ / ç”Ÿæˆé‡‡è´­ç”³è¯·å•
-- =============================================
SET @mps_page = (SELECT id FROM system_menu WHERE deleted = b'0' AND permission = 'mes:pro-mps:query' ORDER BY id LIMIT 1);
-- 3.0 æ¸…理旧版页面内「审核」按钮(审批已改为在协同办公完成)
DELETE FROM system_role_menu
WHERE menu_id IN (SELECT id FROM (SELECT id FROM system_menu WHERE permission = 'mes:pro-mps:audit' AND deleted = b'0') t);
DELETE FROM system_menu WHERE permission = 'mes:pro-mps:audit' AND deleted = b'0';
-- 3.1 æäº¤å®¡æ ¸
SET @btn = (SELECT id FROM system_menu WHERE deleted = b'0' AND permission = 'mes:pro-mps:submit' ORDER BY id LIMIT 1);
@@ -74,14 +88,7 @@
SELECT @next, '生产计划-提交审核', 'mes:pro-mps:submit', 3, 20, @mps_page, '', '', '', '', 0, b'1', b'0', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL WHERE @btn IS NULL AND @mps_page IS NOT NULL;
-- 3.2 å®¡æ ¸
SET @btn = (SELECT id FROM system_menu WHERE deleted = b'0' AND permission = 'mes:pro-mps:audit' ORDER BY id LIMIT 1);
SET @next = (SELECT IFNULL(MAX(id), 0) + 1 FROM system_menu);
INSERT INTO system_menu(id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
SELECT @next, '生产计划-审核', 'mes:pro-mps:audit', 3, 21, @mps_page, '', '', '', '', 0, b'1', b'0', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL WHERE @btn IS NULL AND @mps_page IS NOT NULL;
-- 3.3 ç”Ÿæˆé‡‡è´­ç”³è¯·å•
-- 3.2 ç”Ÿæˆé‡‡è´­ç”³è¯·å•
SET @btn = (SELECT id FROM system_menu WHERE deleted = b'0' AND permission = 'mes:pro-mps:generate-purchase' ORDER BY id LIMIT 1);
SET @next = (SELECT IFNULL(MAX(id), 0) + 1 FROM system_menu);
INSERT INTO system_menu(id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
@@ -89,12 +96,31 @@
FROM DUAL WHERE @btn IS NULL AND @mps_page IS NOT NULL;
-- =============================================
-- 4. æŽˆæƒï¼šè¶…级管理员直接拥有全部菜单,此处仅补普通角色按需授权(示例:生产计划已有查询权限的角色)
-- 4. ååŒåŠžå…¬æµç¨‹åˆ†ç±»ï¼šç”Ÿäº§è®¡åˆ’å®¡æ‰¹ï¼ˆæµç¨‹å®šä¹‰ mes-pro-mps-audit éƒ¨ç½²åŽå½’属该分类)
-- =============================================
INSERT INTO bpm_category(name, code, description, status, sort, creator, create_time, updater, update_time, deleted)
SELECT '生产计划审批', 'mes_pro_mps_approve', 'MES ç”Ÿäº§è®¡åˆ’审批', 0, 72, '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM bpm_category WHERE code = 'mes_pro_mps_approve' AND deleted = b'0');
-- =============================================
-- 5. ååŒåŠžå…¬å®¡æ‰¹è¯¦æƒ…ï¼šç»‘å®šç”Ÿäº§è®¡åˆ’ä¸šåŠ¡è¯¦æƒ…ç»„ä»¶ï¼Œå®¡æ‰¹äººå¯æŸ¥çœ‹è®¡åˆ’æ•°æ®
-- =============================================
INSERT INTO bpm_business_attach(process_definition_key, business_type, component_path, status, remark,
                                creator, create_time, updater, update_time, deleted)
SELECT 'mes-pro-mps-audit', 'mes_pro_mps', '/mes/pro/mps/detail/approve/index.vue', 0,
       '生产计划审批展示生产计划详情', '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM bpm_business_attach
                  WHERE process_definition_key = 'mes-pro-mps-audit' AND deleted = b'0');
-- =============================================
-- 6. æŽˆæƒï¼šè¶…级管理员直接拥有全部菜单,此处仅补普通角色按需授权(示例:生产计划已有查询权限的角色)
-- =============================================
INSERT INTO system_role_menu(role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT DISTINCT rm.role_id, m.id, '1', NOW(), '1', NOW(), b'0'
FROM system_role_menu rm
JOIN system_menu m ON m.permission IN ('mes:pro-mps:submit', 'mes:pro-mps:audit', 'mes:pro-mps:generate-purchase')
JOIN system_menu m ON m.permission IN ('mes:pro-mps:submit', 'mes:pro-mps:generate-purchase')
    AND m.deleted = b'0'
JOIN system_menu parent ON parent.id = rm.menu_id AND parent.permission = 'mes:pro-mps:query' AND parent.deleted = b'0'
WHERE rm.deleted = b'0'
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/mps/MesProMpsController.java
@@ -9,7 +9,6 @@
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsAuditReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsSaveReqVO;
@@ -149,14 +148,6 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-mps:submit')")
    public CommonResult<Boolean> submitMps(@RequestParam("id") Long id) {
        mpsService.submitMps(id);
        return success(true);
    }
    @PostMapping("/audit")
    @Operation(summary = "审核生产主计划")
    @PreAuthorize("@ss.hasPermission('mes:pro-mps:audit')")
    public CommonResult<Boolean> auditMps(@Valid @RequestBody MesProMpsAuditReqVO reqVO) {
        mpsService.auditMps(reqVO);
        return success(true);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/mps/vo/MesProMpsAuditReqVO.java
ÎļþÒÑɾ³ý
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/workorder/MesProWorkOrderBomController.java
@@ -143,19 +143,15 @@
            return success(Collections.emptyList());
        }
        // 2. èŽ·å–å·¥å•å·¥åºåˆ—è¡¨ï¼ˆå«å„å·¥åºçš„æŠ•æ–™æ˜Žç»†ä¸Žäº§å‡ºå“ï¼‰
        List<MesProWorkOrderProcessDO> processList = workOrderProcessService
                .getWorkOrderProcessListByWorkOrderId(workOrderId);
        if (CollUtil.isEmpty(processList)) {
            return success(Collections.emptyList());
        }
        // 3. èŽ·å–å·¥å• BOM è¡Œï¼ˆå„工序的投料记录,按 processId åˆ†ç»„使用)
        // 2. èŽ·å–å·¥å• BOM è¡Œï¼ˆå„工序的投料记录,单耗口径)
        List<MesProWorkOrderBomDO> bomList = workOrderBomService.getWorkOrderBomListByWorkOrderId(workOrderId);
        if (CollUtil.isEmpty(bomList)) {
            return success(Collections.emptyList());
        }
        // 3. èŽ·å–å·¥å•å·¥åºåˆ—è¡¨ï¼ˆå«å„å·¥åºçš„æŠ•æ–™æ˜Žç»†ä¸Žäº§å‡ºå“ï¼‰
        List<MesProWorkOrderProcessDO> processList = workOrderProcessService
                .getWorkOrderProcessListByWorkOrderId(workOrderId);
        // 4. é€šè¿‡äº§å“ID反查工艺路线(工单 routeId å¯èƒ½ä¸ºç©ºï¼‰
        Long routeId = null;
@@ -167,7 +163,22 @@
        }
        // 5. æŒ‰å·¥åºäº§å‡ºé“¾é€å±‚展开 BOM,得到叶子物料(每单位成品的用量)
        Map<Long, BigDecimal> leafItems = buildWorkOrderItems(processList, bomList, routeId);
        Map<Long, BigDecimal> leafItems = CollUtil.isEmpty(processList)
                ? Collections.emptyMap()
                : buildWorkOrderItems(processList, bomList, routeId);
        // 5.1 å›žé€€ï¼šå·¥å•没有工序,或投料未挂在最后一道工序上(例如投料配置在首道配料工序)时,
        //     é€å±‚展开会得到空结果。此时按全部投料行汇总单耗,与「按工单生成领料单」的取数口径保持一致,
        //     å¦åˆ™å·¥å•明明有投料、领料也领过了,打印领料单却显示「暂无辅料需求」。
        if (CollUtil.isEmpty(leafItems)) {
            Map<Long, BigDecimal> allBomItems = new LinkedHashMap<>();
            for (MesProWorkOrderBomDO bom : bomList) {
                if (bom.getItemId() == null || bom.getQuantity() == null) {
                    continue;
                }
                allBomItems.merge(bom.getItemId(), bom.getQuantity(), BigDecimal::add);
            }
            leafItems = allBomItems;
        }
        if (CollUtil.isEmpty(leafItems)) {
            return success(Collections.emptyList());
        }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/mps/MesProMpsDO.java
@@ -169,6 +169,13 @@
     */
    private String auditRemark;
    /**
     * BPM æµç¨‹å®žä¾‹ç¼–号
     *
     * æäº¤å®¡æ ¸åŽåœ¨ååŒåŠžå…¬å®¡æ‰¹ï¼Œå®¡æ‰¹ç»“æžœé€šè¿‡ç›‘å¬å™¨å›žå†™
     */
    private String processInstanceId;
    // ========== é‡‡è´­å•关联 ==========
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -383,10 +383,10 @@
    ErrorCode PRO_MPS_HAS_WORK_ORDER = new ErrorCode(1_040_509_007, "MPS å·²å…³è”生产工单,无法删除");
    ErrorCode PRO_MPS_MES_PRODUCT_NOT_EXISTS = new ErrorCode(1_040_509_008, "MES äº§å“ä¸å­˜åœ¨ï¼Œæ— æ³•创建 MPS,产品:{}");
    ErrorCode PRO_MPS_WORK_ORDER_HAS_FEEDBACK = new ErrorCode(1_040_509_009, "该计划关联的生产订单已有报工记录,无法取消!");
    ErrorCode PRO_MPS_NOT_AUDITING = new ErrorCode(1_040_509_010, "只有审核中状态的 MPS æ‰èƒ½å®¡æ ¸");
    ErrorCode PRO_MPS_NOT_APPROVED = new ErrorCode(1_040_509_011, "只有已审核状态的 MPS æ‰èƒ½æ‰§è¡Œæ­¤æ“ä½œï¼Œè¯·å…ˆæäº¤å®¡æ ¸");
    ErrorCode PRO_MPS_AUDIT_REMARK_REQUIRED = new ErrorCode(1_040_509_012, "驳回审核时必须填写驳回原因");
    ErrorCode PRO_MPS_NO_MATERIAL_SHORTAGE = new ErrorCode(1_040_509_013, "该计划所需物料库存充足,无需生成采购单");
    ErrorCode PRO_MPS_PROCESS_NOT_DEPLOYED = new ErrorCode(1_040_509_014, "提交审核失败:生产计划审批流程(mes-pro-mps-audit)尚未部署,请联系管理员在「协同办公 - æµç¨‹æ¨¡åž‹ã€ä¸­éƒ¨ç½²è¯¥æµç¨‹åŽé‡æ–°æäº¤");
    ErrorCode PRO_MPS_NO_MATERIAL_BOM = new ErrorCode(1_040_509_015, "生产计划{}的产品【{}】尚未维护产品 BOM æˆ–工艺路线投料 BOM,无法计算物料缺口,请先到「生产基础数据 - ç‰©æ–™BOM维护」或「生产基础数据 - å·¥è‰ºè·¯çº¿ã€ä¸­é…ç½®æŠ•料后再生成采购申请单");
    // ========== MES è´¨é‡ç®¡ç†-质检方案(1-040-600-000) ==========
    ErrorCode QC_TEMPLATE_NOT_EXISTS = new ErrorCode(1_040_600_000, "质检方案不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/material/MesProMaterialRequirementServiceImpl.java
@@ -117,6 +117,10 @@
                    }
                    continue;
                }
                // é¡¶çº§äº§å“æ²¡æœ‰ä»»ä½•下级 BOM æ—¶ï¼Œè¯´æ˜Žå°šæœªç»´æŠ¤ BOM,不能把产品自身当成辅料
                if (entry.getKey().equals(productId)) {
                    continue;
                }
                // æ— ä¸‹çº§ BOM,视为叶子物料(外购件)
                leafItems.merge(entry.getKey(), qty, BigDecimal::add);
            }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsService.java
@@ -61,20 +61,23 @@
    /**
     * æäº¤å®¡æ ¸
     *
     * è‰ç¨¿çŠ¶æ€ â†’ å®¡æ ¸ä¸­
     * è‰ç¨¿çŠ¶æ€ â†’ å®¡æ ¸ä¸­ï¼Œå¹¶å¯åЍ BPM æµç¨‹ï¼Œå®¡æ‰¹åœ¨ååŒåŠžå…¬å®Œæˆ
     *
     * @param id MPS ç¼–号
     */
    void submitMps(Long id);
    /**
     * å®¡æ ¸ MPS
     * å¤„理 BPM å®¡æ‰¹ç»“果(协同办公审批完成后回调)
     *
     * å®¡æ ¸ä¸­ â†’ å·²å®¡æ ¸ï¼ˆé€šè¿‡ï¼‰/ è‰ç¨¿ï¼ˆé©³å›žï¼‰
     *
     * @param reqVO å®¡æ ¸ä¿¡æ¯
     * @param id          MPS ç¼–号
     * @param bpmResult   BPM å®¡æ‰¹ç»“æžœ
     * @param reason      å®¡æ‰¹æ„è§ï¼ˆé©³å›žåŽŸå› ï¼‰
     * @param auditUserId å®¡æ ¸äººç¼–号
     */
    void auditMps(@Valid cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsAuditReqVO reqVO);
    void handleBpmProcessResult(Long id, Integer bpmResult, String reason, Long auditUserId);
    /**
     * èŽ·å¾—è®¡åˆ’çš„è¾…æ–™éœ€æ±‚æ˜Žç»†ï¼ˆå«åº“å­˜ã€åœ¨é€”ã€æ˜¯å¦å……è¶³ï¼‰
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java
@@ -3,14 +3,17 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseOrderApi;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseRequestApi;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseOrderItemRespDTO;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseRequestCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsAuditReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.MesProWorkOrderItemRespVO;
@@ -77,6 +80,11 @@
     */
    private static final String MPS_CODE_RULE = "MPS";
    /**
     * MPS å®¡æ‰¹æµç¨‹å®šä¹‰ Key(BPM æµç¨‹æ¨¡åž‹ä¸­é…ç½®ï¼‰
     */
    private static final String MPS_APPROVE_PROCESS_DEFINITION_KEY = "mes-pro-mps-audit";
    @Resource
    private MesProMpsMapper mpsMapper;
    @Resource
@@ -105,6 +113,8 @@
    private ErpPurchaseOrderApi purchaseOrderApi;
    @Resource
    private ErpPurchaseRequestApi purchaseRequestApi;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -194,11 +204,8 @@
        // æ ¡éªŒç¼–码唯一
        validateMpsCodeUnique(updateReqVO.getId(), updateReqVO.getCode());
        // æ›´æ–°ï¼›å®¡æ ¸ä¸­çš„计划被修改后需要重新提交审核
        // æ›´æ–°ï¼ˆä»…草稿可编辑)
        MesProMpsDO updateObj = BeanUtils.toBean(updateReqVO, MesProMpsDO.class);
        if (MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            updateObj.setStatus(MesProMpsStatusEnum.DRAFT.getStatus());
        }
        mpsMapper.updateById(updateObj);
    }
@@ -221,35 +228,71 @@
    @Transactional(rollbackFor = Exception.class)
    public void submitMps(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())
                && !MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
        // åªæœ‰è‰ç¨¿å¯ä»¥æäº¤å®¡æ ¸ï¼›å®¡æ ¸ä¸­çš„计划需等待协同办公审批结果
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_DRAFT);
        }
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸åš BPM æµç¨‹ï¼Œç›´æŽ¥æŒ‰å®¡æ‰¹é€šè¿‡å¤„理
        if (processInstanceApi.isGlobalAutoApprove()) {
            mpsMapper.updateById(new MesProMpsDO()
                    .setId(id)
                    .setStatus(MesProMpsStatusEnum.AUDITING.getStatus())
                    .setProcessInstanceId(""));
            handleBpmProcessResult(id, BpmProcessInstanceStatusEnum.APPROVE.getStatus(), "全局免审批自动通过", null);
            return;
        }
        // å¯åЍ BPM æµç¨‹ï¼Œå®¡æ‰¹åœ¨ååŒåŠžå…¬å®Œæˆï¼Œç»“æžœç”±ç›‘å¬å™¨å›žå†™
        String processInstanceId;
        try {
            processInstanceId = processInstanceApi.createProcessInstance(SecurityFrameworkUtils.getLoginUserId(),
                    new BpmProcessInstanceCreateReqDTO()
                            .setProcessDefinitionKey(MPS_APPROVE_PROCESS_DEFINITION_KEY)
                            .setBusinessKey(String.valueOf(id)));
        } catch (ServiceException ex) {
            // æµç¨‹æœªéƒ¨ç½²æ—¶ BPM åªæŠ›ã€Œæµç¨‹å®šä¹‰ä¸å­˜åœ¨ã€ï¼Œè¡¥ä¸Šå¯æ‰§è¡Œçš„æç¤º
            if (StrUtil.contains(ex.getMessage(), "流程定义不存在")) {
                log.warn("[submitMps] ç”Ÿäº§è®¡åˆ’审批流程({}) æœªéƒ¨ç½²ï¼ŒmpsId={}", MPS_APPROVE_PROCESS_DEFINITION_KEY, id);
                throw exception(PRO_MPS_PROCESS_NOT_DEPLOYED);
            }
            throw ex;
        }
        mpsMapper.updateById(new MesProMpsDO()
                .setId(id)
                .setStatus(MesProMpsStatusEnum.AUDITING.getStatus()));
                .setStatus(MesProMpsStatusEnum.AUDITING.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void auditMps(MesProMpsAuditReqVO reqVO) {
        MesProMpsDO mps = validateMpsExists(reqVO.getId());
    public void handleBpmProcessResult(Long id, Integer bpmResult, String reason, Long auditUserId) {
        // 1. æŸ”性校验:计划存在且处于审核中
        MesProMpsDO mps = mpsMapper.selectById(id);
        if (mps == null) {
            log.warn("[handleBpmProcessResult] ç”Ÿäº§è®¡åˆ’({}) ä¸å­˜åœ¨", id);
            return;
        }
        if (!MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_AUDITING);
            log.warn("[handleBpmProcessResult] ç”Ÿäº§è®¡åˆ’({}) ä¸å¤„于审核中状态,当前状态:{}", id, mps.getStatus());
            return;
        }
        boolean approved = Boolean.TRUE.equals(reqVO.getApproved());
        if (!approved && StrUtil.isBlank(reqVO.getAuditRemark())) {
            throw exception(PRO_MPS_AUDIT_REMARK_REQUIRED);
        // 2. æ ¹æ®å®¡æ‰¹ç»“果更新状态
        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            mpsMapper.updateById(new MesProMpsDO()
                    .setId(id)
                    .setStatus(MesProMpsStatusEnum.APPROVED.getStatus())
                    .setAuditUserId(auditUserId)
                    .setAuditTime(LocalDateTime.now())
                    .setAuditRemark(StrUtil.blankToDefault(reason, "审核通过")));
        } else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(bpmResult)
                || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            mpsMapper.updateById(new MesProMpsDO()
                    .setId(id)
                    .setStatus(MesProMpsStatusEnum.DRAFT.getStatus())
                    .setAuditUserId(auditUserId)
                    .setAuditTime(LocalDateTime.now())
                    .setAuditRemark(StrUtil.blankToDefault(reason, "审批驳回")));
        }
        mpsMapper.updateById(new MesProMpsDO()
                .setId(reqVO.getId())
                .setStatus(approved ? MesProMpsStatusEnum.APPROVED.getStatus()
                        : MesProMpsStatusEnum.DRAFT.getStatus())
                .setAuditUserId(SecurityFrameworkUtils.getLoginUserId())
                .setAuditTime(LocalDateTime.now())
                .setAuditRemark(approved
                        ? StrUtil.blankToDefault(reqVO.getAuditRemark(), "审核通过")
                        : reqVO.getAuditRemark()));
    }
    @Override
@@ -267,9 +310,16 @@
        if (!MesProMpsStatusEnum.APPROVED.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_APPROVED);
        }
        // 1. åªå¯¹ç¼ºå£ç‰©æ–™ç”Ÿæˆé‡‡è´­æ˜Žç»†ï¼šç¼ºå£ = éœ€æ±‚ - å¯ç”¨åº“å­˜ - åœ¨é€”采购
        // 1. ç‰©æ–™éœ€æ±‚为空说明产品未维护任何 BOM,直接提示,避免误报“库存充足”
        List<MesProWorkOrderItemRespVO> materialItems = getMpsItemList(id);
        if (CollUtil.isEmpty(materialItems)) {
            MesMdItemDO product = itemService.getItem(mps.getProductId());
            throw exception(PRO_MPS_NO_MATERIAL_BOM, mps.getCode(),
                    product != null ? product.getName() : String.valueOf(mps.getProductId()));
        }
        // 2. åªå¯¹ç¼ºå£ç‰©æ–™ç”Ÿæˆé‡‡è´­æ˜Žç»†ï¼šç¼ºå£ = éœ€æ±‚ - å¯ç”¨åº“å­˜ - åœ¨é€”采购
        List<ErpPurchaseRequestCreateReqDTO.Item> shortageItems = new ArrayList<>();
        for (MesProWorkOrderItemRespVO item : getMpsItemList(id)) {
        for (MesProWorkOrderItemRespVO item : materialItems) {
            if (Boolean.TRUE.equals(item.getStockSufficient())) {
                continue;
            }
@@ -293,14 +343,14 @@
        if (CollUtil.isEmpty(shortageItems)) {
            throw exception(PRO_MPS_NO_MATERIAL_SHORTAGE);
        }
        // 2. ç”Ÿæˆé‡‡è´­ç”³è¯·å•(草稿,无供应商,后续由采购人员补充并按流程提交)
        // 3. ç”Ÿæˆé‡‡è´­ç”³è¯·å•(草稿,无供应商,后续由采购人员补充并按流程提交)
        ErpPurchaseRequestCreateReqDTO createReqDTO = new ErpPurchaseRequestCreateReqDTO()
                .setRequestTime(LocalDateTime.now())
                .setRequestReason("生产计划 " + mps.getCode() + " è¾…料采购")
                .setRemark("由生产计划自动生成,共 " + shortageItems.size() + " é¡¹ç‰©æ–™åº“存不足")
                .setItems(shortageItems);
        Long purchaseRequestId = purchaseRequestApi.createPurchaseRequest(createReqDTO).getCheckedData();
        // 3. å›žå†™é‡‡è´­ç”³è¯·å•编号,便于从计划追溯
        // 4. å›žå†™é‡‡è´­ç”³è¯·å•编号,便于从计划追溯
        mpsMapper.updateById(new MesProMpsDO().setId(id).setPurchaseRequestId(purchaseRequestId));
        log.info("[generatePurchaseRequest] è®¡åˆ’({}) ç”Ÿæˆé‡‡è´­ç”³è¯·å•({}),明细 {} é¡¹", mps.getCode(), purchaseRequestId, shortageItems.size());
        return purchaseRequestId;
@@ -638,12 +688,11 @@
    }
    /**
     * æ ¡éªŒ MPS å­˜åœ¨ + å¯ç¼–辑状态(未开始、审核中)
     * æ ¡éªŒ MPS å­˜åœ¨ + å¯ç¼–辑状态(仅草稿;审核中的计划需等待协同办公审批)
     */
    private MesProMpsDO validateMpsExistsAndEditable(Long id) {
        MesProMpsDO mps = validateMpsExists(id);
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())
                && !MesProMpsStatusEnum.AUDITING.getStatus().equals(mps.getStatus())) {
        if (!MesProMpsStatusEnum.DRAFT.getStatus().equals(mps.getStatus())) {
            throw exception(PRO_MPS_NOT_DRAFT);
        }
        return mps;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/listener/MesProMpsStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.mes.service.pro.mps.listener;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants;
import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService;
import cn.iocoder.yudao.module.mes.service.pro.mps.MesProMpsService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
/**
 * ç”Ÿäº§ä¸»è®¡åˆ’(MPS)审批状态监听器
 *
 * ç›‘听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´äº‹ä»¶ï¼ŒæŠŠååŒåŠžå…¬çš„å®¡æ‰¹ç»“æžœå›žå†™åˆ° MPS çŠ¶æ€
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class MesProMpsStatusListener extends BpmProcessInstanceStatusEventListener {
    /**
     * MPS å®¡æ‰¹æµç¨‹å®šä¹‰ Key
     */
    private static final String BPM_PROCESS_DEFINITION_KEY = "mes-pro-mps-audit";
    @Resource
    private MesProMpsService mpsService;
    @Resource
    private BpmTaskService bpmTaskService;
    @Override
    protected String getProcessDefinitionKey() {
        return BPM_PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        Long mpsId;
        try {
            mpsId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„生产计划编号", event.getBusinessKey());
            return;
        }
        String reason = event.getReason();
        Long auditUserId = null;
        if (event.getId() != null) {
            // å½“ event.getReason() ä¸ºç©ºæ—¶ï¼Œå°è¯•从最后一个完成的任务中获取审批意见与审批人
            List<HistoricTaskInstance> tasks = bpmTaskService.getTaskListByProcessInstanceId(event.getId(), false);
            if (CollUtil.isNotEmpty(tasks)) {
                HistoricTaskInstance lastTask = tasks.stream()
                        .filter(task -> task.getEndTime() != null)
                        // æŽ’除多实例或签满足条件后,被系统自动取消的兄弟任务(deleteReason éžç©ºï¼‰
                        .filter(task -> task.getDeleteReason() == null)
                        .max(Comparator.comparing(HistoricTaskInstance::getEndTime))
                        .orElse(null);
                if (lastTask != null) {
                    if (StrUtil.isEmpty(reason) && lastTask.getTaskLocalVariables() != null) {
                        reason = (String) lastTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_REASON);
                    }
                    if (StrUtil.isNotEmpty(lastTask.getAssignee())) {
                        try {
                            auditUserId = Long.parseLong(lastTask.getAssignee());
                        } catch (NumberFormatException ignored) {
                            log.warn("[onEvent] å®¡æ‰¹äºº({}) ä¸æ˜¯æœ‰æ•ˆçš„用户编号", lastTask.getAssignee());
                        }
                    }
                }
            }
        }
        log.info("[onEvent] æ”¶åˆ°BPM审批事件,mpsId={}, status={}, reason={}, auditUserId={}",
                mpsId, event.getStatus(), reason, auditUserId);
        mpsService.handleBpmProcessResult(mpsId, event.getStatus(), reason, auditUserId);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productreceipt/MesWmProductReceiptServiceImpl.java
@@ -2,6 +2,7 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -19,6 +20,8 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
@@ -32,6 +35,9 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -54,6 +60,8 @@
    private MesWmTransactionService wmTransactionService;
    @Resource
    private MesProWorkOrderService workOrderService;
    @Resource
    private MesMdItemService itemService;
    @Resource
    private MesWmWarehouseService warehouseService;
    @Resource
@@ -139,11 +147,81 @@
            throw exception(WM_PRODUCT_RECPT_STATUS_ERROR);
        }
        // æ ¡éªŒä¸Šæž¶æ˜Žç»†å·²è¦†ç›–每一行收货数量:状态一旦推进到「待执行入库」,明细就不能再编辑,
        // è‹¥æ­¤æ—¶æ˜Žç»†ä¸ºç©ºæˆ–数量不足,单据将永远无法执行入库(会被卡死在待执行入库)
        validateProductReceiptStockDetail(receipt);
        // æ‰§è¡Œä¸Šæž¶ï¼ˆå¾…上架 â†’ å¾…入库)
        productReceiptMapper.updateById(new MesWmProductReceiptDO()
                .setId(id).setStatus(MesWmProductReceiptStatusEnum.APPROVED.getStatus()));
    }
    /**
     * æ ¡éªŒæ‰§è¡Œä¸Šæž¶å‰çš„上架明细:每一行的收货数量都必须被该行的上架明细合计覆盖
     *
     * æç¤ºæŒ‰ã€Œå…·ä½“行 + åŒæ–¹æ•°é‡ + ä¸‹ä¸€æ­¥åŠ¨ä½œã€ç»„ç»‡ï¼Œé¿å…åªæŠ¥ä¸€å¥ç¬¼ç»Ÿçš„"数量不匹配"
     */
    private void validateProductReceiptStockDetail(MesWmProductReceiptDO receipt) {
        List<MesWmProductReceiptLineDO> lines = productReceiptLineService.getProductReceiptLineListByRecptId(receipt.getId());
        if (CollUtil.isEmpty(lines)) {
            throw new ServiceException(WM_PRODUCT_RECPT_NO_LINE.getCode(),
                    String.format("收货单[%s]没有收货行,无法执行上架:请先在单据中添加收货行(物料与收货数量)后再执行上架。",
                            receipt.getCode()));
        }
        Map<Long, MesMdItemDO> itemMap = itemService.getItemMap(lines.stream()
                .map(MesWmProductReceiptLineDO::getItemId).filter(Objects::nonNull).collect(Collectors.toSet()));
        for (int i = 0; i < lines.size(); i++) {
            MesWmProductReceiptLineDO line = lines.get(i);
            String lineLabel = String.format("第 %d è¡Œ[%s]", i + 1, formatItemLabel(line.getItemId(), itemMap));
            if (line.getBatchCode() != null) {
                lineLabel += String.format("(批次[%s])", line.getBatchCode());
            }
            BigDecimal lineQuantity = line.getQuantity() == null ? BigDecimal.ZERO : line.getQuantity();
            List<MesWmProductReceiptDetailDO> details =
                    productReceiptDetailService.getProductReceiptDetailListByLineId(line.getId());
            // æ˜Žç»†ä¸ºç©ºæ—¶ç›´æŽ¥æç¤º"去点上架",比提示"合计 0 ä¸Ž 20 ä¸ä¸€è‡´"更明确
            if (CollUtil.isEmpty(details)) {
                throw new ServiceException(WM_PRODUCT_RECPT_NO_DETAIL.getCode(),
                        String.format("收货单[%s]%s还没有上架明细(收货数量[%s]):请先在该行点「上架」,填写入库仓库与上架数量,"
                                + "合计填满 %s åŽå†æ‰§è¡Œä¸Šæž¶ã€‚", receipt.getCode(), lineLabel,
                                formatQuantity(lineQuantity), formatQuantity(lineQuantity)));
            }
            BigDecimal detailQuantity = CollectionUtils.getSumValue(details,
                    MesWmProductReceiptDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
            if (detailQuantity.compareTo(lineQuantity) != 0) {
                BigDecimal diff = lineQuantity.subtract(detailQuantity);
                String action = diff.signum() > 0
                        ? String.format("还差[%s]:请在该行点「上架」补登缺少的上架明细", formatQuantity(diff))
                        : String.format("多出[%s]:请删除或调整该行多余的上架明细", formatQuantity(diff.negate()));
                throw new ServiceException(WM_PRODUCT_RECPT_DETAIL_QUANTITY_MISMATCH.getCode(),
                        String.format("收货单[%s]%s上架明细合计[%s]与收货数量[%s]不一致,%s,调整一致后再执行上架。",
                                receipt.getCode(), lineLabel, formatQuantity(detailQuantity),
                                formatQuantity(lineQuantity), action));
            }
        }
    }
    /**
     * æ•°é‡æ˜¾ç¤ºï¼šåŽ»æŽ‰æ•°æ®åº“ scale å¸¦å‡ºçš„多余 0(20.000000 â†’ 20)
     */
    private String formatQuantity(BigDecimal quantity) {
        if (quantity == null) {
            return "0";
        }
        return quantity.stripTrailingZeros().toPlainString();
    }
    /**
     * ç‰©æ–™æ˜¾ç¤ºåï¼šä¼˜å…ˆã€Œåç§°/编码」,查不到时回退为 ID,避免把裸 ID ç›´æŽ¥æŠ›ç»™ç”¨æˆ·
     */
    private String formatItemLabel(Long itemId, Map<Long, MesMdItemDO> itemMap) {
        MesMdItemDO item = itemId == null ? null : itemMap.get(itemId);
        if (item == null) {
            return itemId == null ? "物料[未选择]" : String.format("物料[ID=%s]", itemId);
        }
        return String.format("%s/%s", item.getName(), item.getCode());
    }
    @Override
    public Boolean checkProductReceiptQuantity(Long id) {
        List<MesWmProductReceiptLineDO> lines = productReceiptLineService.getProductReceiptLineListByRecptId(id);