2 天以前 684e4306a08feeae188070f264148e6765da8dcf
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -2,10 +2,13 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.mapper.sales.AccountInvoiceApplicationMapper;
import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
import com.ruoyi.basic.pojo.Vehicle;
import com.ruoyi.basic.service.IVehicleService;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
@@ -15,8 +18,14 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.outsourcing.enums.OutsourcingOrderStatusEnum;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderProductMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.dto.StockUninventoryDto;
@@ -34,7 +43,10 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
 * <p>
@@ -47,11 +59,19 @@
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> implements StockOutRecordService {
    /**
     * 出库类别:销售。销售类油品出库在审批通过时按批次扣减库存
     */
    private static final String OUT_CATEGORY_SALE = "销售";
    private final StockOutRecordMapper stockOutRecordMapper;
    private final IVehicleService vehicleService;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockUninventoryMapper stockUninventoryMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final AccountInvoiceApplicationMapper accountInvoiceApplicationMapper;
    private final OutsourcingOrderProductMapper outsourcingOrderProductMapper;
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
@@ -60,8 +80,21 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", stockOutRecordDto.getCreateTime() != null ? stockOutRecordDto.getCreateTime() : LocalDateTime.now());
        if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
            // 销售类油品出库审批通过时要按批次扣减库存,批次不能为空
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("销售类出库必须选择出库批次");
            }
            resolveVehicle(stockOutRecordDto);
            checkSaleOutAvailable(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);
        if (StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode().equals(stockOutRecordDto.getRecordType())){
            stockOutRecordDto.setApprovalStatus(3);
        }
