2026-09-04 335f64189bdf0fd51864ac3873f6199e94282c27
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java
@@ -3,10 +3,15 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockAdjustReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockFreezeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockImportExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockImportRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockListReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockMoveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmMaterialStockPageReqVO;
@@ -16,9 +21,14 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
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;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemTypeService;
@@ -28,19 +38,24 @@
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
/**
@@ -63,6 +78,9 @@
    private MesWmWarehouseAreaService areaService;
    @Resource
    @Lazy
    private MesWmWarehouseLocationService locationService;
    @Resource
    @Lazy
    private MesWmWarehouseService warehouseService;
    @Resource
    @Lazy
@@ -70,6 +88,12 @@
    @Resource
    @Lazy
    private MesWmBatchService batchService;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private PlatformTransactionManager transactionManager;
    @Override
    public MesWmMaterialStockDO getMaterialStock(Long id) {
@@ -179,6 +203,13 @@
        }
        // 2. 不存在则新建
        return createMaterialStock(itemId, warehouseId, locationId, areaId, batchId, batchCode, vendorId, receiptTime);
    }
    @Override
    public MesWmMaterialStockDO createMaterialStock(Long itemId, Long warehouseId, Long locationId, Long areaId,
                                                     Long batchId, String batchCode, Long vendorId, LocalDateTime receiptTime) {
        // 始终创建新库存记录
        MesMdmItemDO item = mdmItemService.validateItemExists(itemId);
        MesWmMaterialStockDO newStock = MesWmMaterialStockDO.builder()
                .itemId(itemId).itemTypeId(item.getCategoryId())
@@ -189,7 +220,7 @@
                .frozen(false)
                .build();
        materialStockMapper.insert(newStock);
        // 3. 重新查询,确保拿到数据库生成的完整记录(id、createTime 等)
        // 重新查询,确保拿到数据库生成的完整记录(id、createTime 等)
        MesWmMaterialStockDO inserted = materialStockMapper.selectById(newStock.getId());
        return inserted != null ? inserted : newStock;
    }
@@ -247,20 +278,87 @@
            throw exception(WM_MATERIAL_STOCK_REQUIRED);
        }
        MesWmMaterialStockDO stock = validateMaterialStockExists(materialStockId);
        // 2. 校验库存记录的物料、批次、仓库、库区、库位等信息与前端选择的一致,避免串单或越权提交
        if (ObjUtil.notEqual(stock.getItemId(), itemId)
                || ObjUtil.notEqual(stock.getBatchId(), batchId)
                || (batchCode != null && ObjUtil.notEqual(stock.getBatchCode(), batchCode))
                || ObjUtil.notEqual(stock.getWarehouseId(), warehouseId)
                || ObjUtil.notEqual(stock.getLocationId(), locationId)
                || ObjUtil.notEqual(stock.getAreaId(), areaId)) {
            throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
        // 2. 校验库存记录的物料、批次、仓库、库区、库位等信息与单据一致,避免串单或越权提交。
        //    逐个维度比对,命中时提示具体差异(库存侧 vs 单据侧)并给出处理办法,避免笼统报"信息不一致"
        String mismatch = buildSelectedStockMismatch(
                stock, itemId, batchId, batchCode, warehouseId, locationId, areaId);
        if (mismatch != null) {
            throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
                    "所选库存记录与单据不一致:" + mismatch + "。请重新选择该物料正确的库存;若仓库/库区/库位/批次被禁用无法修改,请删除本条明细后重新添加。");
        }
        // 3. 校验库存数量充足(如果前端传了 quantity,则必须保证库存数量 >= quantity)
        if (quantity != null && stock.getQuantity() != null && stock.getQuantity().compareTo(quantity) < 0) {
            throw exception(WM_MATERIAL_STOCK_INSUFFICIENT);
        }
        return stock;
    }
    /**
     * 逐维度比对待选库存与单据传入的信息,返回第一个不一致维度的可读描述;全部一致返回 null
     */
    private String buildSelectedStockMismatch(MesWmMaterialStockDO stock, Long itemId, Long batchId, String batchCode,
                                              Long warehouseId, Long locationId, Long areaId) {
        if (ObjUtil.notEqual(stock.getItemId(), itemId)) {
            return "所选库存归属物料[" + itemIdText(itemId) + "],单据物料为[" + itemIdText(stock.getItemId()) + "]";
        }
        if (ObjUtil.notEqual(stock.getBatchId(), batchId)) {
            return "所选库存批次[" + batchText(stock.getBatchId(), stock.getBatchCode())
                    + "],单据批次为[" + batchText(batchId, batchCode) + "]";
        }
        if (batchCode != null && ObjUtil.notEqual(stock.getBatchCode(), batchCode)) {
            return "所选库存批次号[" + stock.getBatchCode() + "],单据批次号为[" + batchCode + "]";
        }
        if (ObjUtil.notEqual(stock.getWarehouseId(), warehouseId)) {
            return "所选库存仓库[" + warehouseText(stock.getWarehouseId())
                    + "],单据仓库为[" + warehouseText(warehouseId) + "]";
        }
        if (ObjUtil.notEqual(stock.getLocationId(), locationId)) {
            return "所选库存库区[" + locationText(stock.getLocationId())
                    + "],单据库区为[" + locationText(locationId) + "]";
        }
        if (ObjUtil.notEqual(stock.getAreaId(), areaId)) {
            return "所选库存库位[" + areaText(stock.getAreaId())
                    + "],单据库位为[" + areaText(areaId) + "]";
        }
        return null;
    }
    private String itemIdText(Long itemId) {
        return itemId == null ? "未选择" : ("物料ID=" + itemId);
    }
    private String batchText(Long batchId, String batchCode) {
        if (StrUtil.isNotBlank(batchCode)) {
            return batchCode;
        }
        return batchId == null ? "未选择" : ("批次ID=" + batchId);
    }
    private String warehouseText(Long warehouseId) {
        if (warehouseId == null) {
            return "未选择";
        }
        MesWmWarehouseDO warehouse = warehouseService.getWarehouse(warehouseId);
        return warehouse != null && StrUtil.isNotBlank(warehouse.getName())
                ? warehouse.getName() : ("仓库ID=" + warehouseId);
    }
    private String locationText(Long locationId) {
        if (locationId == null) {
            return "未选择";
        }
        MesWmWarehouseLocationDO location = locationService.getWarehouseLocation(locationId);
        return location != null && StrUtil.isNotBlank(location.getName())
                ? location.getName() : ("库区ID=" + locationId);
    }
    private String areaText(Long areaId) {
        if (areaId == null) {
            return "未选择";
        }
        MesWmWarehouseAreaDO area = areaService.getWarehouseArea(areaId);
        return area != null && StrUtil.isNotBlank(area.getName())
                ? area.getName() : ("库位ID=" + areaId);
    }
    @Override
