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
@@ -21,7 +21,7 @@ import java.util.Map;
* @description: TODO
* @date 2024/5/10 8:58
**/
@FeignClient(contextId = "remoteWmsServiceFeign",value = ServiceNameConstants.WMS_SERVICE,url = "http://10.33.0.109:8016",fallbackFactory = RemoteWmsFallbackFactory.class, configuration = FeignAutoConfiguration.class)
@FeignClient(contextId = "remoteWmsServiceFeign",value = ServiceNameConstants.WMS_SERVICE,fallbackFactory = RemoteWmsFallbackFactory.class, configuration = FeignAutoConfiguration.class)
public interface WmsServiceFeign {
/**
@@ -112,11 +112,46 @@ public interface WmsServiceFeign {
/**
* oms下发前库存校验:可用库存不足时返回错误,OMS据此在推送前拦截下发,
* 避免单据先落库、分配才失败而在WMS残留"已审核"状态的垃圾单
* 避免单据先落库、分配才失败而在WMS残留"已审核"状态的垃圾单
* businessOrderNumber(可空):OMS出库业务单号,下单冻结模式下校验可用量=库存可用+本单冻结记录量
*/
@ApiOperation("oms下发前库存校验")
@PostMapping(value = "/stockOutOrderApi/checkOmsAssignInventory")
public AjaxResult checkOmsAssignInventory(@RequestBody List<OutMaterialDetail> outMaterialDetails);
public AjaxResult checkOmsAssignInventory(@RequestBody List<OutMaterialDetail> outMaterialDetails,
@RequestParam(value = "businessOrderNumber", required = false) String businessOrderNumber);
/**
* oms出库业务单下单冻结库存(防超卖):WMS按明细冻结库存(可用-、冻结+,库存数量不变),
* 条件更新防并发超卖,任一行可用不足整体失败并返回错误;同业务单号幂等。
* 冻结失败必须阻断OMS下单(返回非200由OMS整体回滚)。
*/
@ApiOperation("oms出库业务单下单冻结库存")
@PostMapping(value = "/stockOutOrderApi/omsFreezeInventory")
public AjaxResult omsFreezeInventory(@RequestBody OmsFreezeInventoryParam param);
/**
* oms出库业务单取消/删除-释放冻结库存:可用+、冻结-(库存数量不变);按业务单号幂等;
* 冻结已被执行单承接时拒绝(须先取消下发)
*/
@ApiOperation("oms出库业务单释放冻结库存")
@PostMapping(value = "/stockOutOrderApi/omsUnfreezeInventory")
public AjaxResult omsUnfreezeInventory(@RequestBody OmsFreezeInventoryParam param);
/**
* oms出库业务单编辑-原子重锁:WMS同一事务内先释放原冻结再按新明细重新冻结,
* 任一新明细可用不足整体回滚(旧冻结同样还原),满足"先释放再重新锁定"原子性要求
*/
@ApiOperation("oms出库业务单编辑重锁库存")
@PostMapping(value = "/stockOutOrderApi/omsRelockInventory")
public AjaxResult omsRelockInventory(@RequestBody OmsFreezeInventoryParam param);
/**
* 仓库是否存在活跃冻结记录(status in 1-业务单冻结 2-执行单承接):
* 仓库"库存锁定模式"(stock_lock_mode)切换守卫,存在在途冻结单时禁止切换
*/
@ApiOperation("仓库是否存在活跃冻结记录")
@GetMapping(value = "/stockOutOrderApi/hasActiveFreezeByWarehouse/{warehouseId}")
public AjaxResult hasActiveFreezeByWarehouse(@PathVariable("warehouseId") Long warehouseId);
/**
* oms下发单明细保存(返回执行结果,明细保存含自动审核链路,失败时OMS据此回滚)
@@ -0,0 +1,42 @@
package com.mhd.system.api.domain;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* OMS出库业务单冻结库存请求参数
*
* @description OMS下单冻结库存模式(防超卖):OMS出库业务单「下单」即冻结WMS库存
* (可用-、冻结+,库存数量不变,库存数量=可用数量+冻结数量);
* 「编辑」原子释放旧冻结+按新明细重锁;「取消」释放冻结。
* 冻结/解冻以业务单号为幂等键。
* @date 2026-09-12
*/
@Data
public class OmsFreezeInventoryParam implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("OMS出库业务单号(幂等键)")
private String outOrderNumber;
@ApiModelProperty("执行出库单号(预留,冻结/解冻/重锁时不使用)")
private String executionOrderNumber;
@ApiModelProperty("库存行ID集合(预留)")
private List<Long> materialInventoryIds;
@ApiModelProperty("出库明细列表(须携带 materialInventoryId 定位库存行)")
private List<OutMaterialDetail> details;
@ApiModelProperty("操作人ID(Feign调用可能无登录上下文时的兜底)")
private Long operatorId;
@ApiModelProperty("操作人姓名(Feign调用可能无登录上下文时的兜底)")
private String operatorName;
@ApiModelProperty("备注")
private String remark;
}
@@ -124,6 +124,10 @@ public class WarehouseFeignPO extends BaseVOEntity{
@Excel(name = "作业模式")
private String workMode;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
@Excel(name = "库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
@ApiModelProperty("订单号生成规则(0-日期累加 1-历史累加)")
@Excel(name = "单号生成规则")
private String orderNoGenerateRule;
@@ -102,11 +102,35 @@ public class RemoteWmsFallbackFactory implements FallbackFactory<WmsServiceFeign
}
@Override
public AjaxResult checkOmsAssignInventory(List<OutMaterialDetail> outMaterialDetails) {
public AjaxResult checkOmsAssignInventory(List<OutMaterialDetail> outMaterialDetails, String businessOrderNumber) {
//校验接口本身不可用时返回错误,OMS会阻止下发(不能在WMS状态未知时盲目推送)
return AjaxResult.error("WMS库存校验服务不可用:" + cause.getMessage());
}
@Override
public AjaxResult omsFreezeInventory(OmsFreezeInventoryParam param) {
//返回错误而非静默成功:下单冻结失败必须阻断OMS下单落库(冻结成功与否状态未知时不允许继续)
return AjaxResult.error("WMS下单冻结库存失败(服务降级):" + cause.getMessage());
}
@Override
public AjaxResult omsUnfreezeInventory(OmsFreezeInventoryParam param) {
//返回错误而非静默成功:解冻失败让OMS取消/删除感知(解冻可重试,WMS解冻幂等)
return AjaxResult.error("WMS释放冻结库存失败(服务降级):" + cause.getMessage());
}
@Override
public AjaxResult omsRelockInventory(OmsFreezeInventoryParam param) {
//返回错误而非静默成功:编辑重锁是原子操作,失败必须让OMS编辑整体回滚
return AjaxResult.error("WMS编辑重锁库存失败(服务降级):" + cause.getMessage());
}
@Override
public AjaxResult hasActiveFreezeByWarehouse(Long warehouseId) {
//返回错误(fail-closed):模式切换守卫校验不可用时拒绝切换,避免在途冻结单语义错乱
return AjaxResult.error("WMS冻结记录校验服务不可用:" + cause.getMessage());
}
@Override
public AjaxResult omsInOrderAddDetail(List<InMaterialDetail> inMaterialDetails) {
//返回错误而非静默成功:明细保存(含自动审核)失败必须让OMS感知并回滚下发标记
@@ -35,4 +35,7 @@ public class WarehousePO implements Serializable {
@Excel(name = "是否启用: 1-否 2-是")
private Integer isEnable;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
}
@@ -12,7 +12,9 @@ import com.mhd.basic.domain.warehouse.repository.todo.WarehouseDO;
import com.mhd.basic.domain.warehouse.service.WarehouseDomainService;
import com.mhd.common.core.enums.DictCode;
import com.mhd.common.core.exception.ServiceException;
import com.mhd.common.core.web.domain.AjaxResult;
import com.mhd.common.security.utils.SecurityUtils;
import com.mhd.system.api.WmsServiceFeign;
import com.mhd.system.api.domain.SysDictData;
import com.mhd.system.api.model.LoginUser;
import com.mhd.system.service.ISysDictTypeService;
@@ -43,6 +45,8 @@ public class WarehouseApplicationService {
private ISysDictTypeService dictTypeService;
@Autowired
private IAssociationWarehouseService associationWarehouseService;
@Autowired
private WmsServiceFeign wmsServiceFeign;
/**
@@ -239,12 +243,78 @@ public class WarehouseApplicationService {
/**
* 修改仓库
*/
/**
* @description 查询仓库库存锁定模式(OMS下单冻结库存/防超卖功能的仓库级开关)
* 1-下单冻结:OMS出库业务单「下单」即冻结WMS库存(可用-、冻结+,库存数量不变)
* 2-分配库存冻结:现有模式,WMS「分配库存」时才冻结
* 仓库不存在或未配置(null)时返回默认值 2(与代码侧降级口径一致)
* @author gen
* @date 2026-09-12
*/
public Integer getStockLockMode(Long warehouseId) {
WarehousePO warehousePO = warehouseDomainService.getInfo(warehouseId);
return warehousePO == null ? 2
: cn.hutool.core.util.ObjectUtil.defaultIfNull(warehousePO.getStockLockMode(), 2);
}
public Boolean update(WarehouseDO warehouseDO) {
// 库存锁定模式切换守卫:模式发生变化时,存在在途冻结单(OMS下单冻结未释放)则禁止切换,
// 否则在途单的解冻/承接逻辑会按新模式错乱。fail-closed:校验服务不可用时拒绝切换
if (warehouseDO.getWarehouseId() != null && warehouseDO.getStockLockMode() != null) {
WarehousePO existingPO = warehouseDomainService.getInfo(warehouseDO.getWarehouseId());
if (existingPO != null) {
checkStockLockModeSwitchable(warehouseDO.getWarehouseId(), warehouseDO.getStockLockMode(), existingPO);
}
}
//设置数据字典键值
setDataDict(warehouseDO);
return warehouseDomainService.update(warehouseDO);
}
/**
* @description 单独切换仓库库存锁定模式(不改动仓库其他信息)
* 1-下单冻结:OMS出库业务单「下单」即冻结WMS库存
* 2-分配库存冻结(默认):WMS「分配库存」时才冻结
* 切换守卫与通用编辑一致:模式变化且存在在途冻结单时禁止切换,fail-closed
* @author gen
* @date 2026-09-14
*/
public Boolean switchStockLockMode(Long warehouseId, Integer stockLockMode) {
if (warehouseId == null) {
throw new ServiceException("仓库ID不能为空");
}
if (stockLockMode == null || (stockLockMode.intValue() != 1 && stockLockMode.intValue() != 2)) {
throw new ServiceException("库存锁定模式不合法,仅支持 1-下单冻结 2-分配库存冻结");
}
WarehousePO existingPO = warehouseDomainService.getInfo(warehouseId);
if (existingPO == null) {
throw new ServiceException("仓库不存在");
}
checkStockLockModeSwitchable(warehouseId, stockLockMode, existingPO);
return warehouseDomainService.switchStockLockMode(warehouseId, stockLockMode);
}
/**
* @description 库存锁定模式切换守卫:模式未变化直接放行;模式变化时查询WMS是否存在
* 在途下单冻结单(freeze_status 1/2),存在则禁止切换。
* fail-closed:WMS服务不可用/返回异常时一律拒绝切换,防止切换后冻结语义错乱
* @author gen
* @date 2026-09-14
*/
private void checkStockLockModeSwitchable(Long warehouseId, Integer newMode, WarehousePO existingPO) {
if (newMode.compareTo(cn.hutool.core.util.ObjectUtil.defaultIfNull(existingPO.getStockLockMode(), 2)) == 0) {
return;
}
AjaxResult freezeCheck = wmsServiceFeign.hasActiveFreezeByWarehouse(warehouseId);
if (freezeCheck == null || !"200".equals(String.valueOf(freezeCheck.get("code")))) {
throw new ServiceException("库存锁定模式切换校验失败,请稍后重试:"
+ (freezeCheck == null ? "WMS服务不可用" : freezeCheck.get("msg")));
}
if (Boolean.TRUE.equals(freezeCheck.get("data"))) {
throw new ServiceException("该仓库存在下单冻结中的出库单,不可切换库存锁定模式");
}
}
/**
* @description 设置数据字典键值
* @author ZhouGY
@@ -130,6 +130,10 @@ public class Warehouse extends BaseVOEntity{
@Excel(name = "作业模式")
private String workMode;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
@Excel(name = "库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
@ApiModelProperty("订单号生成规则0日期累加1历史累加")
@Excel(name = "单号生成规则")
private String orderNoGenerateRule;
@@ -132,6 +132,10 @@ public class WarehousePO extends BaseVOEntity{
@Excel(name = "作业模式")
private String workMode;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
@Excel(name = "库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
@ApiModelProperty("订单号生成规则")
@Excel(name = "单号生成规则")
@@ -140,6 +140,10 @@ public class WarehouseDO extends BaseVOEntity{
@Excel(name = "作业模式")
private String workMode;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
@Excel(name = "库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
@ApiModelProperty("订单号生成规则")
@Excel(name = "单号生成规则")
@@ -142,6 +142,29 @@ public class WarehouseDomainService {
.eq(Warehouse::getDelFlag, 1));
}
/**
* @description 单独切换仓库库存锁定模式(仅更新 STOCK_LOCK_MODE 与审计字段,不触碰仓库其他数据)
* 1-下单冻结:OMS出库业务单「下单」即冻结WMS库存
* 2-分配库存冻结(默认):WMS「分配库存」时才冻结
* @author gen
* @date 2026-09-14
*/
public Boolean switchStockLockMode(Long warehouseId, Integer stockLockMode) {
Long updateBy = null;
try {
LoginUser loginUser = SecurityUtils.getLoginUser();
updateBy = loginUser == null ? null : loginUser.getUserid();
} catch (Exception ignore) {
}
Long finalUpdateBy = updateBy;
return warehouseService.update(new UpdateWrapper<Warehouse>().lambda()
.set(Warehouse::getStockLockMode, stockLockMode)
.set(finalUpdateBy != null, Warehouse::getUpdateBy, finalUpdateBy)
.set(Warehouse::getUpdateTime, new Date())
.eq(Warehouse::getWarehouseId, warehouseId)
.eq(Warehouse::getDelFlag, 1));
}
/**
* @description 获取指定仓库下的所有库区及库区下所有库位
* @author ZhouGY
@@ -145,6 +145,10 @@ public class WarehouseDTO extends BaseVOEntity{
@Excel(name = "作业模式")
private String workMode;
@ApiModelProperty("库存锁定模式: 1-下单冻结 2-分配库存冻结")
@Excel(name = "库存锁定模式: 1-下单冻结 2-分配库存冻结")
private Integer stockLockMode;
@ApiModelProperty("订单号生成规则")
@Excel(name = "单号生成规则")
@@ -104,6 +104,33 @@ public class WarehouseApi extends BaseController{
return AjaxResult.success(warehouseApplicationService.getInfo(warehouseId));
}
/**
* 查询仓库库存锁定模式(OMS下单冻结库存/防超卖功能的仓库级开关):
* 1-下单冻结(OMS出库业务单「下单」即冻结WMS库存:可用-、冻结+,库存数量不变;
* 分配不动数量;编辑=原子重锁;取消=释放;交接=库存-冻结-)
* 2-分配库存冻结(默认,现有行为:WMS「分配库存」时才 可用-冻结+)
* 仓库不存在或未配置时返回 2(与 OMS/WMS 代码侧降级口径一致)
*/
@ApiOperation("查询仓库库存锁定模式: 1-下单冻结 2-分配库存冻结")
@GetMapping(value = "/getStockLockMode/{warehouseId}")
public AjaxResult getStockLockMode(@PathVariable("warehouseId") Long warehouseId)
{
return AjaxResult.success(warehouseApplicationService.getStockLockMode(warehouseId));
}
/**
* 单独切换仓库库存锁定模式(不改动仓库其他信息):
* 模式变化且该仓库存在在途下单冻结出库单(freeze_status 1/2)时拒绝切换;
* WMS校验服务不可用时同样拒绝(fail-closed
*/
@ApiOperation("切换仓库库存锁定模式: 1-下单冻结 2-分配库存冻结")
@PostMapping(value = "/switchStockLockMode/{warehouseId}/{stockLockMode}")
public AjaxResult switchStockLockMode(@PathVariable("warehouseId") Long warehouseId,
@PathVariable("stockLockMode") Integer stockLockMode)
{
return toAjax(warehouseApplicationService.switchStockLockMode(warehouseId, stockLockMode));
}
@ApiOperation("批量作废仓库")
@GetMapping("/nullifyByIds/{warehouseIds}")
@@ -43,6 +43,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="orderNoGenerateRule" column="ORDER_NO_GENERATE_RULE" />
<result property="inOrderNoPrefix" column="IN_ORDER_NO_PREFIX" />
<result property="outOrderNoPrefix" column="OUT_ORDER_NO_PREFIX" />
<result property="stockLockMode" column="STOCK_LOCK_MODE" />
</resultMap>
@@ -51,7 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
warehouse_type, warehouse_type_name, warehouse_structure, warehouse_nature, warm_layer_code, warm_layer_name,
director_name, director_phone, region_code, region_name, detail_address, longitude, latitude, building_region,
usage_region, remark, opening_up, nullify, create_time, create_by, create_by_name, update_time, update_by,
update_by_name, del_flag, IS_ENABLE ,WORK_MODE,TASK_MODE,SAMPLE_OUT_ORDER_NO_PREFIX,ORDER_NO_GENERATE_RULE,IN_ORDER_NO_PREFIX,
update_by_name, del_flag, IS_ENABLE ,WORK_MODE,TASK_MODE,STOCK_LOCK_MODE,SAMPLE_OUT_ORDER_NO_PREFIX,ORDER_NO_GENERATE_RULE,IN_ORDER_NO_PREFIX,
OUT_ORDER_NO_PREFIX
from warehouse
</sql>
@@ -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);
}
@@ -66,6 +66,7 @@ import com.mhd.wms.domain.stockOutOrder.repository.mapper.StockOutOrderMapper;
import com.mhd.wms.domain.stockOutOrder.repository.po.StockOutOrderPO;
import com.mhd.wms.domain.stockOutOrder.repository.todo.StockOutOrderDO;
import com.mhd.wms.domain.stockOutOrder.service.StockOutOrderDomainService;
import com.mhd.wms.domain.outOrderFreezeRecord.service.OutOrderFreezeRecordDomainService;
import com.mhd.wms.interfaces.dto.stockOutOrder.StockOutOrderDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -99,6 +100,8 @@ public class StockOutOrderApplicationService {
@Autowired
private StockOutOrderDomainService stockOutOrderDomainService;
@Autowired
private OutOrderFreezeRecordDomainService outOrderFreezeRecordDomainService;
@Autowired
private OutMaterialDetailDomainService outMaterialDetailDomainService;
@Autowired
private MaterialInventoryDomainService materialInventoryDomainService;
@@ -1062,6 +1065,13 @@ public class StockOutOrderApplicationService {
}
// 当前物料的可用库存
BigDecimal availableStock = materialInventory.getAllocationQuantity();
// 下单冻结模式:加回本单该行的冻结记录量(下单时已冻结导致可用=0,冻结行会被跳过
// 导致分配不到量;承接时数量不动,此处仅校验/选行口径)
BigDecimal orderFrozen = outOrderFreezeRecordDomainService.sumOrderFreezeQuantity(
stockOutOrderDb.getBusinessOrderNo(), materialInventory.getMaterialInventoryId());
if (orderFrozen.compareTo(BigDecimal.ZERO) > 0) {
availableStock = availableStock.add(orderFrozen);
}
// 如果当前物料没有库存,跳过
if (availableStock == null || availableStock.compareTo(BigDecimal.ZERO) <= 0) {
continue;
@@ -1149,14 +1159,23 @@ public class StockOutOrderApplicationService {
// 当前物料的可用库存
BigDecimal availableStock = materialInventory.getAllocationQuantity();
// 下单冻结模式:加回本单该行的冻结记录量(下单时已冻结导致可用=0,纯可用口径会把冻结明细
// 跳过/报错,永远到不了 batchAssignUpdate 的承接分支;承接时数量不动,此处仅校验口径)
BigDecimal orderFrozen = outOrderFreezeRecordDomainService.sumOrderFreezeQuantity(
stockOutOrderDb.getBusinessOrderNo(), materialInventoryId);
if (orderFrozen.compareTo(BigDecimal.ZERO) > 0) {
availableStock = availableStock.add(orderFrozen);
}
// 如果当前物料没有库存,跳过
if (availableStock == null || availableStock.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
if (availableStock.compareTo(remainingQuantity) < 0) {
throw new ServiceException(
String.format("出库单[%s]物料[%s]可用库存不足,计划数量:%s,当前可用库存:%s",
outOrderNumber, materialDesc, remainingQuantity, availableStock)
String.format("出库单[%s]物料[%s]可用库存不足,计划数量:%s,当前可用库存:%s(含下单冻结%s",
outOrderNumber, materialDesc, remainingQuantity,
availableStock.stripTrailingZeros().toPlainString(),
orderFrozen.stripTrailingZeros().toPlainString())
);
}
// detail.setMaterialInventoryId(materialInventory.getMaterialInventoryId());
@@ -1187,9 +1206,12 @@ public class StockOutOrderApplicationService {
/**
* OMS下发前库存校验:按库存ID聚合各明细计划数量,逐一比对WMS可用库存,
* 全部比对完成后汇总所有不足项一次性抛错。用于OMS在下发推送前拦截
* (此前单据先落库、分配才失败,会在WMS残留"已审核"状态的垃圾单)
* (此前单据先落库、分配才失败,会在WMS残留"已审核"状态的垃圾单)
* 下单冻结模式:businessOrderNumber 非空时行级可用量 = 库存可用 + 本单该行冻结记录量
* (下单时已冻结导致可用=0,按纯可用校验必失败;冻结量加回后校验=防冻结漂移),
* 冻结量<需求即报"下单冻结记录不足";重复下发保护原样保留。
*/
public void checkOmsAssignInventory(List<OutMaterialDetail> outMaterialDetails) {
public void checkOmsAssignInventory(List<OutMaterialDetail> outMaterialDetails, String businessOrderNumber) {
if (CollectionUtils.isEmpty(outMaterialDetails)) {
return;
}
@@ -1232,9 +1254,17 @@ public class StockOutOrderApplicationService {
}
BigDecimal available = materialInventory.getAllocationQuantity() == null
? BigDecimal.ZERO : materialInventory.getAllocationQuantity();
// 下单冻结模式:可用量加回本单该行的冻结记录量(下单冻结导致可用=0,纯可用校验必失败);
// 正常流冻结量≥需求(下单冻结/重锁保证),冻结量不足即报错=防冻结被异常释放的漂移
BigDecimal frozen = BigDecimal.ZERO;
if (businessOrderNumber != null && !businessOrderNumber.isEmpty()) {
frozen = outOrderFreezeRecordDomainService.sumOrderFreezeQuantity(businessOrderNumber, materialInventoryId);
available = available.add(frozen);
}
if (available.compareTo(need) < 0) {
errors.add(String.format("物料[%s]可用库存不足:需要%s,当前可用%s",
materialDesc, need.stripTrailingZeros().toPlainString(), available.stripTrailingZeros().toPlainString()));
errors.add(String.format("物料[%s]可用库存不足:需要%s,当前可用%s(含下单冻结%s",
materialDesc, need.stripTrailingZeros().toPlainString(),
available.stripTrailingZeros().toPlainString(), frozen.stripTrailingZeros().toPlainString()));
}
}
if (!errors.isEmpty()) {
@@ -80,6 +80,8 @@ public class HandoverTaskOrderDomainService {
@Autowired
private IHandoverTaskOrderService handoverTaskOrderService;
@Autowired
private com.mhd.wms.domain.outOrderFreezeRecord.service.OutOrderFreezeRecordDomainService outOrderFreezeRecordDomainService;
@Autowired
private MaterialInventoryMapper materialInventoryMapper;
@Autowired
private OutMaterialDetailMapper outMaterialDetailMapper;
@@ -591,6 +593,9 @@ public class HandoverTaskOrderDomainService {
}
}
}
// 下单冻结模式交接扣减完成库存-冻结-执行单承接的冻结记录置已交接终态
// 无冻结记录时幂等无操作数量扣减本身两种模式同逻辑零改动
outOrderFreezeRecordDomainService.markHandoverCompleted(orderNumber);
return clearoutParamDOs;
}
@@ -140,4 +140,41 @@ public interface MaterialInventoryMapper extends BaseMapper<MaterialInventory>
public Map<String,Object> queryShipperParameters(@Param("shipperId") Long shipperId,@Param("parametersName") String parametersName);
public Map<String, Object> queryMaterialClassify(@Param("id") Long id);
/**
* OMS下单冻结-条件扣减可用数量-quantity冻结数量+quantity库存数量不变
* WHERE 带可用数量充足条件返回影响行数0=可用不足由调用方判0抛异常整体回滚防止并发超卖
* 不可用现有 GREATEST 截断版批量SQL静默截为0会掩盖超卖
*
* @param materialInventoryId 库存行ID
* @param quantity 冻结数量
* @return 影响行数1-成功 0-可用数量不足
*/
public int omsFreezeDeductAvailable(@Param("materialInventoryId") Long materialInventoryId, @Param("quantity") BigDecimal quantity);
/**
* OMS下单解冻-条件释放可用数量+quantity冻结数量-quantity库存数量不变
* WHERE 带冻结数量充足条件返回影响行数0=冻结不足由调用方判0抛异常防止重复解冻/负数
*
* @param materialInventoryId 库存行ID
* @param quantity 释放数量
* @return 影响行数1-成功 0-冻结数量不足
*/
public int omsReleaseFreezeToAvailable(@Param("materialInventoryId") Long materialInventoryId, @Param("quantity") BigDecimal quantity);
/**
* OMS下单冻结单出库反审-还原为冻结实物库存+quantity冻结数量+quantity可用不变
* 并还原重量/体积/面积将已出库扣减状态还原为"下单已冻结待出库"与交接扣减 ckjj 库存-冻结- 互逆
*
* @param materialInventoryId 库存行ID
* @param quantity 还原数量
* @param netWeight 还原净重
* @param grossWeight 还原毛重
* @param volume 还原体积
* @param area 还原面积
* @return 影响行数
*/
public int omsRestoreInventoryToFrozen(@Param("materialInventoryId") Long materialInventoryId, @Param("quantity") BigDecimal quantity,
@Param("netWeight") BigDecimal netWeight, @Param("grossWeight") BigDecimal grossWeight,
@Param("volume") BigDecimal volume, @Param("area") BigDecimal area);
}
@@ -647,7 +647,7 @@ public class MaterialInventoryImpl extends ServiceImpl<MaterialInventoryMapper,
}
inventoryAdjustmentRecord.setMaterialBaseInfoId(materialInventory.getMaterialBaseInfoId());
inventoryAdjustmentRecord.setAdjustType(1L);
if ("库存冻结".equals(adjustAction)) {
if ("库存冻结".equals(adjustAction) || "下单冻结".equals(adjustAction)) {
inventoryAdjustmentRecord.setAdjustType(3L);
}
inventoryAdjustmentRecord.setOrganizationId(materialInventory.getOrganizationId());
@@ -803,6 +803,44 @@ public class MaterialInventoryImpl extends ServiceImpl<MaterialInventoryMapper,
inventoryAdjustmentRecord.setAdjustedGrossWeight(materialInventory.getGrossWeight());
inventoryAdjustmentRecord.setAdjustedVolume(materialInventory.getVolume());
inventoryAdjustmentRecord.setAdjustedArea(materialInventory.getArea());
} else if ("下单冻结".equals(adjustAction)) {
// OMS下单冻结防超卖总库存不变可用减少冻结增加数量从可用转至冻结"库存冻结"同构
BigDecimal invBefore = nz(materialInventory.getInventoryQuantity());
BigDecimal allocBefore = nz(materialInventory.getAllocationQuantity());
BigDecimal freezeBefore = nz(materialInventory.getFreezeQuantity());
inventoryAdjustmentRecord.setInventoryBeforeQuantity(invBefore);
inventoryAdjustmentRecord.setInventoryAfterQuantity(invBefore);
inventoryAdjustmentRecord.setAllocationBeforeQuantity(allocBefore);
inventoryAdjustmentRecord.setAllocationAfterQuantity(allocBefore.subtract(qty));
inventoryAdjustmentRecord.setFreezeBeforeQuantity(freezeBefore);
inventoryAdjustmentRecord.setFreezeAfterQuantity(freezeBefore.add(qty));
inventoryAdjustmentRecord.setNetWeight(nz(materialInventory.getNetWeight()));
inventoryAdjustmentRecord.setGrossWeight(nz(materialInventory.getGrossWeight()));
inventoryAdjustmentRecord.setVolume(nz(materialInventory.getVolume()));
inventoryAdjustmentRecord.setArea(nz(materialInventory.getArea()));
inventoryAdjustmentRecord.setAdjustedNetWeight(nz(materialInventory.getNetWeight()));
inventoryAdjustmentRecord.setAdjustedGrossWeight(nz(materialInventory.getGrossWeight()));
inventoryAdjustmentRecord.setAdjustedVolume(nz(materialInventory.getVolume()));
inventoryAdjustmentRecord.setAdjustedArea(nz(materialInventory.getArea()));
} else if ("下单解冻".equals(adjustAction)) {
// OMS取消/编辑重锁释放总库存不变可用增加冻结减少"取消分配"同构
BigDecimal invBefore = nz(materialInventory.getInventoryQuantity());
BigDecimal allocBefore = nz(materialInventory.getAllocationQuantity());
BigDecimal freezeBefore = nz(materialInventory.getFreezeQuantity());
inventoryAdjustmentRecord.setInventoryBeforeQuantity(invBefore);
inventoryAdjustmentRecord.setInventoryAfterQuantity(invBefore);
inventoryAdjustmentRecord.setAllocationBeforeQuantity(allocBefore);
inventoryAdjustmentRecord.setAllocationAfterQuantity(allocBefore.add(qty));
inventoryAdjustmentRecord.setFreezeBeforeQuantity(freezeBefore);
inventoryAdjustmentRecord.setFreezeAfterQuantity(freezeBefore.subtract(qty));
inventoryAdjustmentRecord.setNetWeight(nz(materialInventory.getNetWeight()));
inventoryAdjustmentRecord.setGrossWeight(nz(materialInventory.getGrossWeight()));
inventoryAdjustmentRecord.setVolume(nz(materialInventory.getVolume()));
inventoryAdjustmentRecord.setArea(nz(materialInventory.getArea()));
inventoryAdjustmentRecord.setAdjustedNetWeight(nz(materialInventory.getNetWeight()));
inventoryAdjustmentRecord.setAdjustedGrossWeight(nz(materialInventory.getGrossWeight()));
inventoryAdjustmentRecord.setAdjustedVolume(nz(materialInventory.getVolume()));
inventoryAdjustmentRecord.setAdjustedArea(nz(materialInventory.getArea()));
}
com.mhd.system.api.domain.InventoryAdjustmentRecord inventoryAdjustmentRecordOMS = new com.mhd.system.api.domain.InventoryAdjustmentRecord();
BeanUtils.copyProperties(inventoryAdjustmentRecord, inventoryAdjustmentRecordOMS);
@@ -1,6 +1,7 @@
package com.mhd.wms.domain.outMaterialDetail.repository.persistence;
import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -40,6 +41,7 @@ import com.mhd.wms.domain.outMaterialDetail.repository.po.OutMaterialDetailWithO
import com.mhd.wms.domain.outMaterialDetail.repository.po.OutStockListMaterialAgg;
import com.mhd.wms.domain.outMaterialDetail.repository.todo.OutMaterialDetailBaseDO;
import com.mhd.wms.domain.outMaterialDetail.repository.todo.OutMaterialDetailDO;
import com.mhd.wms.domain.outOrderFreezeRecord.service.OutOrderFreezeRecordDomainService;
import com.mhd.wms.domain.outMaterialDetailSerialNumber.entity.OutMaterialDetailSerialNumber;
import com.mhd.wms.domain.outMaterialDetailSerialNumber.repository.facade.IOutMaterialDetailSerialNumberService;
import com.mhd.wms.domain.overStock.repository.todo.QueryOrderOverStockDO;
@@ -71,6 +73,8 @@ public class OutMaterialDetailImpl extends ServiceImpl<OutMaterialDetailMapper,
@Autowired
private OutMaterialDetailMapper outMaterialDetailMapper;
@Autowired
private OutOrderFreezeRecordDomainService outOrderFreezeRecordDomainService;
@Autowired
private IOutMaterialDetailSerialNumberService outMaterialDetailSerialNumberService;
@Autowired
private IdGenerator idGenerator;
@@ -427,7 +431,9 @@ public class OutMaterialDetailImpl extends ServiceImpl<OutMaterialDetailMapper,
.eq(StockOutOrder::getOutOrderNumber, outOrderNumber)
.eq(StockOutOrder::getDelFlag, 1));
if (!CollectionUtils.isEmpty(stockOutOrderList) && !"yang_pin_chu_ku".equals(stockOutOrderList.get(0).getBusinessType())) {
xgkc(outMaterialDetailList,outOrderNumber,loginUser);
// 下单冻结模式分流本单冻结明细其库存行存在本单活跃冻结记录数量不动下单时已冻结
// 再调 xgkc 会冻结翻倍/可用错扣仅校验冻结量充足并做承接标记其余明细走现有分配扣减
assignByFreezeMode(outMaterialDetailList, outOrderNumber, loginUser, stockOutOrderList.get(0).getBusinessOrderNo());
}
//保存物料明细信息
outMaterialDetailList.forEach(outMaterialDetail -> {
@@ -1016,15 +1022,61 @@ public class OutMaterialDetailImpl extends ServiceImpl<OutMaterialDetailMapper,
return stockOutOrderPO;
}
/**
* @description 分配扣减按库存冻结模式分流
* 明细的库存行存在本单活跃冻结记录OMS下单冻结模式下单时已冻结 库存数量不动
* 再调 xgkc 会二次冻结导致冻结翻倍/可用错扣仅校验本单冻结量充足并做执行单承接标记
* FIFO 按量承接余量保留业务单冻结支持部分分配明细绑定由后续统一逻辑完成
* 其他明细分配库存冻结模式=现有行为 xgkc 分配扣减可用-冻结+
* 单据级隔离冻结记录按 businessOrderNo + materialInventoryId 双条件过滤
* 双单冻结同一库存行时互不误消费businessOrderNo 为空历史单 查不到记录安全走原逻辑
* @author gen
* @date 2026/9/12
*/
private void assignByFreezeMode(List<OutMaterialDetail> outMaterialDetailList, String outOrderNumber,
LoginUser loginUser, String businessOrderNo) {
List<OutMaterialDetail> normalAssignList = new ArrayList<>();
for (OutMaterialDetail detail : outMaterialDetailList) {
if (detail.getNowAllocationQuantity() == null
|| detail.getNowAllocationQuantity().compareTo(BigDecimal.ZERO) <= 0
|| detail.getMaterialInventoryId() == null) {
// 无效行交给现有逻辑自然跳过保持行为一致
normalAssignList.add(detail);
continue;
}
BigDecimal frozenSum = outOrderFreezeRecordDomainService.sumOrderFreezeQuantity(businessOrderNo, detail.getMaterialInventoryId());
if (frozenSum.compareTo(BigDecimal.ZERO) > 0) {
if (frozenSum.compareTo(detail.getNowAllocationQuantity()) < 0) {
throw new ServiceException("物料[" + (detail.getMaterialCode() != null ? detail.getMaterialCode() : "")
+ "]库存行[" + detail.getMaterialInventoryId() + "]本单下单冻结量[" + frozenSum
+ "]小于本次分配数量[" + detail.getNowAllocationQuantity() + "],分配失败");
}
// 下单冻结模式明细数量不动冻结记录承接给执行单本分配事务内失败随事务回滚
outOrderFreezeRecordDomainService.undertakeFromFreezeRecords(
businessOrderNo, detail.getMaterialInventoryId(), outOrderNumber, detail.getNowAllocationQuantity());
} else {
normalAssignList.add(detail);
}
}
if (!CollectionUtils.isEmpty(normalAssignList)) {
xgkc(normalAssignList, outOrderNumber, loginUser);
}
}
private synchronized void qxfp(List<OutMaterialDetailDO> outMaterialDetailList,String outOrderNumber,LoginUser loginUser) {
StockOutOrder stockOutOrder = stockOutOrderMapper.selectOne(new QueryWrapper<StockOutOrder>().lambda()
.eq(StockOutOrder::getDelFlag,1)
.eq(StockOutOrder::getOutOrderNumber, outOrderNumber));
// 下单冻结判据用业务单号null 安全查不到单/无单号时按无冻结记录处理走原还原逻辑
String businessOrderNo = stockOutOrder == null ? null : stockOutOrder.getBusinessOrderNo();
for (OutMaterialDetailDO outMaterialDetail : outMaterialDetailList) {
Long materialInventoryId = outMaterialDetail.getMaterialInventoryId();
BigDecimal allocationQuantity = outMaterialDetail.getActualQuantity();
MaterialInventory materialInventoryPO = materialInventoryMapper.selectById(materialInventoryId);
if (materialInventoryPO != null) {
// 下单冻结模式明细本单该库存行存在活跃冻结记录分配时数量未动取消分配不还原数量
// 不可用 continue会跳过循环尾部的子行 childrenqxfp 处理 if 包裹还原块
if (!outOrderFreezeRecordDomainService.hasOrderFreezeRecord(businessOrderNo, materialInventoryId)) {
MaterialInventoryParamDO materialInventoryParamDO = new MaterialInventoryParamDO();
materialInventoryParamDO.setMaterialInventoryId(materialInventoryId);
materialInventoryParamDO.setQuantity(allocationQuantity);
@@ -1046,6 +1098,7 @@ public class OutMaterialDetailImpl extends ServiceImpl<OutMaterialDetailMapper,
materialInventoryOmsParamDO.setKctzType("取消分配");
materialInventoryOmsParamDO.setLoginUser(loginUser);
omsServiceFeign.omsEdit(materialInventoryOmsParamDO);
}
// //更新库存数量
// //可用数量
// BigDecimal oldAllocationQuantity = materialInventoryPO.getAllocationQuantity();
@@ -1132,11 +1185,18 @@ public class OutMaterialDetailImpl extends ServiceImpl<OutMaterialDetailMapper,
StockOutOrder stockOutOrder = stockOutOrderMapper.selectOne(new QueryWrapper<StockOutOrder>().lambda()
.eq(StockOutOrder::getDelFlag,1)
.eq(StockOutOrder::getOutOrderNumber, outOrderNumber));
// 下单冻结判据用业务单号null 安全查不到单/无单号时按无冻结记录处理走原还原逻辑
String businessOrderNo = stockOutOrder == null ? null : stockOutOrder.getBusinessOrderNo();
for (OutMaterialDetailDO outMaterialDetail : outMaterialDetailList) {
Long materialInventoryId = outMaterialDetail.getMaterialInventoryId();
BigDecimal allocationQuantity = outMaterialDetail.getActualQuantity();
MaterialInventory materialInventoryPO = materialInventoryMapper.selectById(materialInventoryId);
if (materialInventoryPO != null) {
// 下单冻结模式明细本单该库存行存在活跃冻结记录分配时数量未动取消分配不还原数量
// 叶子子行无嵌套子项可安全 continue
if (outOrderFreezeRecordDomainService.hasOrderFreezeRecord(businessOrderNo, materialInventoryId)) {
continue;
}
MaterialInventoryParamDO materialInventoryParamDO = new MaterialInventoryParamDO();
materialInventoryParamDO.setMaterialInventoryId(materialInventoryId);
@@ -0,0 +1,77 @@
package com.mhd.wms.domain.outOrderFreezeRecord.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.mhd.common.core.web.domain.BaseVOEntity;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/**
* WMS出库单库存冻结记录对象 out_order_freeze_record
*
* @description OMS下单冻结库存模式防超卖业务单库存行冻结数量占用关系
* OMS出库业务单下单即冻结WMS库存可用-冻结+库存数量不变
* 编辑=原子释放旧冻结+按新明细重锁取消=释放冻结分配=承接数量不动
* 交接=扣库存+释放冻结反审=还原为冻结
* 本表是编辑重锁/取消解冻/分配承接/交接标记/反审还原的对账依据与单据级隔离判据
* @date 2026-09-12
*/
@Data
@TableName("out_order_freeze_record")
public class OutOrderFreezeRecord extends BaseVOEntity {
private static final long serialVersionUID = 1L;
@ApiModelProperty("冻结记录id")
@TableId(type = IdType.AUTO)
private Long freezeRecordId;
@ApiModelProperty("OMS出库业务单号")
private String businessOrderNumber;
@ApiModelProperty("执行出库单号(分配承接时回填,可空)")
private String executionOrderNumber;
@ApiModelProperty("明细唯一标识(OMS明细uniqueId)")
private String materialDetailUniqueId;
@ApiModelProperty("库存行ID(material_inventory.material_inventory_id)")
private Long materialInventoryId;
@ApiModelProperty("仓库ID")
private Long warehouseId;
@ApiModelProperty("物料基础信息ID")
private Long materialBaseInfoId;
@ApiModelProperty("冻结数量")
private BigDecimal freezeQuantity;
@ApiModelProperty("冻结状态: 1-业务单冻结 2-执行单承接 3-已交接 4-已释放")
private Integer freezeStatus;
@ApiModelProperty("关联单号(业务单号#明细uniqueId,与库存调整记录relate_no对应)")
private String relateNo;
@ApiModelProperty("备注")
private String remark;
/**
* 冻结状态: 业务单冻结下单/编辑重锁后
*/
public static final int STATUS_BUSINESS_FROZEN = 1;
/**
* 冻结状态: 执行单承接WMS分配时数量不动仅记录归属转移到执行单
*/
public static final int STATUS_EXECUTION_UNDERTAKE = 2;
/**
* 冻结状态: 已交接出库交接扣减库存后终态
*/
public static final int STATUS_HANDOVER = 3;
/**
* 冻结状态: 已释放取消/解冻/重锁时的旧记录
*/
public static final int STATUS_RELEASED = 4;
}
@@ -0,0 +1,13 @@
package com.mhd.wms.domain.outOrderFreezeRecord.repository.facade;
import com.baomidou.mybatisplus.extension.service.IService;
import com.mhd.wms.domain.outOrderFreezeRecord.entity.OutOrderFreezeRecord;
/**
* WMS出库单库存冻结记录Service接口
*
* @date 2026-09-12
*/
public interface IOutOrderFreezeRecordService extends IService<OutOrderFreezeRecord>
{
}
@@ -0,0 +1,13 @@
package com.mhd.wms.domain.outOrderFreezeRecord.repository.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mhd.wms.domain.outOrderFreezeRecord.entity.OutOrderFreezeRecord;
/**
* WMS出库单库存冻结记录Mapper接口
*
* @date 2026-09-12
*/
public interface OutOrderFreezeRecordMapper extends BaseMapper<OutOrderFreezeRecord>
{
}
@@ -0,0 +1,16 @@
package com.mhd.wms.domain.outOrderFreezeRecord.repository.persistence;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mhd.wms.domain.outOrderFreezeRecord.entity.OutOrderFreezeRecord;
import com.mhd.wms.domain.outOrderFreezeRecord.repository.facade.IOutOrderFreezeRecordService;
import com.mhd.wms.domain.outOrderFreezeRecord.repository.mapper.OutOrderFreezeRecordMapper;
import org.springframework.stereotype.Service;
/**
* WMS出库单库存冻结记录Service业务层处理
*
* @date 2026-09-12
*/
@Service
public class OutOrderFreezeRecordImpl extends ServiceImpl<OutOrderFreezeRecordMapper, OutOrderFreezeRecord> implements IOutOrderFreezeRecordService {
}
@@ -0,0 +1,536 @@
package com.mhd.wms.domain.outOrderFreezeRecord.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mhd.common.core.exception.ServiceException;
import com.mhd.common.security.utils.SecurityUtils;
import com.mhd.system.api.OmsServiceFeign;
import com.mhd.system.api.domain.MaterialInventoryOmsParamDO;
import com.mhd.system.api.domain.OmsFreezeInventoryParam;
import com.mhd.system.api.domain.OutMaterialDetail;
import com.mhd.system.api.model.LoginUser;
import com.mhd.wms.domain.materialInventory.entity.MaterialInventory;
import com.mhd.wms.domain.materialInventory.repository.facade.IMaterialInventoryService;
import com.mhd.wms.domain.materialInventory.repository.mapper.MaterialInventoryMapper;
import com.mhd.wms.domain.materialInventory.repository.todo.MaterialInventoryParamDO;
import com.mhd.wms.domain.outOrderFreezeRecord.entity.OutOrderFreezeRecord;
import com.mhd.wms.domain.outOrderFreezeRecord.repository.facade.IOutOrderFreezeRecordService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
/**
* WMS出库单库存冻结记录领域服务
*
* @description OMS下单冻结库存模式防超卖解决OMS下单与WMS分配库存时间差导致的出库库存不足问题
* 库存数量 = 可用数量 + 冻结数量交接前全程不变出库交接才扣减实物库存
* 下单 冻结可用-冻结+条件更新防并发超卖
* 编辑 omsRelockInventory 同一事务先释放原锁定再按新明细重新锁定原子操作
* 取消 omsUnfreezeInventory 释放冻结可用+冻结-
* 分配 不动数量冻结记录由业务单承接给执行单undertakeFromFreezeRecords FIFO按量承接
* 取消下发 releaseExecutionUndertake 承接归还业务单
* 交接 markHandoverCompleted 置已交接终态数量由现有 ckjj 扣减
* 反审 已出库 reactivateHandoverFreeze 重激活 + 实物/冻结还原
* @date 2026-09-12
*/
@Service
public class OutOrderFreezeRecordDomainService {
private static final Logger log = LoggerFactory.getLogger(OutOrderFreezeRecordDomainService.class);
@Autowired
private IOutOrderFreezeRecordService outOrderFreezeRecordService;
@Autowired
private MaterialInventoryMapper materialInventoryMapper;
@Autowired
private IMaterialInventoryService materialInventoryService;
@Autowired
private OmsServiceFeign omsServiceFeign;
// ================================ OMS 冻结三接口 ================================
/**
* OMS出库业务单下单冻结库存幂等同业务单号已有活跃冻结记录时直接返回成功
* 明细必须携带 materialInventoryId 精确定位库存行下单冻结模式仓库明细强制必选OMS侧已前置校验
*/
@Transactional(rollbackFor = Exception.class)
public Boolean omsFreezeInventory(OmsFreezeInventoryParam param) {
validateParam(param);
String businessOrderNumber = param.getOutOrderNumber();
List<OutOrderFreezeRecord> activeRecords = listActiveRecords(businessOrderNumber);
if (!CollectionUtils.isEmpty(activeRecords)) {
// 幂等Feign超时重试/重复提交场景已有活跃冻结视为成功
log.info("下单冻结幂等:出库业务单[{}]已存在{}条活跃冻结记录,跳过重复冻结", businessOrderNumber, activeRecords.size());
return true;
}
freezeByDetails(param, businessOrderNumber);
return true;
}
/**
* OMS出库业务单取消/删除-释放冻结库存可用+冻结-库存数量不变幂等无活跃冻结直接成功
*/
@Transactional(rollbackFor = Exception.class)
public Boolean omsUnfreezeInventory(String businessOrderNumber, Long operatorId, String operatorName) {
if (!StringUtils.hasText(businessOrderNumber)) {
throw new ServiceException("出库业务单号不能为空");
}
List<OutOrderFreezeRecord> activeRecords = listActiveRecords(businessOrderNumber);
if (CollectionUtils.isEmpty(activeRecords)) {
// 幂等未冻结模式切换前老单/重复取消直接成功
log.info("释放冻结幂等:出库业务单[{}]无活跃冻结记录", businessOrderNumber);
return true;
}
// 已承接给执行单/已交接的冻结不可直接释放必须先走取消下发/反审链路
for (OutOrderFreezeRecord record : activeRecords) {
if (record.getFreezeStatus() != null && record.getFreezeStatus() == OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]冻结已被执行单承接,请先取消下发");
}
if (record.getFreezeStatus() != null && record.getFreezeStatus() == OutOrderFreezeRecord.STATUS_HANDOVER) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]冻结已交接(已出库),请走反审流程");
}
}
LoginUserHolder holder = resolveLoginUser(operatorId, operatorName);
for (OutOrderFreezeRecord record : activeRecords) {
releaseOneRecord(record, businessOrderNumber, holder);
}
return true;
}
/**
* OMS出库业务单编辑-原子重锁同一事务内先释放原冻结再按新明细重新冻结
* 任一新明细可用不足抛异常整体回滚旧冻结同样还原满足"先释放再重新锁定"的原子性要求
*/
@Transactional(rollbackFor = Exception.class)
public Boolean omsRelockInventory(OmsFreezeInventoryParam param) {
validateParam(param);
String businessOrderNumber = param.getOutOrderNumber();
List<OutOrderFreezeRecord> activeRecords = listActiveRecords(businessOrderNumber);
for (OutOrderFreezeRecord record : activeRecords) {
if (record.getFreezeStatus() != null && record.getFreezeStatus() != OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]已下发WMS(冻结已被承接),不可编辑,请先取消下发");
}
}
LoginUserHolder holder = resolveLoginUser(param.getOperatorId(), param.getOperatorName());
// 1. 先按原冻结记录精确释放数量以冻结记录为准防止与明细数量漂移
for (OutOrderFreezeRecord record : activeRecords) {
releaseOneRecord(record, businessOrderNumber, holder);
}
// 2. 再按新明细重新冻结任一行失败抛异常整个事务回滚=旧冻结也还原
freezeByDetails(param, businessOrderNumber);
return true;
}
// ================================ 核心链路状态机方法 ================================
/**
* WMS分配-冻结承接FIFO按量校验本单本行冻结量本次分配量后从本单该行 status=1 记录
* 按id升序逐条扣减承接量记录余量承接完status=2+执行单号余量不足整条只改freeze_quantity为承接量
* 剩余量另插一条status=1新记录保证任一时刻记录数量之和=该单该行总冻结量守恒
* 数量字段不动下单时已冻结在分配事务内调用失败随分配整体回滚
*
* @param businessOrderNumber 业务单号单据级隔离判据防止误消费别单冻结
* @param materialInventoryId 库存行ID
* @param executionOrderNumber 执行出库单号WMS出库单号
* @param undertakeQuantity 本次承接分配数量
* @return true-承接成功调用前需先校验冻结量充足
*/
@Transactional(rollbackFor = Exception.class)
public Boolean undertakeFromFreezeRecords(String businessOrderNumber, Long materialInventoryId,
String executionOrderNumber, BigDecimal undertakeQuantity) {
BigDecimal remaining = undertakeQuantity;
List<OutOrderFreezeRecord> records = listOrderRowActiveRecords(businessOrderNumber, materialInventoryId);
records.sort(Comparator.comparing(r -> r.getFreezeRecordId() == null ? Long.MAX_VALUE : r.getFreezeRecordId()));
for (OutOrderFreezeRecord record : records) {
if (remaining.compareTo(BigDecimal.ZERO) <= 0) {
break;
}
BigDecimal recordQty = record.getFreezeQuantity() == null ? BigDecimal.ZERO : record.getFreezeQuantity();
if (recordQty.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
if (recordQty.compareTo(remaining) <= 0) {
// 整条记录承接完
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE);
record.setExecutionOrderNumber(executionOrderNumber);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
remaining = remaining.subtract(recordQty);
} else {
// 部分承接本条记承接量余量拆一条新记录保留 status=1
BigDecimal keepQty = recordQty.subtract(remaining);
record.setFreezeQuantity(remaining);
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE);
record.setExecutionOrderNumber(executionOrderNumber);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
OutOrderFreezeRecord keepRecord = new OutOrderFreezeRecord();
keepRecord.setBusinessOrderNumber(record.getBusinessOrderNumber());
keepRecord.setMaterialDetailUniqueId(record.getMaterialDetailUniqueId());
keepRecord.setMaterialInventoryId(record.getMaterialInventoryId());
keepRecord.setWarehouseId(record.getWarehouseId());
keepRecord.setMaterialBaseInfoId(record.getMaterialBaseInfoId());
keepRecord.setFreezeQuantity(keepQty);
keepRecord.setFreezeStatus(OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN);
keepRecord.setRelateNo(record.getRelateNo());
keepRecord.setCreateBy(record.getCreateBy());
keepRecord.setCreateByName(record.getCreateByName());
keepRecord.setCreateTime(new Date());
keepRecord.setDelFlag(1);
outOrderFreezeRecordService.save(keepRecord);
remaining = BigDecimal.ZERO;
}
}
return true;
}
/**
* 取消下发/反审(未出库)-解除执行单承接冻结归还业务单数量不动仅状态 21
*/
@Transactional(rollbackFor = Exception.class)
public Boolean releaseExecutionUndertake(String executionOrderNumber) {
if (!StringUtils.hasText(executionOrderNumber)) {
return true;
}
List<OutOrderFreezeRecord> records = outOrderFreezeRecordService.list(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getExecutionOrderNumber, executionOrderNumber)
.eq(OutOrderFreezeRecord::getFreezeStatus, OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE)
.eq(OutOrderFreezeRecord::getDelFlag, 1));
for (OutOrderFreezeRecord record : records) {
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN);
record.setExecutionOrderNumber(null);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
}
return true;
}
/**
* 出库交接完成-冻结记录置已交接终态数量已在交接时由现有 ckjj 扣减库存-冻结-
*/
@Transactional(rollbackFor = Exception.class)
public Boolean markHandoverCompleted(String executionOrderNumber) {
if (!StringUtils.hasText(executionOrderNumber)) {
return true;
}
List<OutOrderFreezeRecord> records = outOrderFreezeRecordService.list(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getExecutionOrderNumber, executionOrderNumber)
.eq(OutOrderFreezeRecord::getFreezeStatus, OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE)
.eq(OutOrderFreezeRecord::getDelFlag, 1));
for (OutOrderFreezeRecord record : records) {
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_HANDOVER);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
}
return true;
}
/**
* 反审(已出库)-重新激活已交接冻结记录status=3归还业务单status=1数量不动
* 业务单回到未下发可编辑态冻结继续由业务单持有实物/冻结数量由调用方还原
*/
@Transactional(rollbackFor = Exception.class)
public Boolean reactivateHandoverFreeze(String executionOrderNumber) {
if (!StringUtils.hasText(executionOrderNumber)) {
return true;
}
List<OutOrderFreezeRecord> records = outOrderFreezeRecordService.list(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getExecutionOrderNumber, executionOrderNumber)
.eq(OutOrderFreezeRecord::getFreezeStatus, OutOrderFreezeRecord.STATUS_HANDOVER)
.eq(OutOrderFreezeRecord::getDelFlag, 1));
for (OutOrderFreezeRecord record : records) {
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN);
record.setExecutionOrderNumber(null);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
}
return true;
}
// ================================ 查询判据 ================================
/**
* 执行单是否存在活跃冻结记录status in 2-承接 3-已交接判断该执行单是否下单冻结模式单据
*/
public boolean hasFreezeForExecutionOrder(String executionOrderNumber) {
if (!StringUtils.hasText(executionOrderNumber)) {
return false;
}
int count = outOrderFreezeRecordService.count(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getExecutionOrderNumber, executionOrderNumber)
.in(OutOrderFreezeRecord::getFreezeStatus, Arrays.asList(
OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE,
OutOrderFreezeRecord.STATUS_HANDOVER))
.eq(OutOrderFreezeRecord::getDelFlag, 1));
return count > 0;
}
/**
* 本单该库存行的活跃冻结量合计status in 1-业务单冻结 2-执行单承接
* 单据级隔离判据businessOrderNumber 必传双单冻结同行时防止误消费别单冻结
*/
public BigDecimal sumOrderFreezeQuantity(String businessOrderNumber, Long materialInventoryId) {
if (!StringUtils.hasText(businessOrderNumber) || materialInventoryId == null) {
return BigDecimal.ZERO;
}
List<OutOrderFreezeRecord> records = listOrderRowActiveRecords(businessOrderNumber, materialInventoryId);
BigDecimal sum = BigDecimal.ZERO;
for (OutOrderFreezeRecord record : records) {
if (record.getFreezeQuantity() != null) {
sum = sum.add(record.getFreezeQuantity());
}
}
return sum;
}
/**
* 本单该库存行是否存在活跃冻结记录取消分配分支判据
*/
public boolean hasOrderFreezeRecord(String businessOrderNumber, Long materialInventoryId) {
return sumOrderFreezeQuantity(businessOrderNumber, materialInventoryId).compareTo(BigDecimal.ZERO) > 0;
}
/**
* 仓库是否存在活跃冻结记录status in 1,2仓库"库存锁定模式"切换守卫用
* 存在在途冻结单时切换模式会导致在途单解冻/承接逻辑错乱
*/
public boolean hasActiveFreezeByWarehouse(Long warehouseId) {
if (warehouseId == null) {
return false;
}
int count = outOrderFreezeRecordService.count(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getWarehouseId, warehouseId)
.in(OutOrderFreezeRecord::getFreezeStatus, Arrays.asList(
OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN,
OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE))
.eq(OutOrderFreezeRecord::getDelFlag, 1));
return count > 0;
}
// ================================ 私有方法 ================================
private void validateParam(OmsFreezeInventoryParam param) {
if (param == null || !StringUtils.hasText(param.getOutOrderNumber())) {
throw new ServiceException("出库业务单号不能为空");
}
if (CollectionUtils.isEmpty(param.getDetails())) {
throw new ServiceException("冻结明细不能为空");
}
for (OutMaterialDetail detail : param.getDetails()) {
if (detail.getQuantity() == null || detail.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("出库业务单[" + param.getOutOrderNumber() + "]明细["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : "未知物料") + "]冻结数量必须大于0");
}
}
}
/**
* 按明细冻结明细必须携带 materialInventoryId下单冻结模式强制必选
* 逐明细单行冻结先写台账调整前快照再条件更新0行=可用不足抛异常整体回滚
* 全部扣减成功后统一写冻结记录+推OMS镜像推镜像放事务末尾缩小漂移窗口
*/
private void freezeByDetails(OmsFreezeInventoryParam param, String businessOrderNumber) {
LoginUserHolder holder = resolveLoginUser(param.getOperatorId(), param.getOperatorName());
List<PendingFreeze> pendingFreezes = new ArrayList<>();
for (OutMaterialDetail detail : param.getDetails()) {
if (detail.getMaterialInventoryId() == null || detail.getMaterialInventoryId() <= 0) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]物料["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : detail.getMaterialBaseInfoId())
+ "]未关联库存行,无法冻结(下单冻结模式仓库明细必须选择库存批次)");
}
MaterialInventory row = materialInventoryMapper.selectById(detail.getMaterialInventoryId());
if (row == null || (row.getDelFlag() != null && row.getDelFlag() != 1)) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]物料["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : "") + "]关联的库存行["
+ detail.getMaterialInventoryId() + "]不存在,冻结失败");
}
BigDecimal freezeQuantity = detail.getQuantity();
BigDecimal available = row.getAllocationQuantity() == null ? BigDecimal.ZERO : row.getAllocationQuantity();
// 先行级预检给出友好错误并发下仍由条件更新兜底判定
if (available.compareTo(freezeQuantity) < 0) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]物料["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : "") + "]库存行["
+ row.getMaterialInventoryId() + "]可用数量不足,需要:" + freezeQuantity + ",当前可用:" + available + ",下单冻结失败");
}
String relateNo = buildRelateNo(businessOrderNumber, detail.getUniqueId());
// 1.先写库存调整记录调整前快照2.条件更新扣可用加冻结0行=并发下可用不足失败抛异常整体回滚
MaterialInventoryParamDO paramDO = buildParamDO(row, freezeQuantity, "7", relateNo);
materialInventoryService.kctzjl(paramDO, "下单冻结", holder.loginUser);
int affected = materialInventoryMapper.omsFreezeDeductAvailable(row.getMaterialInventoryId(), freezeQuantity);
if (affected != 1) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]物料["
+ (detail.getMaterialCode() != null ? detail.getMaterialCode() : "") + "]库存行["
+ row.getMaterialInventoryId() + "]可用数量不足,冻结失败(需求:" + freezeQuantity + "");
}
pendingFreezes.add(new PendingFreeze(row, freezeQuantity, detail.getUniqueId(), relateNo));
}
// 全部扣减成功后写冻结记录 + 推送OMS镜像库存
for (PendingFreeze pending : pendingFreezes) {
insertFreezeRecord(pending.row, pending.quantity, businessOrderNumber, pending.uniqueId, pending.relateNo, holder);
MaterialInventoryParamDO paramDO = buildParamDO(pending.row, pending.quantity, "7", pending.relateNo);
pushOmsMirror(paramDO, "下单冻结");
}
}
/**
* 释放单条冻结记录库存调整记录下单解冻+ 条件更新可用+冻结-+ 记录置已释放 + 推OMS镜像
*/
private void releaseOneRecord(OutOrderFreezeRecord record, String businessOrderNumber, LoginUserHolder holder) {
MaterialInventory row = materialInventoryMapper.selectById(record.getMaterialInventoryId());
if (row == null || (row.getDelFlag() != null && row.getDelFlag() != 1)) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]冻结记录对应库存行["
+ record.getMaterialInventoryId() + "]不存在,解冻失败");
}
MaterialInventoryParamDO paramDO = buildParamDO(row, record.getFreezeQuantity(), "3", record.getRelateNo());
materialInventoryService.kctzjl(paramDO, "下单解冻", holder.loginUser);
int affected = materialInventoryMapper.omsReleaseFreezeToAvailable(record.getMaterialInventoryId(), record.getFreezeQuantity());
if (affected != 1) {
throw new ServiceException("出库业务单[" + businessOrderNumber + "]库存行["
+ record.getMaterialInventoryId() + "]冻结数量不足,解冻失败");
}
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_RELEASED);
record.setUpdateTime(new Date());
outOrderFreezeRecordService.updateById(record);
pushOmsMirror(buildParamDO(row, record.getFreezeQuantity(), "3", record.getRelateNo()), "下单解冻");
}
/**
* 组装库存变更参数匹配键全部从库存行取值OMS镜像按
* materialBaseInfoId+warehouseId+inOrderNumber+lotNumber+batchNumber+purchaseDate 精确匹配
* 从明细取会因缺 lotNo/purchaseDate 匹配到错误镜像行
*/
private MaterialInventoryParamDO buildParamDO(MaterialInventory row, BigDecimal quantity, String type, String relateNo) {
MaterialInventoryParamDO paramDO = new MaterialInventoryParamDO();
BigDecimal zero = BigDecimal.ZERO;
paramDO.setArea(zero);
paramDO.setVolume(zero);
paramDO.setNetWeight(zero);
paramDO.setGrossWeight(zero);
paramDO.setQuantity(quantity);
paramDO.setType(type);
paramDO.setMaterialInventoryId(row.getMaterialInventoryId());
paramDO.setRelateNo(relateNo);
paramDO.setMaterialBaseInfoId(row.getMaterialBaseInfoId());
paramDO.setWarehouseId(row.getWarehouseId());
paramDO.setLotNumber(row.getLotNumber());
paramDO.setBatchNumber(row.getBatchNumber());
paramDO.setInOrderNumber(row.getInOrderNumber());
paramDO.setProductionDate(row.getProductionDate());
paramDO.setExpiryDate(row.getExpiryDate());
paramDO.setPurchaseDate(row.getPurchaseDate());
return paramDO;
}
private void insertFreezeRecord(MaterialInventory row, BigDecimal quantity, String businessOrderNumber,
Long uniqueId, String relateNo, LoginUserHolder holder) {
OutOrderFreezeRecord record = new OutOrderFreezeRecord();
record.setBusinessOrderNumber(businessOrderNumber);
record.setMaterialDetailUniqueId(uniqueId == null ? null : String.valueOf(uniqueId));
record.setMaterialInventoryId(row.getMaterialInventoryId());
record.setWarehouseId(row.getWarehouseId());
record.setMaterialBaseInfoId(row.getMaterialBaseInfoId());
record.setFreezeQuantity(quantity);
record.setFreezeStatus(OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN);
record.setRelateNo(relateNo);
record.setCreateBy(holder.loginUser.getUserid());
record.setCreateByName(holder.operatorName);
record.setCreateTime(new Date());
record.setDelFlag(1);
outOrderFreezeRecordService.save(record);
}
/**
* 推送OMS镜像库存type=7下单冻结:可用-冻结+ / type=3下单解冻:可用+冻结-与分配/交接推送同模式
*/
private void pushOmsMirror(MaterialInventoryParamDO paramDO, String kctzType) {
try {
MaterialInventoryOmsParamDO omsParamDO = new MaterialInventoryOmsParamDO();
BeanUtils.copyProperties(paramDO, omsParamDO);
omsParamDO.setKctzType(kctzType);
omsServiceFeign.omsEdit(omsParamDO);
} catch (Exception e) {
// 镜像推送失败不阻断冻结主流程与分配/交接链路同口径由OMS侧定时对账兜底
log.error("下单冻结推送OMS镜像库存失败:relateNo={}, kctzType={}", paramDO.getRelateNo(), kctzType, e);
}
}
private List<OutOrderFreezeRecord> listActiveRecords(String businessOrderNumber) {
return outOrderFreezeRecordService.list(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getBusinessOrderNumber, businessOrderNumber)
.in(OutOrderFreezeRecord::getFreezeStatus, Arrays.asList(
OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN,
OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE,
OutOrderFreezeRecord.STATUS_HANDOVER))
.eq(OutOrderFreezeRecord::getDelFlag, 1));
}
/**
* 本单该行的活跃冻结记录status 1/2承接/校验/取消分配判据共用
*/
private List<OutOrderFreezeRecord> listOrderRowActiveRecords(String businessOrderNumber, Long materialInventoryId) {
return outOrderFreezeRecordService.list(new LambdaQueryWrapper<OutOrderFreezeRecord>()
.eq(OutOrderFreezeRecord::getBusinessOrderNumber, businessOrderNumber)
.eq(OutOrderFreezeRecord::getMaterialInventoryId, materialInventoryId)
.in(OutOrderFreezeRecord::getFreezeStatus, Arrays.asList(
OutOrderFreezeRecord.STATUS_BUSINESS_FROZEN,
OutOrderFreezeRecord.STATUS_EXECUTION_UNDERTAKE))
.eq(OutOrderFreezeRecord::getDelFlag, 1));
}
private String buildRelateNo(String businessOrderNumber, Long uniqueId) {
return uniqueId != null ? businessOrderNumber + "#" + uniqueId : businessOrderNumber;
}
/**
* Feign调用可能无登录上下文SecurityUtils抛异常容错获取并兜底 reverseExecutionOrder 同模式
*/
private LoginUserHolder resolveLoginUser(Long operatorId, String operatorName) {
LoginUserHolder holder = new LoginUserHolder();
try {
holder.loginUser = SecurityUtils.getLoginUser();
} catch (Exception e) {
holder.loginUser = new LoginUser();
holder.loginUser.setUserid(operatorId != null ? operatorId : 0L);
holder.loginUser.setUsername(StringUtils.hasText(operatorName) ? operatorName : "system");
}
holder.operatorName = StringUtils.hasText(operatorName) ? operatorName
: (holder.loginUser.getUsername() != null ? holder.loginUser.getUsername() : "system");
return holder;
}
private static class LoginUserHolder {
private LoginUser loginUser;
private String operatorName;
}
/**
* 待落库的冻结占位先全部扣减成功再统一写冻结记录+推镜像
*/
private static class PendingFreeze {
private final MaterialInventory row;
private final BigDecimal quantity;
private final Long uniqueId;
private final String relateNo;
private PendingFreeze(MaterialInventory row, BigDecimal quantity, Long uniqueId, String relateNo) {
this.row = row;
this.quantity = quantity;
this.uniqueId = uniqueId;
this.relateNo = relateNo;
}
}
}
@@ -123,6 +123,8 @@ public class StockOutOrderDomainService {
@Autowired
private IStockOutOrderService stockOutOrderService;
@Autowired
private com.mhd.wms.domain.outOrderFreezeRecord.service.OutOrderFreezeRecordDomainService outOrderFreezeRecordDomainService;
@Autowired
private IOutMaterialDetailService outMaterialDetailService;
@Autowired
private IDeliveryDetailsLinkService deliveryDetailsLinkService;
@@ -2611,6 +2613,13 @@ public class StockOutOrderDomainService {
stockOutOrderService.updateById(stockOutOrderDB);
return;
}
// 下单冻结模式判断该执行单持有承接/已交接冻结记录时分配时数量未动下单时已冻结
// 取消下发不还原库存数量仅解除承接冻结归还业务单无冻结记录走现有分配占用还原
// 两分支互斥双走会虚增可用
boolean freezeMode = outOrderFreezeRecordDomainService.hasFreezeForExecutionOrder(outOrderNumber);
if (freezeMode) {
outOrderFreezeRecordDomainService.releaseExecutionUndertake(outOrderNumber);
}
for (OutMaterialDetail materialDetail : materialDetails) {
//还原库存
BigDecimal allocationQuantity = materialDetail.getAllocationQuantity();
@@ -2619,7 +2628,7 @@ public class StockOutOrderDomainService {
//以下两种情况都无可还原的冻结量跳过还原直接删单保证此类单据也能被取消/补偿清理
//1.库存不存在如OMS漏传库存ID的历史脏单
//2.未分配的明细allocationQuantity为空或0如下发失败后的补偿清理
if (materialInventory != null && allocationQuantity != null && allocationQuantity.compareTo(BigDecimal.ZERO) > 0) {
if (!freezeMode && materialInventory != null && allocationQuantity != null && allocationQuantity.compareTo(BigDecimal.ZERO) > 0) {
materialInventory.setAllocationQuantity((materialInventory.getAllocationQuantity() == null ? BigDecimal.ZERO : materialInventory.getAllocationQuantity()).add(allocationQuantity));
materialInventory.setFreezeQuantity((materialInventory.getFreezeQuantity() == null ? BigDecimal.ZERO : materialInventory.getFreezeQuantity()).subtract(allocationQuantity));
materialInventoryMapper.updateById(materialInventory);
@@ -2702,12 +2711,17 @@ public class StockOutOrderDomainService {
// 出库/未出库分支: 未出库单据(WMS状态非9)实物库存未被扣减, 只还原分配占用(可用+冻结-),
// 不做实物还原也不推OMS否则会虚增库存; 已出库(9)分支还原实物并推OMS(防御保留, 业务单反审已拦截已出库单)
boolean shipped = stockOutOrderDB.getStatus() != null && stockOutOrderDB.getStatus() == 9;
// 下单冻结模式判断该执行单持有承接/已交接冻结记录时按下单冻结语义还原
// 数量从"可用"口径转为"冻结"口径分配时数量未动交接扣的是下单冻结量还原必须回到冻结待出库态
boolean freezeMode = outOrderFreezeRecordDomainService.hasFreezeForExecutionOrder(outOrderNumber);
for (OutMaterialDetail materialDetail : materialDetails) {
MaterialInventory materialInventory = materialInventoryMapper.selectById(materialDetail.getMaterialInventoryId());
if (!shipped) {
// ===== 未出库: 还原分配占用( cancelExecutionOrder 的取消分配同口径) =====
// 下单冻结模式分配时数量未动下单时已冻结跳过数量还原
// 冻结归还业务单由循环后统一处理21
BigDecimal allocationQuantity = materialDetail.getAllocationQuantity();
if (materialInventory != null && allocationQuantity != null && allocationQuantity.compareTo(BigDecimal.ZERO) > 0) {
if (!freezeMode && materialInventory != null && allocationQuantity != null && allocationQuantity.compareTo(BigDecimal.ZERO) > 0) {
materialInventory.setAllocationQuantity((materialInventory.getAllocationQuantity() == null ? BigDecimal.ZERO : materialInventory.getAllocationQuantity()).add(allocationQuantity));
materialInventory.setFreezeQuantity((materialInventory.getFreezeQuantity() == null ? BigDecimal.ZERO : materialInventory.getFreezeQuantity()).subtract(allocationQuantity));
materialInventory.setUpdateTime(new Date());
@@ -2761,15 +2775,31 @@ public class StockOutOrderDomainService {
//库存调整记录出库反审(kctzjl 内部取调整前快照, 与交接扣减先记录后改库存的顺序一致)
restoreParam.setRelateNo(outOrderNumber + "#" + materialDetail.getUniqueId());
materialInventoryService.kctzjl(restoreParam, "出库反审", loginUser);
//type=1 上架还原: 实物库存/可用数量加回, 冻结数量不动
restoreParam.setType("1");
materialInventoryService.xgkc(restoreParam);
//推送OMS还原预约库存(type=1: OMS侧实物/可用加回, 与交接 omsEdit 扣减互逆)
MaterialInventoryOmsParamDO omsParam = new MaterialInventoryOmsParamDO();
BeanUtils.copyProperties(restoreParam, omsParam);
omsParam.setKctzType("出库反审");
omsParam.setLoginUser(loginUser);
omsServiceFeign.omsEdit(omsParam);
if (freezeMode) {
//下单冻结模式还原为"下单已冻结待出库"实物+冻结+可用不变与交接扣减(库存-冻结-)互逆
materialInventoryMapper.omsRestoreInventoryToFrozen(materialInventoryId,
effectiveQuantity, netWeight, grossWeight, volume, area);
//推送OMS镜像两步合成"实物+冻结+"缺一不可type=1 实物/可用加回 type=7 可用转冻结
MaterialInventoryOmsParamDO omsFrozen = new MaterialInventoryOmsParamDO();
BeanUtils.copyProperties(restoreParam, omsFrozen);
omsFrozen.setKctzType("出库反审");
omsFrozen.setLoginUser(loginUser);
omsFrozen.setType("1");
omsServiceFeign.omsEdit(omsFrozen);
omsFrozen.setType("7");
omsFrozen.setKctzType("出库反审-还原冻结");
omsServiceFeign.omsEdit(omsFrozen);
} else {
//type=1 上架还原: 实物库存/可用数量加回, 冻结数量不动
restoreParam.setType("1");
materialInventoryService.xgkc(restoreParam);
//推送OMS还原预约库存(type=1: OMS侧实物/可用加回, 与交接 omsEdit 扣减互逆)
MaterialInventoryOmsParamDO omsParam = new MaterialInventoryOmsParamDO();
BeanUtils.copyProperties(restoreParam, omsParam);
omsParam.setKctzType("出库反审");
omsParam.setLoginUser(loginUser);
omsServiceFeign.omsEdit(omsParam);
}
}
}
//逻辑删除出库单明细
@@ -2779,6 +2809,15 @@ public class StockOutOrderDomainService {
materialDetail.setReverseFlag(1);
outMaterialDetailService.updateById(materialDetail);
}
// 下单冻结模式冻结记录归还业务单未出库承接21已出库已交接31重激活
// 业务单回 status=1 可编辑/再下发/取消冻结语义闭环
if (freezeMode) {
if (shipped) {
outOrderFreezeRecordDomainService.reactivateHandoverFreeze(outOrderNumber);
} else {
outOrderFreezeRecordDomainService.releaseExecutionUndertake(outOrderNumber);
}
}
//逻辑删除出库单(用按单号查出的DB对象更新: 传入对象无主键, 直接updateById不生效, cancelExecutionOrder 一致)
stockOutOrderDB.setDelFlag(2);
stockOutOrderDB.setReverseFlag(1);
@@ -6,6 +6,7 @@ import com.mhd.common.core.web.controller.BaseController;
import com.mhd.common.core.web.domain.AjaxResult;
import com.mhd.common.core.web.page.TableDataInfo;
import com.mhd.system.api.domain.DeliveryDetailsLink;
import com.mhd.system.api.domain.OmsFreezeInventoryParam;
import com.mhd.wms.application.service.handoverTaskOrder.HandoverTaskOrderApplicationService;
import com.mhd.wms.application.service.stockOutOrder.StockOutOrderApplicationService;
import com.mhd.wms.domain.copyCodeReference.entity.CopyCodeReference;
@@ -25,6 +26,7 @@ import com.mhd.wms.domain.stockOutOrder.repository.facade.IStockOutOrderService;
import com.mhd.wms.domain.stockOutOrder.repository.listener.ExcelParseService;
import com.mhd.wms.domain.stockOutOrder.repository.po.StockOutOrderPO;
import com.mhd.wms.domain.stockOutOrder.repository.todo.StockOutOrderDO;
import com.mhd.wms.domain.outOrderFreezeRecord.service.OutOrderFreezeRecordDomainService;
import com.mhd.wms.interfaces.assember.stockOutOrder.StockOutOrderAssembler;
import com.mhd.wms.interfaces.dto.outMaterialDetail.OutMaterialDetailDTO;
import com.mhd.wms.interfaces.dto.stockOutOrder.StockOutOrderDTO;
@@ -58,6 +60,8 @@ import java.util.Map;
public class StockOutOrderApi extends BaseController {
@Autowired
private StockOutOrderApplicationService stockOutOrderApplicationService;
@Autowired
private OutOrderFreezeRecordDomainService outOrderFreezeRecordDomainService;
@Resource
private StockOutOrderAssembler stockOutOrderAssembler;
@@ -561,8 +565,9 @@ public class StockOutOrderApi extends BaseController {
*/
@ApiOperation("oms下发前库存校验")
@PostMapping(value = "/checkOmsAssignInventory")
public AjaxResult checkOmsAssignInventory(@RequestBody List<OutMaterialDetail> outMaterialDetails) {
stockOutOrderApplicationService.checkOmsAssignInventory(outMaterialDetails);
public AjaxResult checkOmsAssignInventory(@RequestBody List<OutMaterialDetail> outMaterialDetails,
@RequestParam(value = "businessOrderNumber", required = false) String businessOrderNumber) {
stockOutOrderApplicationService.checkOmsAssignInventory(outMaterialDetails, businessOrderNumber);
return AjaxResult.success("库存校验通过");
}
@@ -584,6 +589,46 @@ public class StockOutOrderApi extends BaseController {
return AjaxResult.success();
}
/**
* oms出库业务单下单冻结库存防超卖: 按明细冻结WMS库存可用-冻结+库存数量不变
* 条件更新防并发超卖任一行可用不足整体失败同业务单号幂等
*/
@ApiOperation("oms出库业务单下单冻结库存")
@PostMapping(value = "/omsFreezeInventory")
public AjaxResult omsFreezeInventory(@RequestBody OmsFreezeInventoryParam param) {
return AjaxResult.success(outOrderFreezeRecordDomainService.omsFreezeInventory(param));
}
/**
* oms出库业务单取消/删除-释放冻结库存: 可用+冻结-库存数量不变按业务单号幂等
* 冻结已被执行单承接时拒绝须先取消下发
*/
@ApiOperation("oms出库业务单释放冻结库存")
@PostMapping(value = "/omsUnfreezeInventory")
public AjaxResult omsUnfreezeInventory(@RequestBody OmsFreezeInventoryParam param) {
return AjaxResult.success(outOrderFreezeRecordDomainService.omsUnfreezeInventory(
param.getOutOrderNumber(), param.getOperatorId(), param.getOperatorName()));
}
/**
* oms出库业务单编辑原子重锁: 同一事务先释放原冻结再按新明细重新冻结
* 任一新明细可用不足整体回滚旧冻结同样还原
*/
@ApiOperation("oms出库业务单编辑重锁库存")
@PostMapping(value = "/omsRelockInventory")
public AjaxResult omsRelockInventory(@RequestBody OmsFreezeInventoryParam param) {
return AjaxResult.success(outOrderFreezeRecordDomainService.omsRelockInventory(param));
}
/**
* 仓库是否存在活跃冻结记录仓库"库存锁定模式"切换守卫在途冻结单存在时禁止切换
*/
@ApiOperation("仓库是否存在活跃冻结记录")
@GetMapping(value = "/hasActiveFreezeByWarehouse/{warehouseId}")
public AjaxResult hasActiveFreezeByWarehouse(@PathVariable("warehouseId") Long warehouseId) {
return AjaxResult.success(outOrderFreezeRecordDomainService.hasActiveFreezeByWarehouse(warehouseId));
}
/**
* oms下发单配送明细保存
*/
@@ -1894,4 +1894,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
1 = 1
AND MATERIAL_CLASSIFY_ID = #{id} AND DEL_FLAG = 1
</select>
<!-- OMS下单冻结-条件扣减:可用-、冻结+,库存数量不变;可用不足时影响行数为0,
由调用方抛异常回滚(防并发超卖,绝不能用 GREATEST 截断版) -->
<update id="omsFreezeDeductAvailable">
UPDATE material_inventory
SET allocation_quantity = allocation_quantity - #{quantity},
freeze_quantity = freeze_quantity + #{quantity},
update_time = SYSDATE
WHERE material_inventory_id = #{materialInventoryId}
AND del_flag = 1
AND allocation_quantity &gt;= #{quantity}
</update>
<!-- OMS下单解冻-条件释放:可用+、冻结-,库存数量不变;冻结不足时影响行数为0,
由调用方抛异常回滚(防重复解冻/负数) -->
<update id="omsReleaseFreezeToAvailable">
UPDATE material_inventory
SET allocation_quantity = allocation_quantity + #{quantity},
freeze_quantity = freeze_quantity - #{quantity},
update_time = SYSDATE
WHERE material_inventory_id = #{materialInventoryId}
AND del_flag = 1
AND freeze_quantity &gt;= #{quantity}
</update>
<!-- OMS下单冻结单出库反审-还原为冻结:实物+、冻结+(可用不变)+重量体积面积还原,
与交接扣减 ckjj(库存-冻结-)互逆;整行加回无条件(还原语义明确) -->
<update id="omsRestoreInventoryToFrozen">
UPDATE material_inventory
SET inventory_quantity = inventory_quantity + #{quantity},
freeze_quantity = freeze_quantity + #{quantity},
net_weight = net_weight + #{netWeight},
gross_weight = gross_weight + #{grossWeight},
volume = volume + #{volume},
area = area + #{area},
update_time = SYSDATE
WHERE material_inventory_id = #{materialInventoryId}
AND del_flag = 1
</update>
</mapper>
@@ -0,0 +1,33 @@
-- ============================================================
-- 功能:仓库表新增"库存锁定模式"字段(OMS下单冻结库存/防超卖功能开关)
-- 数据库:达梦(DM8),兼容 Oracle 语法,表名/字段名统一大写
-- 模式:NGWL_TEST_SYSTEMmhd-system 服务对应业务库;生产环境按实际模式名替换)
-- 表名:WAREHOUSE
-- 日期:2026-09-12
-- 说明:
-- 1-下单冻结:OMS出库业务单「下单」即冻结WMS库存(可用-、冻结+,库存数量不变)
-- 2-分配库存冻结:现有模式,WMS「分配库存」时才冻结(默认值,存量仓库行为零变化)
-- 不变式:库存数量 = 可用数量 + 冻结数量(出库交接前全程成立)
-- NULL 视为 2(代码侧 isOrderFreezeWarehouse 仅 =1 才走下单冻结,兜底降级安全)
-- ============================================================
-- 0. 执行前检查(确认列是否已存在,已存在则跳过本脚本)
-- SELECT OWNER, TABLE_NAME, COLUMN_NAME, DATA_TYPE
-- FROM ALL_TAB_COLUMNS
-- WHERE OWNER = 'NGWL_TEST_SYSTEM'
-- AND TABLE_NAME = 'WAREHOUSE'
-- AND COLUMN_NAME = 'STOCK_LOCK_MODE';
-- 1. 添加字段(达梦 ADD COLUMN 不支持 AFTER,列追加在表尾;带 DEFAULT 时存量行自动填默认值)
ALTER TABLE NGWL_TEST_SYSTEM.WAREHOUSE ADD STOCK_LOCK_MODE TINYINT DEFAULT 2;
-- 2. 添加注释(达梦必须单独写)
COMMENT ON COLUMN NGWL_TEST_SYSTEM.WAREHOUSE.STOCK_LOCK_MODE IS '库存锁定模式: 1-下单冻结 2-分配库存冻结';
-- 3. 验证
-- SELECT WAREHOUSE_ID, WAREHOUSE_CODE, STOCK_LOCK_MODE FROM NGWL_TEST_SYSTEM.WAREHOUSE WHERE ROWNUM <= 10;
-- ----------------------------
-- 回滚脚本(按需,须确认无 status∈(1,2) 的在途冻结单后再执行)
-- ALTER TABLE NGWL_TEST_SYSTEM.WAREHOUSE DROP COLUMN STOCK_LOCK_MODE;
-- ----------------------------
@@ -0,0 +1,77 @@
-- ============================================================
-- 功能:WMS出库单库存冻结记录表(OMS下单冻结模式/防超卖)
-- 数据库:达梦(DM8),兼容 Oracle 语法,表名/字段名统一大写
-- 模式:NGWL_TEST_WMSmhd-wms 服务对应业务库,与 MATERIAL_INVENTORY 同库;
-- 生产环境按实际模式名替换)
-- 表名:OUT_ORDER_FREEZE_RECORD
-- 日期:2026-09-12
-- 说明:
-- 记录「业务单↔库存行↔冻结数量」占用关系与状态流转,是编辑重锁/取消解冻/
-- 分配承接/交接标记/反审还原的对账依据与单据级隔离判据。
-- 状态流转:1-业务单冻结(下单) → 2-执行单承接(WMS分配,数量不动)
-- → 3-已交接(出库交接扣减库存后终态);任一环节取消 → 4-已释放;
-- 反审(已出库) 3→1 重新激活。
-- ============================================================
-- 0. 执行前检查(确认表是否已存在,已存在则跳过本脚本)
-- SELECT OWNER, TABLE_NAME FROM ALL_TABLES
-- WHERE OWNER = 'NGWL_TEST_WMS' AND TABLE_NAME = 'OUT_ORDER_FREEZE_RECORD';
-- 1. 建表
CREATE TABLE OUT_ORDER_FREEZE_RECORD (
FREEZE_RECORD_ID BIGINT IDENTITY(1,1) NOT NULL,
BUSINESS_ORDER_NUMBER VARCHAR(50) DEFAULT NULL,
EXECUTION_ORDER_NUMBER VARCHAR(50) DEFAULT NULL,
MATERIAL_DETAIL_UNIQUE_ID VARCHAR(64) DEFAULT NULL,
MATERIAL_INVENTORY_ID BIGINT NOT NULL,
WAREHOUSE_ID BIGINT DEFAULT NULL,
MATERIAL_BASE_INFO_ID BIGINT DEFAULT NULL,
FREEZE_QUANTITY DECIMAL(24,6) NOT NULL,
FREEZE_STATUS INT DEFAULT 1 NOT NULL,
RELATE_NO VARCHAR(100) DEFAULT NULL,
REMARK VARCHAR(500) DEFAULT NULL,
CREATE_BY BIGINT DEFAULT NULL,
CREATE_BY_NAME VARCHAR(50) DEFAULT NULL,
CREATE_TIME DATETIME DEFAULT NULL,
UPDATE_BY BIGINT DEFAULT NULL,
UPDATE_BY_NAME VARCHAR(50) DEFAULT NULL,
UPDATE_TIME DATETIME DEFAULT NULL,
DEL_FLAG INT DEFAULT 1,
CONSTRAINT PK_OUT_ORDER_FREEZE_RECORD PRIMARY KEY (FREEZE_RECORD_ID)
);
-- 2. 表注释
COMMENT ON TABLE OUT_ORDER_FREEZE_RECORD IS 'WMS出库单库存冻结记录表(OMS下单冻结模式)';
-- 3. 列注释(达梦必须单独写)
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.FREEZE_RECORD_ID IS '编号';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.BUSINESS_ORDER_NUMBER IS 'OMS出库业务单号';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.EXECUTION_ORDER_NUMBER IS '执行出库单号(分配承接时回填,可空)';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.MATERIAL_DETAIL_UNIQUE_ID IS '明细唯一标识(OMS明细uniqueId)';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.MATERIAL_INVENTORY_ID IS '库存行ID(MATERIAL_INVENTORY.MATERIAL_INVENTORY_ID)';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.WAREHOUSE_ID IS '仓库ID';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.MATERIAL_BASE_INFO_ID IS '物料基础信息ID';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.FREEZE_QUANTITY IS '冻结数量';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.FREEZE_STATUS IS '冻结状态: 1-业务单冻结 2-执行单承接 3-已交接 4-已释放';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.RELATE_NO IS '关联单号(业务单号#明细uniqueId,与库存调整记录RELATE_NO对应)';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.REMARK IS '备注';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.CREATE_BY IS '创建者';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.CREATE_BY_NAME IS '创建者姓名';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.CREATE_TIME IS '创建时间';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.UPDATE_BY IS '更新者';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.UPDATE_BY_NAME IS '更新人姓名';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.UPDATE_TIME IS '更新时间';
COMMENT ON COLUMN OUT_ORDER_FREEZE_RECORD.DEL_FLAG IS '删除标记:0-无状态,1-正常,2-已删除';
-- 4. 索引(最热路径:按业务单号取记录[幂等/解冻/重锁/承接校验];按执行单号[解除承接/交接标记/重激活];按库存行[分配/取消分配判据])
CREATE INDEX IDX_OOFR_BIZ_ORDER ON OUT_ORDER_FREEZE_RECORD (BUSINESS_ORDER_NUMBER, DEL_FLAG);
CREATE INDEX IDX_OOFR_EXEC_ORDER ON OUT_ORDER_FREEZE_RECORD (EXECUTION_ORDER_NUMBER);
CREATE INDEX IDX_OOFR_INV_ID ON OUT_ORDER_FREEZE_RECORD (MATERIAL_INVENTORY_ID);
-- 5. 验证
-- SELECT FREEZE_STATUS, COUNT(*) FROM OUT_ORDER_FREEZE_RECORD GROUP BY FREEZE_STATUS;
-- ----------------------------
-- 回滚脚本(按需,须确认无在途冻结单[status∈(1,2)]后再执行)
-- DROP TABLE OUT_ORDER_FREEZE_RECORD;
-- ----------------------------
@@ -0,0 +1,24 @@
-- ============================================================
-- 功能:修复冻结记录表主键列名(实体字段 freezeRecordId → 列 FREEZE_RECORD_ID
-- 建表脚本误写为 ID,导致 MyBatis-Plus 查询报"无效的列名[FREEZE_RECORD_ID]"
-- 数据库:达梦(DM8)
-- 模式:NGWL_TEST_WMSmhd-wms 服务数据源;生产按实际模式名替换)
-- 表名:OUT_ORDER_FREEZE_RECORD
-- 日期:2026-09-12
-- ============================================================
-- 0. 执行前检查(确认当前列名)
-- SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS
-- WHERE OWNER = 'NGWL_TEST_WMS' AND TABLE_NAME = 'OUT_ORDER_FREEZE_RECORD'
-- ORDER BY COLUMN_ID;
-- 1. 重命名主键列 ID → FREEZE_RECORD_ID(自增属性/主键约束随列保留)
ALTER TABLE NGWL_TEST_WMS.OUT_ORDER_FREEZE_RECORD RENAME COLUMN ID TO FREEZE_RECORD_ID;
-- 2. 验证(应能看到 FREEZE_RECORD_ID 且不再有 ID 列)
-- SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS
-- WHERE OWNER = 'NGWL_TEST_WMS' AND TABLE_NAME = 'OUT_ORDER_FREEZE_RECORD'
-- ORDER BY COLUMN_ID;
-- SELECT FREEZE_RECORD_ID, BUSINESS_ORDER_NUMBER, FREEZE_STATUS FROM NGWL_TEST_WMS.OUT_ORDER_FREEZE_RECORD;
-- 回滚: ALTER TABLE NGWL_TEST_WMS.OUT_ORDER_FREEZE_RECORD RENAME COLUMN FREEZE_RECORD_ID TO ID;