云
2026-10-28 dd637406e18a7afbf08d323c6ece913fe545b7ad
feat(mes): 增加盘号追溯功能和销售退货单优化

- 在ERP销售订单中新增盘号字段用于产品追溯
- 优化MES模块中的盘号管理,包括生产计划和工单的盘号快照
- 完善销售退货流程中的盘号匹配验证逻辑
- 增加退货批次与销售订单明细的盘号一致性校验
- 修复IPQC质检项值显示逻辑,空值显示为"未填写"
- 优化批次查询逻辑,精确匹配盘号避免大小写差异
- 新增多个退货相关错误码处理异常情况
- 完善退货单据中客户和创建者信息展示
已添加6个文件
已修改33个文件
598 ■■■■■ 文件已修改
production-rework-legacy-code.txt 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261028_mdm_technical_parameters.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261028_reel_history_audit.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderImportExcelVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/rqc/MesQcRqcController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/rqc/vo/MesQcRqcRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/vo/MesWmBatchRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesDetailController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesLineController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/detail/MesWmProductSalesDetailRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/line/MesWmProductSalesLineRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesDetailController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesLineController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/detail/MesWmReturnSalesDetailRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/salesnotice/MesWmSalesNoticeLineController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/salesnotice/vo/line/MesWmSalesNoticeLineRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transfer/MesWmTransferLineController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transfer/vo/line/MesWmTransferLineRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/batch/MesWmBatchMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesDetailMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesDetailServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeLineServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/batch/MesWmBatchServiceImplTest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineReelTest.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmFinishedSaleBatchSqlTest.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesReelTest.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeLineReelTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
production-rework-legacy-code.txt
@@ -4,8 +4,12 @@
待办:工资计算公式尚未与客户确认;本次不实施工资计算,待确认工序、设备、工时定额和辅助工时的计算口径后再改造。
查询权限清理待办:前端已移除一部分详情入口、销售订单查看及工作台报工记录对查询按钮权限的依赖,首页经营卡片按页面菜单控制。其他查询权限仍被办理、设置、首页汇总及跨页面入口引用;在逐项完成权限替代并验证之前,不要执行 `sql/mysql/remove_query_button_permissions.sql`。
盘号追溯:新生产产出即使物料未启用普通批次管理,也会为非空盘号创建内部追溯批次,库存仍按批次分开;库存页面和拣货选择器显示盘号,销售拣货及出库时核对销售明细盘号。历史无盘号库存保留空值,不自动推测或拆分;销售明细有盘号时,这类旧库存不能直接作为匹配库存出库,需先核实并处理历史数据。内部追溯批次受现有库位批次混放规则约束,正式使用前需验证现场库位配置。
盘号待核实:新建或编辑关联销售订单的发货通知单行时,须明确选择销售明细并校验订单与物料;历史无关联行不会自动推断,已生成的下游历史单据仍需逐笔核实。带盘号的退货批次须在该销售订单中有明确关联销售明细且已完成的实际出库记录,并在上架时保留批次;尚未记录具体出库明细或累计可退数量,实际退货数量和来源仍需核实。调拨与库存调整已保留追溯批次,仍需数据库及页面端到端验证;不得按物料或行顺序推测历史关联。
| ä½ç½® | å½“前状态 | æ”¹é€ æ³¨æ„äº‹é¡¹ |
| --- | --- | --- |
| `MesProMpsController.mergeAndIssueMps` å’Œ `MesProMpsServiceImpl.mergeAndIssueMps` | å·²åœç”¨ï¼šæ—§å…¥å£è¿”回停用提示 | æ–°æŽ¥å£é€æ¡æ‰¹é‡ä¸‹å‘;历史合并工单及明细仍需保留查询和取消兼容。 |
sql/mysql/20261028_mdm_technical_parameters.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
-- æµ‹è¯•库页面联调:补充物料技术参数字段。
-- å…ˆé€‰æ‹©ç›®æ ‡æ•°æ®åº“;已存在时跳过,不覆盖既有数据。
-- MySQL çš„ ALTER TABLE ä¼šéšå¼æäº¤ï¼Œæ‰§è¡Œå‰è¯·ç¡®è®¤æ•°æ®åº“及备份。
SET @mdm_ddl = IF(
    EXISTS (SELECT 1 FROM information_schema.COLUMNS
            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdm_item'
              AND COLUMN_NAME = 'technical_parameters'),
    'SELECT ''mdm_item.technical_parameters å·²å­˜åœ¨ï¼Œè·³è¿‡'' AS message',
    'ALTER TABLE `mdm_item` ADD COLUMN `technical_parameters` TEXT NULL COMMENT ''技术参数'''
);
PREPARE mdm_stmt FROM @mdm_ddl;
EXECUTE mdm_stmt;
DEALLOCATE PREPARE mdm_stmt;
sql/mysql/20261028_reel_history_audit.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
-- åªè¯»æŽ’查:关联销售订单但缺少销售明细关联的历史单据,不能按物料自动猜测盘号。
-- è¯·åœ¨ç›®æ ‡æ•°æ®åº“执行并逐条核对;本脚本不修改数据。
SELECT n.id AS notice_id, n.code AS notice_code, n.sale_order_id,
       l.id AS notice_line_id, l.item_id, l.quantity, l.sale_order_item_id
FROM mes_wm_sales_notice n
JOIN mes_wm_sales_notice_line l ON l.notice_id = n.id
WHERE n.deleted = b'0' AND l.deleted = b'0'
  AND n.sale_order_id IS NOT NULL AND l.sale_order_item_id IS NULL
ORDER BY n.id, l.id;
SELECT s.id AS sales_id, s.code AS sales_code, s.sale_order_id,
       l.id AS sales_line_id, l.notice_line_id, l.item_id, l.quantity, l.sale_order_item_id
FROM mes_wm_product_sales s
JOIN mes_wm_product_sales_line l ON l.sales_id = s.id
WHERE s.deleted = b'0' AND l.deleted = b'0'
  AND s.sale_order_id IS NOT NULL AND l.sale_order_item_id IS NULL
ORDER BY s.id, l.id;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java
@@ -183,6 +183,18 @@
        // 4. ç»„装响应
        ErpSaleOrderRespVO saleOrderVO = BeanUtils.toBean(saleOrder, ErpSaleOrderRespVO.class);
        saleOrderVO.setItems(itemVOList);
        saleOrderVO.setNeedProduction(itemVOList.stream()
                .anyMatch(item -> Integer.valueOf(1).equals(item.getNeedProduction())) ? 1 : 0);
        if (saleOrder.getCustomerId() != null) {
            MapUtils.findAndThen(customerApi.getCustomerMap(List.of(saleOrder.getCustomerId())),
                    saleOrder.getCustomerId(), customer -> saleOrderVO.setCustomerName(customer.getName()));
        }
        if (saleOrder.getCreator() != null && saleOrder.getCreator().matches("\\d+")) {
            AdminUserRespDTO creator = adminUserApi.getUser(Long.valueOf(saleOrder.getCreator()));
            if (creator != null) {
                saleOrderVO.setCreatorName(creator.getNickname());
            }
        }
        saleOrderVO.setAttachmentList(storageAttachmentApi.listAttachments("erp_sale_order", id));
        return success(saleOrderVO);
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderImportExcelVO.java
@@ -48,6 +48,10 @@
    @Schema(description = "产品名称(仅供对照,匹配仍以产品编码为准)")
    private String productName;
    @ExcelProperty("盘号")
    @Schema(description = "产品明细盘号,手工填写,最多128个字符")
    private String reelNo;
    @ExcelProperty("数量")
    @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED)
    private BigDecimal count;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java