@@ -494,4 +592,245 @@
        wmTransactionService.createTransaction(inTransaction);
    }
    @Override
    public MesWmMaterialStockImportRespVO importMaterialStockList(List<MesWmMaterialStockImportExcelVO> importList) {
        // 操作类型取值:增加 / 减少 / 覆盖
        final String OP_ADD = "增加";
        final String OP_REDUCE = "减少";
        final String OP_COVER = "覆盖";
        // 1. 过滤全空行
        importList = filterList(importList, item -> StrUtil.isNotBlank(item.getItemCode())
                || StrUtil.isNotBlank(item.getBatchCode()) || item.getQuantity() != null);
        if (CollUtil.isEmpty(importList)) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_LIST_IS_EMPTY);
        }
        // 2. 预加载 编码/名称 -> 实体 映射(编码优先,名称兜底)
        Map<String, MdmItemRespDTO> itemMap = mdmItemApi.getItemListByStatus(null).getCheckedData().stream()
                .collect(Collectors.toMap(MdmItemRespDTO::getCode, item -> item, (v1, v2) -> v1));
        Map<String, MesWmWarehouseDO> warehouseCodeMap = new HashMap<>();
        Map<String, MesWmWarehouseDO> warehouseNameMap = new HashMap<>();
        for (MesWmWarehouseDO warehouse : warehouseService.getWarehouseList()) {
            warehouseCodeMap.putIfAbsent(warehouse.getCode(), warehouse);
            warehouseNameMap.putIfAbsent(warehouse.getName(), warehouse);
        }
        Map<String, MesWmWarehouseLocationDO> locationCodeMap = new HashMap<>();
        Map<String, MesWmWarehouseLocationDO> locationNameMap = new HashMap<>();
        for (MesWmWarehouseLocationDO location : locationService.getWarehouseLocationList((Long) null)) {
            locationCodeMap.putIfAbsent(location.getCode(), location);
            locationNameMap.putIfAbsent(location.getName(), location);
        }
        Map<String, MesWmWarehouseAreaDO> areaCodeMap = new HashMap<>();
        Map<String, MesWmWarehouseAreaDO> areaNameMap = new HashMap<>();
        for (MesWmWarehouseAreaDO area : areaService.getWarehouseAreaList((Long) null)) {
            areaCodeMap.putIfAbsent(area.getCode(), area);
            areaNameMap.putIfAbsent(area.getName(), area);
        }
        Map<String, SrmSupplierRespDTO> supplierCodeMap = new HashMap<>();
        Map<String, SrmSupplierRespDTO> supplierNameMap = new HashMap<>();
        for (SrmSupplierRespDTO supplier : srmSupplierApi.getSupplierSimpleList().getCheckedData()) {
            supplierCodeMap.putIfAbsent(supplier.getCode(), supplier);
            supplierNameMap.putIfAbsent(supplier.getName(), supplier);
        }
        // 3. 逐条处理
        MesWmMaterialStockImportRespVO respVO = MesWmMaterialStockImportRespVO.builder()
                .createNames(new ArrayList<>())
                .updateNames(new ArrayList<>())
                .failureNames(new LinkedHashMap<>())
                .build();
        TransactionTemplate importTxTemplate = new TransactionTemplate(transactionManager);
        for (MesWmMaterialStockImportExcelVO importStock : importList) {
            String key = StrUtil.blankToDefault(importStock.getItemCode(), "") + "-"
                    + StrUtil.blankToDefault(importStock.getWarehouse(), "");
            try {
                importTxTemplate.executeWithoutResult(status -> {
                // 3.1 物料
                MdmItemRespDTO item = resolveImportItem(itemMap, importStock.getItemCode());
                // 3.2 仓库/库区/库位(编码优先、名称兜底,含父子关系校验)
                MesWmWarehouseDO warehouse = resolveImportWarehouse(warehouseCodeMap, warehouseNameMap,
                        importStock.getWarehouse());
                Long locationId = null;
                if (StrUtil.isNotBlank(importStock.getLocation())) {
                    MesWmWarehouseLocationDO location = resolveImportLocationOrArea(locationCodeMap,
                            locationNameMap, importStock.getLocation(), WM_MATERIAL_STOCK_IMPORT_LOCATION_NOT_EXISTS);
                    if (ObjUtil.notEqual(location.getWarehouseId(), warehouse.getId())) {
                        throw exception(WM_WAREHOUSE_AREA_WAREHOUSE_MISMATCH);
                    }
                    locationId = location.getId();
                }
                Long areaId = null;
                if (StrUtil.isNotBlank(importStock.getArea())) {
                    MesWmWarehouseAreaDO area = resolveImportLocationOrArea(areaCodeMap,
                            areaNameMap, importStock.getArea(), WM_MATERIAL_STOCK_IMPORT_AREA_NOT_EXISTS);
                    if (ObjUtil.notEqual(area.getLocationId(),
                            locationId != null ? locationId : null)) {
                        throw exception(WM_WAREHOUSE_AREA_RELATION_INVALID);
                    }
                    areaId = area.getId();
                }
                // 3.3 批次(物料启用批次管理时必填,且批次需归属该物料)
                Long batchId = null;
                String batchCode = null;
                if (Boolean.TRUE.equals(item.getIsBatchManaged())) {
                    if (StrUtil.isBlank(importStock.getBatchCode())) {
                        throw exception(WM_TRANSACTION_BATCH_REQUIRED);
                    }
                    MesWmBatchDO batch = batchService.getBatchByCode(importStock.getBatchCode().trim());
                    if (batch == null || ObjUtil.notEqual(batch.getItemId(), item.getId())) {
                        throw exception(WM_MATERIAL_STOCK_IMPORT_BATCH_NOT_EXISTS,
                                importStock.getBatchCode().trim());
                    }
                    batchId = batch.getId();
                    batchCode = batch.getCode();
                }
                // 3.4 供应商(可选)
                Long vendorId = resolveImportVendor(supplierCodeMap, supplierNameMap, importStock.getVendor());
                // 3.5 操作类型(必填:增加/减少/覆盖)
                String operationType = importStock.getOperationType() == null
                        ? null : importStock.getOperationType().trim();
                if (StrUtil.isBlank(operationType)) {
                    throw exception(WM_MATERIAL_STOCK_IMPORT_FIELD_REQUIRED, "操作类型");
                }
                if (!OP_ADD.equals(operationType) && !OP_REDUCE.equals(operationType)
                        && !OP_COVER.equals(operationType)) {
                    throw exception(WM_MATERIAL_STOCK_IMPORT_OPERATION_INVALID, operationType);
                }
                // 3.6 数量校验:增加/减少为变动量须为正数;覆盖为目标现有量须 >= 0
                BigDecimal quantity = importStock.getQuantity();
                boolean cover = OP_COVER.equals(operationType);
                if (quantity == null || quantity.compareTo(BigDecimal.ZERO) < 0
                        || (!cover && quantity.compareTo(BigDecimal.ZERO) == 0)) {
                    throw exception(WM_MATERIAL_STOCK_IMPORT_QUANTITY_INVALID,
                            quantity == null ? "空" : quantity);
                }
                // 3.7 按操作类型定位并处理库存记录(不存在则创建,存在则调整数量)
                MesWmMaterialStockDO existStock = materialStockMapper.selectByCompositeKey(
                        item.getId(), warehouse.getId(), locationId, areaId, batchId, vendorId);
                Long wId = warehouse.getId();
                if (OP_REDUCE.equals(operationType)) {
                    // 减少:库存必须存在,扣减不足时由出库流水校验并失败
                    if (existStock == null) {
                        throw exception(WM_MATERIAL_STOCK_IMPORT_STOCK_NOT_EXISTS);
                    }
                    createImportTransaction(existStock, item.getId(), batchId, batchCode,
                            wId, locationId, areaId, vendorId, quantity.negate());
                    respVO.getUpdateNames().add(key);
                } else if (OP_ADD.equals(operationType)) {
                    // 增加:不存在则建库存再入库;已存在则直接追加变动量
                    if (existStock == null) {
                        MesWmMaterialStockDO newStock = getOrCreateMaterialStock(
                                item.getId(), wId, locationId, areaId,
                                batchId, batchCode, vendorId, LocalDateTime.now());
                        createImportTransaction(newStock, item.getId(), batchId, batchCode,
                                wId, locationId, areaId, vendorId, quantity);
                        respVO.getCreateNames().add(key);
                    } else {
                        createImportTransaction(existStock, item.getId(), batchId, batchCode,
                                wId, locationId, areaId, vendorId, quantity);
                        respVO.getUpdateNames().add(key);
                    }
                } else {
                    // 覆盖:数量为目标现有量;不存在则新建并置量,已存在按差额出入库
                    if (existStock == null) {
                        MesWmMaterialStockDO newStock = getOrCreateMaterialStock(
                                item.getId(), wId, locationId, areaId,
                                batchId, batchCode, vendorId, LocalDateTime.now());
                        if (quantity.compareTo(BigDecimal.ZERO) > 0) {
                            createImportTransaction(newStock, item.getId(), batchId, batchCode,
                                    wId, locationId, areaId, vendorId, quantity);
                        }
                        respVO.getCreateNames().add(key);
                    } else {
                        BigDecimal currentQty = existStock.getQuantity() != null
                                ? existStock.getQuantity() : BigDecimal.ZERO;
                        BigDecimal diff = quantity.subtract(currentQty);
                        if (diff.compareTo(BigDecimal.ZERO) != 0) {
                            createImportTransaction(existStock, item.getId(), batchId, batchCode,
                                    wId, locationId, areaId, vendorId, diff);
                        }
                        respVO.getUpdateNames().add(key);
                    }
                }
                });
            } catch (Exception ex) {
                respVO.getFailureNames().put(key, ex.getMessage());
            }
        }
        return respVO;
    }
    private MdmItemRespDTO resolveImportItem(Map<String, MdmItemRespDTO> itemMap, String itemCode) {
        if (StrUtil.isBlank(itemCode)) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_FIELD_REQUIRED, "物料编码");
        }
        MdmItemRespDTO item = itemMap.get(itemCode.trim());
        if (item == null) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_ITEM_NOT_EXISTS, itemCode.trim());
        }
        return item;
    }
    private MesWmWarehouseDO resolveImportWarehouse(Map<String, MesWmWarehouseDO> codeMap,
                                                    Map<String, MesWmWarehouseDO> nameMap, String value) {
        if (StrUtil.isBlank(value)) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_FIELD_REQUIRED, "仓库");
        }
        String trimmed = value.trim();
        MesWmWarehouseDO warehouse = codeMap.get(trimmed) != null ? codeMap.get(trimmed) : nameMap.get(trimmed);
        if (warehouse == null) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_WAREHOUSE_NOT_EXISTS, trimmed);
        }
        return warehouse;
    }
    private <T> T resolveImportLocationOrArea(Map<String, T> codeMap, Map<String, T> nameMap,
                                              String value, ErrorCode errorCode) {
        String trimmed = value.trim();
        T result = codeMap.get(trimmed) != null ? codeMap.get(trimmed) : nameMap.get(trimmed);
        if (result == null) {
            throw exception(errorCode, trimmed);
        }
        return result;
    }
    private Long resolveImportVendor(Map<String, SrmSupplierRespDTO> codeMap,
                                     Map<String, SrmSupplierRespDTO> nameMap, String value) {
        if (StrUtil.isBlank(value)) {
            return null;
        }
        String trimmed = value.trim();
        SrmSupplierRespDTO supplier = codeMap.get(trimmed) != null ? codeMap.get(trimmed) : nameMap.get(trimmed);
        if (supplier == null) {
            throw exception(WM_MATERIAL_STOCK_IMPORT_VENDOR_NOT_EXISTS, trimmed);
        }
        return supplier.getId();
    }
    /**
     * 创建导入产生的库存事务流水(复用库存调整的流水模式)
     *
     * @param quantity 变动数量(正数=入库,负数=出库)
     */
    private void createImportTransaction(MesWmMaterialStockDO stock, Long itemId, Long batchId, String batchCode,
                                         Long warehouseId, Long locationId, Long areaId, Long vendorId,
                                         BigDecimal quantity) {
        boolean inbound = quantity.compareTo(BigDecimal.ZERO) > 0;
        MesWmTransactionSaveReqDTO transaction = new MesWmTransactionSaveReqDTO()
                .setType(inbound ? MesWmTransactionTypeEnum.IN.getType() : MesWmTransactionTypeEnum.OUT.getType())
                .setBizType(inbound ? MesBizTypeConstants.WM_STOCK_ADJUST_IN : MesBizTypeConstants.WM_STOCK_ADJUST_OUT)
                .setBizId(stock.getId())
                .setBizCode("STOCK_IMPORT_" + stock.getId())
                .setBizLineId(stock.getId())
                .setItemId(itemId)
                .setQuantity(quantity.abs())
                .setBatchId(batchId)
                .setBatchCode(batchCode)
                .setWarehouseId(warehouseId)
                .setLocationId(locationId)
                .setAreaId(areaId)
                .setVendorId(vendorId)
                .setReceiptTime(LocalDateTime.now())
                .setCheckFlag(!inbound);
        wmTransactionService.createTransaction(transaction);
    }
}