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节。
- 海外采购按 `procurement-workflow.md` 的七状态流程执行:创建待审核,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
- 海外采购按 `procurement-workflow.md` 的状态流程执行:客户创建资金处理中,异步冻结成功待审核、失败原单重试,审核后分批收货,平台提交后由客户确认,再分批出库、派送与上传签收凭证;出库预留数量、签收扣采购库存,整单不得提前完成。采购库存属于独立台账,不写 WMS 可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
+6 -4
View File
@@ -4,7 +4,9 @@
| 主状态 | 编码 | 操作 |
|---|---|---|
| 待审核 | `PENDING_REVIEW` | 客户创建采购单,平台审核并确定采购仓 |
| 资金处理中 | `FUNDING_PENDING` | 订单已受理,等待Finance冻结回执 |
| 资金失败 | `FUNDING_FAILED` | 展示失败原因,可在原订单重试 |
| 待审核 | `PENDING_REVIEW` | 冻结成功后,平台审核并确定采购仓 |
| 收货中 | `RECEIVING` | 平台分批登记数量和照片 |
| 待客户确认 | `WAIT_CUSTOMER_CONFIRM` | 平台结束本次采购收货,提交客户确认 |
| 已确认 | `CONFIRMED` | 客户确认实际收货结果,可创建出库批次 |
@@ -28,7 +30,7 @@
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。
- 客户创建采购单后先资金处理中,冻结成功进入待审核,失败保留原单重试;审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
@@ -50,8 +52,8 @@
## 2026-10-29 客户下单预算与页面调整
客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。
客户采购直接提交创建请求;预算预占、FUNDING_PENDING订单及冻结Outbox在同一事务落库,响应表示订单已受理。Finance冻结成功后进入PENDING_REVIEW,明确失败进入FUNDING_FAILED,展示失败原因并允许以原订单、原金额及币种重试。处理中重复点击不重复冻结,旧版本回执不能推进新指令。免费样品零金额不发资金指令并直接进入待审核。旧预算预校验接口仅兼容保留,客户端不再等待冻结后建单。未绑定预占15分钟后释放,已绑定订单终态释放,失败定时重试;迟到回执不得复活取消订单。预算预占独立于人工费用冻结/实扣,不计入已扣款汇总。
本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
本土卡车采用相同的先受理后异步冻结流程,平台仅可审核PENDING_REVIEW订单。托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -39,6 +39,11 @@ public class DispatchDeliveryRepository {
public void create(OutboundOrder value) { outbounds.insert(value); }
/** 不可变出库来源明细在同一安排事务保存。 */
public void create(OutboundPalletItem value) { items.insert(value); }
/** 实际扫码归属核平后,原计划明细统一替换为实际来源明细。 */
public void replaceItems(Long outboundId) {
items.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OutboundPalletItem>()
.eq(OutboundPalletItem::getOutboundOrderId,outboundId));
}
/** 每客户目的地分别保存派送单。 */
public void create(DeliveryOrder value) { deliveries.insert(value); }
/** 仅更新已锁定并复验的客户出库执行事实。 */
@@ -58,14 +63,22 @@ public class DispatchDeliveryRepository {
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); }
/** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) {
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now,boolean outboundCompleted) {
if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo)
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
.set(LastlegOrder::getSubOrderStatus,outboundCompleted?"OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY":"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化");
}
/** PDA拣货核平即完成出库节点,保留尚未生成的派送单为空。 */
public void markOutbound(LastlegOrder source,String outboundNo,java.time.LocalDateTime now) {
if(sources.update(null,new LambdaUpdateWrapper<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); }
}
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
@Service
public class DispatchDeliveryService implements DispatchDeliveryPort {
public class DispatchDeliveryService implements DispatchDeliveryPort, com.baiyue.lastleg.dispatch.port.DispatchOutboundPort {
private final DispatchExecutionPort execution;
private final DispatchDeliveryRepository repository;
private final DeliveryCargoGroups grouping;
@@ -118,9 +118,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复");
if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地");
// 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。
var existingOutbounds=repository.outbounds(context.dispatchId(),true);
var existingOutbounds=activeOutbounds(context.dispatchId(),true);
var existingDeliveries=repository.deliveries(context.dispatchId(),true);
if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
if (existingOutbounds.stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
for (var group:groups) {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
@@ -153,7 +153,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
return fact(saved);
}
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
if(activeOutbounds(context.dispatchId(),true).stream().anyMatch(o->!"OUTBOUNDED".equals(o.getStatus())) || !repository.deliveries(context.dispatchId(),true).isEmpty())
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var groups=grouping.groups(context);var external=fees.external(context);
var inputs=new HashMap<String,ArrangeGroup>();
@@ -167,13 +167,97 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
}
/** 理货审核后只创建待出库单,不提前生成派送、费用或真实拣货事实。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void prepare(Context context, Actor actor) {
if(context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.pickingReconciled()
|| context.cargo().isEmpty() || context.cargo().stream().mapToLong(CargoLine::packages).sum()!=context.counts().packageCount())
throw conflict("理货审核货物不完整,不能生成出库单");
var existing=repository.outbounds(context.dispatchId(),true);
if(existing.stream().anyMatch(o->!"CANCELLED".equals(o.getStatus())))throw conflict("本车已有有效出库单");
for(var customer:context.cargo().stream().map(CargoLine::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var order=existing.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
boolean created=order==null;
if(created) {order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
order.setOutboundType("DISPATCH_DELIVERY");order.setLxCodes("");order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(Instant.now()));
order.setIdempotencyKey(grouping.hash(List.of("REVIEW_OUTBOUND",context.dispatchId(),customer)));}
else repository.replaceItems(order.getId());
order.setStatus("WAIT_OUTBOUND");order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count());
order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm));
order.setRequestHash(context.cargoHash());order.setUpdatedAt(local(Instant.now()));
if(created)repository.create(order);else repository.save(order);
for(var line:cargo)repository.create(outboundItem(order,line,false));
}
}
/** PDA 全部来源核平后,把对应客户出库单推进为已出库,发车阶段只推进派送单。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void complete(Context context) {
if(!context.pickingReconciled() || context.cargo().stream().anyMatch(c->c.attributionId()==null))throw conflict("真实拣货来源尚未核平");
var outbounds=activeOutbounds(context.dispatchId(),true);
if(outbounds.isEmpty())throw conflict("理货审核出库单缺失");
for (var outbound:outbounds) {
var cargo=context.cargo().stream().filter(c->outbound.getCustomerId().equals(c.customerId())).toList();
if(cargo.stream().mapToLong(CargoLine::packages).sum()!=outbound.getPackageCount())throw conflict("出库单与真实客户拣货数量不一致");
repository.replaceItems(outbound.getId());
for(var line:cargo)repository.create(outboundItem(outbound,line,true));
outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
var now=local(Instant.now());
for(var orderId:cargo.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList()) {
var source=repository.lockSource(outbound.getCustomerId(),orderId);
if(source==null)throw conflict("真实拣货来源订单缺失");
repository.markOutbound(source,outbound.getOutboundNo(),now);
}
}
}
/** 零扫码退回时撤销审核阶段预生成的下游单据。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void revoke(long dispatchId) {
for (var outbound: repository.outbounds(dispatchId, true)) {
if(!Set.of("WAIT_OUTBOUND","CANCELLED").contains(outbound.getStatus()))throw conflict("已出库不能退回重审");
outbound.setStatus("CANCELLED");outbound.setUpdatedAt(local(Instant.now()));repository.save(outbound);
}
}
/** 预生成和实拣共用字段映射,计划行永远不携带虚构拣货数量。 */
private OutboundPalletItem outboundItem(OutboundOrder order,CargoLine row,boolean picked) {
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(order.getId());
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode());
item.setDestinationWarehouseId(row.destination().warehouseId());item.setDestinationWarehouseCode(row.destination().warehouseCode());item.setDestinationWarehouseName(row.destination().warehouseName());
item.setPackageCount(Math.toIntExact(row.packages()));item.setPickedPackageCount(picked?Math.toIntExact(row.packages()):0);item.setStatus(picked?"PICKED":"WAIT_PICK");
item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark());item.setOrderRefs(row.orderNo());item.setCreatedAt(local(Instant.now()));
return item;
}
/** 已撤销的审核出库保留审计,但不参与本轮有效单据集合校验。 */
private List<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,
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
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()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var existing=prepared.stream().filter(o->customer.equals(o.getCustomerId())).findFirst().orElse(null);
if(existing!=null) {
if(!"OUTBOUNDED".equals(existing.getStatus()) || existing.getPackageCount()!=cargo.stream().mapToLong(CargoLine::packages).sum())
throw conflict("审核出库单尚未按真实拣货完成");
outbounds.put(customer,existing);continue;
}
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
order.setOutboundNo(numbers.next("OUTBOUND"));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
@@ -193,6 +277,8 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
repository.create(delivery);deliveries.put(group.key(),delivery);
for (var row:group.cargo()) {
// 提前创建的出库单已在PDA拣货核平时写入实际归属,发运不能再插一次。
if(prepared.stream().anyMatch(o->Objects.equals(o.getId(),outbound.getId())))continue;
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId());
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
@@ -224,12 +310,12 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
@Transactional(propagation=Propagation.MANDATORY)
public DepartureFacts departureFacts(Context context,boolean lock) {
var arrangement=repository.arrangement(context.dispatchId(),lock);
var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
var outbounds=activeOutbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
String reason=arrangement==null?"请先安排本车全部客户派送":
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排":
outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
outbounds.stream().anyMatch(o->!Set.of("WAIT_OUTBOUND","OUTBOUNDED").contains(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
|| d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(),
arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),
@@ -242,7 +328,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
if (departedAt==null) throw conflict("缺少实际发车时间");
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason());
var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
var outbounds=activeOutbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); }
@@ -256,7 +342,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
public NormalDeliveryFacts normalDeliveryFacts(Context context) {
var saved=repository.arrangement(context.dispatchId(),false);
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
var outbounds=repository.outbounds(context.dispatchId(),false);
var outbounds=activeOutbounds(context.dispatchId(),false);
var actual=new ArrayList<NormalDeliveryFact>();
for (var delivery:repository.deliveries(context.dispatchId(),false)) {
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId())
@@ -311,7 +397,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId());
var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
.map(g->deliveries.get(g.key())).toList();
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now));
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now),"OUTBOUNDED".equals(outbound.getStatus()));
for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId());
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);}
@@ -397,3 +483,4 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
/** 任一客户失败由外层事务回滚全部单据和费用。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
}
@@ -90,6 +90,14 @@ public class DispatchAggregateController {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 派送异常登记不触发签收或退货入库。 */
/** 标记异常时按本车真实派送来源选择退回货物。 */
@GetMapping("/{dispatchNo}/exception-return-options")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<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")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
@@ -8,6 +8,14 @@ import java.util.List;
/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */
@Mapper
public interface DispatchReadMapper {
/** 普通计划一票一计划,换标和退回使用冻结履约订单,避免使用混托展示代表。 */
@org.apache.ibatis.annotations.Select("SELECT i.id AS itemId,i.customer_id AS customerId,"
+ "CASE WHEN i.source_business_type='RELABEL_ORDER' THEN i.order_id ELSE COALESCE((SELECT MIN(l.order_id) "
+ "FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id "
+ "WHERE a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id HAVING COUNT(DISTINCT l.order_id)=1),i.order_id) END AS orderId "
+ "FROM ll_dispatch_item i "
+ "WHERE i.dispatch_order_id=#{id} ORDER BY i.customer_id,i.id")
java.util.List<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}")
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,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
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,
@@ -78,7 +81,9 @@ public final class DispatchCommands {
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
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); }
}
@@ -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<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) {
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
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
@jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.port.DispatchExceptionPort exceptionWorkflow;
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
@@ -509,7 +511,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED) {
row.setStage(Stage.LOADING.name());
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class));
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(row.getTaskType())) {
masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class));
if(outboundWorkflow!=null) outboundWorkflow.prepare(plannedOutboundContext(actor,row),actor);
}
}
} else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
@@ -532,6 +537,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审");
if(trim(request.reason())==null)throw bad("退回原因不能为空");
if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前");
if(outboundWorkflow!=null) outboundWorkflow.revoke(row.getId());
row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason());
return saveCommand(row,actor,identity,request.reason());
}
@@ -656,6 +662,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
return context(null,row);
}
/** 审核建单使用逐计划来源;该上下文不作为真实拣货或装车核平证明。 */
private Context plannedOutboundContext(Actor actor,DispatchAggregate row) {
var sources=repository.outboundSources(row.getId()).stream().collect(Collectors.toMap(DispatchOutboundPort.PlannedSource::itemId,s->s));
var cargo=new ArrayList<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) {
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化");
var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
@@ -844,6 +868,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
var initial=required(actor,number,false);
if ("LOCAL_TRUCK".equals(initial.getTaskType()) || "PICKUP".equals(initial.getTaskType()))throw conflict("仅派送中的仓库派送排车可以标记异常");
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
@@ -858,10 +884,19 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
var records=new ArrayList<>(exceptionRecords(row));
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
exceptionWorkflow.register(row.getId(),actor.operatorId(),request);
row.setExceptionsJson(write(records));
return saveCommand(row,actor,identity,description);
}
/** 退回候选与标记异常共用平台和阶段门禁。 */
@Transactional(readOnly=true)
public java.util.List<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) {
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); }
}
@@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service
public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchOutboundPort outboundWorkflow;
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules;
@@ -100,7 +101,8 @@ public class DispatchPickingService {
row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败");
boolean complete=workflow.preview(actor,number).pickingReconciled();
var actual=workflow.preview(actor,number);boolean complete=actual.pickingReconciled();
if(complete && outboundWorkflow!=null)outboundWorkflow.complete(actual);
if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存");
var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED,
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
@@ -23,6 +23,7 @@ import java.util.List;
@RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController {
@Resource private ExceptionOrderService service;
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionReturnInboundService returns;
@GetMapping("/exception-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')")
@@ -63,6 +64,16 @@ public class ExceptionOrderController {
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
}
public record GeneratedReturn(String orderNo,String status,String sourceOrderNo) {}
/** 生成退回尾程单后由PDA退货入库列表接收,建单不恢复库存。 */
@PostMapping("/exception-orders/{exceptionNo}/generate-return-inbound")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<?> generateReturn(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId,
@PathVariable String exceptionNo,@Valid @RequestBody ExceptionOrderRequests.StartProcessing request) {
var tail=returns.generate(requiredScope(user,customerId),operatorId(user),exceptionNo,request);
return ApiResponse.success(new GeneratedReturn(tail.getOrderNo(),tail.getCurrentStatus(),tail.getSourceOrderNo()));
}
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
public ApiResponse<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,
String palletNo, String skuCode, long maxReturnPackages,
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,
List<Redispatch> redispatches) {}
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
public class ExceptionOrderService {
@Resource private com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository workflow;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private ExceptionOrderMapper exceptionOrders;
@Resource private LastlegOrderMapper lastMileOrders;
@@ -118,7 +119,7 @@ public class ExceptionOrderService {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order),
returnHistory(order));
returnHistory(order), workflow.returnOrder(order)==null?null:workflow.returnOrder(order).getOrderNo());
}
/** 普通仓位与原箱回仓都只展示实际入账凭据。 */
@@ -235,6 +236,9 @@ public class ExceptionOrderService {
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested);
int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum());
// 部分签收只能退回未签收余量,不能把已交付货物再次计入本趟退回。
long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0);
if(signed<0 || delivery.getPackageCount()==null || packages > delivery.getPackageCount()-signed)throw conflict("退回箱数超过派送未签收数量");
Set<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());
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
@@ -378,6 +382,12 @@ public class ExceptionOrderService {
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
var returnTail=workflow.returnOrder(order);
if(returnTail!=null) {
returnTail.setReceivedPackageCount(Math.toIntExact(stored.stream().mapToLong(ReturnReceiptPolicy::received).sum()));
returnTail.setCurrentStatus(completed?"INBOUND_COMPLETED":"PENDING_INBOUND");
returnTail.setVersion(Objects.requireNonNullElse(returnTail.getVersion(),0)+1); returnTail.setUpdatedAt(now); workflow.update(returnTail);
}
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
@@ -635,7 +645,9 @@ public class ExceptionOrderService {
}
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
if (received < delivery.getPackageCount()) return;
long signed=Objects.requireNonNullElse(delivery.getSignedPackages(),0);
if(signed<0 || received+signed>delivery.getPackageCount())throw conflict("实签与实退数量超过原实发数量");
if (received+signed < delivery.getPackageCount()) return;
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
@@ -678,8 +690,10 @@ public class ExceptionOrderService {
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
case "PROCESSING" -> List.of("COMPLETE_EXCEPTION");
case "PENDING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())
? List.of("GENERATE_RETURN_INBOUND","CANCEL_EXCEPTION") : List.of("START_PROCESSING", "CANCEL_EXCEPTION");
case "PROCESSING" -> "RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection()) && workflow.returnOrder(order)==null
? List.of("GENERATE_RETURN_INBOUND") : List.of("COMPLETE_EXCEPTION");
default -> List.of();
};
}
@@ -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,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo());
()->service.createFundingPending(customer(user),operator(user),request),result->result.summary().orderNo());
return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
}
@@ -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 MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String FUNDING_PENDING = "FUNDING_PENDING";
public static final String FUNDING_FAILED = "FUNDING_FAILED";
public static final String WAIT_PURCHASE = "WAIT_PURCHASE";
public static final String REJECTED = "REJECTED";
private static final Set<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",
"DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
@@ -64,6 +66,19 @@ public class OverseasProcurementService {
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
return create(customerId, operatorId, request, PENDING_REVIEW);
}
/** 客户下单先受理,资金异步冻结完成后再开放平台审核。 */
@Transactional
public OverseasProcurementResponses.Detail createFundingPending(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
return create(customerId, operatorId, request, FUNDING_PENDING);
}
/** 按入口保存初始状态,并保持创建幂等。 */
private OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request, String initialStatus) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
OverseasProcurementOrder replay = orders.findKey(customerId, key);
@@ -87,7 +102,7 @@ public class OverseasProcurementService {
order.setContactName(request.contactName().trim());
order.setContactPhone(request.contactPhone().trim());
order.setInboundQuantity(0L);
order.setStatus(PENDING_REVIEW);
order.setStatus(initialStatus);
order.setRemark(trim(request.remark()));
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
@@ -102,11 +117,13 @@ public class OverseasProcurementService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_REVIEW, operatorId,
history(order, "CREATE", null, initialStatus, operatorId,
key, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public List<OverseasProcurementResponses.CustomerOption> customerOptions(String keyword, int limit) {
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
return orders.customerOptions(trim(keyword), limit).stream()
@@ -220,7 +237,7 @@ public class OverseasProcurementService {
return detail(customerId, orderNo);
}
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("订单资金尚未冻结成功,暂不能审核");
String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection())
: ProcurementDirection.normalize(request.procurementDirection());
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) {
@@ -32,7 +32,7 @@ public class MerchantUsTruckOrderController {
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()));
()->service.createFundingPending(customer(user),operator(user),request),result->result.summary().orderNo()));
}
@GetMapping
@@ -37,6 +37,8 @@ public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String FUNDING_PENDING = "FUNDING_PENDING";
public static final String FUNDING_FAILED = "FUNDING_FAILED";
public static final String WAITING_INBOUND = "WAITING_INBOUND";
public static final String RECEIVING = "RECEIVING";
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION";
@@ -49,7 +51,7 @@ public class UsTruckOrderService {
public static final String COMPLETED = "COMPLETED";
public static final String REJECTED = "REJECTED";
public static final String CANCELLED = "CANCELLED";
private static final Set<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,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
@@ -63,6 +65,17 @@ public class UsTruckOrderService {
/** 创建卡车需求,校验两端资料并保留创建幂等结果。 */
@Transactional
public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) {
return create(customerId, operatorId, request, PENDING_REVIEW);
}
/** 客户下单先受理,资金异步冻结完成后再开放平台审核。 */
@Transactional
public UsTruckOrderResponses.Detail createFundingPending(String customerId, String operatorId, UsTruckOrderRequests.Create request) {
return create(customerId, operatorId, request, FUNDING_PENDING);
}
/** 按入口保存初始状态,并保持创建幂等。 */
private UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request, String initialStatus) {
String key = request.idempotencyKey().trim();
String hash = hash(request);
UsTruckOrder replay = repository.findKey(customerId, key);
@@ -86,7 +99,7 @@ public class UsTruckOrderService {
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim());
order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(PENDING_REVIEW);
order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(initialStatus);
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { repository.insertOrder(order); }
@@ -95,10 +108,14 @@ public class UsTruckOrderService {
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now);
return detail(customerId, order.getOrderNo());
history(order, "CREATE", null, initialStatus, operatorId, key, request.remark(), now);
// 创建响应直接复用已组装的订单和刚写入的历史,避免再次查询订单、托盘和历史表。
return detail(order, List.of(), List.of(new UsTruckOrderResponses.History(
"CREATE", null, initialStatus, operatorId, key, trim(request.remark()), now)), null);
}
/** 按客户范围分页读取订单并批量关联承运安排。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
@@ -163,7 +180,7 @@ public class UsTruckOrderService {
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("订单资金尚未冻结成功,暂不能审核");
boolean approve = "APPROVE".equals(norm(request.decision()));
if (approve && request.warehouseId() == null) throw badRequest("审核通过时必须选择真实调度仓库");
String next = approve ? PENDING_DISPATCH : REJECTED;
@@ -437,11 +454,20 @@ public class UsTruckOrderService {
List<UsTruckOrderResponses.Pallet> palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream()
.map(this::pallet)
.toList();
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
o.getRemark(), o.getCreatedBy(), palletDetails,
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(),
h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(),
h.getRemark(), h.getCreatedAt())).toList());
return detail(o, palletDetails, hs.stream().map(this::historyResponse).toList(),
fleetPool.arrangement(poolKey(o)));
}
/** 组装已有数据,创建成功响应复用内存对象避免重复读库。 */
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<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) {
@@ -31,6 +31,20 @@ public class MerchantOrderBudgetController {
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key));
}
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */
@GetMapping("/{kind}/orders/{orderNo}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))")
public ApiResponse<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) {
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.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.server.ResponseStatusException;
@@ -16,13 +17,18 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.*;
/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */
@Service @RequiredArgsConstructor
/** 下单与预算指令同事务受理;仅冻结成功的订单才进入待审核。 */
@Service
public class OrderBudgetService implements WalletResultHandler {
public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET";
private final OrderBudgetRepository repository;
private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet;
@Autowired private OrderFundingPort funding;
/** 绑定预算持久化、汇率与资金事件发布依赖。 */
public OrderBudgetService(OrderBudgetRepository repository, OverseasFeeCurrencyConverter converter, WalletCommandOutboxService wallet) {
this.repository = repository; this.converter = converter; this.wallet = wallet;
}
public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { }
/** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */
@@ -33,7 +39,10 @@ public class OrderBudgetService implements WalletResultHandler {
var row=repository.lock(customer,kind,key);
if(row!=null) {
requireAmount(row,amount,currency);
if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) sendFreeze(row);
if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) {
if (row.getOrderNo() != null) funding.apply(row, "FUNDING_PENDING", null);
sendFreeze(row);
}
return view(row);
}
row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key);
@@ -51,20 +60,51 @@ public class OrderBudgetService implements WalletResultHandler {
if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row);
}
/** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */
/** 预算锁、创建订单及绑定在同一事务;创建失败时连同新预占和资金指令回滚。 */
@Transactional
public <T> 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);
if(row==null) throw bad("请先完成预估金额及余额授信校验");
if (row == null) {
// 与订单共用事务,任一步失败都回滚,不留下孤立冻结请求。
prepare(customer, kind, key, amount, currency);
row = repository.lock(customer, kind, key);
}
requireAmount(row,amount,currency);
if(row.getOrderNo()!=null) {
var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior;
}
if(!"FROZEN".equals(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message());
if(!Set.of("FROZEN", "FREEZE_PENDING").contains(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message());
if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单");
// 订单先受理;资金仍处于异步冻结时,订单保持 FUNDING_PENDING,回执成功后再开放审核。
var result=create.get();String no=number.apply(result);
if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单");
row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);return result;
row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);
if ("FROZEN".equals(row.getStatus())) {
funding.apply(row, "PENDING_REVIEW", null);
}
return result;
}
/** 客户只读取自身已绑定订单的资金进度及失败原因。 */
@Transactional
public View readOrder(String customer, String kind, String orderNo) {
requireKind(kind);
var row = repository.lockOrder(customer, kind, orderNo);
if (row == null) throw bad("未找到订单资金记录");
return view(row);
}
/** 仅明确冻结失败允许重试,处理中重复点击复用原事件,禁止重复冻结。 */
@Transactional
public View retryOrder(String customer, String kind, String orderNo) {
requireKind(kind);
var row = repository.lockOrder(customer, kind, orderNo);
if (row == null || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad("订单资金记录不存在或已释放");
if ("FAILED".equals(row.getStatus())) {
funding.apply(row, "FUNDING_PENDING", null);
sendFreeze(row);
}
return view(row);
}
/** 原订单预算锁与普通编辑同事务,金额变化须重新下单冻结。 */
@@ -114,6 +154,9 @@ public class OrderBudgetService implements WalletResultHandler {
row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage());
} else throw bad("预算资金结果无效");
row.setUpdatedAt(now());repository.save(row);
if ("FREEZE".equals(operation) && !Boolean.TRUE.equals(row.getReleaseRequested()) && row.getOrderNo() != null) {
funding.apply(row, "FROZEN".equals(row.getStatus()) ? "PENDING_REVIEW" : "FUNDING_FAILED", row.getErrorMessage());
}
}
/** 未提交预占到期释放;终态订单不再占用预算;冻结在途时先登记释放意图。 */
@@ -147,7 +190,7 @@ public class OrderBudgetService implements WalletResultHandler {
case "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功";
case "FAILED","RELEASE_FAILED" -> row.getErrorMessage();
case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单";
default -> "余额及授信校验处理中,请稍后再次提交";
default -> "订单已受理,资金处理中,完成后自动进入待审核";
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
}
/** 仅开放本轮客户下单业务。 */
@@ -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:
title: 海外客户下单预估资金校验
version: 1.0.0
description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。
security:
- bearerAuth: []
paths:
@@ -11,7 +11,7 @@ paths:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 发起冻结或读取原请求,失败后可用原键重试
summary: 兼容接口:发起或读取预算冻结请求
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody:
required: true
@@ -28,10 +28,29 @@ paths:
'403': {description: 身份或业务创建权限不足}
get:
summary: 读取当前客户本次下单的权威资金回执
description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。
description: 与POST相同创建权限;该接口保留用于旧客户端兼容。新客户端应直接创建订单,并通过订单资金状态接口读取异步结果。
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
get:
summary: 读取订单资金状态
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。
responses:
'200': {$ref: '#/components/responses/Result'}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 重试订单资金冻结
description: 仅当前客户可重试FUNDING_FAILED订单,沿用原金额和币种;重复请求幂等。
responses:
'200': {$ref: '#/components/responses/Result'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -1741,7 +1741,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions:
post:
summary: 登记派送异常
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,同事务生成异常工单;选择退回海外仓时必须提交原出库来源和退回数量,冻结退回范围但不增加库存。原键重试恢复原结果。退回尾程建单与PDA列表验收契约见exception-return-inbound-v1.yaml。
security:
- bearerAuth: []
responses:
@@ -2639,7 +2639,7 @@ paths:
/api/merchant/v1/last-leg/overseas-procurements:
post:
summary: 客户创建海外采购订单
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单先进入 FUNDING_PENDING,异步冻结成功进入 PENDING_REVIEW,失败进入 FUNDING_FAILED 并可原单重试;零金额直接待审核。同幂等键同请求返回既有订单,异请求返回 A0409。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -2682,7 +2682,7 @@ paths:
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post:
summary: 客户创建美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
description: 权限 merchant:cross-border:domestic-trucking:create;新单先进入 FUNDING_PENDING,异步冻结成功进入 PENDING_REVIEW,失败进入 FUNDING_FAILED 并可原单重试;客户不能调用平台审核或状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -5077,7 +5077,7 @@ components:
actualVolumeCbm: { type: number, nullable: true }
status: { type: string }
version: { type: integer }
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED, REJECTED, CANCELLED, EXCEPTION_CLOSED, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER] }
ProcurementStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED, REJECTED, CANCELLED, EXCEPTION_CLOSED, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER] }
ProcurementReview:
type: object
required: [version, decision, idempotencyKey]
@@ -5126,7 +5126,7 @@ components:
properties:
code: { type: string, enum: ['200'] }
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } }
TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
TruckStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
TruckReview:
type: object
required: [version, decision, idempotencyKey]
@@ -8378,7 +8378,7 @@ components:
DispatchReturnDirection:
type: string
enum: [OVERSEAS_WAREHOUSE, OTHER]
description: 退回海外仓、其他;仅登记去向,不自动退货入库。
description: 退回海外仓、其他;退回海外仓须选择原出库货物生成异常单,建单不自动增加库存。
DispatchMarkException:
type: object
x-java-record: DispatchCommands.MarkException
@@ -8409,6 +8409,27 @@ components:
items:
type: string
pattern: ^[1-9][0-9]*$
returnSelections:
type: array
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。
items:
type: object
required: [deliveryNo, lastMileOrderId, items]
properties:
deliveryNo: { type: string }
lastMileOrderId: { type: string }
items:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [outboundPalletItemId, packageCount]
properties:
outboundPalletItemId: { type: string }
inventoryPositionId: { type: string, nullable: true }
returnCartonId: { type: string, nullable: true }
packageCount: { type: integer, minimum: 1 }
DispatchExceptionRecord:
type: object
x-java-record: DispatchViews.ExceptionRecord
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 简化海外采购批次流程 API
version: 1.0.0
description: 创建采购单后为待审核;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。
description: 客户创建采购单后为资金处理中;冻结成功进入待审核,失败保留原单重试;审核通过进入收货中。实收可分批登记,客户确认后按采购库存分批出库。库存仅在本批次签收完成时扣减,创建出库单先占用可用库存。
security:
- bearerAuth: []
paths:
@@ -276,7 +276,7 @@ components:
description: Long 主键在 JSON 中按字符串传输,避免前端精度损失
Status:
type: string
enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED]
enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED]
description: 依次为待审核、收货中、待客户确认、已确认、运输中、派送中、签收完成
Command:
type: object
@@ -633,6 +633,8 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
org.springframework.test.util.ReflectionTestUtils.setField(service,"exceptionWorkflow",mock(com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
@@ -133,6 +133,7 @@ class DispatchPickingPersistenceTest {
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
}
@@ -49,7 +49,8 @@ class ExceptionOrderDispatchReturnSourceTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
@@ -50,7 +50,8 @@ class ExceptionOrderLifecycleTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands);
@@ -29,7 +29,8 @@ class ExceptionOrderServiceTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
@@ -31,7 +31,8 @@ class ExceptionReturnConcurrencyTest {
var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var sessions = new SqlSessionTemplate(factory.getObject());
var service = new ExceptionOrderService();
var service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class));
var tx = new TransactionTemplate(new DataSourceTransactionManager(data));
@@ -16,6 +16,7 @@ import static org.mockito.Mockito.*;
class ExceptionReturnHistoryTest {
private final ExceptionOrderService service = new ExceptionOrderService();
{ org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); }
private final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24);
private List<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组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() {
var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -64,7 +64,7 @@ class OverseasProcurementServiceTest {
}
@Test
void customerOrderAlwaysStartsPendingReviewWithZeroInventory() {
void platformOrderStartsPendingReviewWithZeroInventory() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> {
saved.set(invocation.getArgument(0));
@@ -80,6 +80,19 @@ class OverseasProcurementServiceTest {
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
}
/** 客户采购在资金处理中或失败时均不能由平台提前审核。 */
@Test
void fundingStatesCannotBeReviewed() {
for (String status : List.of("FUNDING_PENDING", "FUNDING_FAILED")) {
var pending = order(status, 0, 0);
when(orders.lockNo("100", "HC1")).thenReturn(pending);
assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","funding-review","checked",10L)));
assertEquals(status, pending.getStatus());
}
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
}
@Test
void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() {
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
@@ -81,7 +81,7 @@ class UsTruckOrderServiceTest {
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
lenient().when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck");
var detail = service.create("100", "operator", request);
@@ -96,6 +96,16 @@ class UsTruckOrderServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
}
/** 资金处理中与失败订单不能绕过审核直接推进履约。 */
@Test
void fundingStatesCannotBeReviewed() {
for (String state : List.of("FUNDING_PENDING", "FUNDING_FAILED")) {
order.setStatus(state);
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator",
new UsTruckOrderRequests.Review(0L, "APPROVE", "funding-review", null, 1L)));
}
}
@Test
void reviewEntersDispatchWithoutInventingPallets() {
order.setPalletCount(0);
@@ -19,6 +19,7 @@ class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderFundingPort funding() { return mock(OrderFundingPort.class); }
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@@ -39,6 +40,19 @@ class OrderBudgetAuthorizationTest {
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.class));
}
@Test void orderFundingReadAndRetryRespectSeparateBusinessPermissions() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var service=context.getBean(OrderBudgetService.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
controller.readOrder(reader,"procurement","CG1");
verify(service).readOrder("9","procurement","CG1");
assertThatThrownBy(()->controller.readOrder(reader,"trucking","CG1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.retry(reader,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
var writer=login(9,"MERCHANT","merchant:cross-border:domestic-trucking:create");
controller.retry(writer,"trucking","KC1");
verify(service).retryOrder("9","trucking","KC1");
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
@@ -18,17 +18,65 @@ import static org.assertj.core.api.Assertions.*;
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
OrderBudgetService service;
OrderFundingPort funding;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
config.addMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
var sessions = new SqlSessionTemplate(factory.getObject());
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
var wallet = new com.baiyue.lastleg.wallet.WalletCommandOutboxService(
sessions.getMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class),
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
var target = new OrderBudgetService(repository,
new com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter(null), wallet);
funding = org.mockito.Mockito.mock(OrderFundingPort.class);
org.springframework.test.util.ReflectionTestUtils.setField(target,"funding",funding);
var proxy = new org.springframework.aop.framework.ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(
new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds),
new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
service = (OrderBudgetService) proxy.getProxy();
}
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
for (String kind : new String[]{"procurement","trucking"}) {
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
throw new IllegalStateException("订单明细写入失败");
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order_budget_reservation",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero();
}
}
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
for (String kind : new String[]{"procurement","trucking"}) {
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 免费样品无需发出冻结指令,直接开放审核门禁。 */
@Test void freeSampleSkipsFinanceAndEntersReview() {
service.submit("9","procurement","zero",BigDecimal.ZERO,"CNY",()->"FREE1",v->v);
assertThat(repository.lockOrder("9","procurement","FREE1").getStatus()).isEqualTo("FROZEN");
org.mockito.Mockito.verify(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("PENDING_REVIEW"),org.mockito.ArgumentMatchers.isNull());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero();
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
@@ -15,23 +15,35 @@ class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderFundingPort funding=mock(OrderFundingPort.class);
OrderBudget row;
@BeforeEach void setup() {
org.springframework.test.util.ReflectionTestUtils.setField(service,"funding",funding);
row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row);
}
@Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() {
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中");
/** 下单先受理,资金不足保留失败单,明确重试才发送新版本。 */
@Test void acceptsPendingThenRecordsFailureAndRetriesSameOrder() {
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");
assertThat(row.getOrderNo()).isEqualTo("CG1");
service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
verify(funding).apply(row,"FUNDING_FAILED","可用余额和授信额度不足");
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足");
when(repository.lockOrder("9","procurement","CG1")).thenReturn(row);
when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry");
assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry");
assertThat(service.retryOrder("9","procurement","CG1").status()).isEqualTo("FREEZE_PENDING");
verify(funding).apply(row,"FUNDING_PENDING",null);
service.retryOrder("9","procurement","CG1");
verify(wallet,times(1)).publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L));
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("retry","FREEZE","SUCCEEDED","FROZEN",null));
verify(funding).apply(row,"PENDING_REVIEW","");
}
@Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验");
assertThatThrownBy(()->service.readOrder("10","procurement","CG1")).hasMessageContaining("未找到");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化");
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
@@ -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;
}
}