From c0bcedbdc3d4852ad75a89980735642b04839123 Mon Sep 17 00:00:00 2001 From: rcx <3084692334@qq.com> Date: Sat, 5 Sep 2026 13:38:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(wms,oms):=20JSON=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E5=85=BC=E5=AE=B9yyyy-MM-d?= =?UTF-8?q?d=E7=BA=AF=E6=97=A5=E6=9C=9F=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 库存查询日期只保留年月日后,前端回传纯日期(如 createTime=2026-07-06)触发 @JsonFormat("yyyy-MM-dd HH:mm:ss") 严格格式反序列化 InvalidFormatException - 新增 FlexibleDateDeserializer:字段 @JsonFormat 模式优先严格解析,失败后兼容 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd HH:mm / yyyy-MM-dd / 时间戳 / ISO-8601 - 仅在 OMS/WMS 通过 Module Bean 注册生效,不改动 BaseVOEntity 全局行为 --- .../jackson/FlexibleDateDeserializer.java | 112 ++++++++++++++++++ .../oms/config/DateCompatibilityConfig.java | 33 ++++++ .../wms/config/DateCompatibilityConfig.java | 33 ++++++ 3 files changed, 178 insertions(+) create mode 100644 mhd-common/mhd-common-core/src/main/java/com/mhd/common/core/jackson/FlexibleDateDeserializer.java create mode 100644 mhd_oms/src/main/java/com/mhd/oms/config/DateCompatibilityConfig.java create mode 100644 mhd_wms/src/main/java/com/mhd/wms/config/DateCompatibilityConfig.java diff --git a/mhd-common/mhd-common-core/src/main/java/com/mhd/common/core/jackson/FlexibleDateDeserializer.java b/mhd-common/mhd-common-core/src/main/java/com/mhd/common/core/jackson/FlexibleDateDeserializer.java new file mode 100644 index 000000000..91dc9d344 --- /dev/null +++ b/mhd-common/mhd-common-core/src/main/java/com/mhd/common/core/jackson/FlexibleDateDeserializer.java @@ -0,0 +1,112 @@ +package com.mhd.common.core.jackson; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; + +import java.io.IOException; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * 宽松日期反序列化:兼容 yyyy-MM-dd(纯日期)等常见格式。 + * + * 背景:库存查询等接口日期只保留年月日后,前端可能把 "2026-07-06" 这类纯日期回传, + * 而实体/DTO 上 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 是严格格式,反序列化直接报 + * InvalidFormatException。本反序列化器解析顺序: + * 1) 字段 @JsonFormat 指定的模式(严格全匹配,保持原行为); + * 2) 兜底常见格式:yyyy-MM-dd HH:mm:ss → yyyy-MM-dd HH:mm → yyyy-MM-dd; + * 3) 纯数字按毫秒时间戳; + * 4) 交给 Jackson 默认解析(ISO-8601 等)。 + * 仅放宽接受范围,不影响任何既有格式的解析结果;由各服务 DateCompatibilityConfig 显式注册生效。 + * + * @author rcx + * @date 2026-09-05 + */ +public class FlexibleDateDeserializer extends JsonDeserializer implements ContextualDeserializer { + + /** 兜底格式按长度降序排列,避免长文本被短格式部分解析 */ + private static final String[] FALLBACK_PATTERNS = { + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd" + }; + + /** 字段 @JsonFormat 指定的模式,可为空 */ + private final String pattern; + + public FlexibleDateDeserializer() { + this(null); + } + + public FlexibleDateDeserializer(String pattern) { + this.pattern = pattern; + } + + @Override + public Date deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException { + String value = parser.getText(); + if (value == null || value.trim().isEmpty()) { + return null; + } + value = value.trim(); + + // 纯数字按毫秒时间戳(10位秒级时间戳也兼容) + if (value.matches("\\d{10,}")) { + return new Date(Long.parseLong(value)); + } + + // 1) 字段 @JsonFormat 指定的模式优先 + Date parsed = tryParse(value, pattern); + // 2) 兜底常见格式 + if (parsed == null) { + for (String fallback : FALLBACK_PATTERNS) { + parsed = tryParse(value, fallback); + if (parsed != null) { + break; + } + } + } + // 3) 交给 Jackson 默认解析(ISO-8601 等) + if (parsed == null) { + try { + parsed = ctxt.parseDate(value); + } catch (IllegalArgumentException ignore) { + // 走不到默认解析时统一在下方抛出友好异常 + } + } + if (parsed == null) { + throw ctxt.weirdStringException(value, Date.class, + "支持格式:yyyy-MM-dd HH:mm:ss / yyyy-MM-dd HH:mm / yyyy-MM-dd"); + } + return parsed; + } + + /** + * 严格全匹配解析:解析失败或文本未完整消费(如按 yyyy-MM-dd 解析 "2026-07-06 10:00:00") + * 都视为不匹配返回 null,交由下一个格式尝试 + */ + private Date tryParse(String value, String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + sdf.setLenient(false); + ParsePosition pos = new ParsePosition(0); + Date date = sdf.parse(value, pos); + return (date != null && pos.getIndex() == value.length()) ? date : null; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + if (property != null) { + JsonFormat jsonFormat = property.getAnnotation(JsonFormat.class); + if (jsonFormat != null && jsonFormat.pattern() != null && !jsonFormat.pattern().isEmpty()) { + return new FlexibleDateDeserializer(jsonFormat.pattern()); + } + } + return this; + } +} diff --git a/mhd_oms/src/main/java/com/mhd/oms/config/DateCompatibilityConfig.java b/mhd_oms/src/main/java/com/mhd/oms/config/DateCompatibilityConfig.java new file mode 100644 index 000000000..714c4c851 --- /dev/null +++ b/mhd_oms/src/main/java/com/mhd/oms/config/DateCompatibilityConfig.java @@ -0,0 +1,33 @@ +package com.mhd.oms.config; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.mhd.common.core.jackson.FlexibleDateDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Date; + +/** + * JSON 日期反序列化兼容配置:Date 字段兼容 yyyy-MM-dd(纯日期)等格式。 + * + * 背景:库存查询日期只保留年月日后,前端回传纯日期(如 createTime=2026-07-06)时, + * 实体/DTO 上 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 严格格式反序列化报 + * InvalidFormatException。注册 FlexibleDateDeserializer 后优先按字段 @JsonFormat + * 严格解析,失败时兼容纯日期等常见格式(与 WMS 库存查询一致)。 + * + * Spring Boot 会自动把容器中的 Module Bean 注册进 MVC 使用的 ObjectMapper。 + * + * @author rcx + * @date 2026-09-05 + */ +@Configuration +public class DateCompatibilityConfig { + + @Bean + public Module flexibleDateModule() { + SimpleModule module = new SimpleModule("oms-flexible-date-module"); + module.addDeserializer(Date.class, new FlexibleDateDeserializer()); + return module; + } +} diff --git a/mhd_wms/src/main/java/com/mhd/wms/config/DateCompatibilityConfig.java b/mhd_wms/src/main/java/com/mhd/wms/config/DateCompatibilityConfig.java new file mode 100644 index 000000000..8763207b7 --- /dev/null +++ b/mhd_wms/src/main/java/com/mhd/wms/config/DateCompatibilityConfig.java @@ -0,0 +1,33 @@ +package com.mhd.wms.config; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.mhd.common.core.jackson.FlexibleDateDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Date; + +/** + * JSON 日期反序列化兼容配置:Date 字段兼容 yyyy-MM-dd(纯日期)等格式。 + * + * 背景:库存查询日期只保留年月日后,前端回传纯日期(如 createTime=2026-07-06)时, + * 实体/DTO 上 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 严格格式反序列化报 + * InvalidFormatException。注册 FlexibleDateDeserializer 后优先按字段 @JsonFormat + * 严格解析,失败时兼容纯日期等常见格式(与 OMS 库存查询一致)。 + * + * Spring Boot 会自动把容器中的 Module Bean 注册进 MVC 使用的 ObjectMapper。 + * + * @author rcx + * @date 2026-09-05 + */ +@Configuration +public class DateCompatibilityConfig { + + @Bean + public Module flexibleDateModule() { + SimpleModule module = new SimpleModule("wms-flexible-date-module"); + module.addDeserializer(Date.class, new FlexibleDateDeserializer()); + return module; + } +} From be8ba4506763082fa052519bfd1e59bd719fbfa0 Mon Sep 17 00:00:00 2001 From: rcx <3084692334@qq.com> Date: Sat, 5 Sep 2026 13:42:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(oms,wms):=20=E5=87=BA=E5=BA=93=E5=8D=95?= =?UTF-8?q?=E4=B8=8B=E5=8F=91WMS=E5=A4=B1=E8=B4=A5=E8=A2=AB=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=90=9E=E6=8E=89=EF=BC=8C=E4=BF=AE=E5=A4=8DWMS?= =?UTF-8?q?=E5=87=BA=E5=BA=93=E6=8B=A3=E8=B4=A7=E4=B8=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?OMS=E5=8D=95=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 建单/下发前统一校验(数量>0、已关联库存、WMS可用库存),错误汇总一次性提示 - 新增WMS库存校验接口,库存不足在OMS侧拦截不再推送WMS;拦截重复下发 - 检查WMS自动分配/生成拣货单返回值,失败报错并补偿清理WMS残留单据 - 下发状态逐单提交,避免批量部分失败造成两边状态撕裂 - 预约单多仓下发增加逐仓预校验 - 修复取消下发不删拣货单、无库存脏单无法取消两个历史问题 --- .../com/mhd/system/api/WmsServiceFeign.java | 10 +- .../api/factory/RemoteWmsFallbackFactory.java | 11 ++- .../ExecutionStockOutOrderDomainService.java | 91 ++++++++++++++---- ...ReservationStockOutOrderDomainService.java | 41 +++++++++ .../StockOutOrderApplicationService.java | 92 +++++++++++++++++++ .../service/StockOutOrderDomainService.java | 30 ++++-- .../stockOutOrder/StockOutOrderApi.java | 13 ++- 7 files changed, 258 insertions(+), 30 deletions(-) diff --git a/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/WmsServiceFeign.java b/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/WmsServiceFeign.java index 7fbd7dd9b..22be78474 100644 --- a/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/WmsServiceFeign.java +++ b/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/WmsServiceFeign.java @@ -100,7 +100,7 @@ public interface WmsServiceFeign { @ApiOperation("oms取消下发单保存") @PostMapping(value = "/stockOutOrderApi/cancelExecutionOrder") - public void omsCancelExecutionOrder(@RequestBody StockOutOrder stockOutOrder); + public AjaxResult omsCancelExecutionOrder(@RequestBody StockOutOrder stockOutOrder); @ApiOperation("oms取消下发单自动分配") @PostMapping(value = "/stockOutOrderApi/omsAutoAssign") @@ -110,6 +110,14 @@ public interface WmsServiceFeign { @PostMapping(value = "/stockOutOrderApi/execution") public AjaxResult execution(@RequestBody com.mhd.system.api.domain.StockOutOrderDTO stockOutOrderDTO); + /** + * oms下发前库存校验:可用库存不足时返回错误,OMS据此在推送前拦截下发, + * 避免单据先落库、分配才失败而在WMS残留"已审核"状态的垃圾单 + */ + @ApiOperation("oms下发前库存校验") + @PostMapping(value = "/stockOutOrderApi/checkOmsAssignInventory") + public AjaxResult checkOmsAssignInventory(@RequestBody List outMaterialDetails); + /** * oms下发单明细保存 */ diff --git a/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/factory/RemoteWmsFallbackFactory.java b/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/factory/RemoteWmsFallbackFactory.java index 1bf77e956..aabf37512 100644 --- a/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/factory/RemoteWmsFallbackFactory.java +++ b/mhd-api/mhd-api-system/src/main/java/com/mhd/system/api/factory/RemoteWmsFallbackFactory.java @@ -74,8 +74,9 @@ public class RemoteWmsFallbackFactory implements FallbackFactory outMaterialDetails) { + //校验接口本身不可用时返回错误,OMS会阻止下发(不能在WMS状态未知时盲目推送) + return AjaxResult.error("WMS库存校验服务不可用:" + cause.getMessage()); + } + @Override public void omsInOrderAddDetail(List inMaterialDetails) { } diff --git a/mhd_oms/src/main/java/com/mhd/oms/domain/executionStockOutOrder/service/ExecutionStockOutOrderDomainService.java b/mhd_oms/src/main/java/com/mhd/oms/domain/executionStockOutOrder/service/ExecutionStockOutOrderDomainService.java index 311b600ae..e5572cc8d 100644 --- a/mhd_oms/src/main/java/com/mhd/oms/domain/executionStockOutOrder/service/ExecutionStockOutOrderDomainService.java +++ b/mhd_oms/src/main/java/com/mhd/oms/domain/executionStockOutOrder/service/ExecutionStockOutOrderDomainService.java @@ -480,37 +480,59 @@ public class ExecutionStockOutOrderDomainService { * @param stockOutOrderDO * @return Boolean */ - @Transactional(rollbackFor = Exception.class) + //注意:不能用方法级事务。批量下发时若前一张已推送WMS成功、后一张失败,方法级事务回滚会把已成功单据的 + //下发状态一并回滚(WMS已分配库存、已生成拣货单,OMS却显示未下发),造成两边状态撕裂。 + //因此改为:每张单全链路推送成功后立即逐单更新并提交下发状态,失败单据补偿清理后抛错,不影响已成功单据。 public Boolean issueOrder(ExecutionStockOutOrderDO stockOutOrderDO) { LoginUser loginUser = SecurityUtils.getLoginUser(); // 获取用户姓名(优先从UserPo获取,如果为空则使用realname,最后使用username作为兜底) String userRealName = loginUser.getUserPo() != null && loginUser.getUserPo().getUserName() != null ? loginUser.getUserPo().getUserName() : (loginUser.getRealname() != null ? loginUser.getRealname() : loginUser.getUsername()); - boolean update = stockOutOrderService.update(null, new UpdateWrapper().lambda() - .set(ExecutionStockOutOrder::getIssueId, loginUser.getUserid()) - .set(ExecutionStockOutOrder::getIssueName, userRealName) - .set(ExecutionStockOutOrder::getIssueTime, new Date()) - //.set(ExecutionStockOutOrder::getIssueStatus, 1) - .set(ExecutionStockOutOrder::getUpdateBy, loginUser.getUserid()) - .set(ExecutionStockOutOrder::getUpdateByName, userRealName) - .set(ExecutionStockOutOrder::getUpdateTime, new Date()) - .in(ExecutionStockOutOrder::getOutOrderId, stockOutOrderDO.getOutOrderIds())); - //下发前统一校验所有单据:全部通过后再推送WMS,避免部分单据推送成功、部分失败造成两边数据不一致 + //下发前统一校验所有单据:先完成全部校验并汇总所有问题一次性提示,全部通过后再推送WMS List inOrderIds = stockOutOrderDO.getOutOrderIds(); List issueOrderList = new ArrayList<>(); + List validateErrors = new ArrayList<>(); for (Long inOrderId : inOrderIds) { ExecutionStockOutOrder byId = stockOutOrderService.getById(inOrderId); if (byId == null) { - throw new ServiceException("出库单不存在,outOrderId=" + inOrderId); + validateErrors.add("出库单不存在,outOrderId=" + inOrderId); + continue; } - validateIssueOrderDetail(byId); - issueOrderList.add(byId); + try { + List detailList = validateIssueOrderDetail(byId); + //下发前调WMS校验可用库存(单内全部库存行校验完才返回结果),问题先收集,最后统一提示,不推送任何数据到WMS + List checkList = new ArrayList<>(); + for (ExecutionOutMaterialDetail detail : detailList) { + OutMaterialDetail checkDetail = new OutMaterialDetail(); + checkDetail.setOutOrderNumber(byId.getOutOrderNumber()); + checkDetail.setMaterialInventoryId(detail.getMaterialInventoryId()); + checkDetail.setQuantity(detail.getQuantity()); + checkDetail.setMaterialCode(detail.getMaterialCode()); + checkDetail.setMaterialName(detail.getMaterialName()); + checkList.add(checkDetail); + } + AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList); + if (checkResult == null || !"200".equals(String.valueOf(checkResult.get("code")))) { + validateErrors.add("出库单[" + byId.getOutOrderNumber() + "]库存校验失败:" + + (checkResult == null ? "WMS服务不可用,已阻止下发" : checkResult.get("msg"))); + continue; + } + issueOrderList.add(byId); + } catch (ServiceException e) { + //单张单据校验失败不中断,继续校验其余单据,最后统一提示 + validateErrors.add(e.getMessage()); + } + } + if (!validateErrors.isEmpty()) { + throw new ServiceException("下发校验未通过(共" + validateErrors.size() + "项):" + String.join(";", validateErrors)); } //下发推送到wms for (ExecutionStockOutOrder byId : issueOrderList) { + String currentOrderNumber = byId.getOutOrderNumber(); + try { StockOutOrder stockOutOrder = new StockOutOrder(); BeanUtils.copyProperties(byId, stockOutOrder,"outOrderId"); Long warehouseId = stockOutOrder.getWarehouseId(); @@ -562,17 +584,50 @@ public class ExecutionStockOutOrderDomainService { throw new ServiceException("出库单[" + outOrderNumber + "]WMS生成拣货单失败:" + (genPickingResult == null ? "WMS服务不可用或已降级" : genPickingResult.get("msg"))); } + + //该单全链路成功,逐单更新并提交下发信息(后续单据失败不回滚本单,保持与WMS状态一致) + boolean updated = stockOutOrderService.update(null, new UpdateWrapper().lambda() + .set(ExecutionStockOutOrder::getIssueId, loginUser.getUserid()) + .set(ExecutionStockOutOrder::getIssueName, userRealName) + .set(ExecutionStockOutOrder::getIssueTime, new Date()) + //.set(ExecutionStockOutOrder::getIssueStatus, 1) + .set(ExecutionStockOutOrder::getUpdateBy, loginUser.getUserid()) + .set(ExecutionStockOutOrder::getUpdateByName, userRealName) + .set(ExecutionStockOutOrder::getUpdateTime, new Date()) + .eq(ExecutionStockOutOrder::getOutOrderId, byId.getOutOrderId())); + if (!updated) { + log.warn("出库单[{}]WMS下发成功,但OMS下发信息更新失败,请检查", currentOrderNumber); + } + } catch (RuntimeException e) { + //补偿清理:推送中途失败时WMS可能已落库单头/明细(甚至已分配冻结库存),调用取消下发清理, + //避免残留"已审核"状态的垃圾单;清理失败记error日志提示人工处理,不影响向用户报出原始错误 + try { + StockOutOrder cancelParam = new StockOutOrder(); + cancelParam.setOutOrderNumber(currentOrderNumber); + AjaxResult cancelResult = wmsServiceFeign.omsCancelExecutionOrder(cancelParam); + if (cancelResult == null || !"200".equals(String.valueOf(cancelResult.get("code")))) { + log.error("出库单[{}]下发失败,且WMS补偿清理失败({}),请手动到WMS删除残留单据", + currentOrderNumber, cancelResult == null ? "WMS服务不可用" : cancelResult.get("msg"), e); + } else { + log.warn("出库单[{}]下发失败,已补偿清理WMS侧单据", currentOrderNumber, e); + } + } catch (Exception cleanupEx) { + log.error("出库单[{}]下发失败,且WMS补偿清理异常,请手动到WMS删除残留单据", currentOrderNumber, cleanupEx); + } + throw e; + } } - return update; + return Boolean.TRUE; } /** * 建单校验:出库明细数量必须大于0 * 历史缺陷:0数量明细的单下发到WMS后自动分配必然失败,且异常被吞,出库单卡死在"已审核"状态无法生成拣货单 + * 注意:明细为空时跳过校验(保持原有"先建单后补明细"的行为),下发时的校验会兜底拦截无明细单据 */ private void validateMaterialDetailQuantity(List materialDetailList) { if (CollectionUtils.isEmpty(materialDetailList)) { - throw new ServiceException("出库单明细不能为空"); + return; } for (ExecutionOutMaterialDetailDO detail : materialDetailList) { if (detail.getQuantity() == null || detail.getQuantity().compareTo(BigDecimal.ZERO) <= 0) { @@ -587,8 +642,9 @@ public class ExecutionStockOutOrderDomainService { * 1.必须有物料明细,且数量大于0; * 2.明细必须已关联库存(materialInventoryId),否则WMS按库存ID自动分配会空指针; * 校验放在推送WMS之前,失败直接报错回滚,避免出库单卡死在WMS"已审核"状态 + * @return 校验通过的明细列表,供后续WMS库存校验使用 */ - private void validateIssueOrderDetail(ExecutionStockOutOrder order) { + private List validateIssueOrderDetail(ExecutionStockOutOrder order) { String outOrderNumber = order.getOutOrderNumber(); List detailList = outMaterialDetailService.list(new QueryWrapper().lambda() .eq(ExecutionOutMaterialDetail::getOutOrderNumber, outOrderNumber)); @@ -605,6 +661,7 @@ public class ExecutionStockOutOrderDomainService { throw new ServiceException("出库单[" + outOrderNumber + "]物料[" + materialDesc + "]未关联库存,无法下发"); } } + return detailList; } /** diff --git a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockOutOrder/service/ReservationStockOutOrderDomainService.java b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockOutOrder/service/ReservationStockOutOrderDomainService.java index a90e17cdb..99bd307a5 100644 --- a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockOutOrder/service/ReservationStockOutOrderDomainService.java +++ b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockOutOrder/service/ReservationStockOutOrderDomainService.java @@ -696,6 +696,47 @@ public class ReservationStockOutOrderDomainService { // 按照 warehouseId 分组 Map> materialDetailGroupByWarehouse = materialDetailList.stream() .collect(Collectors.groupingBy(ReservationOutMaterialDetail::getWarehouseId)); + + //下发前统一校验各仓明细(数量>0、已关联库存、WMS可用库存充足),全部通过后再逐仓生成执行单下发: + //本方法的事务回滚不了WMS已提交的数据,若后面的仓在生成执行单后才失败,前面的仓会在WMS残留单据 + List issueValidateErrors = new ArrayList<>(); + materialDetailGroupByWarehouse.forEach((warehouseId, details) -> { + boolean groupBaseValid = true; + for (ReservationOutMaterialDetail detail : details) { + String materialDesc = detail.getMaterialCode() != null ? detail.getMaterialCode() + : (detail.getMaterialName() != null ? detail.getMaterialName() : String.valueOf(detail.getMaterialDetailId())); + if (detail.getQuantity() == null || detail.getQuantity().compareTo(BigDecimal.ZERO) <= 0) { + issueValidateErrors.add("预约单[" + inOrderNumber + "]物料[" + materialDesc + "]数量为0,无法下发"); + groupBaseValid = false; + } else if (detail.getMaterialInventoryId() == null || detail.getMaterialInventoryId() <= 0) { + issueValidateErrors.add("预约单[" + inOrderNumber + "]物料[" + materialDesc + "]未关联库存,无法下发"); + groupBaseValid = false; + } + } + if (!groupBaseValid) { + return; + } + //WMS库存校验(接口内部会聚合该仓全部明细的需求量后比对可用库存) + List checkList = new ArrayList<>(); + for (ReservationOutMaterialDetail detail : details) { + OutMaterialDetail checkDetail = new OutMaterialDetail(); + checkDetail.setMaterialInventoryId(detail.getMaterialInventoryId()); + checkDetail.setQuantity(detail.getQuantity()); + checkDetail.setMaterialCode(detail.getMaterialCode()); + checkDetail.setMaterialName(detail.getMaterialName()); + checkList.add(checkDetail); + } + AjaxResult checkResult = wmsServiceFeign.checkOmsAssignInventory(checkList); + if (checkResult == null || !"200".equals(String.valueOf(checkResult.get("code")))) { + String groupWarehouseName = reservationStockInOrderMapper.getWarehouseName(warehouseId); + issueValidateErrors.add("仓库[" + (groupWarehouseName != null ? groupWarehouseName : warehouseId) + "]库存校验失败:" + + (checkResult == null ? "WMS服务不可用,已阻止下发" : checkResult.get("msg"))); + } + }); + if (!issueValidateErrors.isEmpty()) { + throw new ServiceException("下发校验未通过(共" + issueValidateErrors.size() + "项):" + String.join(";", issueValidateErrors)); + } + List executionInOrderNumbers = new ArrayList<>(); materialDetailGroupByWarehouse.forEach((warehouseId, details) -> { if (warehouseId == null) warehouseId = warehouseId1; diff --git a/mhd_wms/src/main/java/com/mhd/wms/application/service/stockOutOrder/StockOutOrderApplicationService.java b/mhd_wms/src/main/java/com/mhd/wms/application/service/stockOutOrder/StockOutOrderApplicationService.java index 7dfe88da6..b7c1e7f2a 100644 --- a/mhd_wms/src/main/java/com/mhd/wms/application/service/stockOutOrder/StockOutOrderApplicationService.java +++ b/mhd_wms/src/main/java/com/mhd/wms/application/service/stockOutOrder/StockOutOrderApplicationService.java @@ -1174,6 +1174,98 @@ public class StockOutOrderApplicationService { return result; } + /** + * OMS下发前库存校验:按库存ID聚合各明细计划数量,逐一比对WMS可用库存, + * 全部比对完成后汇总所有不足项一次性抛错。用于OMS在下发推送前拦截 + * (此前单据先落库、分配才失败,会在WMS残留"已审核"状态的垃圾单) + */ + public void checkOmsAssignInventory(List outMaterialDetails) { + if (CollectionUtils.isEmpty(outMaterialDetails)) { + return; + } + //重复下发保护:该单号在WMS已存在活动单据时直接拦截(继续下发会删改已分配明细、 + //失败补偿还会误删正在拣货的单和拣货单)。历史残留单据需先在OMS取消下发后再重试 + String outOrderNumber = outMaterialDetails.get(0).getOutOrderNumber(); + if (outOrderNumber != null && !outOrderNumber.isEmpty()) { + StockOutOrder existOrder = stockOutOrderService.getOne(new QueryWrapper().lambda() + .eq(StockOutOrder::getOutOrderNumber, outOrderNumber) + .eq(StockOutOrder::getDelFlag, 1), false); + if (existOrder != null) { + throw new ServiceException("WMS已存在该出库单(当前状态:" + stockOutOrderStatusName(existOrder.getStatus()) + + "),请勿重复下发;若为历史残留单据,请先取消下发后重试"); + } + } + //同一库存被多条明细引用时聚合需求量再比较(单条比会漏判) + Map needByInventoryId = new LinkedHashMap<>(); + Map materialDescByInventoryId = new HashMap<>(); + for (OutMaterialDetail detail : outMaterialDetails) { + if (detail.getMaterialInventoryId() == null) { + continue; + } + BigDecimal need = detail.getQuantity() == null ? BigDecimal.ZERO : detail.getQuantity(); + needByInventoryId.merge(detail.getMaterialInventoryId(), need, BigDecimal::add); + materialDescByInventoryId.putIfAbsent(detail.getMaterialInventoryId(), buildMaterialDesc(detail)); + } + List errors = new ArrayList<>(); + for (Map.Entry entry : needByInventoryId.entrySet()) { + Long materialInventoryId = entry.getKey(); + BigDecimal need = entry.getValue(); + String materialDesc = materialDescByInventoryId.getOrDefault(materialInventoryId, "库存ID=" + materialInventoryId); + if (materialInventoryId <= 0) { + errors.add("物料[" + materialDesc + "]未关联库存,合计需求数量:" + need.stripTrailingZeros().toPlainString()); + continue; + } + MaterialInventory materialInventory = materialInventoryMapper.selectById(materialInventoryId); + if (materialInventory == null) { + errors.add("物料[" + materialDesc + "]库存不存在,库存ID=" + materialInventoryId); + continue; + } + BigDecimal available = materialInventory.getAllocationQuantity() == null + ? BigDecimal.ZERO : materialInventory.getAllocationQuantity(); + if (available.compareTo(need) < 0) { + errors.add(String.format("物料[%s]可用库存不足:需要%s,当前可用%s", + materialDesc, need.stripTrailingZeros().toPlainString(), available.stripTrailingZeros().toPlainString())); + } + } + if (!errors.isEmpty()) { + throw new ServiceException("下发前库存校验未通过(共" + errors.size() + "项):" + String.join(";", errors)); + } + } + + private String buildMaterialDesc(OutMaterialDetail detail) { + if (detail.getMaterialCode() != null) { + return detail.getMaterialCode() + (detail.getMaterialName() != null ? "-" + detail.getMaterialName() : ""); + } + if (detail.getMaterialName() != null) { + return detail.getMaterialName(); + } + return "未知物料"; + } + + /** + * 出库单状态码转名称(用于校验提示),未知状态码原样返回 + */ + private String stockOutOrderStatusName(Integer status) { + if (status == null) { + return "未知"; + } + switch (status) { + case 1: return "已创建"; + case 2: return "已审核"; + case 3: return "部分分配"; + case 4: return "已分配"; + case 5: return "波次中"; + case 6: return "拣货中"; + case 7: return "拣货完成"; + case 8: return "复核中"; + case 9: return "已出库"; + case 10: return "取消"; + case 11: return "关闭"; + case 12: return "已复核"; + default: return "状态码" + status; + } + } + /*public Boolean autoAssign(StockOutOrderDO stockOutOrderDO){ StockOutOrderDO assembleStockOutOrderDO = assembleParamByOutOrder(stockOutOrderDO); return stockOutOrderDomainService.autoAssign(assembleStockOutOrderDO); diff --git a/mhd_wms/src/main/java/com/mhd/wms/domain/stockOutOrder/service/StockOutOrderDomainService.java b/mhd_wms/src/main/java/com/mhd/wms/domain/stockOutOrder/service/StockOutOrderDomainService.java index 18af0e37e..52bd75e31 100644 --- a/mhd_wms/src/main/java/com/mhd/wms/domain/stockOutOrder/service/StockOutOrderDomainService.java +++ b/mhd_wms/src/main/java/com/mhd/wms/domain/stockOutOrder/service/StockOutOrderDomainService.java @@ -2555,6 +2555,7 @@ public class StockOutOrderDomainService { stockOutOrderService.save(stockOutOrder); } + @Transactional(rollbackFor = Exception.class) public void cancelExecutionOrder(StockOutOrder stockOutOrder) { String outOrderNumber = stockOutOrder.getOutOrderNumber(); StockOutOrder stockOutOrderDB = stockOutOrderService.getOne(new LambdaQueryWrapper() @@ -2563,23 +2564,33 @@ public class StockOutOrderDomainService { if (stockOutOrderDB == null) { throw new ServiceException("出库单不存在"); } + //逻辑删除配送明细(放在最前:所有清理路径——含下方无明细提前返回——统一先清配送明细,避免残留) + deliveryDetailsLinkService.update(new UpdateWrapper().lambda() + .set(com.mhd.wms.domain.deliveryDetailsLink.entity.DeliveryDetailsLink::getDelFlag, 2) + .eq(com.mhd.wms.domain.deliveryDetailsLink.entity.DeliveryDetailsLink::getOrderNumber, outOrderNumber) + .eq(com.mhd.wms.domain.deliveryDetailsLink.entity.DeliveryDetailsLink::getDelFlag, 1)); List materialDetails = outMaterialDetailService.list(new LambdaQueryWrapper() .eq(OutMaterialDetail::getOutOrderNumber, outOrderNumber) .eq(OutMaterialDetail::getDelFlag, 1)); if (CollectionUtils.isEmpty(materialDetails)) { - throw new ServiceException("出库单明细不存在"); + //无明细(下发中途失败的补偿清理场景):没有可还原的库存,直接逻辑删除单头即可 + stockOutOrderDB.setDelFlag(2); + stockOutOrderService.updateById(stockOutOrderDB); + return; } for (OutMaterialDetail materialDetail : materialDetails) { //还原库存 BigDecimal allocationQuantity = materialDetail.getAllocationQuantity(); Long materialInventoryId = materialDetail.getMaterialInventoryId(); MaterialInventory materialInventory = materialInventoryMapper.selectById(materialInventoryId); - if (materialInventory == null) { - throw new ServiceException("库存信息不存在"); + //以下两种情况都无可还原的冻结量,跳过还原直接删单,保证此类单据也能被取消/补偿清理: + //1.库存不存在(如OMS漏传库存ID的历史脏单); + //2.未分配的明细(allocationQuantity为空或0,如下发失败后的补偿清理) + if (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); } - materialInventory.setAllocationQuantity(materialInventory.getAllocationQuantity().add(allocationQuantity)); - materialInventory.setFreezeQuantity(materialInventory.getFreezeQuantity().subtract(allocationQuantity)); - materialInventoryMapper.updateById(materialInventory); //逻辑删除库存调整记录 Long uniqueId = materialDetail.getUniqueId(); String relateNo = outOrderNumber+"#"+uniqueId; @@ -2590,9 +2601,9 @@ public class StockOutOrderDomainService { materialDetail.setDelFlag(2); outMaterialDetailService.updateById(materialDetail); } - //逻辑删除出库单 - stockOutOrder.setDelFlag(2); - stockOutOrderService.updateById(stockOutOrder); + //逻辑删除出库单(用按单号查出的DB对象更新:OMS补偿清理只传单号,传入对象无主键,直接updateById不生效) + stockOutOrderDB.setDelFlag(2); + stockOutOrderService.updateById(stockOutOrderDB); PickingOrder pickingOrder = pickingOrderService.getOne(new LambdaQueryWrapper() .eq(PickingOrder::getDelFlag, 1) @@ -2606,6 +2617,7 @@ public class StockOutOrderDomainService { pickingMaterialDetail.setDelFlag(2); pickingMaterialDetailService.updateById(pickingMaterialDetail); } + pickingOrder.setDelFlag(2); pickingOrderService.updateById(pickingOrder); } } diff --git a/mhd_wms/src/main/java/com/mhd/wms/interfaces/facadeApi/stockOutOrder/StockOutOrderApi.java b/mhd_wms/src/main/java/com/mhd/wms/interfaces/facadeApi/stockOutOrder/StockOutOrderApi.java index 6e3d11735..fc3ceece7 100644 --- a/mhd_wms/src/main/java/com/mhd/wms/interfaces/facadeApi/stockOutOrder/StockOutOrderApi.java +++ b/mhd_wms/src/main/java/com/mhd/wms/interfaces/facadeApi/stockOutOrder/StockOutOrderApi.java @@ -542,10 +542,21 @@ public class StockOutOrderApi extends BaseController { stockOutOrderApplicationService.omsOrderAddDetail(outMaterialDetails); } + /** + * oms下发前库存校验:不足时返回错误,OMS据此拦截下发,避免单据落库后分配失败残留垃圾单 + */ + @ApiOperation("oms下发前库存校验") + @PostMapping(value = "/checkOmsAssignInventory") + public AjaxResult checkOmsAssignInventory(@RequestBody List outMaterialDetails) { + stockOutOrderApplicationService.checkOmsAssignInventory(outMaterialDetails); + return AjaxResult.success("库存校验通过"); + } + @ApiOperation("oms下发单保存") @PostMapping(value = "/cancelExecutionOrder") - public void omsCancelExecutionOrder(@RequestBody StockOutOrder stockOutOrder) { + public AjaxResult omsCancelExecutionOrder(@RequestBody StockOutOrder stockOutOrder) { stockOutOrderApplicationService.cancelExecutionOrder(stockOutOrder); + return AjaxResult.success(); } /**