package com.ruoyi.stock.service.impl;
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;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.EnumUtil;
import com.ruoyi.common.utils.OrderUtils;
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 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;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.dto.StockUninventoryDto;
import com.ruoyi.stock.execl.StockOutRecordExportData;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.mapper.StockUninventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.pojo.StockUninventory;
import com.ruoyi.stock.service.StockOutRecordService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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;
/**
*
* 出库记录表 服务实现类
*
*
* @author 芯导软件(江苏)有限公司
* @since 2026-01-21 05:27:04
*/
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl implements StockOutRecordService {
/**
* 出库类别:销售。唯一的区别是销售类出库必须选车辆、按车辆算上次剩余油量;
* 扣库存、进发货台账这些对所有出库类别一视同仁
*/
private static final String OUT_CATEGORY_SALE = "销售";
/**
* 出库类别:代储。客户把自己的油寄存在我们的罐里。
* 出库要求与销售一致(选批次、审批扣库存、删除归还、进发货台账),只是不要求选车辆
*/
private static final String OUT_CATEGORY_DEPOSIT = "代储";
/**
* 销售台账的销售类型:代储(卖存储位置)。这类台账没有产品明细,只有油库+储罐,见 SalesLedger.ledgerType
*/
private static final String LEDGER_TYPE_DEPOSIT = "代储";
/**
* 销售台账产品明细的行类型:销售。见 sales_ledger_product.type
*/
private static final Integer LEDGER_PRODUCT_TYPE_SALE = 1;
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;
private final SalesLedgerMapper salesLedgerMapper;
private final SalesLedgerProductMapper salesLedgerProductMapper;
private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
@Override
public IPage listPage(Page page, StockOutRecordDto stockOutRecordDto) {
return stockOutRecordMapper.listPage(page, stockOutRecordDto);
}
@Override
public int add(StockOutRecordDto stockOutRecordDto) {
List 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);
}
int rows = stockOutRecordMapper.insert(stockOutRecordDto);
// 出库单 id 由自增主键回填,拿到后才能落绑定行
saveLedgerBindings(stockOutRecordDto.getId(), ledgerBindings);
return rows;
}
@Override
public int update(Long id, StockOutRecordDto stockOutRecordDto) {
// 判断对象是否存在
StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
if (stockOutRecord == null){
throw new BaseException("该出库记录不存在,无法更新!!!");
}
List ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
// 排除 id 属性;recordType/recordId 由后端维护(油品出库恒为 '1' / 0,手工发货单出库由发货流程写入),
// 前端编辑表单不回传会把它们冲成 null,破坏出库记录的来源标识
String[] ignoreProperties = {"id", "outbound_batches", "recordType", "recordId"};
BeanUtils.copyProperties(stockOutRecordDto, stockOutRecord, ignoreProperties);
int rows = stockOutRecordMapper.updateById(stockOutRecord);
rebindLedger(id, ledgerBindings);
return rows;
}
/**
* 绑定销售台账:一次出库可以分送多个客户,所以台账是个列表,每个台账手工填本次分摊到的数量,
* 各台账数量之和必须等于本次出库总量。客户一律以台账为准覆盖,出库类别按台账的销售类型自动定,
* 免得出现「绑了代储台账却按销售要求选车辆」这类矛盾数据。
*
* - 卖油(含 ledgerType 为空):出库类别置「销售」,逐个校验所选规格确实在该台账的销售明细里,
* 并把命中的明细 id 落进绑定行(同一台账同规格多行时固定取 id 最小的那条)
* - 代储:出库类别置「代储」,带入台账的油库/储罐,跳过「规格是否在台账明细里」的校验 ——
* 代储台账本身就是卖存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选。
* 代储不能与其他台账混绑,也不产绑定行(它的油是客户自己的,不走到货)
*
*
* @return 待落库的绑定行;null 表示本次请求没带台账信息,调用方应保持已有绑定不变
*/
private List resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
List 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;
}
if (ledgerList.isEmpty()) {
// 明确的「不绑台账」
stockOutRecordDto.setSalesLedgerId(null);
return ledgerList;
}
List 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("绑定销售台账时必须选择产品规格,销售合同号:" + first.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 details = salesLedgerProductMapper.selectList(
Wrappers.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 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 bindings) {
if (bindings == null) {
return;
}
List existing = stockOutRecordSalesLedgerMapper.selectList(
Wrappers.lambdaQuery()
.eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
if (sameBinding(existing, bindings)) {
return;
}
if (salesLedgerArrivalMapper.countByStockOutRecordId(stockOutRecordId) > 0) {
throw new BaseException("该出库单已登记到货记录,无法修改绑定的销售台账,出库单id:" + stockOutRecordId);
}
stockOutRecordSalesLedgerMapper.delete(Wrappers.lambdaQuery()
.eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
saveLedgerBindings(stockOutRecordId, bindings);
}
/**
* 台账与分摊数量是否完全一致(顺序无关)
*/
private boolean sameBinding(List existing, List incoming) {
if (existing.size() != incoming.size()) {
return false;
}
Map 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
@Transactional(rollbackFor = Exception.class)
public int batchDelete(List ids) {
//todo 如果出库与开票收款有关联则无法删除
if (accountSalesCollectionMapper.existsByStockOutRecordId(ids) ||
accountInvoiceApplicationMapper.existsByStockOutRecordId(ids)) {
throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
}
// 油品出库的开票/收款关联存的是绑定行 id(与出库单 id 是两个独立 id 空间),必须单独判,
// 否则绑了台账的油品出库被开票后还能删掉,留下指向不存在绑定行的关联数据
if (!CollectionUtils.isEmpty(ids)) {
List bindingIds = stockOutRecordSalesLedgerMapper.selectList(
Wrappers.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 (salesLedgerArrivalMapper.countByStockOutRecordId(id) > 0) {
throw new BaseException("出库单已登记到货记录,无法删除,出库批次:" + stockOutRecord.getOutboundBatches());
}
// 绑定行是出库单的附属数据,一并清掉,不留指向已删出库单的孤儿行
stockOutRecordSalesLedgerMapper.delete(Wrappers.lambdaQuery()
.eq(StockOutRecordSalesLedger::getStockOutRecordId, id));
// type 对油品出库是 null,常量写在前面避免 NPE
if ("0".equals(stockOutRecord.getType())) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(StockInventory::getProductModelId, stockOutRecord.getProductModelId());
if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
wrapper.isNull(StockInventory::getBatchNo);
} else {
wrapper.eq(StockInventory::getBatchNo, stockOutRecord.getBatchNo());
}
StockInventory stockInventory = stockInventoryMapper.selectOne(wrapper);
if (stockInventory == null) {
throw new BaseException("库存记录中没有对应的产品,无法删除!!!");
}else {
StockInventoryDto stockInRecordDto = new StockInventoryDto();
stockInRecordDto.setProductModelId(stockInventory.getProductModelId());
stockInRecordDto.setQualitity(stockOutRecord.getStockOutNum());
stockInRecordDto.setBatchNo(stockInventory.getBatchNo());
stockInventoryMapper.updateAddStockInventory(stockInRecordDto);
}
}
else if ("1".equals(stockOutRecord.getType())) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(StockUninventory::getProductModelId, stockOutRecord.getProductModelId());
if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
wrapper.isNull(StockUninventory::getBatchNo);
} else {
wrapper.eq(StockUninventory::getBatchNo, stockOutRecord.getBatchNo());
}
StockUninventory stockUninventory = stockUninventoryMapper.selectOne(wrapper);
if (stockUninventory == null) {
throw new BaseException("库存记录中没有对应的产品,无法删除!!!");
}else {
StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
stockUninventoryDto.setProductModelId(stockUninventory.getProductModelId());
stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
stockUninventoryDto.setBatchNo(stockUninventory.getBatchNo());
stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
}
}
else {
// 油品出库只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
returnOilOutStock(stockOutRecord);
}
}
return stockOutRecordMapper.deleteBatchIds(ids);
}
/**
* 油品出库归还库存,仅对审批通过(即扣过库存)的记录生效。
* 归还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
*/
private void returnOilOutStock(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
public void exportStockOutRecord(HttpServletResponse response, StockOutRecordDto stockOutRecordDto) {
List list = stockOutRecordMapper.listStockOutRecordExportData(stockOutRecordDto);
for (StockOutRecordExportData stockInRecordExportData : list) {
if (stockInRecordExportData.getType().equals("0")) {
stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockOutQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
}else {
stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockInQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
}
}
ExcelUtil util = new ExcelUtil<>(StockOutRecordExportData.class);
util.exportExcel(response,list, "出库记录信息");
}
@Override
@Transactional(rollbackFor = Exception.class)
public int batchDeletePending(List ids) {
for (Long id : ids) {
StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
if (stockOutRecord == null) {
throw new BaseException("出库记录不存在,无法删除!!!");
}
if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
throw new BaseException("只有待审批状态的记录才能删除,出库批次:" + stockOutRecord.getOutboundBatches());
}
}
return stockOutRecordMapper.deleteBatchIds(ids);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int batchApprove(List ids, Integer approvalStatus) {
if (CollectionUtils.isEmpty(ids)) {
throw new BaseException("请选择至少一条数据");
}
if (approvalStatus == null || (!ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus) && !ReviewStatusEnum.REJECTED.getCode().equals(approvalStatus))) {
throw new BaseException("审批状态值无效");
}
for (Long id : ids) {
StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
if (stockOutRecord == null) {
throw new BaseException("出库记录不存在,无法审批!!!");
}
if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
throw new BaseException("只有待审批状态的记录才能审批,出库批次:" + stockOutRecord.getOutboundBatches());
}
// 审批通过时先扣减库存,扣减量(deduct_quantity)要随审批状态一起落库,所以放在更新之前
if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)) {
if ("0".equals(stockOutRecord.getType())) {
// 合格出库 -> 先查库存是否存在,存在才扣减
StockInventory stockInventory = getStockInventory(stockOutRecord.getProductModelId(), stockOutRecord.getBatchNo());
if (stockInventory == null) {
throw new BaseException("合格库存记录不存在,出库批次:" + stockOutRecord.getOutboundBatches());
}
StockInventoryDto stockInventoryDto = new StockInventoryDto();
stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
stockInventoryDto.setQualitity(stockOutRecord.getStockOutNum());
stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
} else if ("1".equals(stockOutRecord.getType())) {
// 不合格出库 -> 先查库存是否存在,存在才扣减
StockUninventory stockUninventory = getStockUninventory(stockOutRecord.getProductModelId(), stockOutRecord.getBatchNo());
if (stockUninventory == null) {
throw new BaseException("不合格库存记录不存在,出库批次:" + stockOutRecord.getOutboundBatches());
}
StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
stockUninventoryDto.setProductModelId(stockOutRecord.getProductModelId());
stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
} else {
// 油品出库(type 为空)审批通过时按批次扣减库存。
// 不再生成发货单,也不改 record_type:油品出库的 record_type 一直是「1」,
// 改成「13」会让出库管理列表按出库类型筛选时看不到它。判断「是不是销售类油品出库」
// 一律以 binding 表(stock_out_record_sales_ledger)为准
deductOilOutStock(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();
}
/**
* 委外报废品出库审批通过后,若该委外订单的报废品选料行已全部出库审批通过,
* 自动把订单从「已确认」推到「已完成」,省掉人工再点一次完成。
*
* 直接用 mapper 而不是 {@code IOutsourcingOrderService#complete}:后者依赖本服务,
* 两边都走构造器注入会形成循环依赖。驳回/反审不回退订单状态(处理错误审批走人工重新审批)。
*/
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 lineIds = outsourcingOrderProductMapper.selectList(
Wrappers.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.lambdaUpdate()
.eq(OutsourcingOrder::getId, orderId)
.eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
.set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.COMPLETED.getCode()));
}
/**
* 油品出库扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
* 所以只从库存补足「出库数量 − 上次剩余油量」的差额;代储/客存没选车辆,
* 上次剩余油量按 0 算,即全额扣减。
* updateSubtractStockInventory 自带 qualitity >= 数量 的守卫,影响行数为 0 即库存不足,
* 抛错由外层事务回滚
*/
private void deductOilOutStock(StockOutRecord stockOutRecord) {
String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
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);
}
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 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) {
LambdaQueryWrapper eq = new LambdaQueryWrapper<>();
eq.eq(StockInventory::getProductModelId, productModelId);
if (StringUtils.isEmpty(batchNo)) {
eq.isNull(StockInventory::getBatchNo);
} else {
eq.eq(StockInventory::getBatchNo, batchNo);
}
return stockInventoryMapper.selectOne(eq);
}
private StockUninventory getStockUninventory(Long productModelId, String batchNo) {
LambdaQueryWrapper eq = new LambdaQueryWrapper<>();
eq.eq(StockUninventory::getProductModelId, productModelId);
if (StringUtils.isEmpty(batchNo)) {
eq.isNull(StockUninventory::getBatchNo);
} else {
eq.eq(StockUninventory::getBatchNo, batchNo);
}
return stockUninventoryMapper.selectOne(eq);
}
/**
* 车辆回填:前端可以直接传 vehicleId,也可以只传车牌号,
* 后者按车辆主数据反查回填。两者都拿不到就无法定位「上次剩余油量」,直接报错
*/
private void resolveVehicle(StockOutRecordDto stockOutRecordDto) {
if (stockOutRecordDto.getVehicleId() != null) {
return;
}
if (StringUtils.isEmpty(stockOutRecordDto.getTruckPlateNo())) {
throw new BaseException("销售类出库必须选择车辆");
}
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(Vehicle::getPlateNumber, stockOutRecordDto.getTruckPlateNo());
Vehicle vehicle = vehicleService.getOne(wrapper, false);
if (vehicle == null) {
throw new BaseException("车牌号 " + stockOutRecordDto.getTruckPlateNo() + " 不在车辆管理中,请先到车辆管理添加");
}
stockOutRecordDto.setVehicleId(vehicle.getId());
}
/**
* 出库数量是本次装车总量,必须落在「上次剩余油量 ~ 上次剩余油量 + 库存」区间内。
* 代储/客存不选车辆,上次剩余油量按 0 算,退化成「不超过当前库存」
*/
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);
}
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 wrapper = new LambdaQueryWrapper()
.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 wrapper = new LambdaQueryWrapper()
.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.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();
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 listByRecordIds(List recordIds, String recordType) {
if (CollectionUtils.isEmpty(recordIds) || StringUtils.isEmpty(recordType)) {
return Collections.emptyList();
}
return stockOutRecordMapper.selectByRecordIds(recordIds, recordType);
}
@Override
public IPage listBindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto) {
// 该查询用 /*data_scope*/ 跳过数据权限,而 MP 默认会把 count 语句优化成「SELECT COUNT(*) FROM sales_ledger
// WHERE ...」,优化后标记丢失,count 反而被追加权限条件,total 会比实际记录少。关掉 count 优化后
// count 退化成「SELECT COUNT(*) FROM (原句) TOTAL」,标记原样保留
page.setOptimizeCountSql(false);
return stockOutRecordMapper.bindableSalesLedgerPage(page, salesLedgerDto);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int releaseByRecordIds(List recordIds, String recordType) {
List 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());
}
}