7 小时以前 7bf754a7835d06f26240c4ca15bc5f83650de377
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -24,10 +24,13 @@
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.StockOutRecordSalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
@@ -49,8 +52,11 @@
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
@@ -64,13 +70,16 @@
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> implements StockOutRecordService {
    /**
     * 出库类别:销售。销售类油品出库在审批通过时按批次扣减库存
     * 出库类别:销售。唯一的区别是销售类出库必须选车辆、按车辆算上次剩余油量;
     * 扣库存、进发货台账这些对所有出库类别一视同仁
     */
    private static final String OUT_CATEGORY_SALE = "销售";
    /**
     * 出库类别:代储。客户把自己的油寄存在我们的罐里,提走只是记录一笔,不扣库存、不进发货台账
     * 出库类别:代储。客户把自己的油寄存在我们的罐里。
     * 出库要求与销售一致(选批次、审批扣库存、删除归还、进发货台账),只是不要求选车辆
     */
    private static final String OUT_CATEGORY_DEPOSIT = "代储";
@@ -94,6 +103,8 @@
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
@@ -102,26 +113,34 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        resolveSalesLedgerBinding(stockOutRecordDto);
        if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
            // 销售类油品出库审批通过时要按批次扣减库存,批次不能为空
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("销售类出库必须选择出库批次");
            }
            resolveVehicle(stockOutRecordDto);
            checkSaleOutAvailable(stockOutRecordDto);
        }
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        LocalDateTime createTime = stockOutRecordDto.getCreateTime();
        if (createTime == null) {
            createTime = LocalDateTime.now();
        }
        // 出库单号先于校验生成,好让校验失败时的报错带上出库批次
        String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", createTime);
        stockOutRecordDto.setOutboundBatches(no);
        stockOutRecordDto.setCreateTime(createTime);
        // 油品出库(type 为空)审批通过时一律按批次扣减库存,批次不能为空。
        // type=0/1 的合格、不合格出库走各自分支,不受这里约束
        if (StringUtils.isEmpty(stockOutRecordDto.getType())) {
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("油品出库必须选择出库批次");
            }
            if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
                // 只有销售类按车辆算残油,代储/客存不要求选车辆
                resolveVehicle(stockOutRecordDto);
            }
            checkOilOutAvailable(stockOutRecordDto);
        }
        if (StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode().equals(stockOutRecordDto.getRecordType())){
            stockOutRecordDto.setApprovalStatus(3);
        }
        return stockOutRecordMapper.insert(stockOutRecordDto);
        int rows = stockOutRecordMapper.insert(stockOutRecordDto);
        // 出库单 id 由自增主键回填,拿到后才能落绑定行
        saveLedgerBindings(stockOutRecordDto.getId(), ledgerBindings);
        return rows;
    }
    @Override