@@ -82,6 +115,7 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchDelete(List<Long> ids) {
        //todo 如果出库与开票收款有关联则无法删除
        if (accountSalesCollectionMapper.existsByStockOutRecordId(ids) ||
@@ -128,8 +162,35 @@
                    stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
                }
            }
            else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                // 销售类油品出库只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnSaleOilOutStock(stockOutRecord);
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
    /**
     * 销售类油品出库归还库存,仅对审批通过(即扣过库存)的记录生效。
     * 归还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
     */
    private void returnSaleOilOutStock(StockOutRecord stockOutRecord) {
        if (!ReviewStatusEnum.APPROVED.getCode().equals(stockOutRecord.getApprovalStatus())
                || StringUtils.isEmpty(stockOutRecord.getBatchNo())
                || stockOutRecord.getStockOutNum() == null) {
            return;
        }
        BigDecimal returnQuantity = stockOutRecord.getDeductQuantity() != null
                ? stockOutRecord.getDeductQuantity()
                : stockOutRecord.getStockOutNum();
        if (returnQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
        stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
        stockInventoryDto.setQualitity(returnQuantity);
        stockInventoryMapper.updateAddStockInventory(stockInventoryDto);
    }
    @Override
@@ -178,9 +239,7 @@
            if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有待审批状态的记录才能审批,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
            stockOutRecordMapper.updateById(stockOutRecord);
            // 审批通过时,扣减库存
            // 审批通过时先扣减库存,扣减量(deduct_quantity)要随审批状态一起落库,所以放在更新之前
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)) {
                if ("0".equals(stockOutRecord.getType())) {
                    // 合格出库 -> 先查库存是否存在,存在才扣减
@@ -204,10 +263,132 @@
                    stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
                } else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                    // 销售类油品出库(type 为空)审批通过时按批次扣减库存
                    deductSaleOilOutStock(stockOutRecord);
                }
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
            stockOutRecordMapper.updateById(stockOutRecord);
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)
                    && StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode()
                            .equals(stockOutRecord.getRecordType())) {
                tryCompleteOutsourcingOrder(stockOutRecord);
            }
        }
        return ids.size();
    }
    /**
     * 委外报废品出库审批通过后,若该委外订单的报废品选料行已<b>全部</b>出库审批通过,
     * 自动把订单从「已确认」推到「已完成」,省掉人工再点一次完成。
     *
     * <p>直接用 mapper 而不是 {@code IOutsourcingOrderService#complete}:后者依赖本服务,
     * 两边都走构造器注入会形成循环依赖。驳回/反审不回退订单状态(处理错误审批走人工重新审批)。</p>
     */
    private void tryCompleteOutsourcingOrder(StockOutRecord record) {
        if (record.getRecordId() == null) {
            return;
        }
        OutsourcingOrderProduct line = outsourcingOrderProductMapper.selectById(record.getRecordId());
        if (line == null || line.getOutsourcingOrderId() == null) {
            return;
        }
        Long orderId = line.getOutsourcingOrderId();
        // isScrap 在 Java 侧过滤,与委外模块自己的 scrapLineIds 一致,不依赖 Boolean 字段的列名推断
        List<Long> lineIds = outsourcingOrderProductMapper.selectList(
                        Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                                .eq(OutsourcingOrderProduct::getOutsourcingOrderId, orderId))
                .stream()
                .filter(item -> Boolean.TRUE.equals(item.getIsScrap()))
                .map(OutsourcingOrderProduct::getId)
                .toList();
        if (lineIds.isEmpty()) {
            return;
        }
        // 出库记录一条对应一行明细;缺记录(被反审删除等)就不完成,与人工完成的校验口径一致
        long approvedCount = listByRecordIds(lineIds, record.getRecordType()).stream()
                .filter(item -> ReviewStatusEnum.APPROVED.getCode().equals(item.getApprovalStatus()))
                .count();
        if (approvedCount < lineIds.size()) {
            return;
        }
        // 条件更新,只有「已确认」能推进;已作废/已完成的订单不动
        outsourcingOrderMapper.update(null, Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, orderId)
                .eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.COMPLETED.getCode()));
    }
    /**
     * 销售类油品出库扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * 所以只从库存补足「出库数量 − 上次剩余油量」的差额。
     * updateSubtractStockInventory 自带 qualitity &gt;= 数量 的守卫,影响行数为 0 即库存不足,
     * 抛错由外层事务回滚
     */
    private void deductSaleOilOutStock(StockOutRecord stockOutRecord) {
        String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
        if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
            throw new BaseException("销售类出库必须选择出库批次" + suffix);
        }
        BigDecimal quantity = stockOutRecord.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecord.getVehicleId(), stockOutRecord.getProductModelId(), stockOutRecord.getId());
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal deduct = quantity.subtract(lastResidual);
        if (deduct.compareTo(BigDecimal.ZERO) > 0) {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
            stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
            stockInventoryDto.setQualitity(deduct);
            if (stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto) == 0) {
                throw new BaseException("库存不足无法出库" + suffix);
            }
        }
        // 车上剩油刚好够(deduct=0)时不涉及库存更新,同样落库便于删除时按实际扣减量归还
        stockOutRecord.setDeductQuantity(deduct);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchReAudit(List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            throw new BaseException("请选择至少一条数据");
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord == null) {
                throw new BaseException("出库记录不存在,无法重新审核!!!");
            }
            if (!ReviewStatusEnum.REJECTED.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有驳回状态的记录才能重新审核,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            stockOutRecord.setApprovalStatus(ReviewStatusEnum.PENDING_REVIEW.getCode());
            stockOutRecordMapper.updateById(stockOutRecord);
        }
        return ids.size();
    }
    @Override
    public int updateResidualOil(Long id, BigDecimal residualOil) {
        StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
        if (stockOutRecord == null) {
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        if (residualOil == null || residualOil.compareTo(BigDecimal.ZERO) < 0) {
            throw new BaseException("剩余油量不能为空且不能小于0");
        }
        if (stockOutRecord.getStockOutNum() != null && residualOil.compareTo(stockOutRecord.getStockOutNum()) > 0) {
            throw new BaseException("剩余油量不能大于出库数量" + toPlain(stockOutRecord.getStockOutNum()));
        }
        StockOutRecord update = new StockOutRecord();
        update.setId(id);
        update.setResidualOil(residualOil);
        return stockOutRecordMapper.updateById(update);
    }
    private StockInventory getStockInventory(Long productModelId, String batchNo) {
@@ -231,4 +412,145 @@
        }
        return stockUninventoryMapper.selectOne(eq);
    }
    /**
     * 车辆回填:前端可以直接传 vehicleId,也可以只传车牌号,
     * 后者按车辆主数据反查回填。两者都拿不到就无法定位「上次剩余油量」,直接报错
     */
    private void resolveVehicle(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getVehicleId() != null) {
            return;
        }
        if (StringUtils.isEmpty(stockOutRecordDto.getTruckPlateNo())) {
            throw new BaseException("销售类出库必须选择车辆");
        }
        LambdaQueryWrapper<Vehicle> wrapper = new LambdaQueryWrapper<Vehicle>()
                .eq(Vehicle::getPlateNumber, stockOutRecordDto.getTruckPlateNo());
        Vehicle vehicle = vehicleService.getOne(wrapper, false);
        if (vehicle == null) {
            throw new BaseException("车牌号 " + stockOutRecordDto.getTruckPlateNo() + " 不在车辆管理中,请先到车辆管理添加");
        }
        stockOutRecordDto.setVehicleId(vehicle.getId());
    }
    /**
     * 出库数量是本次装车总量,必须落在「上次剩余油量 ~ 上次剩余油量 + 库存」区间内
     */
    private void checkSaleOutAvailable(StockOutRecordDto stockOutRecordDto) {
        String suffix = ",出库批次:" + stockOutRecordDto.getOutboundBatches();
        BigDecimal quantity = stockOutRecordDto.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecordDto.getVehicleId(), stockOutRecordDto.getProductModelId(), null);
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal stockQuantity = getStockQuantity(stockOutRecordDto.getProductModelId(), stockOutRecordDto.getBatchNo());
        BigDecimal availableQty = lastResidual.add(stockQuantity);
        if (quantity.compareTo(availableQty) > 0) {
            throw new BaseException("出库数量超出可用量(上次剩余" + toPlain(lastResidual)
                    + ",库存" + toPlain(stockQuantity) + ",可用" + toPlain(availableQty) + ")" + suffix);
        }
    }
    /**
     * 该车该规格上次送完货后剩余的油量,取最近一条填过残油且已审批的记录。
     * excludeId 用于排除「本次要审批的这条自己」——它自己的 residual_oil 是送完之后的值,不能用来算本次扣减
     */
    private BigDecimal getLastResidualOil(Long vehicleId, Long productModelId, Long excludeId) {
        if (vehicleId == null || productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockOutRecord> wrapper = new LambdaQueryWrapper<StockOutRecord>()
                .eq(StockOutRecord::getVehicleId, vehicleId)
                .eq(StockOutRecord::getProductModelId, productModelId)
                .isNotNull(StockOutRecord::getResidualOil)
                .eq(StockOutRecord::getApprovalStatus, ReviewStatusEnum.APPROVED.getCode());
        if (excludeId != null) {
            wrapper.lt(StockOutRecord::getId, excludeId);
        }
        wrapper.orderByDesc(StockOutRecord::getId).last("limit 1");
        StockOutRecord last = stockOutRecordMapper.selectOne(wrapper);
        return last == null || last.getResidualOil() == null ? BigDecimal.ZERO : last.getResidualOil();
    }
    /**
     * 当前库存量,按规格合计;传了批次号再叠加批次条件
     */
    private BigDecimal getStockQuantity(Long productModelId, String batchNo) {
        if (productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                .eq(StockInventory::getProductModelId, productModelId);
        if (!StringUtils.isEmpty(batchNo)) {
            wrapper.eq(StockInventory::getBatchNo, batchNo);
        }
        return stockInventoryMapper.selectList(wrapper).stream()
                .map(StockInventory::getQualitity)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    @Override
    public OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query) {
        if (query == null || query.getVehicleId() == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择车辆和产品规格");
        }
        BigDecimal lastResidualOil = getLastResidualOil(query.getVehicleId(), query.getProductModelId(), query.getId());
        BigDecimal stockQuantity = getStockQuantity(query.getProductModelId(), query.getBatchNo());
        OilOutAvailableDto result = new OilOutAvailableDto();
        result.setLastResidualOil(lastResidualOil);
        result.setStockQuantity(stockQuantity);
        result.setAvailableQty(lastResidualOil.add(stockQuantity));
        return result;
    }
    private String toPlain(BigDecimal value) {
        return value == null ? "0" : value.stripTrailingZeros().toPlainString();
    }
    @Override
    public List<StockOutRecord> listByRecordIds(List<Long> recordIds, String recordType) {
        if (CollectionUtils.isEmpty(recordIds) || StringUtils.isEmpty(recordType)) {
            return Collections.emptyList();
        }
        return stockOutRecordMapper.selectByRecordIds(recordIds, recordType);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int releaseByRecordIds(List<Long> recordIds, String recordType) {
        List<StockOutRecord> records = listByRecordIds(recordIds, recordType);
        if (records.isEmpty()) {
            return 0;
        }
        for (StockOutRecord record : records) {
            if (!ReviewStatusEnum.APPROVED.getCode().equals(record.getApprovalStatus())) {
                continue;
            }
            // type=0 归还合格库存(stock_inventory),type=1 归还不合格库存(stock_uninventory)
            if ("0".equals(record.getType())) {
                StockInventoryDto stockInventoryDto = new StockInventoryDto();
                stockInventoryDto.setProductModelId(record.getProductModelId());
                stockInventoryDto.setBatchNo(record.getBatchNo());
                stockInventoryDto.setQualitity(record.getStockOutNum());
                if (stockInventoryMapper.updateAddStockInventory(stockInventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else if ("1".equals(record.getType())) {
                StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
                stockUninventoryDto.setProductModelId(record.getProductModelId());
                stockUninventoryDto.setBatchNo(record.getBatchNo());
                stockUninventoryDto.setQualitity(record.getStockOutNum());
                if (stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else {
                throw new BaseException("出库记录类型异常,无法归还库存,出库批次:" + record.getOutboundBatches());
            }
        }
        return stockOutRecordMapper.deleteBatchIds(records.stream().map(StockOutRecord::getId).toList());
    }
}