This commit is contained in:
2026-10-30 17:59:12 +08:00
parent 872215367b
commit 51a93a69af
48 changed files with 1143 additions and 74 deletions
+1 -1
View File
@@ -136,7 +136,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 - 一件代发废弃:申请只锁箱 `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`。 - 派车状态只允许 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`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
+6 -4
View File
@@ -4,7 +4,9 @@
| 主状态 | 编码 | 操作 | | 主状态 | 编码 | 操作 |
|---|---|---| |---|---|---|
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 | | 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 |
| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 |
| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 |
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 | | 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 | | 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 | | 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
@@ -28,7 +30,7 @@
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。 2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。 - 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 - 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 - 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。 - 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
@@ -50,8 +52,8 @@
## 2026-10-29 客户下单预算与页面调整 ## 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真实回执与人工截图验收仍待环境联调。 结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -39,6 +39,11 @@ public class DispatchDeliveryRepository {
public void create(OutboundOrder value) { outbounds.insert(value); } public void create(OutboundOrder value) { outbounds.insert(value); }
/** 不可变出库来源明细在同一安排事务保存。 */ /** 不可变出库来源明细在同一安排事务保存。 */
public void create(OutboundPalletItem value) { items.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<OutboundPalletItem>()
.eq(OutboundPalletItem::getOutboundOrderId,outboundId));
}
/** 每客户目的地分别保存派送单。 */ /** 每客户目的地分别保存派送单。 */
public void create(DeliveryOrder value) { deliveries.insert(value); } public void create(DeliveryOrder value) { deliveries.insert(value); }
/** 仅更新已锁定并复验的客户出库执行事实。 */ /** 仅更新已锁定并复验的客户出库执行事实。 */
@@ -58,14 +63,22 @@ public class DispatchDeliveryRepository {
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,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<LastlegOrder>().eq(LastlegOrder::getId,source.getId()) if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo) .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::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); .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<LastlegOrder>().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); } public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); }
} }
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ /** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
@Service @Service
public class DispatchDeliveryService implements DispatchDeliveryPort { public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort {
private final DispatchExecutionPort execution; private final DispatchExecutionPort execution;
private final DispatchDeliveryRepository repository; private final DispatchDeliveryRepository repository;
private final DeliveryCargoGroups grouping; 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("派送分组重复"); 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("必须一次安排本车全部客户目的地"); 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); 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<String,DeliveryFeeCoverageModels.Evidence>(); var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
for (var group:groups) { for (var group:groups) {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); 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); 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("已存在关联单据但缺少完整安排事实,请核对"); throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var groups=grouping.groups(context);var external=fees.external(context); var groups=grouping.groups(context);var external=fees.external(context);
var inputs=new HashMap<String,ArrangeGroup>(); var inputs=new HashMap<String,ArrangeGroup>();
@@ -167,13 +167,97 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of()); 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<OutboundOrder> 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<Group> groups, private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external, Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) { Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>(); var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
var prepared=activeOutbounds(context.dispatchId(),true);
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).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); var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
order.setOutboundNo(numbers.next("OUTBOUND")); order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); 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); var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
repository.create(delivery);deliveries.put(group.key(),delivery); repository.create(delivery);deliveries.put(group.key(),delivery);
for (var row:group.cargo()) { 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()); 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.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); 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) @Transactional(propagation=Propagation.MANDATORY)
public DepartureFacts departureFacts(Context context,boolean lock) { public DepartureFacts departureFacts(Context context,boolean lock) {
var arrangement=repository.arrangement(context.dispatchId(),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.<Group>of():grouping.groups(context); var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
String reason=arrangement==null?"请先安排本车全部客户派送": String reason=arrangement==null?"请先安排本车全部客户派送":
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": ?"派送安排的装车或货物依据已经失效":!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; || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), 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), 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) { public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
if (departedAt==null) throw conflict("缺少实际发车时间"); if (departedAt==null) throw conflict("缺少实际发车时间");
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); 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 outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); 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); } 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) { public NormalDeliveryFacts normalDeliveryFacts(Context context) {
var saved=repository.arrangement(context.dispatchId(),false); var saved=repository.arrangement(context.dispatchId(),false);
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); 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<NormalDeliveryFact>(); var actual=new ArrayList<NormalDeliveryFact>();
for (var delivery:repository.deliveries(context.dispatchId(),false)) { for (var delivery:repository.deliveries(context.dispatchId(),false)) {
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) 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 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()))) var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
.map(g->deliveries.get(g.key())).toList(); .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()); 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.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} 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); } private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
} }
@@ -90,6 +90,14 @@ public class DispatchAggregateController {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
} }
/** 派送异常登记不触发签收或退货入库。 */ /** 派送异常登记不触发签收或退货入库。 */
/** 标记异常时按本车真实派送来源选择退回货物。 */
@GetMapping("/{dispatchNo}/exception-return-options")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<java.util.List<com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Candidate>> exceptionCandidates(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.exceptionCandidates(DispatchActorFactory.platform(user),dispatchNo));
}
@PostMapping("/{dispatchNo}/exceptions") @PostMapping("/{dispatchNo}/exceptions")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) { public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
@@ -8,6 +8,14 @@ import java.util.List;
/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */ /** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */
@Mapper @Mapper
public interface DispatchReadMapper { 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<com.baiyue.lastleg.dispatch.port.DispatchOutboundPort.PlannedSource> 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}") @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); long realPicked(@Param("id") long dispatchId);
@@ -24,8 +24,11 @@ public final class DispatchCommands {
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) { List<CargoSelection> cargo, List<BatchAllocation> 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, 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, public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
ReturnDirection returnDirection, String description, List<Long> imageFileIds) { ReturnDirection returnDirection, String description, List<Long> imageFileIds, List<DispatchExceptionPort.Selection> returnSelections) {
/** 兼容历史仅登记异常的调用方。 */
public MarkException(Long version,String key,ExceptionType type,ReturnDirection direction,String description,List<Long> images) { this(version,key,type,direction,description,images,List.of()); }
/** 必填版本用于阻止过期页面覆盖当前排车。 */ /** 必填版本用于阻止过期页面覆盖当前排车。 */
public MarkException { recordVersion = requireVersion(recordVersion); } public MarkException { recordVersion = requireVersion(recordVersion); }
} }
@@ -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<Source> sources) {}
record Selection(String deliveryNo, Long lastMileOrderId, List<Item> items) {}
record Item(Long outboundPalletItemId, Long inventoryPositionId, Long returnCartonId, long packageCount) {}
/** 按本车真实派送及原拣货事实提供退回候选。 */
List<Candidate> candidates(long dispatchId);
/** 异常工单与排车登记同事务落地。 */
void register(long dispatchId, String operator, DispatchCommands.MarkException request);
}
@@ -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);
}
@@ -42,6 +42,8 @@ public class DispatchAggregateRepository {
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
/** 按整车读取全部真实客户明细。 */ /** 按整车读取全部真实客户明细。 */
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
/** 建出库时读取逐明细真实计划归属。 */
public List<com.baiyue.lastleg.dispatch.port.DispatchOutboundPort.PlannedSource> outboundSources(long id) { return reads.outboundSources(id); }
/** 只读取本次确认入库的原计划关联排车。 */ /** 只读取本次确认入库的原计划关联排车。 */
public List<String> receiptDispatches(List<Long> planIds) { public List<String> receiptDispatches(List<Long> planIds) {
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList(); return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
@@ -32,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service @Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { 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; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos; @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()); row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED) { if(request.decision()==Decision.APPROVED) {
row.setStage(Stage.LOADING.name()); 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 { } else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); 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(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(trim(request.reason())==null)throw bad("退回原因不能为空");
if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前"); 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()); row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason());
return saveCommand(row,actor,identity,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,"排车单不存在"); if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
return context(null,row); 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<CargoLine>();
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) { private Context context(Actor actor,DispatchAggregate row) {
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); 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)); 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) @Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) { 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); var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request); 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); for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
var records=new ArrayList<>(exceptionRecords(row)); var records=new ArrayList<>(exceptionRecords(row));
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now())); 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)); row.setExceptionsJson(write(records));
return saveCommand(row,actor,identity,description); return saveCommand(row,actor,identity,description);
} }
/** 退回候选与标记异常共用平台和阶段门禁。 */
@Transactional(readOnly=true)
public java.util.List<com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Candidate> 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) { private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); 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); } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
} }
@@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service @Service
public class DispatchPickingService { public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow;
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules; @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.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); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); 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("实际扫码证据未保存"); 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, 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); 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);
@@ -23,6 +23,7 @@ import java.util.List;
@RequestMapping("/api/platform/v1/last-leg") @RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController { public class ExceptionOrderController {
@Resource private ExceptionOrderService service; @Resource private ExceptionOrderService service;
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService returns;
@GetMapping("/exception-orders") @GetMapping("/exception-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") @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)); 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") @PostMapping("/exception-orders/{exceptionNo}/start-processing")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing( public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
@@ -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)); }
}
@@ -28,7 +28,10 @@ public final class ExceptionOrderResponses {
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages, String palletNo, String skuCode, long maxReturnPackages,
Long returnCartonId, String cartonMark) {} Long returnCartonId, String cartonMark) {}
public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {} public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts, String returnOrderNo) {
/** 历史调用方没有退回尾程关系时保持空值。 */
public Detail(Row order,List<ReturnSource> sources,List<ReturnReceipt> receipts) { this(order,sources,receipts,null); }
}
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
List<Redispatch> redispatches) {} List<Redispatch> redispatches) {}
public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages, public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages,
@@ -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<Delivery> 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<Task> 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);
}
@@ -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<ExceptionWorkflowMapper.Delivery> 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<ExceptionWorkflowMapper.Task> tasks(String warehouse) { return workflow.tasks(warehouse); }
}
@@ -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<Candidate> candidates(long dispatchId) {
var result=new ArrayList<Candidate>();
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.<Selection>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<String>();
for(int index=0;index<selected.size();index++) {
var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !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));
}
}
@@ -65,6 +65,7 @@ import java.util.Set;
@Service @Service
public class ExceptionOrderService { public class ExceptionOrderService {
@Resource private com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository workflow;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private ExceptionOrderMapper exceptionOrders; @Resource private ExceptionOrderMapper exceptionOrders;
@Resource private LastlegOrderMapper lastMileOrders; @Resource private LastlegOrderMapper lastMileOrders;
@@ -118,7 +119,7 @@ public class ExceptionOrderService {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在"); if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), 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<ExceptionOrderRequests.ReturnItem> requested = request.items(); List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested); validateReturnItems(available, requested);
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum()); 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<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) Set<String> 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()); .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
@@ -378,6 +382,12 @@ public class ExceptionOrderService {
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now); order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order); 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); closeReturnedDelivery(order, operatorId, now);
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored; long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
@@ -635,7 +645,9 @@ public class ExceptionOrderService {
} }
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId()); long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致"); 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.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery); deliveryOrders.updateById(delivery);
@@ -678,8 +690,10 @@ public class ExceptionOrderService {
private List<String> allowedActions(ExceptionOrder order) { private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) { return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); case "PENDING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())
case "PROCESSING" -> List.of("COMPLETE_EXCEPTION"); ? 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(); default -> List.of();
}; };
} }
@@ -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<ExceptionWorkflowMapper.Task> 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); }
}
@@ -33,7 +33,7 @@ public class MerchantOverseasProcurementController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) { @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(), 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())); return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
} }
@@ -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);
}
}
@@ -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);
}
}
@@ -46,9 +46,11 @@ public class OverseasProcurementService {
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW"; 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 WAIT_PURCHASE = "WAIT_PURCHASE";
public static final String REJECTED = "REJECTED"; public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "RECEIVING", "WAIT_CUSTOMER_CONFIRM", "CONFIRMED", "IN_TRANSIT", "DELIVERING", "COMPLETED", "WAIT_OVERSEAS_RECEIPT", private static final Set<String> 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", "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"); "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
@@ -64,6 +66,19 @@ public class OverseasProcurementService {
@Transactional @Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId, public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) { 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 key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
OverseasProcurementOrder replay = orders.findKey(customerId, key); OverseasProcurementOrder replay = orders.findKey(customerId, key);
@@ -87,7 +102,7 @@ public class OverseasProcurementService {
order.setContactName(request.contactName().trim()); order.setContactName(request.contactName().trim());
order.setContactPhone(request.contactPhone().trim()); order.setContactPhone(request.contactPhone().trim());
order.setInboundQuantity(0L); order.setInboundQuantity(0L);
order.setStatus(PENDING_REVIEW); order.setStatus(initialStatus);
order.setRemark(trim(request.remark())); order.setRemark(trim(request.remark()));
order.setIdempotencyKey(key); order.setIdempotencyKey(key);
order.setRequestHash(requestHash); order.setRequestHash(requestHash);
@@ -102,11 +117,13 @@ public class OverseasProcurementService {
if (concurrent != null) return replay(customerId, concurrent, requestHash); if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception; throw exception;
} }
history(order, "CREATE", null, PENDING_REVIEW, operatorId, history(order, "CREATE", null, initialStatus, operatorId,
key, null, request.remark(), now); key, null, request.remark(), now);
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
} }
public List<OverseasProcurementResponses.CustomerOption> customerOptions(String keyword, int limit) { public List<OverseasProcurementResponses.CustomerOption> customerOptions(String keyword, int limit) {
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
return orders.customerOptions(trim(keyword), limit).stream() return orders.customerOptions(trim(keyword), limit).stream()
@@ -220,7 +237,7 @@ public class OverseasProcurementService {
return detail(customerId, orderNo); return detail(customerId, orderNo);
} }
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); 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()) String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection())
: ProcurementDirection.normalize(request.procurementDirection()); : ProcurementDirection.normalize(request.procurementDirection());
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) { if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) {
@@ -32,7 +32,7 @@ public class MerchantUsTruckOrderController {
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) { @Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(), 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 @GetMapping
@@ -37,6 +37,8 @@ public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW"; 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 WAITING_INBOUND = "WAITING_INBOUND";
public static final String RECEIVING = "RECEIVING"; public static final String RECEIVING = "RECEIVING";
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; 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 COMPLETED = "COMPLETED";
public static final String REJECTED = "REJECTED"; public static final String REJECTED = "REJECTED";
public static final String CANCELLED = "CANCELLED"; public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, FUNDING_PENDING, FUNDING_FAILED, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
@@ -63,6 +65,17 @@ public class UsTruckOrderService {
/** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */
@Transactional @Transactional
public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { 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 key = request.idempotencyKey().trim();
String hash = hash(request); String hash = hash(request);
UsTruckOrder replay = repository.findKey(customerId, key); UsTruckOrder replay = repository.findKey(customerId, key);
@@ -86,7 +99,7 @@ public class UsTruckOrderService {
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().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.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { repository.insertOrder(order); } try { repository.insertOrder(order); }
@@ -95,10 +108,14 @@ public class UsTruckOrderService {
if (concurrent != null) return replay(customerId, concurrent, hash); if (concurrent != null) return replay(customerId, concurrent, hash);
throw e; throw e;
} }
history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); history(order, "CREATE", null, initialStatus, operatorId, key, request.remark(), now);
return detail(customerId, order.getOrderNo()); // 创建响应直接复用已组装的订单和刚写入的历史,避免再次查询订单、托盘和历史表。
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) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) { public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
@@ -163,7 +180,7 @@ public class UsTruckOrderService {
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request); UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return replay; if (replay != null) return replay;
requireVersion(order, request.version()); 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())); boolean approve = "APPROVE".equals(norm(request.decision()));
if (approve && request.warehouseId() == null) throw badRequest("审核通过时必须选择真实调度仓库"); if (approve && request.warehouseId() == null) throw badRequest("审核通过时必须选择真实调度仓库");
String next = approve ? PENDING_DISPATCH : REJECTED; String next = approve ? PENDING_DISPATCH : REJECTED;
@@ -437,11 +454,20 @@ public class UsTruckOrderService {
List<UsTruckOrderResponses.Pallet> palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream() List<UsTruckOrderResponses.Pallet> palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream()
.map(this::pallet) .map(this::pallet)
.toList(); .toList();
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), return detail(o, palletDetails, hs.stream().map(this::historyResponse).toList(),
o.getRemark(), o.getCreatedBy(), palletDetails, fleetPool.arrangement(poolKey(o)));
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(), }
h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), /** 组装已有数据,创建成功响应复用内存对象避免重复读库。 */
h.getRemark(), h.getCreatedAt())).toList()); private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderResponses.Pallet> pallets,
List<UsTruckOrderResponses.History> 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) { private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
@@ -31,6 +31,20 @@ public class MerchantOrderBudgetController {
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) { public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,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<OrderBudgetService.View> 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<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
}
/** 客户身份只取认证上下文,平台身份不可借用。 */ /** 客户身份只取认证上下文,平台身份不可借用。 */
private String customer(AuthenticatedUser user) { private String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -4,8 +4,9 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.*; import com.baiyue.lastleg.wallet.*;
import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -16,13 +17,18 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.*; import java.util.function.*;
/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */ /** 下单与预算指令同事务受理;仅冻结成功的订单才进入待审核。 */
@Service @RequiredArgsConstructor @Service
public class OrderBudgetService implements WalletResultHandler { public class OrderBudgetService implements WalletResultHandler {
public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET"; public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET";
private final OrderBudgetRepository repository; private final OrderBudgetRepository repository;
private final OverseasFeeCurrencyConverter converter; private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet; 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) { } public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { }
/** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */ /** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */
@@ -33,7 +39,10 @@ public class OrderBudgetService implements WalletResultHandler {
var row=repository.lock(customer,kind,key); var row=repository.lock(customer,kind,key);
if(row!=null) { if(row!=null) {
requireAmount(row,amount,currency); 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); return view(row);
} }
row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key); 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); if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row);
} }
/** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */ /** 预算锁、创建订单及绑定在同一事务;创建失败时连同新预占和资金指令回滚。 */
@Transactional @Transactional
public <T> T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier<T> create,Function<T,String> number) { public <T> T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier<T> create,Function<T,String> number) {
var row=repository.lock(customer,kind,key); 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); requireAmount(row,amount,currency);
if(row.getOrderNo()!=null) { if(row.getOrderNo()!=null) {
var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior; 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("本次资金预占已过期,请重新下单"); if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单");
// 订单先受理;资金仍处于异步冻结时,订单保持 FUNDING_PENDING,回执成功后再开放审核。
var result=create.get();String no=number.apply(result); var result=create.get();String no=number.apply(result);
if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单"); 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()); row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage());
} else throw bad("预算资金结果无效"); } else throw bad("预算资金结果无效");
row.setUpdatedAt(now());repository.save(row); 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 "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功";
case "FAILED","RELEASE_FAILED" -> row.getErrorMessage(); case "FAILED","RELEASE_FAILED" -> row.getErrorMessage();
case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单"; 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)))); };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))));
} }
/** 仅开放本轮客户下单业务。 */ /** 仅开放本轮客户下单业务。 */
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.wallet.budget;
/** 预算回执通过领域端口推进订单,资金基础设施不依赖具体订单实现。 */
public interface OrderFundingPort {
/** 在预算事务内同步订单资金状态,重复或迟到回执由领域实现校验。 */
void apply(OrderBudget budget, String target, String reason);
}
@@ -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。 }
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: 海外客户下单预估资金校验 title: 海外客户下单预估资金校验
version: 1.0.0 version: 1.0.0
description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。 description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
@@ -11,7 +11,7 @@ paths:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} - {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}} - {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post: post:
summary: 发起冻结或读取原请求,失败后可用原键重试 summary: 兼容接口:发起或读取预算冻结请求
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。 description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody: requestBody:
required: true required: true
@@ -28,10 +28,29 @@ paths:
'403': {description: 身份或业务创建权限不足} '403': {description: 身份或业务创建权限不足}
get: get:
summary: 读取当前客户本次下单的权威资金回执 summary: 读取当前客户本次下单的权威资金回执
description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。 description: 与POST相同创建权限;该接口保留用于旧客户端兼容。新客户端应直接创建订单,并通过订单资金状态接口读取异步结果。
responses: responses:
'200': {$ref: '#/components/responses/Result'} '200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足} '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: components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -1741,7 +1741,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions: /api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions:
post: post:
summary: 登记派送异常 summary: 登记派送异常
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。 description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,同事务生成异常工单;选择退回海外仓时必须提交原出库来源和退回数量,冻结退回范围但不增加库存。原键重试恢复原结果。退回尾程建单与PDA列表验收契约见exception-return-inbound-v1.yaml。
security: security:
- bearerAuth: [] - bearerAuth: []
responses: responses:
@@ -2639,7 +2639,7 @@ paths:
/api/merchant/v1/last-leg/overseas-procurements: /api/merchant/v1/last-leg/overseas-procurements:
post: post:
summary: 客户创建海外采购订单 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: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } 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: 幂等冲突 } } 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' } } } } } responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post: post:
summary: 客户创建美国本土卡订单 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: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } } responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -5077,7 +5077,7 @@ components:
actualVolumeCbm: { type: number, nullable: true } actualVolumeCbm: { type: number, nullable: true }
status: { type: string } status: { type: string }
version: { type: integer } 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: ProcurementReview:
type: object type: object
required: [version, decision, idempotencyKey] required: [version, decision, idempotencyKey]
@@ -5126,7 +5126,7 @@ components:
properties: properties:
code: { type: string, enum: ['200'] } code: { type: string, enum: ['200'] }
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } } 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: TruckReview:
type: object type: object
required: [version, decision, idempotencyKey] required: [version, decision, idempotencyKey]
@@ -8378,7 +8378,7 @@ components:
DispatchReturnDirection: DispatchReturnDirection:
type: string type: string
enum: [OVERSEAS_WAREHOUSE, OTHER] enum: [OVERSEAS_WAREHOUSE, OTHER]
description: 退回海外仓、其他;仅登记去向,不自动退货入库。 description: 退回海外仓、其他;退回海外仓须选择原出库货物生成异常单,建单不自动增加库存。
DispatchMarkException: DispatchMarkException:
type: object type: object
x-java-record: DispatchCommands.MarkException x-java-record: DispatchCommands.MarkException
@@ -8409,6 +8409,27 @@ components:
items: items:
type: string type: string
pattern: ^[1-9][0-9]*$ 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: DispatchExceptionRecord:
type: object type: object
x-java-record: DispatchViews.ExceptionRecord x-java-record: DispatchViews.ExceptionRecord
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: 简化海外采购批次流程 API title: 简化海外采购批次流程 API
version: 1.0.0 version: 1.0.0
description: 创建采购单后为待审核;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。 description: 客户创建采购单后为资金处理中;冻结成功进入待审核,失败保留原单重试;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
@@ -276,7 +276,7 @@ components:
description: Long 主键在 JSON 中按字符串传输,避免前端精度损失 description: Long 主键在 JSON 中按字符串传输,避免前端精度损失
Status: Status:
type: string 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: 依次为待审核、收货中、待客户确认、已确认、运输中、派送中、签收完成 description: 依次为待审核、收货中、待客户确认、已确认、运输中、派送中、签收完成
Command: Command:
type: object type: object
@@ -633,6 +633,8 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository, service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, 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); 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)); 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(); 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())); when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
@@ -133,6 +133,7 @@ class DispatchPickingPersistenceTest {
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); 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)); 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); 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)); 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)); for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
} }
@@ -49,7 +49,8 @@ class ExceptionOrderDispatchReturnSourceTest {
@BeforeEach @BeforeEach
void setUp() { 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, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "returnItems", returnItems); ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
@@ -50,7 +50,8 @@ class ExceptionOrderLifecycleTest {
@BeforeEach @BeforeEach
void setUp() { 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(); objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands); ReflectionTestUtils.setField(service, "commands", commands);
@@ -29,7 +29,8 @@ class ExceptionOrderServiceTest {
@BeforeEach @BeforeEach
void setUp() { 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, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
@@ -31,7 +31,8 @@ class ExceptionReturnConcurrencyTest {
var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class); var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var sessions = new SqlSessionTemplate(factory.getObject()); 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, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class)); ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class));
var tx = new TransactionTemplate(new DataSourceTransactionManager(data)); var tx = new TransactionTemplate(new DataSourceTransactionManager(data));
@@ -16,6 +16,7 @@ import static org.mockito.Mockito.*;
class ExceptionReturnHistoryTest { class ExceptionReturnHistoryTest {
private final ExceptionOrderService service = new ExceptionOrderService(); 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 final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24);
private List<ExceptionOrderResponses.ReturnReceipt> history(List<ReturnHistoryRow> rows) { private List<ExceptionOrderResponses.ReturnReceipt> history(List<ReturnHistoryRow> rows) {
@@ -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);
}
}
@@ -174,6 +174,7 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() { private ExceptionOrderService business() {
var business=new ExceptionOrderService(); 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,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -64,7 +64,7 @@ class OverseasProcurementServiceTest {
} }
@Test @Test
void customerOrderAlwaysStartsPendingReviewWithZeroInventory() { void platformOrderStartsPendingReviewWithZeroInventory() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>(); AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> {
saved.set(invocation.getArgument(0)); saved.set(invocation.getArgument(0));
@@ -80,6 +80,19 @@ class OverseasProcurementServiceTest {
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); 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.<OverseasProcurementOrder>any());
}
@Test @Test
void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() { void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() {
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
@@ -81,7 +81,7 @@ class UsTruckOrderServiceTest {
@Test @Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1"); 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; }); when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck"); var request = create("PALLET_15", "new-truck");
var detail = service.create("100", "operator", request); var detail = service.create("100", "operator", request);
@@ -96,6 +96,16 @@ class UsTruckOrderServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); 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 @Test
void reviewEntersDispatchWithoutInventingPallets() { void reviewEntersDispatchWithoutInventingPallets() {
order.setPalletCount(0); order.setPalletCount(0);
@@ -19,6 +19,7 @@ class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class); AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true) @Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config { static class Config {
@Bean OrderFundingPort funding() { return mock(OrderFundingPort.class); }
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); } @Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); } @Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
} }
@@ -39,6 +40,19 @@ class OrderBudgetAuthorizationTest {
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.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) { private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(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; SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
@@ -18,17 +18,65 @@ import static org.assertj.core.api.Assertions.*;
class OrderBudgetPersistenceTest { class OrderBudgetPersistenceTest {
OrderBudgetRepository repository; OrderBudgetRepository repository;
JdbcTemplate jdbc; JdbcTemplate jdbc;
OrderBudgetService service;
OrderFundingPort funding;
@BeforeEach void setup() throws Exception { @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", ""); 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); jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); 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); var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group()); 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_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))"); 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); 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); 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() { @Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row); var row = row(1,"FROZEN");repository.insert(row);
@@ -15,23 +15,35 @@ class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class); OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class); WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet); OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderFundingPort funding=mock(OrderFundingPort.class);
OrderBudget row; OrderBudget row;
@BeforeEach void setup() { @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)); 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); 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,"可用余额和授信额度不足")); service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
verify(funding).apply(row,"FUNDING_FAILED","可用余额和授信额度不足");
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足"); 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"); 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() { @Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING"); service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null)); 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("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"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"); assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
@@ -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;
}
}