@@ -285,6 +285,10 @@
            }
            ErpSaleOrderSaveReqVO.Item itemVO = new ErpSaleOrderSaveReqVO.Item();
            itemVO.setProductId(item.getId());
            if (row.getReelNo() != null && row.getReelNo().length() > 128) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的盘号不能超过128个字符");
            }
            itemVO.setReelNo(row.getReelNo());
            itemVO.setProductUnitId(item.getUnitMeasureId());
            // å•价:未填写时取物料的销售价(与页面选产品后的默认值一致)
            itemVO.setProductPrice(row.getProductPrice() != null ? row.getProductPrice()
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ipqc/MesQcIpqcController.java
@@ -267,7 +267,7 @@
                    String value = detail.getValue();
                    String remark = detail.getRemark();
                    // æ ¼å¼åŒ–为 "指标名称:值" æˆ– "指标名称:值(备注)"
                    String indicatorStr = indicatorName + ":" + value;
                    String indicatorStr = indicatorName + ":" + (StrUtil.isBlank(value) ? "未填写" : value);
                    if (StrUtil.isNotBlank(remark)) {
                        indicatorStr += "(" + remark + ")";
                    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/rqc/MesQcRqcController.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.rqc;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
@@ -60,6 +62,9 @@
@RequestMapping("/mes/qc/rqc")
@Validated
public class MesQcRqcController {
    @Resource
    private MesWmBatchService batchService;
    @Resource
    private MesQcRqcService rqcService;
@@ -154,8 +159,13 @@
                convertSet(list, MesQcRqcDO::getInspectorUserId));
        // æ‰¹é‡æŸ¥è¯¢æ£€éªŒé¡¹æŒ‡æ ‡
        Map<Long, String> indicatorSummaryMap = buildIndicatorSummaryMap(list);
        Map<String, MesWmBatchDO> batchMap = new HashMap<>();
        for (String code : convertSet(list, MesQcRqcDO::getBatchCode)) {
            if (code != null && !code.isBlank()) batchMap.put(code, batchService.getBatchByCode(code));
        }
        // æ‹¼è£… VO
        return BeanUtils.toBean(list, MesQcRqcRespVO.class, vo -> {
            findAndThen(batchMap, vo.getBatchCode(), batch -> vo.setReelNo(batch.getReelNo()));
            findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification());
                findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/rqc/vo/MesQcRqcRespVO.java
@@ -44,6 +44,9 @@
    @Schema(description = "来源单据行 ID", example = "300")
    private Long sourceLineId;
    @Schema(description = "退货批次盘号")
    private String reelNo;
    @Schema(description = "来源单据编码", example = "RT20250101001")
    @ExcelProperty("来源单据编码")
    private String sourceDocCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/vo/MesWmBatchRespVO.java
@@ -15,6 +15,9 @@
    @Schema(description = "批次编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "BATCH20250314001")
    private String code;
    @Schema(description = "生产追溯盘号")
    private String reelNo;
    @Schema(description = "物料 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesDetailController.java
@@ -10,6 +10,8 @@
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
@@ -55,6 +57,8 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesWmBatchService batchService;
    @PostMapping("/create")
    @Operation(summary = "创建销售出库明细")
@@ -118,8 +122,11 @@
                convertSet(list, MesWmProductSalesDetailDO::getLocationId));
        Map<Long, MesWmWarehouseAreaDO> areaMap = areaService.getWarehouseAreaMap(
                convertSet(list, MesWmProductSalesDetailDO::getAreaId));
        Map<Long, MesWmBatchDO> batchMap = batchService.getBatchMap(
                convertSet(list, MesWmProductSalesDetailDO::getBatchId));
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmProductSalesDetailRespVO.class, vo -> {
            MapUtils.findAndThen(batchMap, vo.getBatchId(), batch -> vo.setReelNo(batch.getReelNo()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesLineController.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.productsales;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderItemRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
@@ -28,6 +30,7 @@
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -47,6 +50,8 @@
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @PostMapping("/create")
@@ -104,8 +109,13 @@
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureList(
                        convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId)).getCheckedData()
                .stream().collect(Collectors.toMap(MdmUnitMeasureRespDTO::getId, e -> e));
        Map<Long, ErpSaleOrderItemRespDTO> saleItems = new HashMap<>();
        for (Long itemId : convertSet(list, MesWmProductSalesLineDO::getSaleOrderItemId)) {
            if (itemId != null) saleItems.put(itemId, saleOrderApi.getSaleOrderItem(itemId).getCheckedData());
        }
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmProductSalesLineRespVO.class, vo -> {
            MapUtils.findAndThen(saleItems, vo.getSaleOrderItemId(), item -> vo.setReelNo(item.getReelNo()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/detail/MesWmProductSalesDetailRespVO.java
@@ -46,6 +46,9 @@
    @Schema(description = "批次号", example = "BATCH001")
    private String batchCode;
    @Schema(description = "实际拣货库存盘号")
    private String reelNo;
    @Schema(description = "仓库ID", example = "1")
    private Long warehouseId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/line/MesWmProductSalesLineRespVO.java
@@ -19,6 +19,12 @@
    @Schema(description = "发货通知单行ID", example = "1")
    private Long noticeLineId;
    @Schema(description = "销售订单明细ID")
    private Long saleOrderItemId;
    @Schema(description = "销售明细盘号")
    private String reelNo;
    @Schema(description = "物料ID", example = "1")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesDetailController.java
@@ -10,6 +10,8 @@
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
@@ -55,6 +57,8 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesWmBatchService batchService;
    @PostMapping("/create")
    @Operation(summary = "创建销售退货明细")
@@ -118,8 +122,11 @@
                convertSet(list, MesWmReturnSalesDetailDO::getLocationId));
        Map<Long, MesWmWarehouseAreaDO> areaMap = areaService.getWarehouseAreaMap(
                convertSet(list, MesWmReturnSalesDetailDO::getAreaId));
        Map<Long, MesWmBatchDO> batchMap = batchService.getBatchMap(
                convertSet(list, MesWmReturnSalesDetailDO::getBatchId));
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmReturnSalesDetailRespVO.class, vo -> {
            MapUtils.findAndThen(batchMap, vo.getBatchId(), batch -> vo.setReelNo(batch.getReelNo()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/MesWmReturnSalesLineController.java
@@ -12,6 +12,8 @@
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
@@ -45,6 +47,8 @@
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private MesWmBatchService batchService;
    @PostMapping("/create")
    @Operation(summary = "创建销售退货单行")
@@ -110,13 +114,17 @@
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureList(
                convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId)).getCheckedData()
                .stream().collect(Collectors.toMap(MdmUnitMeasureRespDTO::getId, e -> e));
        Map<Long, MesWmBatchDO> batchMap = batchService.getBatchMap(
                convertSet(list, MesWmReturnSalesLineDO::getBatchId));
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmReturnSalesLineRespVO.class, vo ->
        return BeanUtils.toBean(list, MesWmReturnSalesLineRespVO.class, vo -> {
                MapUtils.findAndThen(batchMap, vo.getBatchId(), batch -> vo.setReelNo(batch.getReelNo()));
                MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                    vo.setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification());
                    MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
                            unitMeasure -> vo.setItemUnit(unitMeasure.getName()));
        }));
        });
        });
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/detail/MesWmReturnSalesDetailRespVO.java
@@ -48,6 +48,9 @@
    @Schema(description = "批次号", example = "B001")
    private String batchCode;
    @Schema(description = "上架批次盘号")
    private String reelNo;
    @Schema(description = "仓库ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long warehouseId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/line/MesWmReturnSalesLineRespVO.java
@@ -51,6 +51,9 @@
    @Schema(description = "批次号", example = "B001")
    private String batchCode;
    @Schema(description = "退货批次盘号")
    private String reelNo;
    @Schema(description = "质量状态", example = "合格")
    private Integer qualityStatus;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/salesnotice/MesWmSalesNoticeLineController.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.salesnotice;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderItemRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
@@ -24,6 +26,7 @@
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -43,6 +46,8 @@
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @PostMapping("/create")
    @Operation(summary = "创建发货通知单行")
@@ -99,8 +104,13 @@
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureList(
                        convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId)).getCheckedData()
                .stream().collect(java.util.stream.Collectors.toMap(MdmUnitMeasureRespDTO::getId, e -> e));
        Map<Long, ErpSaleOrderItemRespDTO> saleItems = new HashMap<>();
        for (Long itemId : convertSet(list, MesWmSalesNoticeLineDO::getSaleOrderItemId)) {
            if (itemId != null) saleItems.put(itemId, saleOrderApi.getSaleOrderItem(itemId).getCheckedData());
        }
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmSalesNoticeLineRespVO.class, vo -> {
            MapUtils.findAndThen(saleItems, vo.getSaleOrderItemId(), item -> vo.setReelNo(item.getReelNo()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/salesnotice/vo/line/MesWmSalesNoticeLineRespVO.java
@@ -19,6 +19,9 @@
    @Schema(description = "销售订单明细ID", example = "1")
    private Long saleOrderItemId;
    @Schema(description = "销售明细盘号")
    private String reelNo;
    @Schema(description = "物料编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transfer/MesWmTransferLineController.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.transfer;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -42,6 +44,9 @@
@RequestMapping("/mes/wm/transfer-line")
@Validated
public class MesWmTransferLineController {
    @Resource
    private MesWmBatchService batchService;
    @Resource
    private MesWmTransferLineService transferLineService;
@@ -116,8 +121,10 @@
                convertSet(list, MesWmTransferLineDO::getFromLocationId));
        Map<Long, MesWmWarehouseAreaDO> areaMap = areaService.getWarehouseAreaMap(
                convertSet(list, MesWmTransferLineDO::getFromAreaId));
        Map<Long, MesWmBatchDO> batchMap = batchService.getBatchMap(convertSet(list, MesWmTransferLineDO::getBatchId));
        // æ‹¼æŽ¥æ•°æ®
        return BeanUtils.toBean(list, MesWmTransferLineRespVO.class, vo -> {
            MapUtils.findAndThen(batchMap, vo.getBatchId(), batch -> vo.setReelNo(batch.getReelNo()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transfer/vo/line/MesWmTransferLineRespVO.java
@@ -37,6 +37,9 @@
    @Schema(description = "批次号")
    private String batchCode;
    @Schema(description = "调拨批次盘号")
    private String reelNo;
    @Schema(description = "移出仓库编号", example = "1")
    private Long fromWarehouseId;
    @Schema(description = "移出仓库名称")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/batch/MesWmBatchMapper.java
@@ -173,6 +173,12 @@
        // è¿”回 ID æœ€å°çš„æ‰¹æ¬¡
        query.orderByAsc(MesWmBatchDO::getId);
        if (batch.getReelNo() != null) {
            // æ•°æ®åº“排序规则可能忽略大小写或末尾空格;盘号必须按输入值精确匹配。
            return selectList(query).stream()
                    .filter(candidate -> batch.getReelNo().equals(candidate.getReelNo()))
                    .findFirst().orElse(null);
        }
        query.last("LIMIT 1");
        return selectOne(query);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesDetailMapper.java
@@ -3,6 +3,8 @@
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -14,6 +16,19 @@
@Mapper
public interface MesWmProductSalesDetailMapper extends BaseMapperX<MesWmProductSalesDetailDO> {
    /** åªè®¤å¯è¯¥è®¢å•已完成出库、且明确关联销售明细的实际拣货批次。 */
    @Select("SELECT COUNT(*) FROM mes_wm_product_sales_detail d "
            + "JOIN mes_wm_product_sales s ON s.id = d.sales_id AND s.deleted = 0 "
            + "JOIN mes_wm_product_sales_line l ON l.id = d.line_id AND l.sales_id = s.id AND l.deleted = 0 "
            + "JOIN erp_sale_order_items i ON i.id = l.sale_order_item_id AND i.order_id = s.sale_order_id AND i.deleted = 0 "
            + "JOIN mes_wm_batch b ON b.id = d.batch_id AND b.deleted = 0 AND b.item_id = d.item_id "
            + "WHERE d.deleted = 0 AND s.sale_order_id = #{orderId} AND s.status = #{finishedStatus} "
            + "AND d.item_id = #{itemId} AND l.item_id = #{itemId} AND i.product_id = #{itemId} "
            + "AND BINARY i.reel_no = BINARY b.reel_no "
            + "AND d.batch_id = #{batchId}")
    Long countFinishedSaleBatch(@Param("orderId") Long orderId, @Param("itemId") Long itemId,
                                @Param("batchId") Long batchId, @Param("finishedStatus") Integer finishedStatus);
    default List<MesWmProductSalesDetailDO> selectListByLineId(Long lineId) {
        return selectList(MesWmProductSalesDetailDO::getLineId, lineId);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -696,6 +696,10 @@
    ErrorCode WM_RETURN_SALES_LINE_NOT_EXISTS = new ErrorCode(1_040_713_100, "销售退货单行不存在");
    ErrorCode WM_RETURN_SALES_DETAIL_NOT_EXISTS = new ErrorCode(1_040_713_200, "销售退货单明细不存在");
    ErrorCode WM_RETURN_SALES_DETAIL_QUANTITY_EXCEED = new ErrorCode(1_040_713_201, "上架明细总数量不能超过退货行数量");
    ErrorCode WM_RETURN_SALES_DETAIL_SOURCE_MISMATCH = new ErrorCode(1_040_713_202, "退货明细的单据或物料与退货行不一致");
    ErrorCode WM_RETURN_SALES_DETAIL_BATCH_MISMATCH = new ErrorCode(1_040_713_203, "退货明细批次与退货行批次不一致");
    ErrorCode WM_RETURN_SALES_REEL_MISMATCH = new ErrorCode(1_040_713_204, "请选择盘号属于该销售订单产品明细的批次");
    ErrorCode WM_RETURN_SALES_BATCH_NOT_SHIPPED = new ErrorCode(1_040_713_205, "所选盘号批次未在该销售订单中完成出库,不能作为退货来源");
    // ========== MES ä»“库管理-盘点方案/任务/结果(1-040-714-100) ==========
    ErrorCode WM_STOCK_TAKING_PLAN_NOT_EXISTS = new ErrorCode(1_040_714_100, "盘点方案不存在");
@@ -785,6 +789,8 @@
    ErrorCode WM_SALES_NOTICE_LINE_NOT_EXISTS = new ErrorCode(1_040_720_010, "发货通知单行不存在");
    ErrorCode WM_SALES_NOTICE_LINE_EMPTY = new ErrorCode(1_040_720_011, "发货通知单行为空,不能提交");
    ErrorCode WM_SALES_NOTICE_LINE_NOT_MATCH = new ErrorCode(1_040_720_012, "发货通知单行不属于指定的发货通知单");
    ErrorCode WM_SALES_NOTICE_LINE_SALE_ITEM_REQUIRED = new ErrorCode(1_040_720_013, "关联销售订单的发货行必须选择销售订单明细");
    ErrorCode WM_SALES_NOTICE_LINE_SALE_ITEM_MISMATCH = new ErrorCode(1_040_720_014, "销售订单明细与发货通知单或物料不匹配");
    ErrorCode WM_SALES_NOTICE_SALE_ORDER_NOT_EXISTS = new ErrorCode(1_040_720_013, "销售订单不存在");
    ErrorCode WM_SALES_NOTICE_SALE_ORDER_ITEM_EMPTY = new ErrorCode(1_040_720_014, "销售订单明细为空");
    ErrorCode WM_SALES_NOTICE_ALREADY_EXISTS = new ErrorCode(1_040_720_015, "该销售订单已生成发货通知单,不能重复生成");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import org.springframework.context.annotation.Lazy;
import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.mps.vo.MesProMpsSaveReqVO;
@@ -70,6 +71,7 @@
    @Resource
    private MesProMpsMapper mpsMapper;
    @Resource
    @Lazy
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private PermissionApi permissionApi;
@@ -176,6 +178,8 @@
        // æ›´æ–°
        MesProMpsDO updateObj = BeanUtils.toBean(updateReqVO, MesProMpsDO.class);
        // ç›˜å·æ˜¯åˆ›å»ºè®¡åˆ’时的来源快照,编辑其他字段不能改写它。
        updateObj.setReelNo(null);
        mpsMapper.updateById(updateObj);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/workorder/MesProWorkOrderServiceImpl.java
@@ -122,6 +122,8 @@
        // 3. æ›´æ–°
        MesProWorkOrderDO updateObj = BeanUtils.toBean(updateReqVO, MesProWorkOrderDO.class);
        // å·¥å•盘号沿用创建时的来源快照,避免与计划和已生成的下游记录不一致。
        updateObj.setReelNo(null);
        workOrderMapper.updateById(updateObj);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java
@@ -115,6 +115,15 @@
                .setTemplateId(templateId)
                .setSourceDocCode(sourceDocCode)
                .setStatus(MesQcStatusEnum.DRAFT.getStatus());
        if (Objects.equals(createReqVO.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES)
                && createReqVO.getSourceLineId() != null) {
            var line = returnSalesLineService.validateReturnSalesLineExists(createReqVO.getSourceLineId());
            if (!Objects.equals(line.getReturnId(), createReqVO.getSourceDocId())
                    || !Objects.equals(line.getItemId(), createReqVO.getItemId())) {
                throw exception(WM_RETURN_SALES_DETAIL_SOURCE_MISMATCH);
            }
            rqc.setBatchCode(line.getBatchCode());
        }
        rqcMapper.insert(rqc);
        // 3. ä»Žæ¨¡æ¿æŒ‡æ ‡è‡ªåŠ¨ç”Ÿæˆæ£€éªŒè¡Œ
@@ -125,7 +134,7 @@
    @Override
    public void updateRqc(MesQcRqcSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        validateRqcStatusPrepare(updateReqVO.getId());
        MesQcRqcDO existingRqc = validateRqcStatusPrepare(updateReqVO.getId());
        // 1.2 æ ¡éªŒæ•°æ®
        validateRqcSaveData(updateReqVO);
@@ -134,6 +143,8 @@
        updateObj.setSourceDocType(null).setSourceDocId(null).setSourceLineId(null); // ä¸å…è®¸ä¿®æ”¹æ¥æºå•据
        updateObj.setTemplateId(null); // ä¸å…è®¸ä¿®æ”¹æ¨¡æ¿
        updateObj.setItemId(null); // ä¸å…è®¸ä¿®æ”¹ç‰©æ–™
        if (Objects.equals(existingRqc.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES)
                && existingRqc.getSourceLineId() != null) updateObj.setBatchCode(null);
        rqcMapper.updateById(updateObj);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java
@@ -399,7 +399,7 @@
        } else {
            // å‡ºåº“时校验批次存在
            if (batchId != null) {
                batchService.validateBatchExists(batchId, reqVO.getItemId());
                batchCode = batchService.validateBatchExists(batchId, reqVO.getItemId()).getCode();
            }
        }
@@ -457,16 +457,16 @@
     * @return æ‰¹æ¬¡è®°å½•(物料未启用批次管理时返回 null)
     */
    private MesWmBatchDO resolveBatch(MesWmMaterialStockAdjustReqVO reqVO, MesMdmItemDO item) {
        // 1. ç‰©æ–™æœªå¯ç”¨æ‰¹æ¬¡ç®¡ç†ï¼Œè¿”回 null
        if (Boolean.FALSE.equals(item.getIsBatchManaged())) {
            return null;
        }
        // 2. ä¼ å…¥ batchId,校验并返回已有批次
        // å³ä½¿ç‰©æ–™æœªå¯ç”¨æ™®é€šæ‰¹æ¬¡ç®¡ç†ï¼Œä¹Ÿè¦ä¿ç•™å¹¶æ ¡éªŒç›˜å·è¿½æº¯æ‰¹æ¬¡ã€‚
        if (reqVO.getBatchId() != null) {
            return batchService.validateBatchExists(reqVO.getBatchId(), reqVO.getItemId());
        }
        // 1. æœªæŒ‡å®šè¿½æº¯æ‰¹æ¬¡ä¸”物料未启用批次管理,返回 null
        if (Boolean.FALSE.equals(item.getIsBatchManaged())) {
            return null;
        }
        // 3. æœªä¼  batchId,自动创建批次
        MesWmBatchGenerateReqVO batchReqVO = new MesWmBatchGenerateReqVO()
                .setItemId(reqVO.getItemId())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java
@@ -64,10 +64,13 @@
    @Override
    public Long createProductSalesLine(MesWmProductSalesLineSaveReqVO createReqVO) {
        // æ ¡éªŒæ•°æ®
        validateLineSaveData(createReqVO);
        MesWmSalesNoticeLineDO noticeLine = validateLineSaveData(createReqVO);
        // æ–°å¢ž
        MesWmProductSalesLineDO line = BeanUtils.toBean(createReqVO, MesWmProductSalesLineDO.class);
        if (noticeLine != null) {
            line.setSaleOrderItemId(noticeLine.getSaleOrderItemId());
        }
        productSalesLineMapper.insert(line);
        return line.getId();
    }
@@ -78,10 +81,13 @@
        MesWmProductSalesLineDO oldLine = validateProductSalesLineExists(updateReqVO.getId());
        // æ ¡éªŒæ•°æ®
        updateReqVO.setSalesId(oldLine.getSalesId());
        validateLineSaveData(updateReqVO);
        MesWmSalesNoticeLineDO noticeLine = validateLineSaveData(updateReqVO);
        // æ›´æ–°
        MesWmProductSalesLineDO updateObj = BeanUtils.toBean(updateReqVO, MesWmProductSalesLineDO.class);
        if (noticeLine != null) {
            updateObj.setSaleOrderItemId(noticeLine.getSaleOrderItemId());
        }
        productSalesLineMapper.updateById(updateObj);
        // æ£€æŸ¥æ˜¯å¦éœ€è¦åˆ›å»º OQC æ£€éªŒå•:从不需要检验变为需要检验
@@ -98,6 +104,8 @@
            MesWmProductSalesLineDO line = new MesWmProductSalesLineDO();
            line.setId(updateReqVO.getId());
            line.setItemId(updateReqVO.getItemId());
            line.setSaleOrderItemId(updateObj.getSaleOrderItemId() != null
                    ? updateObj.getSaleOrderItemId() : oldLine.getSaleOrderItemId());
            line.setBatchCode(updateReqVO.getBatchCode());
            line.setQuantity(updateReqVO.getQuantity());
@@ -181,15 +189,16 @@
    /**
     * æ ¡éªŒä¿å­˜æ—¶çš„关联数据
     */
    private void validateLineSaveData(MesWmProductSalesLineSaveReqVO reqVO) {
    private MesWmSalesNoticeLineDO validateLineSaveData(MesWmProductSalesLineSaveReqVO reqVO) {
        // æ ¡éªŒä¸»å•存在且为草稿状态
        MesWmProductSalesDO sales = productSalesService.validateProductSalesExistsAndDraft(reqVO.getSalesId());
        // æ ¡éªŒç‰©æ–™å­˜åœ¨
        mdmItemService.validateItemExistsAndEnable(reqVO.getItemId());
        // æ ¡éªŒå…³è”发货通知单行(存在性 + å½’属 + å­—段一致性)
        validateSalesNoticeLine(sales, reqVO);
        MesWmSalesNoticeLineDO noticeLine = validateSalesNoticeLine(sales, reqVO);
        // æ ¹æ® batchCode è§£æž batchId
        fillBatchId(reqVO);
        return noticeLine;
    }
    /**
@@ -198,7 +207,7 @@
     * @param sales å‡ºåº“单
     * @param reqVO å‡ºåº“行请求
     */
    private void validateSalesNoticeLine(MesWmProductSalesDO sales, MesWmProductSalesLineSaveReqVO reqVO) {
    private MesWmSalesNoticeLineDO validateSalesNoticeLine(MesWmProductSalesDO sales, MesWmProductSalesLineSaveReqVO reqVO) {
        Long noticeLineId = reqVO.getNoticeLineId();
        // æƒ…况一:如果出库单关联了发货通知单,则必须关联发货通知单行
        if (sales.getNoticeId() != null) {
@@ -220,13 +229,14 @@
                    && ObjUtil.notEqual(reqVO.getBatchCode(), noticeLine.getBatchCode())) {
                throw exception(WM_PRODUCT_SALES_LINE_NOTICE_LINE_BATCH_MISMATCH);
            }
            return;
            return noticeLine;
        }
        // æƒ…况二:如果出库单没有关联发货通知单,则不允许关联发货通知单行
        if (noticeLineId != null) {
            throw exception(WM_PRODUCT_SALES_LINE_SALES_NOTICE_LINE_NOT_ALLOWED);
        }
        return null;
    }
    @Override
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesDetailServiceImpl.java
@@ -65,7 +65,11 @@
    @Override
    public void updateReturnSalesDetail(MesWmReturnSalesDetailSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        validateReturnSalesDetailExists(updateReqVO.getId());
        MesWmReturnSalesDetailDO oldDetail = validateReturnSalesDetailExists(updateReqVO.getId());
        if (ObjUtil.notEqual(oldDetail.getLineId(), updateReqVO.getLineId())
                || ObjUtil.notEqual(oldDetail.getReturnId(), updateReqVO.getReturnId())) {
            throw exception(WM_RETURN_SALES_DETAIL_SOURCE_MISMATCH);
        }
        // æ ¡éªŒä¿å­˜æ•°æ®
        validateReturnSalesDetailSaveData(updateReqVO);
@@ -134,8 +138,14 @@
    private void validateReturnSalesDetailSaveData(MesWmReturnSalesDetailSaveReqVO reqVO) {
        // æ ¡éªŒçˆ¶æ•°æ®å­˜åœ¨
        MesWmReturnSalesLineDO line = returnSalesLineService.validateReturnSalesLineExists(reqVO.getLineId());
        if (ObjUtil.notEqual(line.getReturnId(), reqVO.getReturnId())
                || ObjUtil.notEqual(line.getItemId(), reqVO.getItemId())) {
            throw exception(WM_RETURN_SALES_DETAIL_SOURCE_MISMATCH);
        }
        // æ ¡éªŒé€€è´§å•为待上架状态
        validateReturnSalesStatusApproved(line.getReturnId());
        // ä¸Šæž¶ä½¿ç”¨é€€è´§è¡Œçš„æ‰¹æ¬¡ï¼›è‹¥è¡Œæ²¡æœ‰æŒ‡å®šæ‰¹æ¬¡ï¼Œå…è®¸æ˜¾å¼é€‰æ‹©å±žäºŽè¯¥ç‰©æ–™çš„æ‰¹æ¬¡ã€‚
        fillBatchId(reqVO, line);
        // æ ¡éªŒä»“库、库区、库位的关联关系
        warehouseAreaService.validateWarehouseAreaExists(
                reqVO.getWarehouseId(), reqVO.getLocationId(), reqVO.getAreaId());
@@ -144,8 +154,6 @@
        // æ ¡éªŒæ˜Žç»†æ€»æ•°é‡ä¸è¶…过行数量(排除自身)
        validateDetailQuantityNotExceed(reqVO.getLineId(), reqVO.getQuantity(), reqVO.getId(), line);
        // æ ¹æ® batchCode è§£æž batchId(必须在混放规则校验之前,因为它依赖 batchId)
        fillBatchId(reqVO);
    }
    /**
@@ -173,13 +181,25 @@
    /**
     * æ ¹æ® batchCode è§£æžå¹¶å›žå¡« batchId
     */
    private void fillBatchId(MesWmReturnSalesDetailSaveReqVO reqVO) {
        if (StrUtil.isBlank(reqVO.getBatchCode())) {
            return;
    private void fillBatchId(MesWmReturnSalesDetailSaveReqVO reqVO, MesWmReturnSalesLineDO line) {
        MesWmBatchDO batch = null;
        if (StrUtil.isNotBlank(reqVO.getBatchCode())) {
            batch = batchService.getBatchByCode(reqVO.getBatchCode());
            if (batch == null) throw exception(WM_BATCH_NOT_EXISTS);
            if (reqVO.getBatchId() != null && ObjUtil.notEqual(batch.getId(), reqVO.getBatchId())) {
                throw exception(WM_RETURN_SALES_DETAIL_BATCH_MISMATCH);
            }
        } else if (reqVO.getBatchId() != null || line.getBatchId() != null) {
            batch = batchService.validateBatchExists(
                    reqVO.getBatchId() != null ? reqVO.getBatchId() : line.getBatchId(), reqVO.getItemId());
        }
        MesWmBatchDO batch = batchService.getBatchByCode(reqVO.getBatchCode());
        if (batch != null) {
            if (ObjUtil.notEqual(batch.getItemId(), reqVO.getItemId())
                    || (line.getBatchId() != null && ObjUtil.notEqual(line.getBatchId(), batch.getId()))) {
                throw exception(WM_RETURN_SALES_DETAIL_BATCH_MISMATCH);
            }
            reqVO.setBatchId(batch.getId());
            reqVO.setBatchCode(batch.getCode());
        }
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java
@@ -2,16 +2,21 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLinePageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import jakarta.annotation.Resource;
@@ -26,6 +31,8 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MD_ITEM_BATCH_REQUIRED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_LINE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_REEL_MISMATCH;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_BATCH_NOT_SHIPPED;
/**
 * MES é”€å”®é€€è´§å•行 Service å®žçŽ°ç±»
@@ -46,6 +53,10 @@
    private MesMdmItemService mdmItemService;
    @Resource
    private MesWmBatchService batchService;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesDetailMapper productSalesDetailMapper;
    @Resource
    @Lazy
    private MesWmReturnSalesDetailService returnSalesDetailService;
@@ -82,9 +93,27 @@
     */
    private void validateLineSaveData(MesWmReturnSalesLineSaveReqVO reqVO) {
        // æ ¡éªŒé€€è´§å•存在 + è‰ç¨¿çŠ¶æ€
        returnSalesService.validateReturnSalesExistsAndPrepare(reqVO.getReturnId());
        MesWmReturnSalesDO returnSales = returnSalesService.validateReturnSalesExistsAndPrepare(reqVO.getReturnId());
        // æ ¡éªŒç‰©æ–™å­˜åœ¨ + æ‰¹æ¬¡ç®¡ç†
        validateItemBatchManagement(reqVO.getReturnId(), reqVO.getItemId(), reqVO.getBatchId());
        MesWmBatchDO batch = validateItemBatchManagement(returnSales, reqVO.getItemId(), reqVO.getBatchId());
        if (batch != null) {
            reqVO.setBatchCode(batch.getCode());
        }
        if (returnSales.getSaleOrderId() != null) {
            var items = saleOrderApi.getSaleOrderItemList(returnSales.getSaleOrderId()).getCheckedData();
            var reelNumbers = items.stream().filter(item -> Objects.equals(reqVO.getItemId(), item.getProductId()))
                    .map(item -> item.getReelNo()).filter(StrUtil::isNotBlank).toList();
            if (!reelNumbers.isEmpty() && (batch == null || !reelNumbers.contains(batch.getReelNo())
                    || (StrUtil.isNotBlank(batch.getSalesOrderCode())
                    && !Objects.equals(returnSales.getSalesOrderCode(), batch.getSalesOrderCode())))) {
                throw exception(WM_RETURN_SALES_REEL_MISMATCH);
            }
            if (!reelNumbers.isEmpty() && productSalesDetailMapper.countFinishedSaleBatch(
                    returnSales.getSaleOrderId(), reqVO.getItemId(), batch.getId(),
                    MesWmProductSalesStatusEnum.FINISHED.getStatus()) == 0) {
                throw exception(WM_RETURN_SALES_BATCH_NOT_SHIPPED);
            }
        }
    }
    @Override
@@ -154,7 +183,7 @@
     * @param itemId   ç‰©æ–™ID
     * @param batchId  æ‰¹æ¬¡ID
     */
    private void validateItemBatchManagement(Long returnId, Long itemId, Long batchId) {
    private MesWmBatchDO validateItemBatchManagement(MesWmReturnSalesDO returnSales, Long itemId, Long batchId) {
        MesMdmItemDO item = mdmItemService.validateItemExistsAndEnable(itemId);
        // å¦‚果物料启用了批次管理,则必须选择批次
        if (Boolean.TRUE.equals(item.getIsBatchManaged()) && batchId == null) {
@@ -163,9 +192,9 @@
        // æ ¡éªŒæ‰¹æ¬¡å­˜åœ¨ä¸”属于当前物料和客户
        if (batchId != null) {
            // ä»Žçˆ¶å•获取客户ID,校验批次归属
            MesWmReturnSalesDO returnSales = returnSalesService.validateReturnSalesExistsAndPrepare(returnId);
            batchService.validateBatchExists(batchId, itemId, returnSales.getClientId(), null);
            return batchService.validateBatchExists(batchId, itemId, returnSales.getClientId(), null);
        }
        return null;
    }
    @Override
@@ -192,6 +221,7 @@
                    .setItemId(sourceLine.getItemId())
                    .setQuantity(unqualifiedQuantity)
                    .setBatchId(sourceLine.getBatchId())
                    .setBatchCode(sourceLine.getBatchCode())
                    .setRqcId(sourceLine.getRqcId())
                    .setRqcCheckFlag(sourceLine.getRqcCheckFlag())
                    .setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeLineServiceImpl.java
@@ -2,6 +2,7 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.salesnotice.vo.line.MesWmSalesNoticeLinePageReqVO;
@@ -40,6 +41,8 @@
    private MesWmBatchService batchService;
    @Resource
    private MesMdmItemService mdmItemService;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Override
    public Long createSalesNoticeLine(MesWmSalesNoticeLineSaveReqVO createReqVO) {
@@ -59,6 +62,9 @@
        MesWmSalesNoticeLineDO line = validateSalesNoticeLineExists(updateReqVO.getId());
        // æ ¡éªŒæ•°æ®
        updateReqVO.setNoticeId(line.getNoticeId());
        if (updateReqVO.getSaleOrderItemId() == null) {
            updateReqVO.setSaleOrderItemId(line.getSaleOrderItemId());
        }
        validateLineSaveData(updateReqVO);
        // æ›´æ–°
@@ -120,6 +126,17 @@
    private void validateLineSaveData(MesWmSalesNoticeLineSaveReqVO reqVO) {
        // æ ¡éªŒçˆ¶å•据存在且为草稿状态
        validateNoticeStatusDraft(reqVO.getNoticeId());
        MesWmSalesNoticeDO notice = salesNoticeService.getSalesNotice(reqVO.getNoticeId());
        if (notice.getSaleOrderId() != null && reqVO.getSaleOrderItemId() == null) {
            throw exception(WM_SALES_NOTICE_LINE_SALE_ITEM_REQUIRED);
        }
        if (reqVO.getSaleOrderItemId() != null) {
            var saleItem = saleOrderApi.getSaleOrderItem(reqVO.getSaleOrderItemId()).getCheckedData();
            if (saleItem == null || !ObjUtil.equal(notice.getSaleOrderId(), saleItem.getOrderId())
                    || !ObjUtil.equal(reqVO.getItemId(), saleItem.getProductId())) {
                throw exception(WM_SALES_NOTICE_LINE_SALE_ITEM_MISMATCH);
            }
        }
        // æ ¡éªŒç‰©æ–™å­˜åœ¨
        if (reqVO.getItemId() != null) {
            mdmItemService.validateItemExistsAndEnable(reqVO.getItemId());
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/batch/MesWmBatchServiceImplTest.java
@@ -83,7 +83,7 @@
    public void testNonBatchManagedItemUsesDistinctTraceBatchesForReels() {
        when(mdmItemService.validateItemExists(10L)).thenReturn(new MesMdmItemDO().setId(10L)
                .setName("产品").setIsBatchManaged(false));
        when(autoCodeRecordService.generateAutoCode(anyString())).thenReturn("TRACE-1", "TRACE-2");
        when(autoCodeRecordService.generateAutoCode(anyString())).thenReturn("TRACE-1", "TRACE-2", "TRACE-3");
        MesWmBatchGenerateReqVO first = new MesWmBatchGenerateReqVO().setItemId(10L)
                .setWorkOrderId(20L).setReelNo("PAN-1");
        MesWmBatchGenerateReqVO second = new MesWmBatchGenerateReqVO().setItemId(10L)
@@ -95,6 +95,9 @@
        assertNotEquals(a.getId(), b.getId());
        assertEquals(a.getId(), batchService.getOrGenerateBatchCode(first).getId());
        assertEquals("PAN-2", batchMapper.selectById(b.getId()).getReelNo());
        MesWmBatchDO caseVariant = batchService.getOrGenerateBatchCode(new MesWmBatchGenerateReqVO()
                .setItemId(10L).setWorkOrderId(20L).setReelNo("pan-1"));
        assertNotEquals(a.getId(), caseVariant.getId());
        assertNull(batchService.getOrGenerateBatchCode(new MesWmBatchGenerateReqVO().setItemId(10L)));
    }
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineReelTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.line.MesWmProductSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.qc.oqc.MesQcOqcService;
import cn.iocoder.yudao.module.mes.service.wm.salesnotice.MesWmSalesNoticeLineService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesWmProductSalesLineReelTest {
    @InjectMocks private MesWmProductSalesLineServiceImpl service;
    @Mock private MesWmProductSalesLineMapper mapper;
    @Mock private MesWmProductSalesService salesService;
    @Mock private MesWmSalesNoticeLineService noticeService;
    @Mock private MesMdmItemService itemService;
    @Mock private MesQcOqcService oqcService;
    private MesWmProductSalesLineSaveReqVO request() {
        var req = new MesWmProductSalesLineSaveReqVO();
        req.setSalesId(1L);
        req.setNoticeLineId(2L);
        req.setItemId(3L);
        req.setQuantity(BigDecimal.ONE);
        when(salesService.validateProductSalesExistsAndDraft(1L))
                .thenReturn(MesWmProductSalesDO.builder().id(1L).noticeId(4L).build());
        when(noticeService.validateSalesNoticeLineExists(2L, 4L))
                .thenReturn(MesWmSalesNoticeLineDO.builder().id(2L).itemId(3L)
                        .quantity(BigDecimal.ONE).saleOrderItemId(5L).build());
        return req;
    }
    @Test
    void createRetainsSalesItemForReelGuard() {
        service.createProductSalesLine(request());
        var line = ArgumentCaptor.forClass(MesWmProductSalesLineDO.class);
        verify(mapper).insert(line.capture());
        assertEquals(5L, line.getValue().getSaleOrderItemId());
    }
    @Test
    void enablingInspectionRetainsSalesItemForOqc() {
        var req = request();
        req.setId(6L);
        req.setOqcCheckFlag(true);
        when(mapper.selectById(6L)).thenReturn(MesWmProductSalesLineDO.builder()
                .id(6L).salesId(1L).oqcCheckFlag(false).build());
        when(salesService.getProductSales(1L)).thenReturn(MesWmProductSalesDO.builder().id(1L).build());
        when(oqcService.createOqcFromProductSales(eq(1L), any(), any(), any())).thenReturn(7L);
        service.updateProductSalesLine(req);
        var line = ArgumentCaptor.forClass(MesWmProductSalesLineDO.class);
        verify(oqcService).createOqcFromProductSales(eq(1L), line.capture(), any(), any());
        assertEquals(5L, line.getValue().getSaleOrderItemId());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmFinishedSaleBatchSqlTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.sql.DriverManager;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** éªŒè¯å®žé™…出库关联查询不会把其他订单、待出库单或其他盘号当作退货来源。 */
class MesWmFinishedSaleBatchSqlTest {
    @Test
    void checksFinishedOrderItemAndBatch() throws Exception {
        try (var c = DriverManager.getConnection("jdbc:h2:mem:reel_source;MODE=MySQL;DB_CLOSE_DELAY=-1")) {
            var st = c.createStatement();
            st.execute("CREATE TABLE mes_wm_product_sales (id BIGINT, sale_order_id BIGINT, status INT, deleted INT)");
            st.execute("CREATE TABLE mes_wm_product_sales_line (id BIGINT, sales_id BIGINT, sale_order_item_id BIGINT, item_id BIGINT, deleted INT)");
            st.execute("CREATE TABLE mes_wm_product_sales_detail (id BIGINT, sales_id BIGINT, line_id BIGINT, item_id BIGINT, batch_id BIGINT, deleted INT)");
            st.execute("CREATE TABLE erp_sale_order_items (id BIGINT, order_id BIGINT, product_id BIGINT, reel_no VARCHAR(128), deleted INT)");
            st.execute("CREATE TABLE mes_wm_batch (id BIGINT, item_id BIGINT, reel_no VARCHAR(128), deleted INT)");
            st.execute("INSERT INTO mes_wm_product_sales VALUES (1, 7, 5, 0), (2, 7, 1, 0), (3, 8, 5, 0)");
            st.execute("INSERT INTO erp_sale_order_items VALUES (11, 7, 3, 'P1', 0), (12, 8, 3, 'P1', 0), (13, 7, 3, 'P2', 0)");
            st.execute("INSERT INTO mes_wm_batch VALUES (21, 3, 'P1', 0), (22, 3, 'P2', 0)");
            st.execute("INSERT INTO mes_wm_product_sales_line VALUES (31, 1, 11, 3, 0), (32, 2, 13, 3, 0), (33, 3, 12, 3, 0), (34, 1, 13, 3, 0)");
            st.execute("INSERT INTO mes_wm_product_sales_detail VALUES (41, 1, 31, 3, 21, 0), (42, 2, 32, 3, 22, 0), (43, 3, 33, 3, 21, 0), (44, 1, 34, 3, 21, 0)");
            String sql = MesWmProductSalesDetailMapper.class.getMethod("countFinishedSaleBatch", Long.class,
                    Long.class, Long.class, Integer.class).getAnnotation(Select.class).value()[0]
                    // H2 çš„字符串比较默认区分大小写;MySQL çš„ BINARY è¯­æ³•在真实库单独验证。
                    .replace("BINARY i.reel_no", "i.reel_no").replace("BINARY b.reel_no", "b.reel_no")
                    .replace("#{orderId}", "?").replace("#{finishedStatus}", "?")
                    .replace("#{itemId}", "?").replace("#{batchId}", "?");
            try (var query = c.prepareStatement(sql)) {
                query.setLong(1, 7); query.setInt(2, 5); query.setLong(3, 3);
                query.setLong(4, 3); query.setLong(5, 3); query.setLong(6, 21);
                try (var rows = query.executeQuery()) { rows.next(); assertEquals(1, rows.getInt(1)); }
                query.setLong(6, 22);
                try (var rows = query.executeQuery()) { rows.next(); assertEquals(0, rows.getInt(1)); }
            }
        }
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesReelTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderItemRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.detail.MesWmReturnSalesDetailSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesDetailMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesWmReturnSalesReelTest {
    @InjectMocks private MesWmReturnSalesDetailServiceImpl details;
    @InjectMocks private MesWmReturnSalesLineServiceImpl lines;
    @Mock private MesWmReturnSalesLineService lineService;
    @Mock private MesWmReturnSalesService returnService;
    @Mock private MesWmReturnSalesDetailMapper detailMapper;
    @Mock private MesWmReturnSalesLineMapper lineMapper;
    @Mock private MesWmProductSalesDetailMapper productSalesDetailMapper;
    @Mock private MesWmWarehouseAreaService areaService;
    @Mock private MesWmMaterialStockService stockService;
    @Mock private MesWmBatchService batchService;
    @Mock private MesMdmItemService itemService;
    @Mock private ErpSaleOrderApi saleOrderApi;
    @Test
    void resolvesBatchBeforeCheckingMixingAndKeepsSource() {
        var req = new MesWmReturnSalesDetailSaveReqVO();
        req.setLineId(1L); req.setReturnId(2L); req.setItemId(3L);
        req.setBatchCode("B1"); req.setQuantity(BigDecimal.ONE); req.setAreaId(4L);
        when(lineService.validateReturnSalesLineExists(1L)).thenReturn(MesWmReturnSalesLineDO.builder()
                .returnId(2L).itemId(3L).batchId(5L).quantity(BigDecimal.TEN).build());
        when(returnService.validateReturnSalesExists(2L)).thenReturn(MesWmReturnSalesDO.builder()
                .status(MesWmReturnSalesStatusEnum.APPROVED.getStatus()).build());
        when(batchService.getBatchByCode("B1")).thenReturn(MesWmBatchDO.builder().id(5L).itemId(3L).code("B1").build());
        when(detailMapper.selectListByLineId(1L)).thenReturn(List.of());
        details.createReturnSalesDetail(req);
        verify(stockService).checkAreaMixingRule(4L, 3L, 5L);
        assertEquals(5L, req.getBatchId());
    }
    @Test
    void rejectsDifferentBatchForSameReturnLine() {
        var req = new MesWmReturnSalesDetailSaveReqVO();
        req.setLineId(1L); req.setReturnId(2L); req.setItemId(3L); req.setBatchCode("B2");
        when(lineService.validateReturnSalesLineExists(1L)).thenReturn(MesWmReturnSalesLineDO.builder()
                .returnId(2L).itemId(3L).batchId(5L).build());
        when(returnService.validateReturnSalesExists(2L)).thenReturn(MesWmReturnSalesDO.builder()
                .status(MesWmReturnSalesStatusEnum.APPROVED.getStatus()).build());
        when(batchService.getBatchByCode("B2")).thenReturn(MesWmBatchDO.builder().id(6L).itemId(3L).build());
        assertThrows(ServiceException.class, () -> details.createReturnSalesDetail(req));
        verifyNoInteractions(stockService);
    }
    @Test
    void returnOrderWithReelsRequiresMatchingBatch() {
        var req = new MesWmReturnSalesLineSaveReqVO();
        req.setReturnId(2L); req.setItemId(3L); req.setBatchId(5L);
        when(returnService.validateReturnSalesExistsAndPrepare(2L)).thenReturn(MesWmReturnSalesDO.builder()
                .saleOrderId(7L).clientId(8L).build());
        when(itemService.validateItemExistsAndEnable(3L)).thenReturn(MesMdmItemDO.builder().isBatchManaged(false).build());
        var batch = MesWmBatchDO.builder().id(5L).itemId(3L).code("B1").reelNo("OTHER").build();
        when(batchService.validateBatchExists(5L, 3L, 8L, null)).thenReturn(batch);
        var item = new ErpSaleOrderItemRespDTO(); item.setProductId(3L); item.setReelNo("REEL-1");
        when(saleOrderApi.getSaleOrderItemList(7L)).thenReturn(CommonResult.success(List.of(item)));
        assertThrows(ServiceException.class, () -> lines.createReturnSalesLine(req));
        batch.setReelNo("REEL-1");
        when(productSalesDetailMapper.countFinishedSaleBatch(eq(7L), eq(3L), eq(5L), anyInt())).thenReturn(0L, 1L);
        assertThrows(ServiceException.class, () -> lines.createReturnSalesLine(req));
        lines.createReturnSalesLine(req);
        assertEquals("B1", req.getBatchCode());
        verify(lineMapper).insert(any(MesWmReturnSalesLineDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeLineReelTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.mes.service.wm.salesnotice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderItemRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.salesnotice.vo.line.MesWmSalesNoticeLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.salesnotice.MesWmSalesNoticeLineMapper;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static cn.iocoder.yudao.module.mes.enums.wm.MesWmSalesNoticeStatusEnum.PREPARE;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesWmSalesNoticeLineReelTest {
    @InjectMocks private MesWmSalesNoticeLineServiceImpl service;
    @Mock private MesWmSalesNoticeService noticeService;
    @Mock private MesWmSalesNoticeLineMapper mapper;
    @Mock private ErpSaleOrderApi saleOrderApi;
    @Mock private MesMdmItemService itemService;
    private MesWmSalesNoticeLineSaveReqVO request() {
        var req = new MesWmSalesNoticeLineSaveReqVO();
        req.setNoticeId(1L);
        req.setItemId(3L);
        when(noticeService.getSalesNotice(1L)).thenReturn(MesWmSalesNoticeDO.builder()
                .id(1L).saleOrderId(2L).status(PREPARE.getStatus()).build());
        return req;
    }
    @Test
    void linkedNoticeRequiresExplicitSalesItem() {
        var req = request();
        assertThrows(ServiceException.class, () -> service.createSalesNoticeLine(req));
        verifyNoInteractions(saleOrderApi);
    }
    @Test
    void linkedNoticeRequiresMatchingOrderAndProduct() {
        var req = request();
        req.setSaleOrderItemId(4L);
        var item = new ErpSaleOrderItemRespDTO();
        item.setOrderId(2L);
        item.setProductId(99L);
        when(saleOrderApi.getSaleOrderItem(4L)).thenReturn(CommonResult.success(item));
        assertThrows(ServiceException.class, () -> service.createSalesNoticeLine(req));
        item.setProductId(3L);
        service.createSalesNoticeLine(req);
        var saved = ArgumentCaptor.forClass(MesWmSalesNoticeLineDO.class);
        verify(mapper).insert(saved.capture());
        assertEquals(4L, saved.getValue().getSaleOrderItemId());
    }
}
yudao-server/src/main/resources/application-local.yaml
@@ -68,9 +68,9 @@
      primary: master
      datasource:
        master:
          url: jdbc:mysql://localhost:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # æœ¬åœ°è”调测试库
          url: jdbc:mysql://36.213.150.237:10000/mom-ntsh?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # æœ¬åœ°è”调测试库
          username: root
          password: 123456
          password: xd@123456..
  # Redis é…ç½®ã€‚Redisson é»˜è®¤çš„配置足够使用,一般不需要进行调优
  data: