From 51a93a69af8fc20cafb59f4ae88a77d7558330f5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 30 Oct 2026 17:59:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/docs/business-index.md | 2 +- lastleg-service/docs/procurement-workflow.md | 10 +- .../DispatchDeliveryRepository.java | 17 ++- .../service/DispatchDeliveryService.java | 105 +++++++++++++++-- .../DispatchAggregateController.java | 8 ++ .../dispatch/mapper/DispatchReadMapper.java | 8 ++ .../dispatch/port/DispatchCommands.java | 11 +- .../dispatch/port/DispatchExceptionPort.java | 17 +++ .../dispatch/port/DispatchOutboundPort.java | 16 +++ .../DispatchAggregateRepository.java | 2 + .../service/DispatchAggregateService.java | 38 ++++++- .../service/DispatchPickingService.java | 4 +- .../controller/ExceptionOrderController.java | 11 ++ .../PdaExceptionReturnController.java | 33 ++++++ .../dto/ExceptionOrderResponses.java | 5 +- .../mapper/ExceptionWorkflowMapper.java | 47 ++++++++ .../ExceptionWorkflowRepository.java | 39 +++++++ .../service/DispatchExceptionAdapter.java | 53 +++++++++ .../service/ExceptionOrderService.java | 22 +++- .../ExceptionReturnInboundService.java | 106 ++++++++++++++++++ ...MerchantOverseasProcurementController.java | 2 +- .../funding/OrderFundingRepository.java | 49 ++++++++ .../funding/OrderFundingService.java | 47 ++++++++ .../service/OverseasProcurementService.java | 25 ++++- .../MerchantUsTruckOrderController.java | 2 +- .../trucking/service/UsTruckOrderService.java | 46 ++++++-- .../budget/MerchantOrderBudgetController.java | 14 +++ .../wallet/budget/OrderBudgetService.java | 61 ++++++++-- .../wallet/budget/OrderFundingPort.java | 7 ++ .../openapi/exception-return-inbound-v1.yaml | 95 ++++++++++++++++ .../resources/openapi/order-budgets-v1.yaml | 25 ++++- .../openapi/overseas-business-v1.yaml | 33 +++++- .../openapi/procurement-workflow-v1.yaml | 4 +- .../DispatchAggregatePersistenceTest.java | 2 + .../DispatchPickingPersistenceTest.java | 1 + ...xceptionOrderDispatchReturnSourceTest.java | 3 +- .../service/ExceptionOrderLifecycleTest.java | 3 +- .../service/ExceptionOrderServiceTest.java | 3 +- .../ExceptionReturnConcurrencyTest.java | 3 +- .../service/ExceptionReturnHistoryTest.java | 1 + .../ExceptionReturnInboundServiceTest.java | 70 ++++++++++++ .../ReturnCartonReturnPersistenceTest.java | 1 + .../OverseasProcurementServiceTest.java | 15 ++- .../service/UsTruckOrderServiceTest.java | 12 +- .../budget/OrderBudgetAuthorizationTest.java | 14 +++ .../budget/OrderBudgetPersistenceTest.java | 50 ++++++++- .../wallet/budget/OrderBudgetServiceTest.java | 22 +++- .../budget/OrderFundingServiceTest.java | 53 +++++++++ 48 files changed, 1143 insertions(+), 74 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExceptionPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchOutboundPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/PdaExceptionReturnController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderFundingPort.java create mode 100644 lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 659a9ee4b..66c3e8be6 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -136,7 +136,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。 +- 海外采购按 `procurement-workflow.md` 的状态流程执行:客户创建资金处理中,异步冻结成功待审核、失败原单重试,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md index 3bdb40856..a311aaac8 100644 --- a/lastleg-service/docs/procurement-workflow.md +++ b/lastleg-service/docs/procurement-workflow.md @@ -4,7 +4,9 @@ | 主状态 | 编码 | 操作 | |---|---|---| -| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 | +| 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 | +| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 | +| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 | | 收货中 | `RECEIVING` | 平台分批登记数量和照片 | | 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 | | 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 | @@ -28,7 +30,7 @@ 2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。 -- 创建采购单后为待审核,审核后直接收货中。 +- 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。 - 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 - 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 - 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。 @@ -50,8 +52,8 @@ ## 2026-10-29 客户下单预算与页面调整 -客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。 +客户采购直接提交创建请求;预算预占、FUNDING_PENDING订单及冻结Outbox在同一事务落库,响应表示订单已受理。Finance冻结成功后进入PENDING_REVIEW,明确失败进入FUNDING_FAILED,展示失败原因并允许以原订单、原金额及币种重试。处理中重复点击不重复冻结,旧版本回执不能推进新指令。免费样品零金额不发资金指令并直接进入待审核。旧预算预校验接口仅兼容保留,客户端不再等待冻结后建单。未绑定预占15分钟后释放,已绑定订单终态释放,失败定时重试;迟到回执不得复活取消订单。预算预占独立于人工费用冻结/实扣,不计入已扣款汇总。 -本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。 +本土卡车采用相同的先受理后异步冻结流程,平台仅可审核PENDING_REVIEW订单。托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。 结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java index 47c4cd3bc..07d51cce6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java @@ -39,6 +39,11 @@ public class DispatchDeliveryRepository { public void create(OutboundOrder value) { outbounds.insert(value); } /** 不可变出库来源明细在同一安排事务保存。 */ public void create(OutboundPalletItem value) { items.insert(value); } + /** 实际扫码归属核平后,原计划明细统一替换为实际来源明细。 */ + public void replaceItems(Long outboundId) { + items.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(OutboundPalletItem::getOutboundOrderId,outboundId)); + } /** 每客户目的地分别保存派送单。 */ public void create(DeliveryOrder value) { deliveries.insert(value); } /** 仅更新已锁定并复验的客户出库执行事实。 */ @@ -58,14 +63,22 @@ public class DispatchDeliveryRepository { /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); } /** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */ - public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) { + public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now,boolean outboundCompleted) { if (sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,source.getId()) .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) .set(LastlegOrder::getDeliveryOrderNo,deliveryNo) - .set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") + .set(LastlegOrder::getSubOrderStatus,outboundCompleted?"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY":"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); } + /** PDA拣货核平即完成出库节点,保留尚未生成的派送单为空。 */ + public void markOutbound(LastlegOrder source,String outboundNo,java.time.LocalDateTime now) { + if(sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,source.getId()) + .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getSubOrderStatus,"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY") + .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) + .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("出库来源状态已变化"); + } /** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */ public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java index d6d062eb3..895037c4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -26,7 +26,7 @@ import java.util.stream.Collectors; /** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ @Service -public class DispatchDeliveryService implements DispatchDeliveryPort { +public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort { private final DispatchExecutionPort execution; private final DispatchDeliveryRepository repository; private final DeliveryCargoGroups grouping; @@ -118,9 +118,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复"); if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地"); // 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。 - var existingOutbounds=repository.outbounds(context.dispatchId(),true); + var existingOutbounds=activeOutbounds(context.dispatchId(),true); var existingDeliveries=repository.deliveries(context.dispatchId(),true); - if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + if (existingOutbounds.stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); var external=fees.external(context);var coverages=new HashMap(); for (var group:groups) { var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); @@ -153,7 +153,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { return fact(saved); } // 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。 - if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty()) + if(activeOutbounds(context.dispatchId(),true).stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !repository.deliveries(context.dispatchId(),true).isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); var groups=grouping.groups(context);var external=fees.external(context); var inputs=new HashMap(); @@ -167,13 +167,97 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of()); } + /** 理货审核后只创建待出库单,不提前生成派送、费用或真实拣货事实。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public void prepare(Context context, Actor actor) { + if(context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.pickingReconciled() + || context.cargo().isEmpty() || context.cargo().stream().mapToLong(CargoLine::packages).sum()!=context.counts().packageCount()) + throw conflict("理货审核货物不完整,不能生成出库单"); + var existing=repository.outbounds(context.dispatchId(),true); + if(existing.stream().anyMatch(o->!"CANCELLED".equals(o.getStatus())))throw conflict("本车已有有效出库单"); + for(var customer:context.cargo().stream().map(CargoLine::customerId).distinct().sorted().toList()) { + var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); + var order=existing.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null); + boolean created=order==null; + if(created) {order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);order.setOutboundNo(numbers.next("OUTBOUND")); + order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); + order.setOutboundType("DISPATCH_DELIVERY");order.setLxCodes("");order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(Instant.now())); + order.setIdempotencyKey(grouping.hash(List.of("REVIEW_OUTBOUND",context.dispatchId(),customer)));} + else repository.replaceItems(order.getId()); + order.setStatus("WAIT_OUTBOUND");order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); + order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count()); + order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm)); + order.setRequestHash(context.cargoHash());order.setUpdatedAt(local(Instant.now())); + if(created)repository.create(order);else repository.save(order); + for(var line:cargo)repository.create(outboundItem(order,line,false)); + } + } + + /** PDA 全部来源核平后,把对应客户出库单推进为已出库,发车阶段只推进派送单。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public void complete(Context context) { + if(!context.pickingReconciled() || context.cargo().stream().anyMatch(c->c.attributionId()==null))throw conflict("真实拣货来源尚未核平"); + var outbounds=activeOutbounds(context.dispatchId(),true); + if(outbounds.isEmpty())throw conflict("理货审核出库单缺失"); + for (var outbound:outbounds) { + var cargo=context.cargo().stream().filter(c->outbound.getCustomerId().equals(c.customerId())).toList(); + if(cargo.stream().mapToLong(CargoLine::packages).sum()!=outbound.getPackageCount())throw conflict("出库单与真实客户拣货数量不一致"); + repository.replaceItems(outbound.getId()); + for(var line:cargo)repository.create(outboundItem(outbound,line,true)); + outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound); + var now=local(Instant.now()); + for(var orderId:cargo.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList()) { + var source=repository.lockSource(outbound.getCustomerId(),orderId); + if(source==null)throw conflict("真实拣货来源订单缺失"); + repository.markOutbound(source,outbound.getOutboundNo(),now); + } + } + } + + /** 零扫码退回时撤销审核阶段预生成的下游单据。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public void revoke(long dispatchId) { + for (var outbound: repository.outbounds(dispatchId, true)) { + if(!Set.of("WAIT_OUTBOUND","CANCELLED").contains(outbound.getStatus()))throw conflict("已出库不能退回重审"); + outbound.setStatus("CANCELLED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound); + } + } + + /** 预生成和实拣共用字段映射,计划行永远不携带虚构拣货数量。 */ + private OutboundPalletItem outboundItem(OutboundOrder order,CargoLine row,boolean picked) { + var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(order.getId()); + item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); + item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); + item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode()); + item.setDestinationWarehouseId(row.destination().warehouseId());item.setDestinationWarehouseCode(row.destination().warehouseCode());item.setDestinationWarehouseName(row.destination().warehouseName()); + item.setPackageCount(Math.toIntExact(row.packages()));item.setPickedPackageCount(picked?Math.toIntExact(row.packages()):0);item.setStatus(picked?"PICKED":"WAIT_PICK"); + item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark());item.setOrderRefs(row.orderNo());item.setCreatedAt(local(Instant.now())); + return item; + } + + /** 已撤销的审核出库保留审计,但不参与本轮有效单据集合校验。 */ + private List activeOutbounds(long id,boolean lock) { + return repository.outbounds(id,lock).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); + } + + /** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */ private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List groups, Map inputs,Map external, Map coverages) { var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); + var prepared=activeOutbounds(context.dispatchId(),true); for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); + var existing=prepared.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null); + if(existing!=null) { + if(!"OUTBOUNDED".equals(existing.getStatus()) || existing.getPackageCount()!=cargo.stream().mapToLong(CargoLine::packages).sum()) + throw conflict("审核出库单尚未按真实拣货完成"); + outbounds.put(customer,existing);continue; + } var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); order.setOutboundNo(numbers.next("OUTBOUND")); order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); @@ -193,6 +277,8 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now); repository.create(delivery);deliveries.put(group.key(),delivery); for (var row:group.cargo()) { + // 提前创建的出库单已在PDA拣货核平时写入实际归属,发运不能再插一次。 + if(prepared.stream().anyMatch(o->Objects.equals(o.getId(),outbound.getId())))continue; var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId()); item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); @@ -224,12 +310,12 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { @Transactional(propagation=Propagation.MANDATORY) public DepartureFacts departureFacts(Context context,boolean lock) { var arrangement=repository.arrangement(context.dispatchId(),lock); - var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); + var outbounds=activeOutbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); var groups=context.cargo()==null||context.cargo().isEmpty()?List.of():grouping.groups(context); String reason=arrangement==null?"请先安排本车全部客户派送": !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) ?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": - outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) + outbounds.stream().anyMatch(o->!Set.of("WAIT_OUTBOUND","OUTBOUNDED").contains(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null; return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups), @@ -242,7 +328,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) { if (departedAt==null) throw conflict("缺少实际发车时间"); var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); - var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); + var outbounds=activeOutbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); } for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); } @@ -256,7 +342,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { public NormalDeliveryFacts normalDeliveryFacts(Context context) { var saved=repository.arrangement(context.dispatchId(),false); var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); - var outbounds=repository.outbounds(context.dispatchId(),false); + var outbounds=activeOutbounds(context.dispatchId(),false); var actual=new ArrayList(); for (var delivery:repository.deliveries(context.dispatchId(),false)) { var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) @@ -311,7 +397,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId()); var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey()))) .map(g->deliveries.get(g.key())).toList(); - repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now)); + repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now),"OUTBOUNDED".equals(outbound.getStatus())); for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId()); history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo()); history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} @@ -397,3 +483,4 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { /** 任一客户失败由外层事务回滚全部单据和费用。 */ private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index c27f77439..8665f2057 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -90,6 +90,14 @@ public class DispatchAggregateController { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } /** 派送异常登记不触发签收或退货入库。 */ + /** 标记异常时按本车真实派送来源选择退回货物。 */ + @GetMapping("/{dispatchNo}/exception-return-options") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse> exceptionCandidates( + @AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.exceptionCandidates(DispatchActorFactory.platform(user),dispatchNo)); + } + @PostMapping("/{dispatchNo}/exceptions") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java index 8d976068b..3065478f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java @@ -8,6 +8,14 @@ import java.util.List; /** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */ @Mapper public interface DispatchReadMapper { + /** 普通计划一票一计划,换标和退回使用冻结履约订单,避免使用混托展示代表。 */ + @org.apache.ibatis.annotations.Select("SELECT i.id AS itemId,i.customer_id AS customerId," + + "CASE WHEN i.source_business_type='RELABEL_ORDER' THEN i.order_id ELSE COALESCE((SELECT MIN(l.order_id) " + + "FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id " + + "WHERE a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id HAVING COUNT(DISTINCT l.order_id)=1),i.order_id) END AS orderId " + + "FROM ll_dispatch_item i " + + "WHERE i.dispatch_order_id=#{id} ORDER BY i.customer_id,i.id") + java.util.List outboundSources(@org.apache.ibatis.annotations.Param("id") long id); /** 即使计数漂移也不得把已有真实扫码当成零扫码。 */ @Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}") long realPicked(@Param("id") long dispatchId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 354674e64..7dc9e3602 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -24,8 +24,11 @@ public final class DispatchCommands { public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, List cargo, List batchAllocations, Boolean publishLooseboardDemand) { - /** 仅智能排车建单时显式选择发布,未选择时不发布。 */ - public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); } + /** 计划发车时间是车次表必填字段;前端未填写时给验收/草稿单一个可编辑的默认时间。 */ + public Create { + plannedDepartureAt = plannedDepartureAt == null ? Instant.now().plusSeconds(3600) : plannedDepartureAt; + publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); + } } public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, @@ -78,7 +81,9 @@ public final class DispatchCommands { /** 派送异常仅登记事实,不改变派送、签收或库存。 */ public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType, - ReturnDirection returnDirection, String description, List imageFileIds) { + ReturnDirection returnDirection, String description, List imageFileIds, List returnSelections) { + /** 兼容历史仅登记异常的调用方。 */ + public MarkException(Long version,String key,ExceptionType type,ReturnDirection direction,String description,List images) { this(version,key,type,direction,description,images,List.of()); } /** 必填版本用于阻止过期页面覆盖当前排车。 */ public MarkException { recordVersion = requireVersion(recordVersion); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExceptionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExceptionPort.java new file mode 100644 index 000000000..0dbfd8e34 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExceptionPort.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; + +/** 派送异常通过公开端口登记工单,客户与货物归属由提供者复核。 */ +public interface DispatchExceptionPort { + record Source(Long outboundPalletItemId, Long inventoryPositionId, Long returnCartonId, + String palletNo, String skuCode, String cartonMark, long maxReturnPackages) {} + record Candidate(String customerId, String customerName, String deliveryNo, Long lastMileOrderId, + String lastMileOrderNo, List sources) {} + record Selection(String deliveryNo, Long lastMileOrderId, List items) {} + record Item(Long outboundPalletItemId, Long inventoryPositionId, Long returnCartonId, long packageCount) {} + /** 按本车真实派送及原拣货事实提供退回候选。 */ + List candidates(long dispatchId); + /** 异常工单与排车登记同事务落地。 */ + void register(long dispatchId, String operator, DispatchCommands.MarkException request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchOutboundPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchOutboundPort.java new file mode 100644 index 000000000..629b1a844 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchOutboundPort.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 审核建出库、真实拣货完结与零扫码退回的单据边界。 */ +public interface DispatchOutboundPort { + /** 计划来源明确到客户和订单,不把混托代表订单当作全部来源。 */ + record PlannedSource(Long itemId, String customerId, Long orderId) { } + /** 理货审核通过后,按客户生成待出库单与未拣货明细。 */ + void prepare(Context context, Actor actor); + /** 真实拣货全部核平后,以实际归属替换计划明细并完成出库。 */ + void complete(Context context); + /** 零扫码退回重审时撤销待出库单。 */ + void revoke(long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index 25e302411..93013015e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -42,6 +42,8 @@ public class DispatchAggregateRepository { public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } /** 按整车读取全部真实客户明细。 */ public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 建出库时读取逐明细真实计划归属。 */ + public List outboundSources(long id) { return reads.outboundSources(id); } /** 只读取本次确认入库的原计划关联排车。 */ public List receiptDispatches(List planIds) { return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index e16a4f870..c2ed5bace 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.port.DispatchExceptionPort exceptionWorkflow; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); @jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos; @@ -509,7 +511,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); if(request.decision()==Decision.APPROVED) { row.setStage(Stage.LOADING.name()); - if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); + if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType())) { + masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); + if(outboundWorkflow!=null) outboundWorkflow.prepare(plannedOutboundContext(actor,row),actor); + } } } else { row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); @@ -532,6 +537,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审"); if(trim(request.reason())==null)throw bad("退回原因不能为空"); if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前"); + if(outboundWorkflow!=null) outboundWorkflow.revoke(row.getId()); row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason()); return saveCommand(row,actor,identity,request.reason()); } @@ -656,6 +662,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); return context(null,row); } + /** 审核建单使用逐计划来源;该上下文不作为真实拣货或装车核平证明。 */ + private Context plannedOutboundContext(Actor actor,DispatchAggregate row) { + var sources=repository.outboundSources(row.getId()).stream().collect(Collectors.toMap(DispatchOutboundPort.PlannedSource::itemId,s->s)); + var cargo=new ArrayList(); + for(var item:repository.cargo(row.getId())) { + var source=sources.get(item.getId()); + if(source==null || source.orderId()==null || !Objects.equals(source.customerId(),item.getCustomerId()))throw conflict("排车货物缺少真实来源订单"); + cargo.add(new CargoLine(item.getId(),item.getCustomerId(),item.getCustomerName(),source.orderId(),item.getOrderNo(),"ORDER:"+source.orderId(),item.getSourceStage(), + null,null,item.getInboundCode(),item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),null,null,null,null,item.getReturnReceiptId(),null, + item.getCartonMarks(),"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),n(item.getPackageCount()),0, + item.getGrossWeightKg(),item.getVolumeCbm(),List.of(),null)); + } + return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),null,row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), + Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),null,null,null, + read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),false,List.copyOf(cargo)); + } + + /** 执行上下文仅包含真实扫码,不以审核计划补齐缺失拣货。 */ private Context context(Actor actor,DispatchAggregate row) { var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); @@ -844,6 +868,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) { + var initial=required(actor,number,false); + if ("LOCAL_TRUCK".equals(initial.getTaskType()) || "PICKUP".equals(initial.getTaskType()))throw conflict("仅派送中的仓库派送排车可以标记异常"); var row=lockPlatform(actor,number); if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request); @@ -858,10 +884,19 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id); var records=new ArrayList<>(exceptionRecords(row)); records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now())); + exceptionWorkflow.register(row.getId(),actor.operatorId(),request); row.setExceptionsJson(write(records)); return saveCommand(row,actor,identity,description); } + /** 退回候选与标记异常共用平台和阶段门禁。 */ + @Transactional(readOnly=true) + public java.util.List exceptionCandidates(Actor actor,String number) { + var row=required(actor,number,false); + requireExceptionAllowed(actor,row,row.getRecordVersion()); + return exceptionWorkflow.candidates(row.getId()); + } + /** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */ private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) { if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); @@ -1240,3 +1275,4 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 明确版本/状态/事实竞争失败。 */ private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 3cd6b2940..37693d30a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ @Service public class DispatchPickingService { + @jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; @@ -100,7 +101,8 @@ public class DispatchPickingService { row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); - boolean complete=workflow.preview(actor,number).pickingReconciled(); + var actual=workflow.preview(actor,number);boolean complete=actual.pickingReconciled(); + if(complete && outboundWorkflow!=null)outboundWorkflow.complete(actual); if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存"); var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED, new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index 718e71837..a496315f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -23,6 +23,7 @@ import java.util.List; @RequestMapping("/api/platform/v1/last-leg") public class ExceptionOrderController { @Resource private ExceptionOrderService service; + @Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService returns; @GetMapping("/exception-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") @@ -63,6 +64,16 @@ public class ExceptionOrderController { return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file)); } + public record GeneratedReturn(String orderNo,String status,String sourceOrderNo) {} + /** 生成退回尾程单后由PDA退货入库列表接收,建单不恢复库存。 */ + @PostMapping("/exception-orders/{exceptionNo}/generate-return-inbound") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse generateReturn(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId, + @PathVariable String exceptionNo,@Valid @RequestBody ExceptionOrderRequests.StartProcessing request) { + var tail=returns.generate(requiredScope(user,customerId),operatorId(user),exceptionNo,request); + return ApiResponse.success(new GeneratedReturn(tail.getOrderNo(),tail.getCurrentStatus(),tail.getSourceOrderNo())); + } + @PostMapping("/exception-orders/{exceptionNo}/start-processing") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") public ApiResponse startProcessing( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/PdaExceptionReturnController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/PdaExceptionReturnController.java new file mode 100644 index 000000000..43b3acf75 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/PdaExceptionReturnController.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.exceptionorder.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** PDA异常退货沿退货入库路由开放,现场只点选当前仓任务。 */ +@RestController +@RequestMapping("/api/pda/v1/last-leg/return-receipts/exceptions") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaExceptionReturnController { + private final ExceptionReturnInboundService service; + /** 装配异常验收业务入口。 */ + public PdaExceptionReturnController(ExceptionReturnInboundService service) { this.service=service; } + /** 当前仓异常号、车牌和剩余数量列表。 */ + @GetMapping + public ApiResponse tasks(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.tasks(PdaWarehouseIdentity.require(user))); } + /** 点击开始作业后加载冻结货物和最新版本。 */ + @PostMapping("/{number}/start") + public ApiResponse start(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number) { return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user),number)); } + /** 上传本次实收证据。 */ + @PostMapping(value="/{number}/evidence",consumes="multipart/form-data") + public ApiResponse evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(PdaWarehouseIdentity.require(user),number,file)); } + /** 分批验收,原来源数量和仓库资格由服务端复核。 */ + @PostMapping("/{number}/receive") + public ApiResponse receive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody ExceptionReturnInboundService.Receipt request) { return ApiResponse.success(service.receive(PdaWarehouseIdentity.require(user),number,request)); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index 7f70b98ec..b4101651b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -28,7 +28,10 @@ public final class ExceptionOrderResponses { public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, String skuCode, long maxReturnPackages, Long returnCartonId, String cartonMark) {} - public record Detail(Row order, List returnSources, List returnReceipts) {} + public record Detail(Row order, List returnSources, List returnReceipts, String returnOrderNo) { + /** 历史调用方没有退回尾程关系时保持空值。 */ + public Detail(Row order,List sources,List receipts) { this(order,sources,receipts,null); } + } public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, List redispatches) {} public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java new file mode 100644 index 000000000..c6c99bb3d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 异常退回任务仅查询本服务权威单据,不接受客户端客户归属。 */ +@Mapper +public interface ExceptionWorkflowMapper { + record Delivery(String customerId, String deliveryNo) {} + record Task(String exceptionNo, String vehiclePlate, String customerName, String status, + long packageCount, long receivedPackages, String returnOrderNo) {} + /** 本车可登记的派送单,客户跟随真实派送记录。 */ + @Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id") + List deliveries(long id); + /** 以异常号和冻结仓锁定现场任务。 */ + @Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE") + ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number); + /** 唯一来源键复用尾程单已有建单幂等约束。 */ + @Select("SELECT * FROM ll_order WHERE customer_id=#{customer} AND source_region='EXCEPTION_RETURN' AND source_inbound_id=#{source} LIMIT 1") + LastlegOrder returnOrder(@Param("customer") String customer, @Param("source") String source); + /** 当前仓全部待验收异常,车牌从原派送排车获取。 */ + @Select(""" + SELECT e.exception_no,p.vehicle_plate,e.customer_name,e.status,e.package_count, + COALESCE((SELECT SUM(i.received_packages) FROM ll_exception_return_item i + WHERE i.exception_order_id=e.id AND i.customer_id=e.customer_id),0) received_packages, + r.order_no return_order_no + FROM ll_exception_order e JOIN ll_order r ON r.customer_id=e.customer_id + AND r.source_region='EXCEPTION_RETURN' AND r.source_inbound_id=CAST(e.id AS CHAR) + JOIN ll_delivery_order d ON d.id=e.delivery_order_id AND d.customer_id=e.customer_id + LEFT JOIN ll_dispatch_order p ON p.id=d.dispatch_order_id + WHERE e.overseas_warehouse_id=#{warehouse} AND e.status='PROCESSING' + AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE' + ORDER BY e.exception_at,e.id LIMIT 200 + """) + List tasks(String warehouse); + + /** 新尾程单按冻结退回货物展示明细,库存仍归原出库来源。 */ + @Insert(""" + INSERT INTO ll_order_detail(customer_id,order_id,source_inbound_detail_id,sku_code,package_count) + SELECT customer_id,#{tail},CAST(id AS CHAR),sku_code,approved_return_packages-received_packages + FROM ll_exception_return_item WHERE exception_order_id=#{exception} AND customer_id=#{customer} + AND approved_return_packages>received_packages + """) + void copyReturnDetails(@Param("tail") long tail,@Param("exception") long exception,@Param("customer") String customer); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java new file mode 100644 index 000000000..882cee507 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.exceptionorder.repository; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 异常退回尾程关系及仓库任务的数据访问边界。 */ +@Repository +public class ExceptionWorkflowRepository { + private final ExceptionWorkflowMapper workflow; + private final ExceptionOrderMapper exceptions; + private final LastlegOrderMapper orders; + /** 装配本服务权威单据映射器。 */ + public ExceptionWorkflowRepository(ExceptionWorkflowMapper workflow, ExceptionOrderMapper exceptions, LastlegOrderMapper orders) { + this.workflow=workflow; this.exceptions=exceptions; this.orders=orders; + } + /** 查询本车派送归属。 */ + public List deliveries(long id) { return workflow.deliveries(id); } + /** 在客户范围锁定异常。 */ + public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); } + /** 在冻结仓范围锁定异常。 */ + public ExceptionOrder lockWarehouse(String warehouse, String number) { return workflow.lockWarehouse(warehouse,number); } + /** 查询原尾程单以冻结来源关系。 */ + public LastlegOrder original(long id) { return orders.selectById(id); } + /** 恢复异常唯一对应的退回尾程单。 */ + public LastlegOrder returnOrder(ExceptionOrder order) { return workflow.returnOrder(order.getCustomerId(),String.valueOf(order.getId())); } + /** 保存新退回尾程单。 */ + public void insert(LastlegOrder order) { orders.insert(order); } + /** 将异常冻结明细投影到退回尾程单,不改变原单与库存。 */ + public void copyDetails(LastlegOrder tail,ExceptionOrder exception) { workflow.copyReturnDetails(tail.getId(),exception.getId(),exception.getCustomerId()); } + /** 更新退回单的真实实收进度。 */ + public void update(LastlegOrder order) { orders.updateById(order); } + /** 读取当前绑定仓待验收任务。 */ + public List tasks(String warehouse) { return workflow.tasks(warehouse); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java new file mode 100644 index 000000000..e88378136 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.dispatch.port.DispatchExceptionPort; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository; +import org.springframework.stereotype.Component; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 将排车异常原子转为客服工单,来源候选不信任客户端客户字段。 */ +@Component +public class DispatchExceptionAdapter implements DispatchExceptionPort { + private final ExceptionWorkflowRepository repository; + private final ExceptionOrderService service; + /** 复用已有异常退回冻结与数量门禁。 */ + public DispatchExceptionAdapter(ExceptionWorkflowRepository repository, ExceptionOrderService service) { + this.repository=repository; this.service=service; + } + /** 仅返回本车、当前仍可退回的真实来源。 */ + public List candidates(long dispatchId) { + var result=new ArrayList(); + for(var delivery:repository.deliveries(dispatchId)) for(var relation:service.returnCandidates(delivery.customerId(),delivery.deliveryNo())) + result.add(new Candidate(relation.customerId(),relation.customerName(),delivery.deliveryNo(),relation.lastMileOrderId(),relation.lastMileOrderNo(), + relation.returnSources().stream().map(s->new Source(s.outboundPalletItemId(),s.inventoryPositionId(),s.returnCartonId(),s.palletNo(),s.skuCode(),s.cartonMark(),s.maxReturnPackages())).toList())); + return result; + } + /** 退回逐来源冻结;其他异常同样进入异常列表。 */ + public void register(long dispatchId,String operator,DispatchCommands.MarkException request) { + var candidates=candidates(dispatchId); + var selected=request.returnSelections()==null?List.of():request.returnSelections(); + boolean warehouse=request.returnDirection()==com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE; + String reason=request.description()==null || request.description().isBlank()?"派送异常:"+request.exceptionType():request.description().trim(); + if(warehouse && selected.isEmpty())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择来源订单、退回货物和数量"); + if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源"); + if(warehouse) { + var seen=new HashSet(); + for(int index=0;index200 || !seen.add(selection.deliveryNo())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物"); + var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车")); + if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效"); + service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null, + selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(), + "dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index)); + } + } else for(var source:candidates) service.create(source.customerId(),operator,operator, + new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index b83ff9ee7..ebd89a395 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -65,6 +65,7 @@ import java.util.Set; @Service public class ExceptionOrderService { + @Resource private com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository workflow; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private ExceptionOrderMapper exceptionOrders; @Resource private LastlegOrderMapper lastMileOrders; @@ -118,7 +119,7 @@ public class ExceptionOrderService { ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); if (order == null) throw notFound("异常订单不存在"); return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), - returnHistory(order)); + returnHistory(order), workflow.returnOrder(order)==null?null:workflow.returnOrder(order).getOrderNo()); } /** 普通仓位与原箱回仓都只展示实际入账凭据。 */ @@ -235,6 +236,9 @@ public class ExceptionOrderService { List requested = request.items(); validateReturnItems(available, requested); int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum()); + // 部分签收只能退回未签收余量,不能把已交付货物再次计入本趟退回。 + long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0); + if(signed<0 || delivery.getPackageCount()==null || packages > delivery.getPackageCount()-signed)throw conflict("退回箱数超过派送未签收数量"); Set skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; @@ -378,6 +382,12 @@ public class ExceptionOrderService { order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now); order.setVersion(version(order) + 1); exceptionOrders.updateById(order); + var returnTail=workflow.returnOrder(order); + if(returnTail!=null) { + returnTail.setReceivedPackageCount(Math.toIntExact(stored.stream().mapToLong(ReturnReceiptPolicy::received).sum())); + returnTail.setCurrentStatus(completed?"INBOUND_COMPLETED":"PENDING_INBOUND"); + returnTail.setVersion(Objects.requireNonNullElse(returnTail.getVersion(),0)+1); returnTail.setUpdatedAt(now); workflow.update(returnTail); + } closeReturnedDelivery(order, operatorId, now); long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored; ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); @@ -635,7 +645,9 @@ public class ExceptionOrderService { } long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId()); if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致"); - if (received < delivery.getPackageCount()) return; + long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0); + if(signed<0 || received+signed>delivery.getPackageCount())throw conflict("实签与实退数量超过原实发数量"); + if (received+signed < delivery.getPackageCount()) return; delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); @@ -678,8 +690,10 @@ public class ExceptionOrderService { private List allowedActions(ExceptionOrder order) { return switch (Objects.requireNonNullElse(order.getStatus(), "")) { - case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); - case "PROCESSING" -> List.of("COMPLETE_EXCEPTION"); + case "PENDING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) + ? List.of("GENERATE_RETURN_INBOUND","CANCEL_EXCEPTION") : List.of("START_PROCESSING", "CANCEL_EXCEPTION"); + case "PROCESSING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) && workflow.returnOrder(order)==null + ? List.of("GENERATE_RETURN_INBOUND") : List.of("COMPLETE_EXCEPTION"); default -> List.of(); }; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java new file mode 100644 index 000000000..fc0c94fb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundService.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper; +import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** 异常退回建单不入账,PDA按原出库来源实收后才恢复库存。 */ +@Service +public class ExceptionReturnInboundService { + private final ExceptionWorkflowRepository repository; + private final ExceptionOrderService exceptions; + private final LastlegOrderNoGenerator numbers; + private final PalletPhysicalGateway pallets; + private final WarehouseResourceGateway resources; + public record Receipt(@NotNull @Min(0) Long expectedVersion,@NotBlank @Size(max=128) String idempotencyKey, + @NotBlank String palletCode,@NotBlank String locationCode, + @NotEmpty @Size(max=200) List<@jakarta.validation.Valid Item> items, + @NotEmpty @Size(max=20) List<@NotNull @Min(1) Long> evidenceFileIds) {} + public record Item(@NotNull @Min(1) Long outboundPalletItemId,Long inventoryPositionId,Long returnCartonId,@Min(1) long packages) {} + /** 装配原异常业务与仓储权威资源入口。 */ + public ExceptionReturnInboundService(ExceptionWorkflowRepository repository,ExceptionOrderService exceptions, + LastlegOrderNoGenerator numbers,PalletPhysicalGateway pallets,WarehouseResourceGateway resources) { + this.repository=repository; this.exceptions=exceptions; this.numbers=numbers; this.pallets=pallets; this.resources=resources; + } + /** 锁定异常并恢复既有结果,重复生成不增加尾程单或库存。 */ + @Transactional + public LastlegOrder generate(String customer,String operator,String number,ExceptionOrderRequests.StartProcessing request) { + var order=repository.lock(customer,number); + if(order==null)throw conflict("异常单不存在"); + var existing=repository.returnOrder(order); + if(existing!=null)return existing; + if(!"RETURN".equals(order.getExceptionType()) || !"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()))throw conflict("仅退回海外仓异常可生成退回入库单"); + if(!Set.of("PENDING","PROCESSING").contains(order.getStatus()) || !Objects.equals(order.getVersion(),request.expectedVersion()))throw conflict("异常状态或版本已变化,请刷新"); + var original=repository.original(order.getLastMileOrderId()); + if(original==null || !customer.equals(original.getCustomerId()))throw conflict("原尾程订单归属无效"); + var sources=exceptions.detail(customer,number).returnSources(); + if(sources.isEmpty())throw conflict("没有剩余待收货物"); + if("PENDING".equals(order.getStatus()))exceptions.startProcessing(customer,operator,number,request); + var tail=new LastlegOrder(); var now=LocalDateTime.now(); + tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("LASTLEG")); tail.setCustomerId(customer); + tail.setCustomerName(order.getCustomerName()); tail.setOrderType("DELIVERY_RETURN"); tail.setDestinationType("OVERSEAS"); + tail.setSourceRegion("EXCEPTION_RETURN"); tail.setSourceInboundId(String.valueOf(order.getId())); + tail.setSourceOrderId(String.valueOf(original.getId())); tail.setSourceOrderNo(original.getOrderNo()); tail.setRootOrderId(original.getRootOrderId()); + // 原入仓码由异常冻结来源保留,新尾程单不重复占用原入仓关系。 + tail.setInboundCode(null); tail.setRootInboundId(original.getRootInboundId()); + tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setOverseasWarehouseName(order.getOverseasWarehouseName()); tail.setOverseasWarehouseCode(original.getOverseasWarehouseCode()); + tail.setCurrentStatus("PENDING_INBOUND"); tail.setSubOrderStatus("EXCEPTION:"+number); + tail.setPackageCount(Math.toIntExact(sources.stream().mapToLong(s->s.maxReturnPackages()).sum())); tail.setReceivedPackageCount(0); + tail.setAbnormal(false); tail.setDetained(false); tail.setVersion(0); tail.setPalletCount(0); tail.setPickedPackageCount(0); tail.setShippedPackageCount(0); + tail.setSourceEventId(number); tail.setSourceLoadingPlanId(number); tail.setSourcePayloadHash("EXCEPTION:"+order.getId()); + tail.setCreatedAt(now); tail.setUpdatedAt(now); repository.insert(tail); repository.copyDetails(tail,order); + return tail; + } + /** 列表只展示已生成退回尾程单且属于当前冻结仓的异常任务。 */ + public List tasks(PdaWarehouseIdentity identity) { return repository.tasks(String.valueOf(identity.overseasWarehouseId())); } + /** 现场点选任务时重新检查仓库与状态,不接受客户端客户归属。 */ + @Transactional + public ExceptionOrderResponses.Detail start(PdaWarehouseIdentity identity,String number) { + var order=required(identity,number); + return exceptions.detail(order.getCustomerId(),number); + } + /** 证据仍按异常单和客户绑定,不能借用客户退货照片。 */ + @Transactional + public ExceptionOrderResponses.UploadedEvidence upload(PdaWarehouseIdentity identity,String number,org.springframework.web.multipart.MultipartFile file) { + var order=required(identity,number); + return exceptions.uploadEvidence(order.getCustomerId(),identity.operatorKey(),number,file); + } + /** 每批实收、库存恢复、异常进度与退回尾程单进度在同一事务提交。 */ + @Transactional + public ExceptionOrderResponses.CommandResult receive(PdaWarehouseIdentity identity,String number,Receipt request) { + var order=repository.lockWarehouse(String.valueOf(identity.overseasWarehouseId()),number); + if(order==null || repository.returnOrder(order)==null)throw conflict("当前仓没有该异常退回任务"); + // 幂等恢复由原异常完成命令先判断;已收齐的同键重放仍可恢复成功结果。 + var pallet=pallets.resolve(request.palletCode().trim()); + var location=resources.resolveLocation(identity.overseasWarehouseId(),request.locationCode().trim()); + if(pallet==null || pallet.warehouseId()!=identity.overseasWarehouseId() || location==null + || !Objects.equals(pallet.locationId(),location.locationId()) || !"ACTIVE".equals(location.status()))throw conflict("目标托盘与库位必须属于当前仓且实际绑定一致"); + var receipts=request.items().stream().map(i->new ExceptionOrderRequests.ReturnReceipt(i.outboundPalletItemId(),i.inventoryPositionId(),i.packages(), + pallet.palletId(),pallet.palletCode(),location.locationId(),location.locationCode(),i.returnCartonId())).toList(); + return exceptions.complete(order.getCustomerId(),identity.operatorKey(),number,new ExceptionOrderRequests.Complete( + request.expectedVersion(),request.idempotencyKey(),"PDA异常退货验收",request.evidenceFileIds(),receipts)); + } + /** 任务资格始终重新读取权威状态与尾程关系。 */ + private ExceptionOrder required(PdaWarehouseIdentity identity,String number) { + var order=repository.lockWarehouse(String.valueOf(identity.overseasWarehouseId()),number); + if(order==null || !"PROCESSING".equals(order.getStatus()) || !"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) || repository.returnOrder(order)==null)throw conflict("当前仓没有该待验收异常任务"); + return order; + } + /** 业务冲突返回统一可识别的错误。 */ + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index 8dbfdc505..ce28bf04f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -33,7 +33,7 @@ public class MerchantOverseasProcurementController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(), - ()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()); + ()->service.createFundingPending(customer(user),operator(user),request),result->result.summary().orderNo()); return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java new file mode 100644 index 000000000..c2915be62 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.procurement.funding; + +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +import lombok.RequiredArgsConstructor; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.time.LocalDateTime; +import org.springframework.stereotype.Repository; + +/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */ +@Repository +@RequiredArgsConstructor +public class OrderFundingRepository { + private final OverseasProcurementOrderMapper purchases; + private final OverseasProcurementStatusHistoryMapper purchaseHistory; + private final UsTruckOrderMapper trucks; + private final UsTruckOrderStatusHistoryMapper truckHistory; + + /** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */ + public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); } + /** 预算锁后按客户锁定卡车原单。 */ + public UsTruckOrder truck(String customer, String number) { return trucks.lockNo(customer, number); } + /** 保存采购资金门禁状态。 */ + public void save(OverseasProcurementOrder order) { purchases.updateById(order); } + /** 保存卡车资金门禁状态。 */ + public void save(UsTruckOrder order) { trucks.updateById(order); } + /** 追加采购资金操作证据。 */ + public void history(OverseasProcurementOrder order, String from, String target, String reason, LocalDateTime now) { + var history = new OverseasProcurementStatusHistory(); + history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId()); + history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target); + history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now); + purchaseHistory.insert(history); + } + /** 追加卡车资金操作证据。 */ + public void history(UsTruckOrder order, String from, String target, String reason, LocalDateTime now) { + var history = new UsTruckOrderStatusHistory(); + history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId()); + history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target); + history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now); + truckHistory.insert(history); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java new file mode 100644 index 000000000..1cdf53315 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.procurement.funding; + +import com.baiyue.lastleg.wallet.budget.OrderBudget; +import com.baiyue.lastleg.wallet.budget.OrderFundingPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Set; + +/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */ +@Service +@RequiredArgsConstructor +public class OrderFundingService implements OrderFundingPort { + private final OrderFundingRepository repository; + + /** 与预算回执同事务推进,取消或已审核订单不受迟到回执影响。 */ + @Transactional + public void apply(OrderBudget budget, String target, String reason) { + if (budget.getOrderNo() == null) return; + if (!Set.of("FUNDING_PENDING", "FUNDING_FAILED", "PENDING_REVIEW").contains(target)) throw new IllegalArgumentException("无效资金状态"); + var now = LocalDateTime.now(); + if ("procurement".equals(budget.getKind())) { + var order = repository.purchase(budget.getCustomerId(), budget.getOrderNo()); + if (order == null) throw new IllegalStateException("资金关联的采购订单不存在"); + String from = order.getStatus(); + if (!changeable(from, target)) return; + order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now); + repository.save(order); + repository.history(order, from, target, reason, now); + } else if ("trucking".equals(budget.getKind())) { + var order = repository.truck(budget.getCustomerId(), budget.getOrderNo()); + if (order == null) throw new IllegalStateException("资金关联的卡车订单不存在"); + String from = order.getStatus(); + if (!changeable(from, target)) return; + order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now); + repository.save(order); + repository.history(order, from, target, reason, now); + } + } + + /** 仅资金等待及失败可转换;重复回执不重复历史、不覆盖客户备注。 */ + private boolean changeable(String from, String target) { + if ("FUNDING_PENDING".equals(target) && !Set.of("FUNDING_PENDING", "FUNDING_FAILED").contains(from)) throw new IllegalStateException("订单当前状态不允许重试资金冻结"); + return !target.equals(from) && Set.of("FUNDING_PENDING", "FUNDING_FAILED").contains(from); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 5316d749a..de2749acd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -46,9 +46,11 @@ public class OverseasProcurementService { public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String FUNDING_PENDING = "FUNDING_PENDING"; + public static final String FUNDING_FAILED = "FUNDING_FAILED"; public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String REJECTED = "REJECTED"; - private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "RECEIVING", "WAIT_CUSTOMER_CONFIRM", "CONFIRMED", "IN_TRANSIT", "DELIVERING", "COMPLETED", "WAIT_OVERSEAS_RECEIPT", + private static final Set STATUSES = Set.of(PENDING_REVIEW, FUNDING_PENDING, FUNDING_FAILED, WAIT_PURCHASE, REJECTED, "RECEIVING", "WAIT_CUSTOMER_CONFIRM", "CONFIRMED", "IN_TRANSIT", "DELIVERING", "COMPLETED", "WAIT_OVERSEAS_RECEIPT", "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "WAIT_OVERSEAS_SHIPMENT", "INTERNATIONAL_TRANSIT", "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); @@ -64,6 +66,19 @@ public class OverseasProcurementService { @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, OverseasProcurementRequests.Create request) { + return create(customerId, operatorId, request, PENDING_REVIEW); + } + + /** 客户下单先受理,资金异步冻结完成后再开放平台审核。 */ + @Transactional + public OverseasProcurementResponses.Detail createFundingPending(String customerId, String operatorId, + OverseasProcurementRequests.Create request) { + return create(customerId, operatorId, request, FUNDING_PENDING); + } + + /** 按入口保存初始状态,并保持创建幂等。 */ + private OverseasProcurementResponses.Detail create(String customerId, String operatorId, + OverseasProcurementRequests.Create request, String initialStatus) { String key = request.idempotencyKey().trim(); String requestHash = hash(request); OverseasProcurementOrder replay = orders.findKey(customerId, key); @@ -87,7 +102,7 @@ public class OverseasProcurementService { order.setContactName(request.contactName().trim()); order.setContactPhone(request.contactPhone().trim()); order.setInboundQuantity(0L); - order.setStatus(PENDING_REVIEW); + order.setStatus(initialStatus); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); @@ -102,11 +117,13 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, "CREATE", null, PENDING_REVIEW, operatorId, + history(order, "CREATE", null, initialStatus, operatorId, key, null, request.remark(), now); return detail(customerId, order.getOrderNo()); } + + public List customerOptions(String keyword, int limit) { if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); return orders.customerOptions(trim(keyword), limit).stream() @@ -220,7 +237,7 @@ public class OverseasProcurementService { return detail(customerId, orderNo); } if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); - if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("订单资金尚未冻结成功,暂不能审核"); String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection()) : ProcurementDirection.normalize(request.procurementDirection()); if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java index 93d3e4ba0..2b3414c80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -32,7 +32,7 @@ public class MerchantUsTruckOrderController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody UsTruckOrderRequests.Create request) { return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(), - ()->service.create(customer(user),operator(user),request),result->result.summary().orderNo())); + ()->service.createFundingPending(customer(user),operator(user),request),result->result.summary().orderNo())); } @GetMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index bb121926b..01bb6ed6b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -37,6 +37,8 @@ public class UsTruckOrderService { @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String FUNDING_PENDING = "FUNDING_PENDING"; + public static final String FUNDING_FAILED = "FUNDING_FAILED"; public static final String WAITING_INBOUND = "WAITING_INBOUND"; public static final String RECEIVING = "RECEIVING"; public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; @@ -49,7 +51,7 @@ public class UsTruckOrderService { public static final String COMPLETED = "COMPLETED"; public static final String REJECTED = "REJECTED"; public static final String CANCELLED = "CANCELLED"; - private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + private static final Set STATUSES = Set.of(PENDING_REVIEW, FUNDING_PENDING, FUNDING_FAILED, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, @@ -63,6 +65,17 @@ public class UsTruckOrderService { /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ @Transactional public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { + return create(customerId, operatorId, request, PENDING_REVIEW); + } + + /** 客户下单先受理,资金异步冻结完成后再开放平台审核。 */ + @Transactional + public UsTruckOrderResponses.Detail createFundingPending(String customerId, String operatorId, UsTruckOrderRequests.Create request) { + return create(customerId, operatorId, request, FUNDING_PENDING); + } + + /** 按入口保存初始状态,并保持创建幂等。 */ + private UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request, String initialStatus) { String key = request.idempotencyKey().trim(); String hash = hash(request); UsTruckOrder replay = repository.findKey(customerId, key); @@ -86,7 +99,7 @@ public class UsTruckOrderService { order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim()); - order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(PENDING_REVIEW); + order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(initialStatus); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); try { repository.insertOrder(order); } @@ -95,10 +108,14 @@ public class UsTruckOrderService { if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } - history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); - return detail(customerId, order.getOrderNo()); + history(order, "CREATE", null, initialStatus, operatorId, key, request.remark(), now); + // 创建响应直接复用已组装的订单和刚写入的历史,避免再次查询订单、托盘和历史表。 + return detail(order, List.of(), List.of(new UsTruckOrderResponses.History( + "CREATE", null, initialStatus, operatorId, key, trim(request.remark()), now)), null); } + + /** 按客户范围分页读取订单并批量关联承运安排。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, UsTruckOrderRequests.Query query) { @@ -163,7 +180,7 @@ public class UsTruckOrderService { UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request); if (replay != null) return replay; requireVersion(order, request.version()); - if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("订单资金尚未冻结成功,暂不能审核"); boolean approve = "APPROVE".equals(norm(request.decision())); if (approve && request.warehouseId() == null) throw badRequest("审核通过时必须选择真实调度仓库"); String next = approve ? PENDING_DISPATCH : REJECTED; @@ -437,11 +454,20 @@ public class UsTruckOrderService { List palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream() .map(this::pallet) .toList(); - return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), - o.getRemark(), o.getCreatedBy(), palletDetails, - hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(), - h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), - h.getRemark(), h.getCreatedAt())).toList()); + return detail(o, palletDetails, hs.stream().map(this::historyResponse).toList(), + fleetPool.arrangement(poolKey(o))); + } + /** 组装已有数据,创建成功响应复用内存对象避免重复读库。 */ + private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List pallets, + List histories, + com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { + return new UsTruckOrderResponses.Detail(summary(o, arrangement), o.getOriginAddress(), o.getDestinationAddress(), + o.getRemark(), o.getCreatedBy(), pallets, histories); + } + /** 将状态历史转换为对外响应。 */ + private UsTruckOrderResponses.History historyResponse(UsTruckOrderStatusHistory h) { + return new UsTruckOrderResponses.History(h.getOperationType(), h.getFromStatus(), h.getToStatus(), + h.getOperatorId(), h.getIdempotencyKey(), h.getRemark(), h.getCreatedAt()); } /** 将实际托盘记录转换为响应。 */ private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java index 9809398a8..621e0591d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java @@ -31,6 +31,20 @@ public class MerchantOrderBudgetController { public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) { return ApiResponse.success(service.read(customer(user),kind,key)); } + /** 列表及详情可核对当前客户订单资金状态和失败原因。 */ + @GetMapping("/{kind}/orders/{orderNo}") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))") + public ApiResponse readOrder(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String kind, @PathVariable String orderNo) { + return ApiResponse.success(service.readOrder(customer(user), kind, orderNo)); + } + /** 失败订单按原预算重试;客户身份只取当前会话。 */ + @PostMapping("/{kind}/orders/{orderNo}/retry") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") + public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String kind, @PathVariable String orderNo) { + return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo)); + } /** 客户身份只取认证上下文,平台身份不可借用。 */ private String customer(AuthenticatedUser user) { if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java index 61a927e11..735b2257a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java @@ -4,8 +4,9 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; import com.baiyue.lastleg.wallet.*; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Transactional; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.web.server.ResponseStatusException; @@ -16,13 +17,18 @@ import java.util.Objects; import java.util.Set; import java.util.function.*; -/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */ -@Service @RequiredArgsConstructor +/** 下单与预算指令同事务受理;仅冻结成功的订单才进入待审核。 */ +@Service public class OrderBudgetService implements WalletResultHandler { public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET"; private final OrderBudgetRepository repository; private final OverseasFeeCurrencyConverter converter; private final WalletCommandOutboxService wallet; + @Autowired private OrderFundingPort funding; + /** 绑定预算持久化、汇率与资金事件发布依赖。 */ + public OrderBudgetService(OrderBudgetRepository repository, OverseasFeeCurrencyConverter converter, WalletCommandOutboxService wallet) { + this.repository = repository; this.converter = converter; this.wallet = wallet; + } public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { } /** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */ @@ -33,7 +39,10 @@ public class OrderBudgetService implements WalletResultHandler { var row=repository.lock(customer,kind,key); if(row!=null) { requireAmount(row,amount,currency); - if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) sendFreeze(row); + if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) { + if (row.getOrderNo() != null) funding.apply(row, "FUNDING_PENDING", null); + sendFreeze(row); + } return view(row); } row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key); @@ -51,20 +60,51 @@ public class OrderBudgetService implements WalletResultHandler { if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row); } - /** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */ + /** 预算锁、创建订单及绑定在同一事务;创建失败时连同新预占和资金指令回滚。 */ @Transactional public T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier create,Function number) { var row=repository.lock(customer,kind,key); - if(row==null) throw bad("请先完成预估金额及余额授信校验"); + if (row == null) { + // 与订单共用事务,任一步失败都回滚,不留下孤立冻结请求。 + prepare(customer, kind, key, amount, currency); + row = repository.lock(customer, kind, key); + } requireAmount(row,amount,currency); if(row.getOrderNo()!=null) { var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior; } - if(!"FROZEN".equals(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message()); + if(!Set.of("FROZEN", "FREEZE_PENDING").contains(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message()); if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单"); + // 订单先受理;资金仍处于异步冻结时,订单保持 FUNDING_PENDING,回执成功后再开放审核。 var result=create.get();String no=number.apply(result); if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单"); - row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);return result; + row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row); + if ("FROZEN".equals(row.getStatus())) { + funding.apply(row, "PENDING_REVIEW", null); + } + return result; + } + + /** 客户只读取自身已绑定订单的资金进度及失败原因。 */ + @Transactional + public View readOrder(String customer, String kind, String orderNo) { + requireKind(kind); + var row = repository.lockOrder(customer, kind, orderNo); + if (row == null) throw bad("未找到订单资金记录"); + return view(row); + } + + /** 仅明确冻结失败允许重试,处理中重复点击复用原事件,禁止重复冻结。 */ + @Transactional + public View retryOrder(String customer, String kind, String orderNo) { + requireKind(kind); + var row = repository.lockOrder(customer, kind, orderNo); + if (row == null || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad("订单资金记录不存在或已释放"); + if ("FAILED".equals(row.getStatus())) { + funding.apply(row, "FUNDING_PENDING", null); + sendFreeze(row); + } + return view(row); } /** 原订单预算锁与普通编辑同事务,金额变化须重新下单冻结。 */ @@ -114,6 +154,9 @@ public class OrderBudgetService implements WalletResultHandler { row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage()); } else throw bad("预算资金结果无效"); row.setUpdatedAt(now());repository.save(row); + if ("FREEZE".equals(operation) && !Boolean.TRUE.equals(row.getReleaseRequested()) && row.getOrderNo() != null) { + funding.apply(row, "FROZEN".equals(row.getStatus()) ? "PENDING_REVIEW" : "FUNDING_FAILED", row.getErrorMessage()); + } } /** 未提交预占到期释放;终态订单不再占用预算;冻结在途时先登记释放意图。 */ @@ -147,7 +190,7 @@ public class OrderBudgetService implements WalletResultHandler { case "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功"; case "FAILED","RELEASE_FAILED" -> row.getErrorMessage(); case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单"; - default -> "余额及授信校验处理中,请稍后再次提交"; + default -> "订单已受理,资金处理中,完成后自动进入待审核"; };return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15)))); } /** 仅开放本轮客户下单业务。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderFundingPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderFundingPort.java new file mode 100644 index 000000000..6005f1b54 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderFundingPort.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.wallet.budget; + +/** 预算回执通过领域端口推进订单,资金基础设施不依赖具体订单实现。 */ +public interface OrderFundingPort { + /** 在预算事务内同步订单资金状态,重复或迟到回执由领域实现校验。 */ + void apply(OrderBudget budget, String target, String reason); +} diff --git a/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml new file mode 100644 index 000000000..598808e81 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: 派送异常退回入库 + version: 1.0.0 + description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。 +paths: + /api/platform/v1/last-leg/dispatches/{dispatchNo}/exception-return-options: + get: + summary: 当前排车的原派送货物候选 + description: 权限platform:overseas-warehouse:dispatch:edit;仅派送中的仓库派送排车。客户归属、尾程单和真实原拣货来源由服务端决定。 + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': { description: ApiResponse,data为customerId/customerName/deliveryNo/lastMileOrderId/lastMileOrderNo/sources数组;sources包含原出库项、互斥库存仓位或退货原箱、SKU及maxReturnPackages。 } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/generate-return-inbound: + post: + summary: 生成关联原尾程单的退回入库单 + description: 权限platform:overseas-warehouse:exception-order:process。customerId显式选择;异常须为退回海外仓且待处理或处理中,版本匹配。以客户和异常ID作为唯一来源,已有结果直接恢复;不创建普通接柜计划、不重复入账或占用原入仓码。data返回orderNo/status/sourceOrderNo。异常详情额外返回returnOrderNo。 + parameters: + - { name: exceptionNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion, idempotencyKey] + properties: + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + responses: + '200': { description: ApiResponse;data为退回尾程单摘要。 } + /api/pda/v1/last-leg/return-receipts/exceptions: + get: + summary: 当前绑定仓的异常退货入库任务列表 + description: 仅ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR及lastleg:pda:operate;服务端冻结仓隔离,不接受客户或仓库参数。只展示已生成退回尾程单的PROCESSING异常,按异常时间排序,最多200条。 + responses: + '200': { description: ApiResponse;data数组含exceptionNo、vehiclePlate、customerName、status、packageCount、receivedPackages和returnOrderNo。 } + /api/pda/v1/last-leg/return-receipts/exceptions/{number}/start: + post: + summary: 点击开始作业,读取异常冻结货物与当前版本 + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + responses: + '200': { description: ApiResponse;data为异常Detail,含order.version、returnSources剩余来源、returnReceipts实收历史和returnOrderNo。跨仓或非待验收任务拒绝。 } + /api/pda/v1/last-leg/return-receipts/exceptions/{number}/evidence: + post: + summary: 上传本次实收证据 + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + '200': { description: ApiResponse;data为绑定异常客户和异常ID的fileId/fileName/contentType/sizeBytes。 } + /api/pda/v1/last-leg/return-receipts/exceptions/{number}/receive: + post: + summary: 确认本批实收并恢复原来源库存 + description: 资格与开始作业一致;当前版本及幂等键必填,原库存仓位与退货原箱恰有一个。本批累计不能超过批准量,原箱须整箱;目标托盘和库位须在冻结仓实际绑定。至少一份本异常证据。原键重放不重复入账,分批使用新键及最新版本;收齐后异常COMPLETED,退回尾程INBOUND_COMPLETED,原单已签收事实保留。 + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion, idempotencyKey, palletCode, locationCode, items, evidenceFileIds] + properties: + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + palletCode: { type: string } + locationCode: { type: string } + evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, packages] + properties: + outboundPalletItemId: { type: string } + inventoryPositionId: { type: string, nullable: true } + returnCartonId: { type: string, nullable: true } + packages: { type: integer, minimum: 1 } + responses: + '200': { description: ApiResponse;data为异常CommandResult,含status/version/restoredPackages/replayed。业务失败仍须读取code。 } diff --git a/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml b/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml index 5e0ff9e78..7bf972c42 100644 --- a/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 海外客户下单预估资金校验 version: 1.0.0 - description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。 + description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。 security: - bearerAuth: [] paths: @@ -11,7 +11,7 @@ paths: - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} - {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} post: - summary: 发起冻结或读取原请求,失败后可用原键重试 + summary: 兼容接口:发起或读取预算冻结请求 description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。 requestBody: required: true @@ -28,10 +28,29 @@ paths: '403': {description: 身份或业务创建权限不足} get: summary: 读取当前客户本次下单的权威资金回执 - description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。 + description: 与POST相同创建权限;该接口保留用于旧客户端兼容。新客户端应直接创建订单,并通过订单资金状态接口读取异步结果。 responses: '200': {$ref: '#/components/responses/Result'} '403': {description: 身份或业务创建权限不足} + /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}: + parameters: + - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} + - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + get: + summary: 读取订单资金状态 + description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。 + responses: + '200': {$ref: '#/components/responses/Result'} + /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry: + parameters: + - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} + - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + post: + summary: 重试订单资金冻结 + description: 仅当前客户可重试FUNDING_FAILED订单,沿用原金额和币种;重复请求幂等。 + responses: + '200': {$ref: '#/components/responses/Result'} + components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 301edc56e..58a7dd071 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1741,7 +1741,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions: post: summary: 登记派送异常 - description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,同事务生成异常工单;选择退回海外仓时必须提交原出库来源和退回数量,冻结退回范围但不增加库存。原键重试恢复原结果。退回尾程建单与PDA列表验收契约见exception-return-inbound-v1.yaml。 security: - bearerAuth: [] responses: @@ -2639,7 +2639,7 @@ paths: /api/merchant/v1/last-leg/overseas-procurements: post: summary: 客户创建海外采购订单 - description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 + description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单先进入 FUNDING_PENDING,异步冻结成功进入 PENDING_REVIEW,失败进入 FUNDING_FAILED 并可原单重试;零金额直接待审核。同幂等键同请求返回既有订单,异请求返回 A0409。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '409': { description: 幂等冲突 } } @@ -2682,7 +2682,7 @@ paths: responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } post: summary: 客户创建美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 + description: 权限 merchant:cross-border:domestic-trucking:create;新单先进入 FUNDING_PENDING,异步冻结成功进入 PENDING_REVIEW,失败进入 FUNDING_FAILED 并可原单重试;客户不能调用平台审核或状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -5077,7 +5077,7 @@ components: actualVolumeCbm: { type: number, nullable: true } status: { type: string } version: { type: integer } - ProcurementStatus: { type: string, enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED, REJECTED, CANCELLED, EXCEPTION_CLOSED, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER] } + ProcurementStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED, REJECTED, CANCELLED, EXCEPTION_CLOSED, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER] } ProcurementReview: type: object required: [version, decision, idempotencyKey] @@ -5126,7 +5126,7 @@ components: properties: code: { type: string, enum: ['200'] } data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } } - TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] } + TruckStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] } TruckReview: type: object required: [version, decision, idempotencyKey] @@ -8378,7 +8378,7 @@ components: DispatchReturnDirection: type: string enum: [OVERSEAS_WAREHOUSE, OTHER] - description: 退回海外仓、其他;仅登记去向,不自动退货入库。 + description: 退回海外仓、其他;退回海外仓须选择原出库货物生成异常单,建单不自动增加库存。 DispatchMarkException: type: object x-java-record: DispatchCommands.MarkException @@ -8409,6 +8409,27 @@ components: items: type: string pattern: ^[1-9][0-9]*$ + returnSelections: + type: array + description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。 + items: + type: object + required: [deliveryNo, lastMileOrderId, items] + properties: + deliveryNo: { type: string } + lastMileOrderId: { type: string } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, packageCount] + properties: + outboundPalletItemId: { type: string } + inventoryPositionId: { type: string, nullable: true } + returnCartonId: { type: string, nullable: true } + packageCount: { type: integer, minimum: 1 } DispatchExceptionRecord: type: object x-java-record: DispatchViews.ExceptionRecord diff --git a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml index 08078b4be..0933c5b33 100644 --- a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 简化海外采购批次流程 API version: 1.0.0 - description: 创建采购单后为待审核;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。 + description: 客户创建采购单后为资金处理中;冻结成功进入待审核,失败保留原单重试;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。 security: - bearerAuth: [] paths: @@ -276,7 +276,7 @@ components: description: Long 主键在 JSON 中按字符串传输,避免前端精度损失 Status: type: string - enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED] + enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED] description: 依次为待审核、收货中、待客户确认、已确认、运输中、派送中、签收完成 Command: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 911417afe..1cb49dbc3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -633,6 +633,8 @@ public class DispatchAggregatePersistenceTest { service=new DispatchAggregateService(repository, new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); + org.springframework.test.util.ReflectionTestUtils.setField(service,"exceptionWorkflow",mock(com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger(); when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index a577e9900..e2af4420f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -133,6 +133,7 @@ class DispatchPickingPersistenceTest { sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); + org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java index 92bbc2ca6..795820e54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -49,7 +49,8 @@ class ExceptionOrderDispatchReturnSourceTest { @BeforeEach void setUp() { - service = new ExceptionOrderService(); + service = new ExceptionOrderService(); // workflow + org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); ReflectionTestUtils.setField(service, "returnItems", returnItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index 4a59c218e..66d0bd779 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -50,7 +50,8 @@ class ExceptionOrderLifecycleTest { @BeforeEach void setUp() { - service = new ExceptionOrderService(); + service = new ExceptionOrderService(); // workflow + org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); objectMapper = new ObjectMapper().findAndRegisterModules(); ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); ReflectionTestUtils.setField(service, "commands", commands); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java index 095dda3ec..4a8eeeec0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java @@ -29,7 +29,8 @@ class ExceptionOrderServiceTest { @BeforeEach void setUp() { - service = new ExceptionOrderService(); + service = new ExceptionOrderService(); // workflow + org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java index 98373940b..30ac488f1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java @@ -31,7 +31,8 @@ class ExceptionReturnConcurrencyTest { var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); var sessions = new SqlSessionTemplate(factory.getObject()); - var service = new ExceptionOrderService(); + var service = new ExceptionOrderService(); // workflow + org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class)); ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class)); var tx = new TransactionTemplate(new DataSourceTransactionManager(data)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java index c90f3ba17..98c567462 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java @@ -16,6 +16,7 @@ import static org.mockito.Mockito.*; class ExceptionReturnHistoryTest { private final ExceptionOrderService service = new ExceptionOrderService(); + { org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); } private final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24); private List history(List rows) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java new file mode 100644 index 000000000..bdb489da1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnInboundServiceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.dto.*; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +/** 验证异常退回建单与现场实收边界,不能用建单替代库存事实。 */ +class ExceptionReturnInboundServiceTest { + private final ExceptionWorkflowRepository repository=mock(ExceptionWorkflowRepository.class); + private final ExceptionOrderService exceptions=mock(ExceptionOrderService.class); + private final LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class); + private final PalletPhysicalGateway pallets=mock(PalletPhysicalGateway.class); + private final WarehouseResourceGateway resources=mock(WarehouseResourceGateway.class); + private final ExceptionReturnInboundService service=new ExceptionReturnInboundService(repository,exceptions,numbers,pallets,resources); + private final PdaWarehouseIdentity identity=new PdaWarehouseIdentity("7","仓管",11L); + /** 构造关联客户与原尾程明确的待退回工单。 */ + private ExceptionOrder order() { + var order=new ExceptionOrder(); order.setId(1L); order.setCustomerId("C1"); order.setExceptionNo("EX1"); order.setVersion(0L); + order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); order.setStatus("PENDING"); + order.setLastMileOrderId(9L); order.setOverseasWarehouseId("11"); order.setInboundCode("ORIGINAL"); return order; + } + /** 首次建单只冻结来源和待入库状态,重放不重复创建或实收。 */ + @Test void generatesOnceAndNeverReceivesStock() { + var exception=order(); var original=new LastlegOrder();original.setCustomerId("C1");original.setId(9L);original.setOrderNo("WC9"); + when(repository.lock("C1","EX1")).thenReturn(exception);when(repository.original(9L)).thenReturn(original); + when(numbers.next("LASTLEG")).thenReturn("WC10"); + when(exceptions.detail("C1","EX1")).thenReturn(new ExceptionOrderResponses.Detail(null,List.of(new ExceptionOrderResponses.ReturnSource(2L,3L,"TP1","SKU",2,null,null)),List.of())); + var generated=service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"key",null)); + assertThat(generated.getSourceOrderNo()).isEqualTo("WC9");assertThat(generated.getSourceInboundId()).isEqualTo("1"); + assertThat(generated.getPackageCount()).isEqualTo(2);assertThat(generated.getReceivedPackageCount()).isZero(); + assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isNull(); + when(repository.returnOrder(exception)).thenReturn(generated); + assertThat(service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"retry",null))).isSameAs(generated); + verify(repository,times(1)).insert(any()); verify(repository).copyDetails(generated,exception); + verify(exceptions,never()).complete(any(),any(),any(),any());verifyNoInteractions(pallets,resources); + } + /** 跨仓任务既不能开始,也不能上传证据或写入实收。 */ + @Test void foreignWarehouseFailsBeforeReadingOrRestoring() { + assertThatThrownBy(()->service.start(identity,"EX-OTHER")).hasMessageContaining("当前仓"); + assertThatThrownBy(()->service.receive(identity,"EX-OTHER",new ExceptionReturnInboundService.Receipt(0L,"key","TP1","L1",List.of(),List.of(5L)))).hasMessageContaining("当前仓"); + verifyNoInteractions(exceptions,pallets,resources); + } + /** 客户随冻结任务取值,目标仓、库位真实绑定后才调用原来源实收入账。 */ + @Test void receiptUsesWarehouseTaskOwnershipAndAuthoritativeTarget() { + var exception=order(); exception.setStatus("PROCESSING"); + when(repository.lockWarehouse("11","EX1")).thenReturn(exception);when(repository.returnOrder(exception)).thenReturn(new LastlegOrder()); + when(pallets.resolve("TP1")).thenReturn(new PalletPhysicalGateway.PalletReference(13,"TP1",11,14L,"IN_USE",null,1)); + when(resources.resolveLocation(11,"L1")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(14,"L1","STORAGE",null,"ACTIVE")); + service.receive(identity,"EX1",new ExceptionReturnInboundService.Receipt(2L,"receive-1","TP1","L1",List.of(new ExceptionReturnInboundService.Item(2L,3L,null,1)),List.of(5L))); + verify(exceptions).complete(eq("C1"),eq("7"),eq("EX1"),argThat(r->r.expectedVersion()==2 && r.receipts().get(0).targetPalletId()==13 && r.receipts().get(0).receivedPackages()==1 && r.evidenceFileIds().equals(List.of(5L)))); + } + /** 错误托盘或库位不能写入任何库存。 */ + @Test void mismatchedLocationCannotReceive() { + var exception=order();when(repository.lockWarehouse("11","EX1")).thenReturn(exception);when(repository.returnOrder(exception)).thenReturn(new LastlegOrder()); + when(pallets.resolve("TP1")).thenReturn(new PalletPhysicalGateway.PalletReference(13,"TP1",11,99L,"IN_USE",null,1)); + when(resources.resolveLocation(11,"L1")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(14,"L1","STORAGE",null,"ACTIVE")); + assertThatThrownBy(()->service.receive(identity,"EX1",new ExceptionReturnInboundService.Receipt(0L,"key","TP1","L1",List.of(new ExceptionReturnInboundService.Item(2L,3L,null,1)),List.of(5L)))).hasMessageContaining("实际绑定一致"); + verifyNoInteractions(exceptions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index 1057f33a6..223555af5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -174,6 +174,7 @@ class ReturnCartonReturnPersistenceTest { /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ private ExceptionOrderService business() { var business=new ExceptionOrderService(); + org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 2da56d2cf..25051bbb4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -64,7 +64,7 @@ class OverseasProcurementServiceTest { } @Test - void customerOrderAlwaysStartsPendingReviewWithZeroInventory() { + void platformOrderStartsPendingReviewWithZeroInventory() { AtomicReference saved = new AtomicReference<>(); when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); @@ -80,6 +80,19 @@ class OverseasProcurementServiceTest { assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); } + /** 客户采购在资金处理中或失败时均不能由平台提前审核。 */ + @Test + void fundingStatesCannotBeReviewed() { + for (String status : List.of("FUNDING_PENDING", "FUNDING_FAILED")) { + var pending = order(status, 0, 0); + when(orders.lockNo("100", "HC1")).thenReturn(pending); + assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", + new OverseasProcurementRequests.Review(0L,"APPROVE","funding-review","checked",10L))); + assertEquals(status, pending.getStatus()); + } + verify(orders, never()).updateById(org.mockito.ArgumentMatchers.any()); + } + @Test void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() { OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index 999ac9273..64e682428 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -81,7 +81,7 @@ class UsTruckOrderServiceTest { @Test void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { when(orderNoGenerator.next(anyString())).thenReturn("TK1"); - when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); + lenient().when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; }); var request = create("PALLET_15", "new-truck"); var detail = service.create("100", "operator", request); @@ -96,6 +96,16 @@ class UsTruckOrderServiceTest { assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); } + /** 资金处理中与失败订单不能绕过审核直接推进履约。 */ + @Test + void fundingStatesCannotBeReviewed() { + for (String state : List.of("FUNDING_PENDING", "FUNDING_FAILED")) { + order.setStatus(state); + assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator", + new UsTruckOrderRequests.Review(0L, "APPROVE", "funding-review", null, 1L))); + } + } + @Test void reviewEntersDispatchWithoutInventingPallets() { order.setPalletCount(0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java index c603a4733..2580f7002 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java @@ -19,6 +19,7 @@ class OrderBudgetAuthorizationTest { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class); @Configuration @EnableMethodSecurity(proxyTargetClass=true) static class Config { + @Bean OrderFundingPort funding() { return mock(OrderFundingPort.class); } @Bean OrderBudgetService service() { return mock(OrderBudgetService.class); } @Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); } } @@ -39,6 +40,19 @@ class OrderBudgetAuthorizationTest { assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class); verifyNoInteractions(context.getBean(OrderBudgetService.class)); } + @Test void orderFundingReadAndRetryRespectSeparateBusinessPermissions() { + var controller=context.getBean(MerchantOrderBudgetController.class); + var service=context.getBean(OrderBudgetService.class); + var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list"); + controller.readOrder(reader,"procurement","CG1"); + verify(service).readOrder("9","procurement","CG1"); + assertThatThrownBy(()->controller.readOrder(reader,"trucking","CG1")).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.retry(reader,"procurement","CG1")).isInstanceOf(AccessDeniedException.class); + var writer=login(9,"MERCHANT","merchant:cross-border:domestic-trucking:create"); + controller.retry(writer,"trucking","KC1"); + verify(service).retryOrder("9","trucking","KC1"); + assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class); + } private AuthenticatedUser login(long customer,String app,String permission) { var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java index 88758d520..99a15dea8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java @@ -18,17 +18,65 @@ import static org.assertj.core.api.Assertions.*; class OrderBudgetPersistenceTest { OrderBudgetRepository repository; JdbcTemplate jdbc; + OrderBudgetService service; + OrderFundingPort funding; @BeforeEach void setup() throws Exception { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", ""); jdbc = new JdbcTemplate(ds); String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql); assertThat(match.find()).isTrue();jdbc.execute(match.group()); + var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql); + assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group()); jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))"); var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class); + config.addMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class); var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); - repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class)); + var sessions = new SqlSessionTemplate(factory.getObject()); + repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class)); + var wallet = new com.baiyue.lastleg.wallet.WalletCommandOutboxService( + sessions.getMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class), + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + var target = new OrderBudgetService(repository, + new com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter(null), wallet); + funding = org.mockito.Mockito.mock(OrderFundingPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(target,"funding",funding); + var proxy = new org.springframework.aop.framework.ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor( + new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds), + new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + service = (OrderBudgetService) proxy.getProxy(); + } + /** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */ + @Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() { + for (String kind : new String[]{"procurement","trucking"}) { + String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order"; + assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> { + jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')"); + throw new IllegalStateException("订单明细写入失败"); + }, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order_budget_reservation",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero(); + } + } + /** 成功受理原子提交并可同键重放,资金指令不能重复。 */ + @Test void acceptsAndReplaysWithoutDuplicateFreeze() { + for (String kind : new String[]{"procurement","trucking"}) { + service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v); + service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v); + assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING"); + } + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); + } + /** 免费样品无需发出冻结指令,直接开放审核门禁。 */ + @Test void freeSampleSkipsFinanceAndEntersReview() { + service.submit("9","procurement","zero",BigDecimal.ZERO,"CNY",()->"FREE1",v->v); + assertThat(repository.lockOrder("9","procurement","FREE1").getStatus()).isEqualTo("FROZEN"); + org.mockito.Mockito.verify(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("PENDING_REVIEW"),org.mockito.ArgumentMatchers.isNull()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero(); } @Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() { var row = row(1,"FROZEN");repository.insert(row); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java index 8f331e7cf..1f7e68dff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java @@ -15,23 +15,35 @@ class OrderBudgetServiceTest { OrderBudgetRepository repository=mock(OrderBudgetRepository.class); WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class); OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet); + OrderFundingPort funding=mock(OrderFundingPort.class); OrderBudget row; @BeforeEach void setup() { + org.springframework.test.util.ReflectionTestUtils.setField(service,"funding",funding); row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row); } - @Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() { - assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中"); + /** 下单先受理,资金不足保留失败单,明确重试才发送新版本。 */ + @Test void acceptsPendingThenRecordsFailureAndRetriesSameOrder() { + assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1"); + assertThat(row.getOrderNo()).isEqualTo("CG1"); service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足")); + verify(funding).apply(row,"FUNDING_FAILED","可用余额和授信额度不足"); assertThat(service.read("9","procurement","key").message()).contains("授信额度不足"); - assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足"); + when(repository.lockOrder("9","procurement","CG1")).thenReturn(row); when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry"); - assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry"); + assertThat(service.retryOrder("9","procurement","CG1").status()).isEqualTo("FREEZE_PENDING"); + verify(funding).apply(row,"FUNDING_PENDING",null); + service.retryOrder("9","procurement","CG1"); + verify(wallet,times(1)).publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L)); + service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null)); + assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING"); + service.handle(result("retry","FREEZE","SUCCEEDED","FROZEN",null)); + verify(funding).apply(row,"PENDING_REVIEW",""); } @Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() { service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING"); service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null)); - assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验"); + assertThatThrownBy(()->service.readOrder("10","procurement","CG1")).hasMessageContaining("未找到"); assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化"); assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化"); assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java new file mode 100644 index 000000000..af65ec873 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.wallet.budget; + +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import com.baiyue.lastleg.procurement.funding.OrderFundingService; +import com.baiyue.lastleg.procurement.funding.OrderFundingRepository; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; + +/** 两业务共享状态门禁,重复与迟到回执不能修改已取消或已审核单。 */ +class OrderFundingServiceTest { + OrderFundingRepository repository = mock(OrderFundingRepository.class); + OrderFundingService service = new OrderFundingService(repository); + + /** 采购冻结成功后才待审核,重复成功无第二条历史。 */ + @Test void purchaseSuccessIsIdempotentAndPreservesRemark() { + var budget = budget("procurement"); + var order = new OverseasProcurementOrder(); order.setId(1L); order.setCustomerId("9"); order.setStatus("FUNDING_PENDING"); order.setVersion(0L); order.setRemark("客户原始备注"); + when(repository.purchase("9", "NO1")).thenReturn(order); + service.apply(budget,"PENDING_REVIEW",null); service.apply(budget,"PENDING_REVIEW",null); + assertThat(order.getStatus()).isEqualTo("PENDING_REVIEW"); assertThat(order.getVersion()).isEqualTo(1L); + assertThat(order.getRemark()).isEqualTo("客户原始备注"); + verify(repository,times(1)).history(eq(order),eq("FUNDING_PENDING"),eq("PENDING_REVIEW"),isNull(),any()); + } + + /** 卡车失败保留备注,重试成功后才开放审核;取消后迟到结果不得复活。 */ + @Test void truckFailureRetrySuccessAndCancellation() { + var budget = budget("trucking"); + var order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("9"); order.setStatus("FUNDING_PENDING"); order.setVersion(0L); order.setRemark("客户路线说明"); + when(repository.truck("9", "NO1")).thenReturn(order); + service.apply(budget,"FUNDING_FAILED","余额不足"); + assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED"); assertThat(order.getRemark()).isEqualTo("客户路线说明"); + service.apply(budget,"FUNDING_PENDING",null); service.apply(budget,"PENDING_REVIEW",null); + assertThat(order.getStatus()).isEqualTo("PENDING_REVIEW"); + order.setStatus("CANCELLED"); service.apply(budget,"PENDING_REVIEW",null); + assertThat(order.getStatus()).isEqualTo("CANCELLED"); + verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any()); + } + + /** 资金状态必须定位可信客户范围的原单。 */ + @Test void missingCustomerOrderRejectsReceipt() { + assertThatThrownBy(() -> service.apply(budget("trucking"),"PENDING_REVIEW",null)).isInstanceOf(IllegalStateException.class); + verify(repository).truck("9","NO1"); + } + + /** 构造已绑定原单的预算身份。 */ + private OrderBudget budget(String kind) { + var row = new OrderBudget(); row.setCustomerId("9"); row.setKind(kind); row.setOrderNo("NO1"); return row; + } +}