feat(oms,wms,system): OMS下单冻结库存(防超卖)——仓库级库存锁定模式

- 仓库新增stock_lock_mode开关(1-下单冻结 2-分配库存冻结默认),查询/独立切换接口+在途冻结单切换守卫
- WMS新增out_order_freeze_record冻结记录表:下单冻结/编辑原子重锁/取消解冻/分配承接/交接扣减/反审还原全状态机,条件更新防并发超卖
- OMS下单即冻结(失败阻断+落库失败补偿解冻)、已下发禁编辑、cancelIssueOrder补调WMS清理(修存量不同步)、cancelAuditState镜像还原修复
- 达梦SQL脚本3个(WAREHOUSE加列/冻结记录建表/主键列名修复),测试库已执行并全流程实测通过
This commit is contained in:
rcx
2026-09-14 15:41:32 +08:00
parent 888a293ebd
commit b1d92fdda8
31 changed files with 1587 additions and 47 deletions
@@ -440,8 +440,9 @@ public class ExecutionStockOutOrderDomainService {
BigDecimal freeze = freezeAfterQuantity.subtract(freezeBeforeQuantity);
BigDecimal allocationQuantity = reservationMaterialInventory.getAllocationQuantity();
BigDecimal freezeQuantity = reservationMaterialInventory.getFreezeQuantity();
allocationQuantity.add(allocation);
freezeQuantity.subtract(freeze);
// 修复:BigDecimal 不可变,add/subtract 返回新对象必须回赋(原实现未回赋,取消下发的镜像库存还原实际不生效)
allocationQuantity = (allocationQuantity == null ? BigDecimal.ZERO : allocationQuantity).add(allocation);
freezeQuantity = (freezeQuantity == null ? BigDecimal.ZERO : freezeQuantity).subtract(freeze);
reservationMaterialInventory.setAllocationQuantity(allocationQuantity);
reservationMaterialInventory.setFreezeQuantity(freezeQuantity);
reservationMaterialInventory.setUpdateTime(new Date());
@@ -505,7 +506,7 @@ public class ExecutionStockOutOrderDomainService {
checkDetail.setMaterialName(detail.getMaterialName());
checkList.add(checkDetail);
}
AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList);
AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList, byId.getBusinessOutOrderNumber());
if (checkResult == null || !"200".equals(String.valueOf(checkResult.get("code")))) {
validateErrors.add("出库单[" + byId.getOutOrderNumber() + "]库存校验失败:"
+ (checkResult == null ? "WMS服务不可用,已阻止下发" : checkResult.get("msg")));
@@ -527,6 +528,9 @@ public class ExecutionStockOutOrderDomainService {
try {
StockOutOrder stockOutOrder = new StockOutOrder();
BeanUtils.copyProperties(byId, stockOutOrder,"outOrderId");
// 下单冻结模式:WMS单头带上业务单号(字段名不同copyProperties不会复制,必须显式set),
// 供WMS分配承接/下发校验做【单据级】冻结记录过滤(双单冻结同行防误消费)
stockOutOrder.setBusinessOrderNo(byId.getBusinessOutOrderNumber());
Long warehouseId = stockOutOrder.getWarehouseId();
Long topOrgId = stockInOrderMapper.getTopOrgId(warehouseId);
Long orgId = stockInOrderMapper.getOrgId(warehouseId);
@@ -236,22 +236,32 @@ public class ReservationStockOutOrderDomainService {
if (stockOutOrderDO.getReservationOrderSource() == null || stockOutOrderDO.getReservationOrderSource().isEmpty()) {
stockOutOrderDO.setReservationOrderSource("手动创建");
}
stockOutOrderService.insert(stockOutOrderDO);
List<ReservationDeliveryDetailsLink> reservationDeliveryDetailsLinkList = stockOutOrderDO.getReservationDeliveryDetailsLinkList();
if (reservationDeliveryDetailsLinkList != null && !reservationDeliveryDetailsLinkList.isEmpty()){
reservationDeliveryDetailsLinkList.forEach(reservationDeliveryDetailsLink -> {
reservationDeliveryDetailsLink.setOrderNumber(stockOutOrderDO.getOutOrderNumber());
reservationDeliveryDetailsLink.setOrderId(stockOutOrderDO.getOutOrderId());
});
if (!CollectionUtils.isEmpty(reservationDeliveryDetailsLinkList)) {
reservationDeliveryDetailsLinkService.saveBatch(reservationDeliveryDetailsLinkList);
// 下单冻结库存(防超卖):下单冻结模式仓库的明细先冻结WMS库存(可用-、冻结+,库存数量不变),
// 冻结失败阻断下单;本地落库失败补偿解冻(对齐执行并下发的补偿模式)
boolean frozen = tryFreezeOnCreate(stockOutOrderDO);
try {
stockOutOrderService.insert(stockOutOrderDO);
List<ReservationDeliveryDetailsLink> reservationDeliveryDetailsLinkList = stockOutOrderDO.getReservationDeliveryDetailsLinkList();
if (reservationDeliveryDetailsLinkList != null && !reservationDeliveryDetailsLinkList.isEmpty()){
reservationDeliveryDetailsLinkList.forEach(reservationDeliveryDetailsLink -> {
reservationDeliveryDetailsLink.setOrderNumber(stockOutOrderDO.getOutOrderNumber());
reservationDeliveryDetailsLink.setOrderId(stockOutOrderDO.getOutOrderId());
});
if (!CollectionUtils.isEmpty(reservationDeliveryDetailsLinkList)) {
reservationDeliveryDetailsLinkService.saveBatch(reservationDeliveryDetailsLinkList);
}
}
List<ReservationOutMaterialDetailDO> materialDetailList = stockOutOrderDO.getMaterialDetailList();
if (materialDetailList != null && !materialDetailList.isEmpty()) {
return outMaterialDetailService.batchInsert(stockOutOrderDO.getOutOrderNumber(), stockOutOrderDO.getMaterialDetailList());
}
return true;
} catch (Exception e) {
if (frozen) {
compensateUnfreeze(stockOutOrderDO.getOutOrderNumber());
}
throw e;
}
List<ReservationOutMaterialDetailDO> materialDetailList = stockOutOrderDO.getMaterialDetailList();
if (materialDetailList != null && !materialDetailList.isEmpty()) {
return outMaterialDetailService.batchInsert(stockOutOrderDO.getOutOrderNumber(), stockOutOrderDO.getMaterialDetailList());
}
return true;
}
/**
@@ -284,12 +294,186 @@ public class ReservationStockOutOrderDomainService {
if (stockOutOrderDO.getCheckTaskDistribution() == null) {
stockOutOrderDO.setCheckTaskDistribution(2);
}
stockOutOrderService.insert(stockOutOrderDO);
outMaterialDetailService.batchInsert(stockOutOrderDO.getOutOrderNumber(), stockOutOrderDO.getMaterialDetailList());
// 下单冻结库存(防超卖):与 insert 同口径,冻结失败阻断、落库失败补偿解冻
boolean frozen = tryFreezeOnCreate(stockOutOrderDO);
try {
stockOutOrderService.insert(stockOutOrderDO);
outMaterialDetailService.batchInsert(stockOutOrderDO.getOutOrderNumber(), stockOutOrderDO.getMaterialDetailList());
} catch (Exception e) {
if (frozen) {
compensateUnfreeze(stockOutOrderDO.getOutOrderNumber());
}
throw e;
}
}
return true;
}
// ================================ 下单冻结库存(防超卖) ================================
/**
* 下单冻结模式仓库判断:stock_lock_mode=1-下单冻结(2-分配库存冻结为默认现有模式)。
* 复用仓库信息兜底组件(Feign优先+本地跨库兜底);兜底时 stockLockMode=null → 按模式2处理(保守降级不冻结)
*/
private boolean isOrderFreezeWarehouse(Long warehouseId) {
if (warehouseId == null) {
return false;
}
WarehouseFeignPO warehouseFeignPO = getWarehouseInfoWithFallback(warehouseId);
return warehouseFeignPO != null && warehouseFeignPO.getStockLockMode() != null && warehouseFeignPO.getStockLockMode() == 1;
}
/**
* 出库单保存时下单冻结:筛选下单冻结模式仓库的明细,先冻结WMS库存再落库(失败抛异常阻断保存)。
* ★下单冻结模式仓库明细强制必选库存批次:未关联 materialInventoryId 无法确定冻结哪条库存行,直接阻断。
*
* @return true-已冻结(落库失败需补偿解冻);false-无冻结模式明细未冻结
*/
private boolean tryFreezeOnCreate(ReservationStockOutOrderDO stockOutOrderDO) {
List<ReservationOutMaterialDetailDO> details = stockOutOrderDO.getMaterialDetailList();
if (CollectionUtils.isEmpty(details)) {
return false;
}
// 样品出库交接不扣减库存,同样不参与下单冻结
if ("yang_pin_chu_ku".equals(stockOutOrderDO.getBusinessType())) {
return false;
}
List<OutMaterialDetail> freezeDetails = filterFreezeDetails(details, stockOutOrderDO.getWarehouseId(), stockOutOrderDO.getOutOrderNumber());
if (freezeDetails.isEmpty()) {
return false;
}
OmsFreezeInventoryParam param = new OmsFreezeInventoryParam();
param.setOutOrderNumber(stockOutOrderDO.getOutOrderNumber());
param.setDetails(freezeDetails);
fillOperator(param);
AjaxResult result = wmsServiceFeign.omsFreezeInventory(param);
if (result == null || !"200".equals(String.valueOf(result.get("code")))) {
throw new ServiceException("下单冻结库存失败,无法保存出库单:"
+ (result == null ? "WMS服务不可用" : result.get("msg")));
}
return true;
}
/**
* 出库单编辑-原子重锁:已下发的冻结单禁止编辑(判据=活动执行单存在,issueStatus 不可靠——下发不改该字段);
* 未下发则WMS同一事务内释放原冻结+按新明细重锁,成功后才改本地
*/
private void handleFreezeOnUpdate(ReservationStockOutOrderDO stockOutOrderDO, ReservationStockOutOrderPO existingPO) {
List<ReservationOutMaterialDetailDO> details = stockOutOrderDO.getMaterialDetailList();
if (CollectionUtils.isEmpty(details)) {
return;
}
// 样品出库交接不扣减库存,同样不参与下单冻结
if ("yang_pin_chu_ku".equals(stockOutOrderDO.getBusinessType())) {
return;
}
List<OutMaterialDetail> freezeDetails = filterFreezeDetails(details, stockOutOrderDO.getWarehouseId(), existingPO.getOutOrderNumber());
if (freezeDetails.isEmpty()) {
return;
}
String orderNumber = existingPO.getOutOrderNumber();
// 已下发WMS(存在活动执行单)禁止编辑:执行单明细与冻结记录必须保持一致,须先取消下发
List<ExecutionStockOutOrder> activeExecutions = executionStockOutOrderService.list(new QueryWrapper<ExecutionStockOutOrder>().lambda()
.eq(ExecutionStockOutOrder::getBusinessOutOrderNumber, orderNumber)
.eq(ExecutionStockOutOrder::getDelFlag, 1));
if (!CollectionUtils.isEmpty(activeExecutions)) {
throw new ServiceException("出库单[" + orderNumber + "]已下发WMS,不可编辑,请先取消下发");
}
OmsFreezeInventoryParam param = new OmsFreezeInventoryParam();
param.setOutOrderNumber(orderNumber);
param.setDetails(freezeDetails);
fillOperator(param);
AjaxResult result = wmsServiceFeign.omsRelockInventory(param);
if (result == null || !"200".equals(String.valueOf(result.get("code")))) {
throw new ServiceException("编辑重锁库存失败,无法修改出库单:"
+ (result == null ? "WMS服务不可用" : result.get("msg")));
}
}
/**
* 删除/关闭单据前释放冻结(WMS按业务单号幂等处理:无冻结直接成功;
* 冻结已被执行单承接时WMS拒绝并提示先取消下发;已交接时拒绝并提示走反审流程)
*/
private void unfreezeIfFrozen(ReservationStockOutOrderPO stockOutOrderPO) {
OmsFreezeInventoryParam param = new OmsFreezeInventoryParam();
param.setOutOrderNumber(stockOutOrderPO.getOutOrderNumber());
fillOperator(param);
AjaxResult result = wmsServiceFeign.omsUnfreezeInventory(param);
if (result == null || !"200".equals(String.valueOf(result.get("code")))) {
throw new ServiceException("释放冻结库存失败:"
+ (result == null ? "WMS服务不可用" : result.get("msg")));
}
}
/**
* 本地落库失败的补偿解冻(对齐执行并下发的补偿模式):补偿失败仅告警,
* 凭业务单号在WMS冻结记录(out_order_freeze_recordrelate_no 前缀=业务单号)中人工处理
*/
private void compensateUnfreeze(String outOrderNumber) {
try {
OmsFreezeInventoryParam param = new OmsFreezeInventoryParam();
param.setOutOrderNumber(outOrderNumber);
fillOperator(param);
AjaxResult result = wmsServiceFeign.omsUnfreezeInventory(param);
if (result == null || !"200".equals(String.valueOf(result.get("code")))) {
log.error("下单冻结补偿解冻失败,请人工核查WMS冻结记录:出库业务单[{}],返回:{}",
outOrderNumber, result == null ? "WMS服务不可用" : result.get("msg"));
}
} catch (Exception ex) {
log.error("下单冻结补偿解冻异常,请人工核查WMS冻结记录:出库业务单[{}]", outOrderNumber, ex);
}
}
/**
* 筛选下单冻结模式仓库的明细并转换为冻结参数。
* ★下单冻结模式仓库明细强制必选库存批次(R2):materialInventoryId 为空直接阻断保存;
* 数量<=0的明细交由WMS侧兜底校验
*/
private List<OutMaterialDetail> filterFreezeDetails(List<ReservationOutMaterialDetailDO> details, Long defaultWarehouseId, String businessOrderNumber) {
List<OutMaterialDetail> freezeDetails = new ArrayList<>();
for (ReservationOutMaterialDetailDO detail : details) {
Long warehouseId = detail.getWarehouseId() != null ? detail.getWarehouseId() : defaultWarehouseId;
if (!isOrderFreezeWarehouse(warehouseId)) {
continue;
}
// 下单冻结必须知道冻结哪条库存行:模式1仓库明细未关联库存直接阻断
if (detail.getMaterialInventoryId() == null || detail.getMaterialInventoryId() <= 0) {
throw new ServiceException("出库单[" + (businessOrderNumber != null ? businessOrderNumber : "当前单据") + "]物料["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : detail.getMaterialName())
+ "]未选择库存批次,下单冻结模式仓库必须先选择库存批次");
}
OutMaterialDetail out = new OutMaterialDetail();
out.setMaterialInventoryId(detail.getMaterialInventoryId());
out.setMaterialBaseInfoId(detail.getMaterialBaseInfoId());
out.setMaterialCode(detail.getMaterialCode());
out.setMaterialName(detail.getMaterialName());
out.setWarehouseId(warehouseId);
out.setQuantity(detail.getQuantity());
out.setBatchNumber(detail.getBatchNumber());
out.setBatchRefNo(detail.getBatchRefNo());
out.setSheetRefNo(detail.getSheetRefNo());
out.setBoxPalletNo(detail.getBoxPalletNo());
out.setUniqueId(detail.getUniqueId());
freezeDetails.add(out);
}
return freezeDetails;
}
/**
* 填充操作人(Feign调用WMS时兜底传递,WMS侧无登录上下文时使用)
*/
private void fillOperator(OmsFreezeInventoryParam param) {
try {
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null) {
param.setOperatorId(loginUser.getUserid());
param.setOperatorName(loginUser.getUsername());
}
} catch (Exception ignore) {
// 无登录上下文场景(Feign内部调用),操作人留空由WMS兜底
}
}
/**
* 修改出库单
*/
@@ -300,6 +484,9 @@ public class ReservationStockOutOrderDomainService {
}
//设置物料信息
setMaterialDetailInfo(stockOutOrderDO);
// 下单冻结模式编辑重锁(原子):先WMS侧释放原冻结+按新明细重锁(同一事务),成功后才改本地;
// 已下发WMS的冻结单不可编辑,须先取消下发(判据=活动执行单,issueStatus 不可靠)
handleFreezeOnUpdate(stockOutOrderDO, stockInOrderPO);
//统计种类
stockOutOrderDO.setMaterialQuantity(stockOutOrderDO.getMaterialDetailList().size());
//统计总数量
@@ -379,6 +566,8 @@ public class ReservationStockOutOrderDomainService {
if (stockInOrderPO.getStatus() != 1){
throw new ServiceException("只有已创建状态下能允许关闭出库单");
}
// 下单冻结模式:关闭单据不再出库,先释放冻结库存(幂等;冻结已被执行单承接时WMS会拒绝并提示先取消下发)
unfreezeIfFrozen(stockInOrderPO);
stockOutOrderDO.setStatus(11);
return stockOutOrderService.update(stockOutOrderDO);
}
@@ -789,6 +978,18 @@ public class ReservationStockOutOrderDomainService {
List<ExecutionStockOutOrder> list = executionStockOutOrderService.list(new QueryWrapper<ExecutionStockOutOrder>().lambda()
.eq(ExecutionStockOutOrder::getBusinessOutOrderNumber, inOrderNumber)
.eq(ExecutionStockOutOrder::getDelFlag, 1));
// 取消下发必须同步WMS(修复存量不同步问题:此前只软删本地执行单,WMS残留单据与分配占用):
// WMS侧还原库存/承接冻结并软删单据;下单冻结模式下仅解除承接(冻结归还业务单),
// 分配库存冻结模式下还原分配占用;失败抛异常整体回滚本地软删(WMS清理幂等可重试)
for (ExecutionStockOutOrder executionStockOutOrder : list) {
StockOutOrder cancelParam = new StockOutOrder();
cancelParam.setOutOrderNumber(executionStockOutOrder.getOutOrderNumber());
AjaxResult cancelResult = wmsServiceFeign.omsCancelExecutionOrder(cancelParam);
if (cancelResult == null || !"200".equals(String.valueOf(cancelResult.get("code")))) {
throw new ServiceException("取消下发失败:WMS执行单[" + executionStockOutOrder.getOutOrderNumber()
+ "]清理失败:" + (cancelResult == null ? "WMS服务不可用" : cancelResult.get("msg")));
}
}
list.forEach(executionStockOutOrder -> {
String outOrderNumber = executionStockOutOrder.getOutOrderNumber();
List<ExecutionOutMaterialDetail> materialDetails = executionOutMaterialDetailService.list(new QueryWrapper<ExecutionOutMaterialDetail>().lambda()
@@ -992,7 +1193,8 @@ public class ReservationStockOutOrderDomainService {
if (!groupBaseValid) {
return;
}
//WMS库存校验(接口内部会聚合该仓全部明细的需求量后比对可用库存
//WMS库存校验(接口内部会聚合该仓全部明细的需求量后比对可用库存
// 传业务单号:下单冻结模式下行级可用量=库存可用+本单冻结记录量)
List<OutMaterialDetail> checkList = new ArrayList<>();
for (ReservationOutMaterialDetail detail : details) {
OutMaterialDetail checkDetail = new OutMaterialDetail();
@@ -1002,7 +1204,7 @@ public class ReservationStockOutOrderDomainService {
checkDetail.setMaterialName(detail.getMaterialName());
checkList.add(checkDetail);
}
AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList);
AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList, inOrderNumber);
if (checkResult == null || !"200".equals(String.valueOf(checkResult.get("code")))) {
String groupWarehouseName = reservationStockInOrderMapper.getWarehouseName(warehouseId);
issueValidateErrors.add("仓库[" + (groupWarehouseName != null ? groupWarehouseName : warehouseId) + "]库存校验失败:"
@@ -1174,6 +1376,13 @@ public class ReservationStockOutOrderDomainService {
* 批量删除出库单
*/
public Boolean delete(Long[] outOrderIds) {
// 下单冻结模式:删除单据前先释放冻结库存(幂等;冻结已被执行单承接时WMS会拒绝并提示先取消下发)
for (Long outOrderId : outOrderIds) {
ReservationStockOutOrderPO stockOutOrderPO = stockOutOrderService.getInfo(outOrderId);
if (stockOutOrderPO != null) {
unfreezeIfFrozen(stockOutOrderPO);
}
}
return stockOutOrderService.delete(outOrderIds);
}