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