@@ -132,53 +151,187 @@
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        resolveSalesLedgerBinding(stockOutRecordDto);
        String[] ignoreProperties = {"id", "outbound_batches"};//排除id属性
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        // 排除 id 属性;recordType/recordId 由后端维护(油品出库恒为 '1' / 0,手工发货单出库由发货流程写入),
        // 前端编辑表单不回传会把它们冲成 null,破坏出库记录的来源标识
        String[] ignoreProperties = {"id", "outbound_batches", "recordType", "recordId"};
        BeanUtils.copyProperties(stockOutRecordDto, stockOutRecord, ignoreProperties);
        return stockOutRecordMapper.updateById(stockOutRecord);
        int rows = stockOutRecordMapper.updateById(stockOutRecord);
        rebindLedger(id, ledgerBindings);
        return rows;
    }
    /**
     * 绑定销售台账:客户一律以台账为准覆盖,避免出库记录与台账的客户对不上;出库类别按台账的销售类型自动定,
     * 免得出现「绑了卖油台账却按代储不扣库存」这类矛盾数据。
     * 绑定销售台账:一次出库可以分送多个客户,所以台账是个列表,每个台账手工填本次分摊到的数量,
     * 各台账数量之和必须等于本次出库总量。客户一律以台账为准覆盖,出库类别按台账的销售类型自动定,
     * 免得出现「绑了代储台账却按销售要求选车辆」这类矛盾数据。
     * <ul>
     *     <li>卖油(含 ledgerType 为空):出库类别置「销售」,并校验所选规格确实在该台账的销售明细里</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过产品校验 ——
     *         代储台账本身就是卖存储位置,没有产品明细</li>
     *     <li>卖油(含 ledgerType 为空):出库类别置「销售」,逐个校验所选规格确实在该台账的销售明细里,
     *         并把命中的明细 id 落进绑定行(同一台账同规格多行时固定取 id 最小的那条)</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过「规格是否在台账明细里」的校验 ——
     *         代储台账本身就是卖存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选。
     *         代储不能与其他台账混绑,也不产绑定行(它的油是客户自己的,不走到货)</li>
     * </ul>
     * salesLedgerId 为空时整体跳过,不绑台账的出库行为与改动前完全一致
     *
     * @return 待落库的绑定行;null 表示本次请求没带台账信息,调用方应保持已有绑定不变
     */
    private void resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getSalesLedgerId() == null) {
            return;
    private List<StockOutRecordSalesLedger> resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        List<StockOutRecordSalesLedger> ledgerList = stockOutRecordDto.getLedgerList();
        if (ledgerList == null && stockOutRecordDto.getSalesLedgerId() != null) {
            // 兼容只传 salesLedgerId 的旧入参:视作「绑一个台账、数量就是本次出库总量」
            StockOutRecordSalesLedger single = new StockOutRecordSalesLedger();
            single.setSalesLedgerId(stockOutRecordDto.getSalesLedgerId());
            single.setQuantity(stockOutRecordDto.getStockOutNum());
            ledgerList = new ArrayList<>();
            ledgerList.add(single);
        } else if (ledgerList == null) {
            return null;
        }
        SalesLedger salesLedger = salesLedgerMapper.selectById(stockOutRecordDto.getSalesLedgerId());
        if (salesLedger == null) {
            throw new BaseException("所选销售台账不存在,销售台账id:" + stockOutRecordDto.getSalesLedgerId());
        if (ledgerList.isEmpty()) {
            // 明确的「不绑台账」
            stockOutRecordDto.setSalesLedgerId(null);
            return ledgerList;
        }
        stockOutRecordDto.setCustomerId(salesLedger.getCustomerId());
        stockOutRecordDto.setCustomerName(salesLedger.getCustomerName());
        if (LEDGER_TYPE_DEPOSIT.equals(salesLedger.getLedgerType())) {
            stockOutRecordDto.setOutCategory(OUT_CATEGORY_DEPOSIT);
            stockOutRecordDto.setOilDepotId(salesLedger.getOilDepotId());
            stockOutRecordDto.setOilDepotName(salesLedger.getOilDepotName());
            stockOutRecordDto.setTankId(salesLedger.getTankId());
            stockOutRecordDto.setTankNo(salesLedger.getTankNo());
            return;
        List<SalesLedger> ledgers = new ArrayList<>(ledgerList.size());
        for (StockOutRecordSalesLedger item : ledgerList) {
            if (item == null || item.getSalesLedgerId() == null) {
                throw new BaseException("绑定的销售台账不能为空");
            }
            SalesLedger salesLedger = salesLedgerMapper.selectById(item.getSalesLedgerId());
            if (salesLedger == null) {
                throw new BaseException("所选销售台账不存在,销售台账id:" + item.getSalesLedgerId());
            }
            ledgers.add(salesLedger);
        }
        // 首项台账继续写进出库记录的单值列,出库管理列表、库存报表都按它展示
        SalesLedger first = ledgers.get(0);
        stockOutRecordDto.setSalesLedgerId(first.getId());
        stockOutRecordDto.setCustomerId(first.getCustomerId());
        stockOutRecordDto.setCustomerName(first.getCustomerName());
        boolean hasDeposit = ledgers.stream().anyMatch(item -> LEDGER_TYPE_DEPOSIT.equals(item.getLedgerType()));
        if (hasDeposit) {
            if (ledgers.size() > 1) {
                throw new BaseException("代储台账不能与其他销售台账一起绑定,一次出库只能绑一个代储台账");
            }
            applyDepositLedger(stockOutRecordDto, first);
            return new ArrayList<>();
        }
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_SALE);
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + first.getSalesContractNo());
        }
        boolean belongsToLedger = salesLedgerProductMapper.selectList(
                        Wrappers.<SalesLedgerProduct>lambdaQuery()
                                .eq(SalesLedgerProduct::getSalesLedgerId, stockOutRecordDto.getSalesLedgerId())
                                .eq(SalesLedgerProduct::getType, LEDGER_PRODUCT_TYPE_SALE))
                .stream()
                .anyMatch(item -> stockOutRecordDto.getProductModelId().equals(item.getProductModelId()));
        if (!belongsToLedger) {
            throw new BaseException("所选产品不在该销售台账明细中,销售合同号:" + salesLedger.getSalesContractNo());
        BigDecimal stockOutNum = stockOutRecordDto.getStockOutNum();
        if (stockOutNum == null || stockOutNum.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("请填写本次出库数量(吨)");
        }
        BigDecimal total = BigDecimal.ZERO;
        for (int index = 0; index < ledgerList.size(); index++) {
            StockOutRecordSalesLedger item = ledgerList.get(index);
            SalesLedger salesLedger = ledgers.get(index);
            BigDecimal quantity = item.getQuantity();
            if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
                throw new BaseException("每个销售台账的绑定数量必须大于0,销售合同号:" + salesLedger.getSalesContractNo());
            }
            SalesLedgerProduct detail = findLedgerSaleDetail(salesLedger.getId(), stockOutRecordDto.getProductModelId());
            if (detail == null) {
                throw new BaseException("所选产品不在该销售台账明细中,销售合同号:" + salesLedger.getSalesContractNo());
            }
            item.setSalesLedgerProductId(detail.getId());
            item.setCustomerId(salesLedger.getCustomerId());
            item.setCustomerName(salesLedger.getCustomerName());
            total = total.add(quantity);
        }
        if (total.compareTo(stockOutNum) != 0) {
            throw new BaseException("各销售台账绑定数量之和必须等于本次出库数量,出库数量:" + toPlain(stockOutNum)
                    + ",绑定数量之和:" + toPlain(total));
        }
        return ledgerList;
    }
    /**
     * 同一台账可能有多行同规格的销售明细,固定取 id 最小的那条,保证每次绑定的明细 id 稳定
     */
    private SalesLedgerProduct findLedgerSaleDetail(Long salesLedgerId, Long productModelId) {
        List<SalesLedgerProduct> details = salesLedgerProductMapper.selectList(
                Wrappers.<SalesLedgerProduct>lambdaQuery()
                        .eq(SalesLedgerProduct::getSalesLedgerId, salesLedgerId)
                        .eq(SalesLedgerProduct::getType, LEDGER_PRODUCT_TYPE_SALE)
                        .eq(SalesLedgerProduct::getProductModelId, productModelId)
                        .orderByAsc(SalesLedgerProduct::getId));
        return details.isEmpty() ? null : details.get(0);
    }
    /**
     * 代储台账:卖的是存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选
     */
    private void applyDepositLedger(StockOutRecordDto stockOutRecordDto, SalesLedger salesLedger) {
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_DEPOSIT);
        stockOutRecordDto.setOilDepotId(salesLedger.getOilDepotId());
        stockOutRecordDto.setOilDepotName(salesLedger.getOilDepotName());
        stockOutRecordDto.setTankId(salesLedger.getTankId());
        stockOutRecordDto.setTankNo(salesLedger.getTankNo());
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("代储出库需要扣减库存,必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
        }
    }
    /**
     * 落库绑定行。明细 id / 客户 / 出库单 id 一律用后端校验时算出的值,前端传的被覆盖。
     * 逐条 insert:本表一次出库只有几行,且 MyBaseMapper 的 insertBatchSomeColumn
     * 项目里没配自定义 SqlInjector,方法根本没注册(调用会报 Invalid bound statement)
     */
    private void saveLedgerBindings(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (stockOutRecordId == null || CollectionUtils.isEmpty(bindings)) {
            return;
        }
        for (StockOutRecordSalesLedger binding : bindings) {
            binding.setId(null);
            binding.setStockOutRecordId(stockOutRecordId);
            stockOutRecordSalesLedgerMapper.insert(binding);
        }
    }
    /**
     * 重新绑定台账:绑定没变就原样不动(保住绑定行 id);变了则先按出库单物理删旧再重插。
     * 已登记到货记录时不允许改 —— 到货记录挂在「台账×出库单」上,改绑会让它指向不存在的分摊数量
     */
    private void rebindLedger(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (bindings == null) {
            return;
        }
        List<StockOutRecordSalesLedger> existing = stockOutRecordSalesLedgerMapper.selectList(
                Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                        .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        if (sameBinding(existing, bindings)) {
            return;
        }
        if (salesLedgerArrivalMapper.countByStockOutRecordId(stockOutRecordId) > 0) {
            throw new BaseException("该出库单已登记到货记录,无法修改绑定的销售台账,出库单id:" + stockOutRecordId);
        }
        stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        saveLedgerBindings(stockOutRecordId, bindings);
    }
    /**
     * 台账与分摊数量是否完全一致(顺序无关)
     */
    private boolean sameBinding(List<StockOutRecordSalesLedger> existing, List<StockOutRecordSalesLedger> incoming) {
        if (existing.size() != incoming.size()) {
            return false;
        }
        Map<Long, BigDecimal> existingQuantity = new HashMap<>(existing.size());
        for (StockOutRecordSalesLedger item : existing) {
            existingQuantity.put(item.getSalesLedgerId(), item.getQuantity());
        }
        for (StockOutRecordSalesLedger item : incoming) {
            BigDecimal quantity = existingQuantity.get(item.getSalesLedgerId());
            if (quantity == null || quantity.compareTo(item.getQuantity()) != 0) {
                return false;
            }
        }
        return true;
    }
    @Override
@@ -189,9 +342,30 @@
                accountInvoiceApplicationMapper.existsByStockOutRecordId(ids)) {
            throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
        }
        // 油品出库的开票/收款关联存的是绑定行 id(与出库单 id 是两个独立 id 空间),必须单独判,
        // 否则绑了台账的油品出库被开票后还能删掉,留下指向不存在绑定行的关联数据
        if (!CollectionUtils.isEmpty(ids)) {
            List<Long> bindingIds = stockOutRecordSalesLedgerMapper.selectList(
                            Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                                    .in(StockOutRecordSalesLedger::getStockOutRecordId, ids))
                    .stream().map(StockOutRecordSalesLedger::getId).toList();
            if (!CollectionUtils.isEmpty(bindingIds)
                    && (accountSalesCollectionMapper.existsByStockOutBindingId(bindingIds)
                    || accountInvoiceApplicationMapper.existsByStockOutBindingId(bindingIds))) {
                throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
            }
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord.getType().equals("0")) {
            // 到货记录挂在出库单下,删了会变成孤儿,销售台账的已发货数量也就说不清了
            if (salesLedgerArrivalMapper.countByStockOutRecordId(id) > 0) {
                throw new BaseException("出库单已登记到货记录,无法删除,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            // 绑定行是出库单的附属数据,一并清掉,不留指向已删出库单的孤儿行
            stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                    .eq(StockOutRecordSalesLedger::getStockOutRecordId, id));
            // type 对油品出库是 null,常量写在前面避免 NPE
            if ("0".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                    .eq(StockInventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
@@ -210,7 +384,7 @@
                    stockInventoryMapper.updateAddStockInventory(stockInRecordDto);
                }
            }
            else if (stockOutRecord.getType().equals("1")) {
            else if ("1".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockUninventory> wrapper = new LambdaQueryWrapper<StockUninventory>()
                        .eq(StockUninventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
@@ -229,19 +403,19 @@
                    stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
                }
            }
            else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                // 销售类油品出库只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnSaleOilOutStock(stockOutRecord);
            else {
                // 油品出库只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnOilOutStock(stockOutRecord);
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
    /**
     * 销售类油品出库归还库存,仅对审批通过(即扣过库存)的记录生效。
     * 油品出库归还库存,仅对审批通过(即扣过库存)的记录生效。
     * 归还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
     */
    private void returnSaleOilOutStock(StockOutRecord stockOutRecord) {
    private void returnOilOutStock(StockOutRecord stockOutRecord) {
        if (!ReviewStatusEnum.APPROVED.getCode().equals(stockOutRecord.getApprovalStatus())
                || StringUtils.isEmpty(stockOutRecord.getBatchNo())
                || stockOutRecord.getStockOutNum() == null) {
@@ -330,9 +504,12 @@
                    stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
                } else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                    // 销售类油品出库(type 为空)审批通过时按批次扣减库存
                    deductSaleOilOutStock(stockOutRecord);
                } else {
                    // 油品出库(type 为空)审批通过时按批次扣减库存。
                    // 不再生成发货单,也不改 record_type:油品出库的 record_type 一直是「1」,
                    // 改成「13」会让出库管理列表按出库类型筛选时看不到它。判断「是不是销售类油品出库」
                    // 一律以 binding 表(stock_out_record_sales_ledger)为准
                    deductOilOutStock(stockOutRecord);
                }
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
@@ -388,19 +565,23 @@
    }
    /**
     * 销售类油品出库扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * 所以只从库存补足「出库数量 − 上次剩余油量」的差额。
     * 油品出库扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * 所以只从库存补足「出库数量 − 上次剩余油量」的差额;代储/客存没选车辆,
     * 上次剩余油量按 0 算,即全额扣减。
     * updateSubtractStockInventory 自带 qualitity &gt;= 数量 的守卫,影响行数为 0 即库存不足,
     * 抛错由外层事务回滚
     */
    private void deductSaleOilOutStock(StockOutRecord stockOutRecord) {
    private void deductOilOutStock(StockOutRecord stockOutRecord) {
        String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
        if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
            throw new BaseException("销售类出库必须选择出库批次" + suffix);
            throw new BaseException("油品出库必须选择出库批次" + suffix);
        }
        if (stockOutRecord.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecord.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecord.getVehicleId(), stockOutRecord.getProductModelId(), stockOutRecord.getId());
        if (lastResidual.compareTo(quantity) > 0) {
@@ -501,13 +682,17 @@
    }
    /**
     * 出库数量是本次装车总量,必须落在「上次剩余油量 ~ 上次剩余油量 + 库存」区间内
     * 出库数量是本次装车总量,必须落在「上次剩余油量 ~ 上次剩余油量 + 库存」区间内。
     * 代储/客存不选车辆,上次剩余油量按 0 算,退化成「不超过当前库存」
     */
    private void checkSaleOutAvailable(StockOutRecordDto stockOutRecordDto) {
    private void checkOilOutAvailable(StockOutRecordDto stockOutRecordDto) {
        String suffix = ",出库批次:" + stockOutRecordDto.getOutboundBatches();
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecordDto.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecordDto.getVehicleId(), stockOutRecordDto.getProductModelId(), null);
        if (lastResidual.compareTo(quantity) > 0) {
@@ -562,9 +747,10 @@
    @Override
    public OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query) {
        if (query == null || query.getVehicleId() == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择车辆和产品规格");
        if (query == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择产品规格");
        }
        // 代储/客存不选车辆,上次剩余取不到,按 0 算,可用量就等于当前库存
        BigDecimal lastResidualOil = getLastResidualOil(query.getVehicleId(), query.getProductModelId(), query.getId());
        BigDecimal stockQuantity = getStockQuantity(query.getProductModelId(), query.getBatchNo());
        OilOutAvailableDto result = new OilOutAvailableDto();