yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/MesWmTransactionServiceImpl.java
@@ -5,6 +5,8 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.wm.transaction.vo.MesWmStockSummaryReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.transaction.vo.MesWmStockSummaryRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.transaction.vo.MesWmTransactionPageReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
@@ -18,10 +20,14 @@
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.stockwarning.MesWmStockWarningService;
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.MesWmWarehouseDefaultService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,6 +36,8 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -56,6 +64,10 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesWmWarehouseDefaultService warehouseDefaultService;
    @Resource
    private MesWmStockWarningService stockWarningService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -70,6 +82,10 @@
        checkQuantity(reqDTO, inbound);
        // 1.3 入库事务:批次号校验 + batchId/batchCode 互补
        checkBatch(reqDTO, inbound);
        // 1.3.1 入库事务未指定库区/库位时,自动落到仓库默认库区/库位(客户未启用库区/库位管理)
        if (inbound) {
            fillDefaultLocationAndArea(reqDTO);
        }
        // 1.4 入库事务:库位混放规则校验
        if (inbound) {
            materialStockService.checkAreaMixingRule(reqDTO.getAreaId(), reqDTO.getItemId(), reqDTO.getBatchId());
@@ -89,12 +105,14 @@
            if (materialStock == null) {
                throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
            }
            // 校验物料、批次、仓库等维度一致(防止传入错误的 materialStockId)
            // 校验物料、批次、仓库等维度一致(防止传入错误的 materialStockId)。
            // 若此处不一致,通常是该库存已被移库/合并/拆分,需退回重新拣货,故给出针对性提示
            if (ObjUtil.notEqual(materialStock.getItemId(), reqDTO.getItemId())
                    || ObjUtil.notEqual(materialStock.getWarehouseId(), reqDTO.getWarehouseId())
                    || ObjUtil.notEqual(materialStock.getLocationId(), reqDTO.getLocationId())
                    || ObjUtil.notEqual(materialStock.getAreaId(), reqDTO.getAreaId())) {
                throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
                throw new ServiceException(WM_MATERIAL_STOCK_SELECTION_MISMATCH.getCode(),
                        "该库存记录已变动(可能被移库/合并/拆分),与出库明细不一致,无法扣减,请退回重新拣货");
            }
        } else if (isInbound) {
            // 入库事务:根据 forceCreateNewStock 标志决定是创建新记录还是查找/创建
@@ -132,6 +150,8 @@
        // 2.3 更新库存数量
        boolean checkFlag = ObjUtil.defaultIfNull(reqDTO.getCheckFlag(), true);
        materialStockService.updateMaterialStockQuantity(materialStock.getId(), reqDTO.getQuantity(), checkFlag);
        // 2.4 库存预警校验:本次变动导致库存跨越上下限时,自动发送站内通知(需求 5.1②)
        stockWarningService.checkAfterStockChange(reqDTO.getItemId(), reqDTO.getWarehouseId(), reqDTO.getQuantity());
        // 3. 插入事务流水
        MesWmTransactionDO transaction = MesWmTransactionDO.builder()
@@ -159,9 +179,41 @@
        return transactionMapper.selectPage(pageReqVO);
    }
    @Override
    public PageResult<MesWmStockSummaryRespVO> getStockSummaryPage(MesWmStockSummaryReqVO reqVO) {
        // 未指定时间时默认统计近三个月:期初为两个月前 1 日 0 点,期末为当前时间
        LocalDateTime endTime = reqVO.getEndTime() != null ? reqVO.getEndTime() : LocalDateTime.now();
        LocalDateTime startTime = reqVO.getStartTime() != null ? reqVO.getStartTime()
                : endTime.toLocalDate().withDayOfMonth(1).minusMonths(2).atStartOfDay();
        if (startTime.isAfter(endTime)) {
            throw new ServiceException(400, StrUtil.format(
                    "查询开始时间[{}]晚于结束时间[{}],请调整时间范围后重试",
                    startTime, endTime));
        }
        IPage<MesWmStockSummaryRespVO> page = transactionMapper.selectStockSummaryPage(
                new Page<>(reqVO.getPageNo(), reqVO.getPageSize()),
                reqVO.getItemId(), reqVO.getWarehouseId(), startTime, endTime);
        return new PageResult<>(page.getRecords(), page.getTotal());
    }
    // ==================== 私有校验方法 ====================
    /**
     * 入库事务未指定库区/库位时,自动使用仓库的默认库区/默认库位
     *
     * 客户未启用库区/库位管理,前端不再让用户选择,库存仍需要完整的仓库-库区-库位维度。
     */
    private void fillDefaultLocationAndArea(MesWmTransactionSaveReqDTO reqDTO) {
        if (reqDTO.getWarehouseId() == null
                || (reqDTO.getLocationId() != null && reqDTO.getAreaId() != null)) {
            return;
        }
        MesWmWarehouseAreaDO defaultArea = warehouseDefaultService.getOrCreateDefaultArea(reqDTO.getWarehouseId());
        reqDTO.setLocationId(defaultArea.getLocationId());
        reqDTO.setAreaId(defaultArea.getId());
    }
    /**
     * 校验数量正负号与事务方向一致
     */
    private void checkQuantity(MesWmTransactionSaveReqDTO reqDTO, boolean inbound) {