From 9e99658bfa440947c21586a084105fdad099fe13 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 10:41:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + ...38__allow_multi_order_delivery_returns.sql | 10 + .../lastleg/dispatch/port/DispatchViews.java | 3 +- .../MerchantTransportPhotoRepository.java | 3 +- .../service/DispatchAggregateService.java | 45 ++- .../mapper/ExceptionWorkflowMapper.java | 4 + .../ExceptionWorkflowRepository.java | 4 + .../service/DispatchExceptionAdapter.java | 13 +- .../service/ExceptionOrderService.java | 52 ++- .../model/OwnedBusinessModels.java | 3 +- .../service/OwnedBusinessQueryService.java | 32 +- .../dto/LocalLooseboardResponses.java | 3 +- .../looseboard/port/LooseboardDispatches.java | 9 + .../service/LocalLooseboardService.java | 5 +- .../openapi/exception-return-inbound-v1.yaml | 2 +- .../openapi/fleet-owned-business-v1.yaml | 10 + .../openapi/overseas-business-v1.yaml | 16 +- .../MerchantTransportPhotoMapperTest.java | 8 +- .../DispatchLooseboardDisplayServiceTest.java | 53 ++++ .../service/ExceptionOrderLifecycleTest.java | 2 +- .../ReturnCartonReturnPersistenceTest.java | 74 ++++- .../ownedbusiness/OwnedBusinessQueryTest.java | 22 ++ .../service/LocalLooseboardServiceTest.java | 16 + scripts/README.md | 1 + ...l-mixed-looseboard-workflow-acceptance.mjs | 299 ++++++++++++++++++ 25 files changed, 656 insertions(+), 35 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardDispatches.java create mode 100644 scripts/local-mixed-looseboard-workflow-acceptance.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index ed7145ad7..c89c7223b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql` 移除派送退回的旧全历史唯一索引,保留普通来源索引,支持同派送一次选择多张原订单各建异常。配套 Lastleg 批次行锁、总量校验及 Admin 多选;可重复执行,不改业务记录。已在本机应用并核对行数,远程 TEST 尚未执行。 + `20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。 退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql new file mode 100644 index 000000000..ebb60e6ac --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql @@ -0,0 +1,10 @@ +-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。 +-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。 +USE baiyue_lastleg; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'), + 'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'), + 'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 3f88b63f6..7555199ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -54,10 +54,11 @@ public final class DispatchViews { List deliveries, Instant createdAt, Instant updatedAt, String remark) { } + /** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */ public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, BigDecimal volumeCbm, String containerNo, String sourceKey, - String sourceBusinessType, String sourceBusinessNo) { } + String sourceBusinessType, String sourceBusinessNo, List sourceKeys) { } /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/MerchantTransportPhotoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/MerchantTransportPhotoRepository.java index b96c60ed3..e368ced56 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/MerchantTransportPhotoRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/MerchantTransportPhotoRepository.java @@ -6,11 +6,12 @@ import java.util.List; /** 客户照片仅查询已核准客户与原单的有效承运关联。 */ @Repository -public class MerchantTransportPhotoRepository { +public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches { private final MerchantTransportPhotoMapper mapper; /** 注入本域查询入口。 */ public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;} /** 返回原散板单实际关联的排车身份。 */ + @Override public List looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);} /** 返回原卡车单有效运输任务。 */ public List truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a527c7122..5898d4033 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -1229,9 +1229,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row)); } - /** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ + /** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */ private List orderDetails(List cargo) { - var result=new ArrayList(); + var groups=new LinkedHashMap,List>(); var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList()); for(var item:cargo) { var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); @@ -1239,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(orders.isEmpty()) orders=Collections.singletonList(null); for(var order:orders) { var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId()); - result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null, - orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo())); + var detail=new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null, + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo(),item.getSourceKey()==null?List.of():List.of(item.getSourceKey())); + // 商业订单必须来自权威关联,不能按LB前缀或跨客户同号合并。 + var key=business==null?List.of("SOURCE",Integer.toString(groups.size())):List.of("LOOSEBOARD",item.getCustomerId(),business.orderNo()); + String pallet=item.getWarehousePalletId()!=null?"WAREHOUSE:"+item.getWarehousePalletId():item.getPlanPalletId()!=null?"PLAN:"+item.getPlanPalletId():"SOURCE:"+Objects.toString(item.getSourceKey(),Objects.toString(item.getId(),Integer.toString(cargo.indexOf(item)))); + var members=groups.computeIfAbsent(key,ignored->new ArrayList<>()); + if(members.stream().noneMatch(member->member.item()==item))members.add(new OrderDisplayContribution(item,detail,pallet)); } } - return List.copyOf(result); + var palletOrders=new HashMap>>(); + groups.forEach((key,members)->members.forEach(member->palletOrders.computeIfAbsent(member.palletKey(),ignored->new HashSet<>()).add(key))); + var sharedPallets=palletOrders.entrySet().stream().filter(entry->entry.getValue().size()>1).map(Map.Entry::getKey).collect(Collectors.toSet()); + return groups.entrySet().stream().map(entry->"LOOSEBOARD".equals(entry.getKey().get(0))?looseboardOrderDetail(entry.getValue(),sharedPallets):entry.getValue().get(0).detail()).toList(); + } + + /** 仅在展示层保存原来源与实托身份,不改写排车货物或库存。 */ + private record OrderDisplayContribution(DispatchItem item,DispatchViews.OrderDetail detail,String palletKey) { } + + /** 同托只计一次;混托无法分摊、缺测量或同托测量冲突均保持未知。 */ + private DispatchViews.OrderDetail looseboardOrderDetail(List members,Set sharedPallets) { + var first=members.get(0).detail(); + var pallets=members.stream().collect(Collectors.groupingBy(OrderDisplayContribution::palletKey,LinkedHashMap::new,Collectors.toList())); + Integer palletCount=members.stream().anyMatch(m->m.detail().palletCount()==null)?null:pallets.size(); + var destinations=members.stream().map(m->m.detail().destinationFbaWarehouse()).filter(Objects::nonNull).filter(s->!s.isBlank()).distinct().toList(); + return new DispatchViews.OrderDetail(first.customerId(),first.customerName(),first.orderNo(),null, + orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::grossWeightKg),palletCount,destinations.isEmpty()?null:String.join("、",destinations), + orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::volumeCbm),null,first.sourceKey(),first.sourceBusinessType(),first.sourceBusinessNo(), + members.stream().flatMap(m->m.detail().sourceKeys().stream()).distinct().toList()); + } + + /** 每个实托必须有一致测量才能汇总,不能把未知量当零或重复累加整托量。 */ + private java.math.BigDecimal orderMeasurement(Collection> pallets,Set sharedPallets,java.util.function.Function measurement) { + var total=java.math.BigDecimal.ZERO; + for(var members:pallets) { + if(sharedPallets.contains(members.get(0).palletKey()))return null; + var value=measurement.apply(members.get(0).detail()); + if(value==null || members.stream().anyMatch(m->measurement.apply(m.detail())==null || value.compareTo(measurement.apply(m.detail()))!=0))return null; + total=total.add(value); + } + return total; } /** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java index 9f1480661..d0158f0f0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionWorkflowMapper.java @@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper { /** 本车可登记的派送单,客户跟随真实派送记录。 */ @Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id") List deliveries(long id); + /** 取消本单时核对同一客户及派送是否仍有其他活动退回。 */ + @Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customer} AND delivery_order_id=#{delivery} " + + "AND exception_type='RETURN' AND id<>#{excluded} AND status IN ('PENDING','PROCESSING')") + long countOtherActiveReturns(@Param("customer") String customer, @Param("delivery") long delivery, @Param("excluded") long excluded); /** 以异常号和冻结仓锁定现场任务。 */ @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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java index fb224fd81..58e42989b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ExceptionWorkflowRepository.java @@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository { } /** 查询本车派送归属。 */ public List deliveries(long id) { return workflow.deliveries(id); } + /** 取消单据不释放同派送其他仍在处理的退回批次。 */ + public boolean hasOtherActiveReturns(String customer, long delivery, long excluded) { + return workflow.countOtherActiveReturns(customer,delivery,excluded)>0; + } /** 在客户范围锁定异常。 */ public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); } /** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java index e88378136..8cf003996 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/DispatchExceptionAdapter.java @@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort { if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源"); if(warehouse) { var seen=new HashSet(); + var grouped=new LinkedHashMap>(); for(int index=0;index200 || !seen.add(selection.deliveryNo())) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物"); + if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 + || !seen.add(selection.deliveryNo()+":"+selection.lastMileOrderId())) + 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, + // 同派送的多张原订单一次锁定并整批冻结,不能让首单的退回中状态拦住后续订单。 + var group=grouped.keySet().stream().filter(row->Objects.equals(row.customerId(),source.customerId()) + && Objects.equals(row.deliveryNo(),source.deliveryNo())).findFirst().orElse(source); + grouped.computeIfAbsent(group,ignored->new ArrayList<>()).add(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)); } + for(var entry:grouped.entrySet()) service.markReturns(entry.getKey().customerId(),operator,operator, + entry.getKey().deliveryNo(),entry.getValue()); } else for(var source:candidates) service.create(source.customerId(),operator,operator, new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index f8ef59eed..112c7b42d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -216,7 +216,7 @@ public class ExceptionOrderService { ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(), request.inboundCode(), request.lastMileOrderNo()); return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(), - request.skuCode(), null, null, "MANUAL", operatorId, operatorName); + request.skuCode(), null, null, "MANUAL", operatorId, operatorName, null); } /** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */ @@ -227,7 +227,44 @@ public class ExceptionOrderService { ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); if (replay != null) return replayCreated(replay, requestHash); DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); + return markReturnLocked(customerId, operatorId, operatorName, delivery, request, requestHash, delivery.getStatus()); + } + /** 同一派送可一次选择多张原订单;整批共用派送行锁,任一来源失败全部回滚。 */ + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public List markReturns(String customerId, String operatorId, String operatorName, + String deliveryNo, List requests) { + if (requests == null || requests.isEmpty() || requests.size() > 200 || requests.stream().anyMatch(request -> request == null + || request.lastMileOrderId() == null || request.idempotencyKey() == null || request.idempotencyKey().isBlank() + || request.items() == null || request.items().isEmpty() || request.items().size() > 200 + || request.items().stream().anyMatch(item -> item == null || item.packageCount() < 1)) + || requests.stream().map(ExceptionOrderRequests.MarkReturn::idempotencyKey).distinct().count() != requests.size() + || requests.stream().map(ExceptionOrderRequests.MarkReturn::lastMileOrderId).distinct().count() != requests.size()) + throw badRequest("请选择不重复的来源订单及有效退回货物"); + var replayed = new ArrayList(); + for (var request : requests) { + var replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); + if (replay != null) replayed.add(replayCreated(replay, hash(request))); + } + if (replayed.size() == requests.size()) return List.copyOf(replayed); + if (!replayed.isEmpty()) throw conflict("退回批次已有部分命令,请刷新后核对原结果"); + DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); + long packages = requests.stream().flatMap(request -> request.items().stream()) + .mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).reduce(0L, Math::addExact); + long signed = Objects.requireNonNullElse(delivery.getSignedPackages(), 0); + if (signed < 0 || delivery.getPackageCount() == null || packages > delivery.getPackageCount() - signed) + throw conflict("退回总箱数超过派送未签收数量"); + var created = new ArrayList(); + String originalStatus = delivery.getStatus(); + for (var request : requests) created.add(markReturnLocked(customerId, operatorId, operatorName, delivery, request, hash(request), originalStatus)); + return List.copyOf(created); + } + + /** 调用方已锁定派送并校验批次总量,这里按原订单冻结真实来源并保存独立异常。 */ + private ExceptionOrderResponses.Created markReturnLocked(String customerId, String operatorId, String operatorName, + DeliveryOrder delivery, ExceptionOrderRequests.MarkReturn request, + String requestHash, String originalDeliveryStatus) { + String deliveryNo = delivery.getDeliveryNo(); List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() .filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId())) @@ -248,7 +285,7 @@ public class ExceptionOrderService { } ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), packages, sku, returnDirection, request.returnLogisticsNo(), - "DELIVERY_RETURN", operatorId, operatorName); + "DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus); for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { ExceptionOrderResponses.ReturnSource source = available.stream() @@ -284,7 +321,7 @@ public class ExceptionOrderService { return created; } - /** Caller keeps the delivery row locked until return creation commits. */ + /** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */ DeliveryOrder lockForReturn(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); @@ -461,7 +498,7 @@ public class ExceptionOrderService { private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, String type, String reason, Integer packages, String sku, String direction, String logisticsNo, String sourceType, - String operatorId, String operatorName) { + String operatorId, String operatorName, String originalDeliveryStatus) { if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) { throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages()); } @@ -482,7 +519,8 @@ public class ExceptionOrderService { value.setReturnLogisticsNo(trim(logisticsNo)); if (delivery != null) { LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId()); - value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(), + // 同批各单冻结批次开始前的派送状态,支持任意顺序取消并恢复。 + value.setReturnContextJson(write(new ReturnContext(originalDeliveryStatus, tail == null ? null : tail.getId(), tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal()))); } if (StringUtils.hasText(direction)) { @@ -695,6 +733,7 @@ public class ExceptionOrderService { private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { } + /** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */ private void restoreCancelledReturn(ExceptionOrder order) { if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; ReturnContext previous; @@ -706,7 +745,8 @@ public class ExceptionOrderService { if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回"); if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0)) throw conflict("已有实际退回入仓,不能取消退回事实"); - delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now()); + boolean otherReturns = workflow.hasOtherActiveReturns(order.getCustomerId(), delivery.getId(), order.getId()); + delivery.setStatus(otherReturns ? "RETURN_PENDING" : previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now()); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); if (previous.tailId() != null) { LastlegOrder tail = lastMileOrders.selectById(previous.tailId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java index bfe57c080..f3fb22d66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java @@ -56,8 +56,9 @@ public final class OwnedBusinessModels { String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues,String taskType) {} public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} public record ListResult(List records,int page,int size,int total,Summary summary) {} + /** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */ public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, - String inboundCode,String palletNo,String destinationKey,String packages) {} + String inboundCode,String palletNo,String destinationKey,String packages,List destinationKeys) {} public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason, Operations operations,List photos) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index fb95f453b..02b32dc51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; import com.baiyue.lastleg.dispatch.port.DispatchContracts; @@ -68,14 +69,37 @@ public class OwnedBusinessQueryService { public Detail detailForWrite(Actor actor,Source source) { return detailForWrite(actor,source,OwnedBusinessScope.OWNED); } - /** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */ + /** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */ public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) { eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids); var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope); var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); - return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), - c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), - projection.photos(repository.record(source.getId()))); + var looseboardOrders=new HashMap,String>(); + if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) { + if(!"LOCAL_LOOSEBOARD".equals(order.sourceBusinessType()) || order.sourceBusinessNo()==null)continue; + for(var key:order.sourceKeys())looseboardOrders.put(List.of(order.customerId(),key),order.sourceBusinessNo()); + } + return new Detail(row,original,cargoDetails(cargo,looseboardOrders),projection.photos(repository.record(source.getId()))); + } + + /** 商业订单由排车公开契约确认,普通货物保持原行,不能按编号前缀猜测散板。 */ + private List cargoDetails(List cargo,Map,String> looseboardOrders) { + var groups=new LinkedHashMap,List>(); + for(var item:cargo) { + var order=looseboardOrders.get(List.of(item.getCustomerId(),item.getSourceKey())); + var key=order==null?List.of("SOURCE",item.getCustomerId(),item.getSourceKey()):List.of("LOOSEBOARD",item.getCustomerId(),order); + groups.computeIfAbsent(key,ignored->new ArrayList<>()).add(item); + } + return groups.entrySet().stream().map(entry->{ + var rows=entry.getValue();var first=rows.get(0);boolean looseboard="LOOSEBOARD".equals(entry.getKey().get(0)); + var destinations=rows.stream().map(projection::destinationKey).distinct().toList(); + var pallets=rows.stream().map(CargoRow::getPalletNo).filter(Objects::nonNull).distinct().toList(); + // 同一来源只贡献一次箱数,实托码展示完整集合,不复制整托箱数。 + var sources=rows.stream().collect(Collectors.toMap(CargoRow::getSourceKey,c->c,(a,b)->a,LinkedHashMap::new)).values(); + String packages=sources.stream().anyMatch(c->c.getPackageCount()==null)?null:Long.toString(sources.stream().mapToLong(CargoRow::getPackageCount).sum()); + return new Cargo(first.getSourceKey(),first.getCustomerId(),first.getCustomerName(),looseboard?entry.getKey().get(2):first.getOrderNo(), + looseboard?null:first.getInboundCode(),pallets.isEmpty()?null:String.join("、",pallets),destinations.get(0),packages,destinations); + }).toList(); } /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ public Source requireOwned(String number) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index 013902c91..a588c0512 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -22,7 +22,8 @@ public final class LocalLooseboardResponses { String operationType, String fromStatus, String toStatus, String operatorId, String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { } + /** 详情独立返回全部有效排车,同车多托合并为一个单号。 */ public record Detail( Summary summary, String originAddress, String destinationAddress, String remark, - String createdBy, List histories) { } + String createdBy, List histories, List dispatchNos) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardDispatches.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardDispatches.java new file mode 100644 index 000000000..dd3065d9f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/port/LooseboardDispatches.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.looseboard.port; + +import java.util.List; + +/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */ +public interface LooseboardDispatches { + /** 按真实客户和散板原单返回去重后的有效排车单号。 */ + List looseboardDispatches(String customerId, String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index d3fd0cad9..125b99e82 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -48,6 +48,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now); } @Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving; + @Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool; public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; public static final String DISPATCHED = "DISPATCHED"; @@ -278,7 +279,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. return order; } - /** 装配需求详情、真实承运安排与不可变历史。 */ + /** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */ private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), @@ -286,7 +287,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList()); + value.getCreatedAt())).toList(), dispatches.looseboardDispatches(order.getCustomerId(), order.getOrderNo())); } /** 构造同客户商业订单的承运查询身份。 */ diff --git a/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml index 865b5000d..d91227698 100644 --- a/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 派送异常退回入库 version: 1.0.0 - description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。 + description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。 paths: /api/platform/v1/last-leg/exception-orders/return-inbound-plans: post: diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index 882fc618b..ed71c2756 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -793,6 +793,7 @@ components: Cargo: type: "object" x-java-record: "OwnedBusinessModels.Cargo" + description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。 required: - "sourceKey" - "customerId" @@ -815,10 +816,19 @@ components: nullable: true palletNo: type: "string" + nullable: true + description: 关联的完整实托码集合,以顿号连接;未提供保持未知。 destinationKey: type: "string" + description: 首个目的地键;展示使用完整destinationKeys集合。 + destinationKeys: + type: array + items: + type: string + description: 本订单全部真实目的地,不能丢失多目的地信息。 packages: type: "string" + nullable: true Detail: type: "object" x-java-record: "OwnedBusinessModels.Detail" diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 31db37442..ce5568f30 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4904,8 +4904,8 @@ components: fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' } LooseboardDetail: type: object - description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。 - required: [summary, histories] + description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。 + required: [summary, histories, dispatchNos] properties: summary: { $ref: '#/components/schemas/LooseboardSummary' } originAddress: { type: string, nullable: true } @@ -4913,6 +4913,7 @@ components: remark: { type: string, nullable: true } createdBy: { type: string, nullable: true } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } + dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 } FleetArrangement: type: object nullable: true @@ -6771,6 +6772,7 @@ components: DispatchOrderDetail: type: object x-java-record: DispatchViews.OrderDetail + description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。 properties: customerId: type: string @@ -6808,6 +6810,11 @@ components: sourceBusinessNo: type: string nullable: true + sourceKeys: + type: array + items: + type: string + description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。 required: - customerId - customerName @@ -6821,6 +6828,7 @@ components: - sourceKey - sourceBusinessType - sourceBusinessNo + - sourceKeys DispatchDetail: type: object x-java-record: DispatchViews.Detail @@ -8504,7 +8512,9 @@ components: pattern: ^[1-9][0-9]*$ returnSelections: type: array - description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。 + description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。 + minItems: 1 + maxItems: 200 items: type: object required: [deliveryNo, lastMileOrderId, items] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/MerchantTransportPhotoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/MerchantTransportPhotoMapperTest.java index 9ef61fa52..cbbe531d2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/MerchantTransportPhotoMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/MerchantTransportPhotoMapperTest.java @@ -11,7 +11,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class MerchantTransportPhotoMapperTest { - /** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */ + /** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */ @Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception { var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var db=new JdbcTemplate(data); @@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest { db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)"); db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)"); db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')"); - db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')"); - db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)"); + db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')"); + db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)"); db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')"); var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class)); - assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1"); + assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4"); assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty(); assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3"); assertThat(repository.truckDispatches("C2","TR1")).isEmpty(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayServiceTest.java index 22d3d3db9..a9093b624 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLooseboardDisplayServiceTest.java @@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; import java.util.*; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; import static org.assertj.core.api.Assertions.*; class DispatchLooseboardDisplayServiceTest { + /** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */ + @Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() { + var repository=mock(LooseboardCargoRepository.class); + when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L))); + var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2"); + first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4")); + var result=details(repository,List.of(first,second,other)); + assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003"); + assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10"); + assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12"); + assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4"); + assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5"); + } + /** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */ + @Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() { + var repository=mock(LooseboardCargoRepository.class); + when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L))); + var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL"))); + assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C"); + assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL"); + } + /** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */ + @Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() { + var repository=mock(LooseboardCargoRepository.class); + when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L))); + var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1"); + first.setWarehousePalletId(91L);second.setWarehousePalletId(91L); + first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00")); + first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0")); + var combined=details(repository,List.of(first,second)).get(0); + assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30"); + second.setWarehousePalletId(92L);second.setVolumeCbm(null); + var partial=details(repository,List.of(first,second)).get(0); + assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60"); + second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31")); + assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull(); + } + /** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */ + @Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() { + var repository=mock(LooseboardCargoRepository.class); + when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L))); + var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5")); + var result=details(repository,List.of(mixed)); + assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull(); + } + /** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */ + private List details(LooseboardCargoRepository repository,List cargo) { + var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper()); + ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository)); + return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo); + } /** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */ @Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() { var repository=mock(LooseboardCargoRepository.class); @@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest { /** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */ private DispatchItem item(String customer,String number,long pallet,String container) { var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number); + item.setSourceKey("PLAN:"+pallet); item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item; } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index 705ba6643..653b92a5c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest { var order = order("PENDING", 1); order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1"); order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}"); - var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L); + var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L); var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt); when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index 223555af5..d8e79e005 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest { var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); - var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, + var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class, DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); @@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest { assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单"); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); } + /** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */ + @Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() { + var requests=prepareTwoMasterReturns();var business=business(); + var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)); + assertThat(created).hasSize(2); + assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L); + assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2); + var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)); + assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue()); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2); + } + /** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */ + @Test void dispatchAdapterCreatesBothReturnsThroughRealService() { + var requests=prepareTwoMasterReturns();var business=business(); + var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class); + when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1"))); + var adapter=new DispatchExceptionAdapter(repository,business); + var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(), + r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList(); + var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT, + com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections); + tx.executeWithoutResult(t->adapter.register(10L,"OP",command)); + assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L); + assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2); + } + /** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) { + var requests=prepareTwoMasterReturns();var business=business(); + var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))); + if(reversed)Collections.reverse(created); + for(int index=0;indexbusiness.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回"))); + assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING"); + } + assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2); + assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true"); + } + /** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */ + @Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() { + var requests=prepareTwoMasterReturns();var business=business(); + var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong"); + assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong)))) + .hasMessageContaining("不属于该派送单和尾程主单"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero(); + assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING"); + } + /** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */ + @Test void multiOrderTotalCannotIncludeAlreadySignedPackages() { + var requests=prepareTwoMasterReturns();var business=business(); + db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3"); + assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); + } + /** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */ + private List prepareTwoMasterReturns() { + prepareMarkReturn(); + db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)"); + return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"), + new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2")); + } /** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */ private void prepareMarkReturn() { db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order"); @@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest { /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ private ExceptionOrderService business() { var business=new ExceptionOrderService(); - org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository( + map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class))); org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java index 80bd86b0d..6497ce0c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -18,6 +18,28 @@ import static org.mockito.Mockito.*; /** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */ class OwnedBusinessQueryTest { + /** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */ + @Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class); + var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class); + var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0); + second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8); + other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER"); + when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of()); + when(dispatch.detail(any(),any())).thenReturn(original); + when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())), + new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey())))); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch); + for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) { + var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo()); + var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope); + assertEquals(2,result.cargo().size());var order=result.cargo().get(0); + assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages()); + assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys()); + assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo()); + } + assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong()); + } /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index 6a6ec0b89..ed3a670bc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -47,13 +47,16 @@ class LocalLooseboardServiceTest { @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; + @Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches; @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private LocalLooseboardService service; private final Map operationRows = new HashMap<>(); + /** 注入真实仓储组合及客户隔离的排车查询端口。 */ @BeforeEach void setUp() { service = new LocalLooseboardService(); + ReflectionTestUtils.setField(service, "dispatches", dispatches); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); var repository = new LocalLooseboardRepository(); ReflectionTestUtils.setField(service, "repository", repository); @@ -77,6 +80,19 @@ class LocalLooseboardServiceTest { }); } + /** 无车队预订的普通散板也返回全部排车,客户和原单必须原样限定。 */ + @Test + void detailIncludesAllDispatchesWithoutFleetBooking() { + when(orders.findNo("100", "LB1")).thenReturn(order(LocalLooseboardService.IN_TRANSIT, 1L)); + when(dispatches.looseboardDispatches("100", "LB1")).thenReturn(List.of("PC1", "PC2")); + + var detail = service.detail("100", "LB1"); + + assertEquals(List.of("PC1", "PC2"), detail.dispatchNos()); + org.junit.jupiter.api.Assertions.assertNull(detail.summary().fleetArrangement()); + verify(dispatches).looseboardDispatches("100", "LB1"); + } + @Test void createIsTenantScopedIdempotentAndStartsPendingAcceptance() { AtomicReference saved = new AtomicReference<>(); diff --git a/scripts/README.md b/scripts/README.md index fc086f0d1..21ddc1277 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -647,3 +647,4 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e 固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。 本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。 +`local-mixed-looseboard-workflow-acceptance.mjs`:仅本机统一 Gateway 的六单混排 HTTP 验收,三单送货到仓分两托实收共九箱、三单异地上门取货共六箱。使用显式开发配置的 `ADMIN_TEST_*` 与 `MIXED_PDA_USERNAME/MIXED_PDA_PASSWORD`(密码可复用显式 `LOCAL_TRANSFER_PDA_PASSWORD`),例如 `node --env-file=<开发配置> --env-file= scripts/local-mixed-looseboard-workflow-acceptance.mjs`。会写客户测试原单、排车、专用测试车及司机和合成照片;默认报告 `reports/local-mixed-looseboard-workflow.json`,`MIXED_ACCEPTANCE_OUTPUT` 可覆盖,`MIXED_ACCEPTANCE_PHASE=prepare` 停在审核后。默认 `MIXED_ACCEPTANCE_SCENARIO=multi-return` 一次选择同派送两张原单各退一箱,生成退回单、计划并由PDA实收,重放核对库存仅恢复一次,剩余七箱保存签收。异常实签实退核平后调用正式车次结案入口;账号缺少结案权限时报告 `pendingAction`,不得声称结案或资源已释放。`MIXED_ACCEPTANCE_SCENARIO=normal` 配合新的独立报告路径,验证全部九箱正常签收及自动结案释放。回仓通过正式资源接口创建两张本轮托盘并绑定既有有效库位,不复用已报废的原出库托。只复用既有客户/PDA权限,不直接写数据库,不删除数据,续跑沿报告原单和幂等键恢复,未知写结果停写。HTTP 通过不代表 PDA 真机扫码、实物测量或真实凭证验收。 diff --git a/scripts/local-mixed-looseboard-workflow-acceptance.mjs b/scripts/local-mixed-looseboard-workflow-acceptance.mjs new file mode 100644 index 000000000..880044846 --- /dev/null +++ b/scripts/local-mixed-looseboard-workflow-acceptance.mjs @@ -0,0 +1,299 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { createHash } from 'node:crypto' +import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' + +const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway') +const output = resolve(process.env.MIXED_ACCEPTANCE_OUTPUT || 'reports/local-mixed-looseboard-workflow.json') +const scenario = process.env.MIXED_ACCEPTANCE_SCENARIO || 'multi-return' +if (!['multi-return', 'normal'].includes(scenario)) throw Error('验收场景仅支持 multi-return 或 normal') +let report +try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } +report ||= { marker: `MIXLB-${Date.now()}`, environment: 'LOCAL', syntheticEvidence: true, orders: [], operations: {}, checks: [], createdResources: {} } +if (report.scenario && report.scenario !== scenario || !report.scenario && report.orders.length && scenario !== 'multi-return') throw Error('不同场景必须使用独立验收报告') +report.scenario = scenario +const root = '/api/platform/v1/last-leg', pr = '/api/pda/v1/last-leg' +const key = name => createHash('sha256').update(`${report.marker}:${name}`).digest('hex') +let admin, pda, merchant +/** 原子保存写请求和回执,失败留单续跑,不删除或重复造单。 */ +async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(`${output}.tmp`, JSON.stringify(report, null, 2)); await rename(`${output}.tmp`, output) } +/** 断言保留业务含义,失败立即停止后续写入。 */ +function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) } +/** 统一Gateway真实HTTP;会话凭据不写入报告。 */ +async function raw(method, path, body, token = admin) { + const form = body instanceof FormData + const response = await fetch(gateway + path, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}), ...(path.endsWith('/exception-orders/return-inbound-plans') ? { 'Idempotency-Key': key(`return-plan-${body.plan.orderId}`) } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) }) + const value = await response.json() + return { http: response.status, ...value, requestId: value.requestId || response.headers.get('x-request-id'), traceId: value.traceId || response.headers.get('x-trace-id') } +} +/** 仅读显式配置的现有账号,不新建或修改权限。 */ +async function login(username, password) { + if (!username || !password) throw Error('缺少显式验收账号配置') + const result = await raw('POST', '/api/auth/login', { loginId: username, password }, '') + if (String(result.code) !== '200') throw Error(`登录失败:${result.code} ${result.message}`) + return result.data.accessToken +} +/** 每条命令前读取当前服务端事实,禁止自行推算版本。 */ +async function get(path, token = admin) { + const result = await raw('GET', path, undefined, token) + if (result.http !== 200 || String(result.code) !== '200') throw Error(`GET ${path}: ${result.http}/${result.code} ${result.message}`) + return result.data +} +/** 原步骤成功回执可恢复;未知写结果停写,保留原幂等键供核对。 */ +async function write(name, method, path, body, token = admin) { + if (report.operations[name]?.status === 'SUCCESS') return report.operations[name].result + if (report.unknownWrite) throw Error(`上次写结果未知:${report.unknownWrite}`) + const entry = { method, path, body: body instanceof FormData ? 'SYNTHETIC_TEST_IMAGE' : body, status: 'SENDING' } + report.operations[name] = entry; await save() + let result + try { result = await raw(method, path, body, token) } catch (error) { entry.status = 'UNKNOWN'; report.unknownWrite = name; await save(); throw error } + Object.assign(entry, { http: result.http, code: result.code, requestId: result.requestId, traceId: result.traceId, message: result.message }) + entry.status = result.http === 200 && String(result.code) === '200' ? 'SUCCESS' : result.http >= 500 || result.code == null ? 'UNKNOWN' : 'FAILED' + if (entry.status === 'UNKNOWN') report.unknownWrite = name + if (entry.status === 'SUCCESS') entry.result = result.data + await save() + if (entry.status !== 'SUCCESS') throw Error(`${method} ${path}: ${result.http}/${result.code} ${result.message}`) + return result.data +} +/** 合成小图仅证明文件协议和归属,不能代表现场物流证据。 */ +async function photo(name, path, token = admin) { + const form = new FormData(); form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${report.marker}-${name}-SYNTHETIC-TEST.png`) + return write(name, 'POST', path, form, token) +} +/** 三箱分两托实收,订单展示应汇总一行,PDA沿用真实托盘明细。 */ +async function receive(order) { + const path = `${pr}/warehouse-transfers/${order.transferNo}`, label = order.orderNo + let d = await get(path, pda) + if (d.status === 'READY_FOR_DISPATCH') return + while (d.pallets.length < 2) { await write(`${label}-draft-${d.pallets.length}`, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${label}-draft-${d.pallets.length}`) }, pda); d = await get(path, pda) } + for (const initial of d.pallets) { + let p = d.pallets.find(row => row.id === initial.id) + if (p.status === 'DRAFT') await write(`${label}-generate-${p.id}`, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${label}-generate-${p.id}`) }, pda) + d = await get(path, pda); p = d.pallets.find(row => row.id === initial.id) + if (p.status === 'GENERATED') await write(`${label}-bind-${p.id}`, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${label}-bind-${p.id}`), palletCode: p.palletNo }, pda) + d = await get(path, pda) + } + if (!d.cartons.every(row => row.countedAt)) await write(`${label}-count`, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${label}-count`), forecastPackageCount: d.packageCount, + boxes: d.cartons.map((row, i) => ({ cartonMark: row.cartonMark, transferPalletId: d.pallets[i === 0 ? 0 : 1].id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) }, pda) + const f = await photo(`${label}-measurement`, `${path}/measurement-photos/files`, pda) + await write(`${label}-palletize`, 'POST', `${path}/palletize`, { idempotencyKey: key(`${label}-palletize`), photoFileIds: [f.fileId] }, pda) + d = await get(path, pda); check(d.status === 'READY_FOR_DISPATCH' && d.pallets.length === 2, `${label}三箱两托实收完成`); order.receiving = d; await save() +} +/** 六张本轮原单:三单到仓实收,三单保留异地上门取货。 */ +async function prepare() { + const customers = (await get('/api/platform/v1/iam/tenants?page=1&size=200')).records + report.customerId ||= customers.find(row => row.status === 'ACTIVE' && /测试/.test(row.tenantName || row.name))?.id + report.warehouseId ||= (await get('/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records.find(row => row.warehouseCode === 'US-LAX-01')?.id + check(report.customerId && report.warehouseId, '复用有效测试客户和洛杉矶仓'); await save() + const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}本机混排验收` }) + check(String(ticket.code) === '200', '获取本轮客户下单会话') + merchant = (await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.data.code }, '')).data.accessToken + for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) for (let i = 0; i < 3; i++) { + const label = `${mode}-${i}`, remote = mode === 'WAREHOUSE_PICKUP' + let order = report.orders.find(row => row.label === label) + if (!order) { + const created = await write(label, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode, + deliveryContactName: '合成验收联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-06T09:00:00', trackingNo: `${report.marker}-${label}`, + origin: 'Ontario TEST', originAddress: `100 TEST Pickup Road ${i}`, destination: 'Ontario TEST', destinationAddress: '200 TEST Destination Road', productName: `${report.marker}-${label}`, + estimatedPalletCount: 2, packageCount: remote ? 2 : 3, totalWeightKg: remote ? 8 : 12, totalVolumeCbm: remote ? 0.06 : 0.09, budgetAmount: 25, currency: 'USD', + originContactName: '合成验收取货员', originContactPhone: '0000000000', destinationContactName: '合成验收收货员', destinationContactPhone: '0000000000', + pickupAt: '2026-11-06T09:00:00', deliveryAt: '2026-11-07T09:00:00', remark: `${report.marker} SYNTHETIC TEST`, idempotencyKey: key(label) }, merchant) + order = { label, mode, orderNo: created.summary.orderNo }; report.orders.push(order); await save() + } + const path = `${root}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}` + let d = await get(path + query) + if (d.summary.status === 'PENDING_ACCEPTANCE') await write(`${label}-review`, 'POST', `${path}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${label}-review`), remark: report.marker }) + d = await get(path + query); order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, `${order.orderNo}审核生成真实履约来源`) + if (!remote && !order.receiving) await receive(order) + } + if (!report.dispatchNo) { report.dispatchNo = (await write('create-dispatch', 'POST', `${root}/dispatches`, { idempotencyKey: key('create-dispatch'), warehouseId: report.warehouseId, publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker} 仓内九箱+异地六箱` })).dispatchNo; await save() } + const path = `${root}/dispatches/${report.dispatchNo}`, cp = `${root}/dispatches/capacity-demands/${report.dispatchNo}/cargo` + if (!report.operations['add-cargo']?.result) { + const selected = (await get(`${cp}?page=1&size=50`)).candidates.filter(row => report.orders.some(order => row.inboundCode === order.transferNo || row.orderNo === order.orderNo || row.sourceBusinessNo === order.orderNo)) + check(selected.length >= 6 && selected.every(row => !row.blockReasons.length), '六单均为正式可选来源') + const sources = new Map(selected.flatMap(row => row.selectionGroup.sources).map(row => [row.sourceKey, row.selection])) + await write('add-cargo', 'POST', cp, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('add-cargo'), cargo: [...sources.values()], batchAllocations: [] }) + } + report.preparedDetail = await get(path); await save() + check(Number(report.preparedDetail.counts.packageCount) === 15, '混排十五箱:仓内九箱、异地六箱') + check(report.preparedDetail.orderDetails.length === 6, '关联订单按六张散板原单汇总') + if (!report.fleet) { + const context = { marker: report.marker, options: {}, createdResources: report.createdResources, checkpoint: save, + request: async (method, url, body, token) => { const r = await raw(method, url, body, token || admin); return { http: r.http, body: r } }, + requestMultipart: async (method, url, file) => { const f = new FormData(); f.append('file', new Blob([file.content], { type: file.type }), file.name); const r = await raw(method, url, f); return { http: r.http, body: r } }, + apiSuccess: (module, label, response) => { check(response.http === 200 && String(response.body.code) === '200', `${label}: ${response.body.message}`); return response.body.data }, assert: (module, label, response, passed) => check(passed, label) } + report.fleet = await prepareFleetTrip(context, 'local-mixed-looseboard', report.dispatchNo, report.warehouseId); await save() + } +} +/** PDA只拣仓内九箱;异地六箱不进入拣货校验和仓内出库。 */ +async function pickAndLoad() { + const path = `${pr}/dispatches/${report.dispatchNo}` + let task = await get(path, pda); report.pdaBefore ||= task; await save() + check(Number(task.counts.packageCount) === 9 && task.items.every(row => row.positions?.length), 'PDA只要求仓内九箱') + for (const initial of task.items) { + task = await get(path, pda); let item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId) + for (const position of item.positions) { + const packages = Math.min(Number(position.availablePackages), Number(item.scheduledPackages) - Number(item.pickedPackages)) + if (packages <= 0) continue + const name = `pick-${item.dispatchItemId}-${position.positionId}` + const prep = await write(`${name}-rent`, 'POST', `${pr}/storage-rent/preparations`, { mode: 'DISPATCH', businessNo: report.dispatchNo, itemId: item.dispatchItemId, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name) }, pda) + check(prep.ready === true, '本轮新入库货物仓租预校验就绪') + await write(name, 'POST', `${path}/items/${item.dispatchItemId}/pick`, { recordVersion: task.recordVersion, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name), remark: report.marker }, pda) + task = await get(path, pda); item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId) + } + } + task = await get(path, pda); report.pdaPicked = task; await save(); check(task.pickingReconciled && Number(task.counts.pickedPackageCount) === 9, '九箱实际拣货核平') + if (task.stage === 'LOADING') { const loading = await get(`${path}/loading`, pda); await write('loading-complete', 'POST', `${path}/loading-complete`, { recordVersion: loading.recordVersion, idempotencyKey: key('loading-complete'), manifestHash: loading.manifestHash, remark: report.marker }, pda) } + report.afterLoading = await get(`${root}/dispatches/${report.dispatchNo}`); await save(); check(report.afterLoading.stage === 'DELIVERING', '仓内装车完成立即发运,不等待异地取货') +} +/** 上门取货三单各存一组取货/送达照片,分托不增加资料块。 */ +async function remoteDocuments() { + const path = `${root}/dispatches/${report.dispatchNo}` + let docs = await get(`${path}/loading-documents`) + for (const slot of ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']) { + if (docs.photos.some(row => row.slot === slot)) continue + const name = `loading-photo-${slot}`, uploadKey = key(`${name}-upload`) + const f = await photo(name, `${path}/loading-documents/${slot}/uploads?reviewRound=${docs.reviewRound}&idempotencyKey=${uploadKey}`) + await write(`${name}-bind`, 'PUT', `${path}/loading-documents/${slot}`, { recordVersion: docs.recordVersion, idempotencyKey: key(`${name}-bind`), fileId: f.fileId, uploadKey }) + docs = await get(`${path}/loading-documents`) + } + report.loadingDocuments = docs; check(docs.complete, '五类装车资料补齐') + let execution = await get(`${path}/looseboard-execution`) + report.looseboardBefore ||= execution; await save() + const items = execution.items.filter(row => row.pickupRequired) + check(items.length === 3 && new Set(items.map(row => row.orderNo)).size === 3, '上门取货按三张订单展示资料,六个计划托不拆六块') + for (const phase of ['PICKUP', 'ARRIVAL']) { + if (phase === 'ARRIVAL') { + execution = await get(`${path}/looseboard-execution`) + if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') }) + } + for (const initial of items) { + execution = await get(`${path}/looseboard-execution`) + const row = execution.items.find(item => item.itemId === initial.itemId), photos = phase === 'PICKUP' ? row.pickupPhotos : row.arrivalPhotos + if (photos.length) continue + const name = `${initial.orderNo}-${phase}`, f = await photo(`${name}-upload`, `${path}/looseboard-execution/${initial.itemId}/${phase}/uploads`) + await write(`${name}-save`, 'PUT', `${path}/looseboard-execution/${initial.itemId}/${phase}`, { recordVersion: execution.recordVersion, idempotencyKey: key(`${name}-save`), fileIds: [f.fileId] }) + } + } + execution = await get(`${path}/looseboard-execution`) + if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') }) + report.looseboardAfter = await get(`${path}/looseboard-execution`); await save() + check(report.looseboardAfter.pickupCompleted && report.looseboardAfter.items.filter(row => row.pickupRequired).every(row => row.pickupPhotos.length && row.arrivalPhotos.length), '三单上门取货和送达资料全部完成') +} +/** 一次选择同派送的两张原订单,生成两张异常并沿PDA分批回仓实收。 */ +async function exceptionReturns() { + const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}` + if (!report.operations['multi-order-return']?.result) { + const candidates = await get(`${path}/exception-return-options`) + report.returnOptions = candidates; await save() + const originals = report.orders.filter(row => row.mode === 'DELIVERY_TO_WAREHOUSE').slice(0, 2) + const selected = originals.map(order => candidates.find(row => row.lastMileOrderNo === order.transferNo || row.lastMileOrderNo === order.orderNo)) + check(selected.every(Boolean) && selected[0].deliveryNo === selected[1].deliveryNo, '同一派送单下两张原单均可选择') + const returns = selected.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId, + items: [{ outboundPalletItemId: row.sources[0].outboundPalletItemId, inventoryPositionId: row.sources[0].inventoryPositionId, + returnCartonId: row.sources[0].returnCartonId, packageCount: 1 }] })) + await write('multi-order-return', 'POST', `${path}/exceptions`, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('multi-order-return'), + exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: `${report.marker} 两单各退一箱,其余签收`, imageFileIds: [], returnSelections: returns }) + } + const rows = (await get(`${root}/exception-orders?page=1&size=100&keyword=${encodeURIComponent(report.marker)}`)).records.filter(row => row.reason?.includes(report.marker)) + check(rows.length === 2, '一次多选实际生成两张独立异常') + report.exceptions ||= [] + for (const row of rows) { + const name = row.exceptionNo, ep = `${root}/exception-orders/${name}`, pp = `${pr}/return-receipts/exceptions/${name}` + let detail = await get(ep + query) + if (!detail.returnOrderNo) await write(`${name}-generate`, 'POST', `${ep}/generate-return-inbound${query}`, { expectedVersion: detail.order.version, idempotencyKey: key(`${name}-generate`), handlingAction: '合成测试退回海外仓' }) + detail = await get(ep + query) + const tails = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.filter(item => item.orderNo === detail.returnOrderNo) + check(tails.length === 1, `${name}唯一关联退回尾程单`) + const tail = tails[0] + if (!report.operations[`${name}-plan`]?.result) await write(`${name}-plan`, 'POST', `${root}/exception-orders/return-inbound-plans`, { expectedArrivalAt: '2026-11-06T10:00:00', + plan: { customerId: report.customerId, orderId: tail.id, expectedOrderVersion: tail.version, palletCount: 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: report.marker } }) + if (detail.order.status !== 'COMPLETED') { + const tasks = await get(`${pr}/return-receipts/exceptions`, pda); check(tasks.some(task => task.exceptionNo === name), `${name}出现在PDA回仓任务`) + const started = await write(`${name}-start`, 'POST', `${pp}/start`, {}, pda) + const original = report.orders.find(order => order.transferNo === row.lastMileOrderNo) + // 原出库托盘可能已报废;回仓另建实际可用托盘,沿用原仓有效虚拟库位。 + const sourcePallet = original.receiving.pallets[0] + const resourcePath = `/api/platform/v1/overseas-warehouse-resources/${report.warehouseId}` + const targetId = await write(`${name}-target-pallet`, 'POST', `${resourcePath}/pallets`, { + palletCode: `${report.marker}-${name}`, palletType: 'STANDARD', capacityCbm: 2, unitPrice: 0, + status: 'IDLE', locationId: sourcePallet.warehouseLocationId, remark: `${report.marker} 异常回仓验收托盘` }) + const target = await get(`${resourcePath}/pallets/${targetId}`) + check(target.status !== 'SCRAPPED' && target.locationId === sourcePallet.warehouseLocationId, `${name}使用当前仓真实有效托盘`) + const f = await photo(`${name}-evidence`, `${pp}/evidence`, pda) + await write(`${name}-receive`, 'POST', `${pp}/receive`, { expectedVersion: started.order.version, idempotencyKey: key(`${name}-receive`), + palletCode: target.palletCode, locationCode: sourcePallet.warehouseLocationCode, evidenceFileIds: [f.fileId], + items: started.returnSources.map(source => ({ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, returnCartonId: source.returnCartonId, packages: Number(source.maxReturnPackages) })) }, pda) + } + detail = await get(ep + query) + check(detail.order.status === 'COMPLETED' && detail.returnReceipts.reduce((sum, receipt) => sum + Number(receipt.receivedPackages), 0) === 1, `${name}PDA实收一箱且异常完结`) + const returnedTail = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.find(item => item.orderNo === detail.returnOrderNo) + check(returnedTail.currentStatus === 'INBOUND_COMPLETED' && Number(returnedTail.receivedPackageCount) === 1, `${name}退回尾程已入库签收`) + const receiptCommand = report.operations[`${name}-receive`] + const replay = await raw(receiptCommand.method, receiptCommand.path, receiptCommand.body, pda) + check(String(replay.code) === '200' && replay.data.replayed, `${name}实收同键重放不重复入账`) + const inventoryPath = `${root}/inventory` + const ledger = await get(`${inventoryPath}/ledger${query}&size=100&businessType=RETURN_STOCK_IN&businessOrderNo=${name}`) + const balances = await get(`${inventoryPath}/balances${query}&size=100&inboundCode=${row.inboundCode}`) + check(ledger.records.reduce((sum, entry) => sum + Number(entry.deltaPackages), 0) === 1, `${name}退回库存流水仅增加一箱`) + check(balances.records.reduce((sum, balance) => sum + Number(balance.availablePackages), 0) === 1, `${name}原单实际可用库存恢复一箱`) + report.inventoryVerification ||= {}; report.inventoryVerification[name] = { ledger, balances, receiptReplay: replay.data } + report.exceptions = [...report.exceptions.filter(item => item.order.exceptionNo !== name), detail]; await save() + } + await finishDelivery(7) +} +/** 签收只记录剩余实际交付量;异常车核平实签实退后通过正式结案入口释放资源。 */ +async function finishDelivery(expectedSigned) { + const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}` + const delivery = report.afterLoading.documents.deliveries[0], dp = `${root}/delivery-orders/${delivery.deliveryNo}` + let documents = await get(`${dp}/documents${query}`) + if (!report.operations['delivery-pod-save']?.result) { + const f = await photo('delivery-pod', `${dp}/documents/RECEIPT/files${query}`) + await write('delivery-pod-save', 'PUT', `${dp}/documents${query}`, { expectedVersion: documents.version, idempotencyKey: key('delivery-pod-save'), bolNo: `${report.marker}-TEST-BOL`, + documents: documents.categories.map(category => ({ category: category.category, fileIds: category.category === 'RECEIPT' ? [f.fileId] : category.files.map(file => file.fileId) })) }) + } + report.deliveryFinal = await get(dp + query) + check(report.deliveryFinal.order.status === 'DELIVERED' && Number(report.deliveryFinal.order.signedPackages) === expectedSigned, `仓内实际签收${expectedSigned}箱凭签收文件完结`) + if (scenario === 'normal') { + for (let attempt = 0; attempt < 15; attempt++) { + report.finalDispatch = await get(path) + if (report.finalDispatch.stage === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + check(report.finalDispatch.stage === 'DELIVERED', '全正常混排自动进入派送完成') + } + report.finalDispatch = await get(path); report.finalTrip = await get(`${root}/fleet/trips/${report.fleet.tripNo}/completion`); await save() + if (scenario === 'multi-return' && report.finalTrip.canComplete) { + const previous = report.operations['exception-trip-complete'] + if (previous?.code !== 'A0403') { + try { + await write('exception-trip-complete', 'POST', `${root}/fleet/trips/${report.fleet.tripNo}/complete`, { + recordVersion: report.finalTrip.recordVersion, idempotencyKey: key('exception-trip-complete'), reason: `${report.marker} 核实七箱签收两箱实际回仓后结案` }) + } catch (error) { if (report.operations['exception-trip-complete']?.code !== 'A0403') throw error } + } + } + report.tripDetail = await get(`${root}/fleet/trips/${report.fleet.tripNo}`) + if (report.operations['exception-trip-complete']?.code === 'A0403') { + report.pendingAction = { action: 'EXCEPTION_TRIP_CLOSE', tripNo: report.fleet.tripNo, reason: '当前验收账号缺少platform:fleet:trip:complete,保留原权限及车次事实,待有权限账号结案。' } + check(report.finalTrip.canComplete && report.finalTrip.outcomes.every(row => row.remainingPackages === 0), '异常车实签实退核平,可由有权限账号结案') + } else check(report.tripDetail.trip.status === 'COMPLETED' && report.tripDetail.trip.vehicleRelease && report.tripDetail.trip.driverRelease, '车次结案并释放车辆及司机') + report.harnessCorrections ||= [] + for (const previous of report.checks.filter(row => row.label === '仓内签收和上门取货送达后整车完结')) { + report.harnessCorrections.push({ ...previous, reason: '现有排车规则仅原实发全部正常签收推进DELIVERED;部分签收及实退通过车次正式结案,不能把实退伪作签收。' }) + } + report.checks = report.checks.filter(row => row.label !== '仓内签收和上门取货送达后整车完结') +} +try { + admin = await login(process.env.ADMIN_TEST_USERNAME, process.env.ADMIN_TEST_PASSWORD) + pda = await login(process.env.MIXED_PDA_USERNAME, process.env.MIXED_PDA_PASSWORD || process.env.LOCAL_TRANSFER_PDA_PASSWORD) + await prepare() + if (process.env.MIXED_ACCEPTANCE_PHASE !== 'prepare') { + if (!report.afterLoading) await pickAndLoad() + await remoteDocuments() + if (scenario === 'multi-return') await exceptionReturns() + else await finishDelivery(9) + } + report.phase = process.env.MIXED_ACCEPTANCE_PHASE === 'prepare' ? 'PREPARED' : report.pendingAction ? 'COMPLETED_WITH_PENDING_ACTION' : 'COMPLETED'; delete report.error; await save() + console.log(JSON.stringify({ phase: report.phase, dispatchNo: report.dispatchNo, orders: report.orders.map(({ orderNo, mode }) => ({ orderNo, mode })), output })) +} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 } From 51c3132195a8abe1762e34305c2e5aa59c3e49c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 13:18:07 +0800 Subject: [PATCH 2/4] fix(deploy): enforce TEST service memory budgets --- deploy/docker-compose.test-limits.yml | 65 +++++++++++++++++++ deploy/scripts/deploy-built-service.sh | 8 +++ .../deploy-built-service-rollback.test.mjs | 16 ++++- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 deploy/docker-compose.test-limits.yml diff --git a/deploy/docker-compose.test-limits.yml b/deploy/docker-compose.test-limits.yml new file mode 100644 index 000000000..353b3a0cd --- /dev/null +++ b/deploy/docker-compose.test-limits.yml @@ -0,0 +1,65 @@ +# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。 +x-test-limits: &test-limits + mem_limit: 1536m + memswap_limit: 1536m + environment: &test-java-environment + JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" + JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m" + +services: + gateway: + <<: *test-limits + + auth: + <<: *test-limits + + iam: + <<: *test-limits + + order: + <<: *test-limits + + firstleg: + <<: *test-limits + mem_limit: 2g + memswap_limit: 2g + environment: + <<: *test-java-environment + JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g" + + finance: + <<: *test-limits + + warehouse: + <<: *test-limits + + warehouse-overseas-service: + <<: *test-limits + + integration-service: + <<: *test-limits + + audit-service: + <<: *test-limits + + file: + <<: *test-limits + mem_limit: 3g + memswap_limit: 3g + environment: + <<: *test-java-environment + JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g" + + print: + <<: *test-limits + environment: + <<: *test-java-environment + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g" + + lastleg-service: + <<: *test-limits + mem_limit: 3g + memswap_limit: 3g + environment: + <<: *test-java-environment + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m" diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 995da8f58..b6f420f4a 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -12,6 +12,7 @@ IMAGE_TAG="$3" SERVICE="$4" ENV_FILE="$5" COMPOSE_FILE="deploy/docker-compose.yml" +TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" PROJECT_NAME="firstleg-$DEPLOY_ENV" @@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then echo "TEST deploy must run on the target host" >&2; exit 2 fi +if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then + echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2 +fi [ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do @@ -74,7 +78,11 @@ local_registry_logout() { if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi } +# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。 local_compose() { + if [ "$DEPLOY_ENV" = "test" ]; then + set -- -f "$TEST_LIMITS_FILE" "$@" + fi docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index f4f82baae..94d659e8d 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -69,7 +69,8 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) { +// 通过临时 Docker/SSH 替身验证环境选择、健康门禁及失败后的镜像恢复。 +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, missingTestLimits = false, topicPrefix = `${environment}-`, remote = false }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -115,7 +116,7 @@ cp "$1" "\${2#*:}" service, envFile.replaceAll('\\', '/') ], { - cwd: repositoryRoot, + cwd: missingTestLimits ? temporaryDirectory : repositoryRoot, encoding: 'utf8', env: { ...process.env, @@ -158,6 +159,16 @@ test('successful deployment keeps the new healthy image', async (context) => { assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood') + for (const command of result.dockerLog.split(/\r?\n/).filter(line => line.startsWith('compose '))) { + assert.match(command, /-f deploy\/docker-compose\.yml -f deploy\/docker-compose\.test-limits\.yml /) + } +}) + +test('TEST rejects a missing memory limits file before Docker', async () => { + const result = await runDeployment({ imageTag: 'newgood', missingTestLimits: true }) + assert.equal(result.status, 2) + assert.equal(result.dockerLog, '') + assert.match(result.stderr, /TEST memory limits file missing/) }) test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => { @@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project', assert.equal(result.status, 0, result.stderr) assert.match(result.dockerLog, /--project-name firstleg-production/) assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/) + assert.doesNotMatch(result.dockerLog, /docker-compose\.test-limits\.yml/) }) test('gray deployment rejects TEST message scope before any Docker operation', async () => { From e995a87b0e148c73a627fd3fc4191649272b85c0 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 13:33:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/workflow/WorkflowTabService.java | 17 +- .../main/resources/openapi/firstleg-v1.yaml | 2 +- .../workflow/WorkflowBoundaryTest.java | 23 +- .../workflow/WorkflowBusinessTest.java | 6 +- .../reporting/TripOperationsQueryMapper.java | 12 +- .../reporting/TripOperationsService.java | 8 +- .../MerchantLocalLooseboardController.java | 3 +- .../mapper/LocalLooseboardOrderMapper.java | 34 +++ .../repository/LocalLooseboardRepository.java | 21 +- .../service/LocalLooseboardService.java | 111 +++++++-- .../funding/OrderFundingRepository.java | 19 ++ .../funding/OrderFundingService.java | 11 +- .../budget/MerchantOrderBudgetController.java | 8 +- .../wallet/budget/OrderBudgetMapper.java | 1 + .../wallet/budget/OrderBudgetService.java | 12 +- .../openapi/fleet-operations-v1.yaml | 2 +- .../resources/openapi/order-budgets-v1.yaml | 12 +- .../openapi/overseas-business-v1.yaml | 30 ++- .../reporting/TripOperationsServiceTest.java | 27 +++ .../LocalLooseboardMerchantStatusTest.java | 220 ++++++++++++++++++ .../service/LocalLooseboardServiceTest.java | 10 +- .../budget/OrderBudgetAuthorizationTest.java | 12 + .../budget/OrderBudgetPersistenceTest.java | 126 +++++++++- .../wallet/budget/OrderBudgetServiceTest.java | 2 +- .../budget/OrderFundingServiceTest.java | 18 ++ .../service/UnifiedOrderQueryService.java | 3 +- .../src/main/resources/openapi/order-v1.yaml | 4 +- .../service/UnifiedOrderQueryServiceTest.java | 29 +++ 28 files changed, 709 insertions(+), 74 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardMerchantStatusTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 6a8b56e57..01f2d9e88 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -27,7 +27,9 @@ public class WorkflowTabService { if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源"); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } - var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + // 出库单由尾程审核建单触发,不能因国内开船节点尚未补录而跳过事实查询。 + var sources=facts.stream().map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + var requiredSources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { try { @@ -40,8 +42,10 @@ public class WorkflowTabService { if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源"); overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); } - if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size() - +" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList()); + // 未开船来源可能尚未产生尾程订单;已开船来源仍须完整返回,避免依赖缺项被误当作零。 + var missing=requiredSources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).toList(); + if(!missing.isEmpty()) throw unavailable("尾程事实缺少来源 expected="+requiredSources.size()+" actual="+(requiredSources.size()-missing.size()) + +" missing="+missing.stream().limit(20).toList()); } catch(BizException e) { throw e; } catch(Exception e) { throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e); } @@ -61,6 +65,11 @@ public class WorkflowTabService { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); boolean signed="DELIVERED".equals(process); + // 有效出库与派送事实独立于船期、清关资料;签收后不再保留待出库或派送任务。 + if(!signed && o!=null) { + if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT"); + if(o.delivering()) keys.add("DELIVERING"); + } // 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。 boolean clearanceStage=f.departed(); if(!clearanceStage && !signed) { @@ -75,8 +84,6 @@ public class WorkflowTabService { if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD"); // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); - if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT"); - if(o.delivering()) keys.add("DELIVERING"); } } return keys; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4d77099fe..2522a1661 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1679,7 +1679,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] nullable: true - description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 + description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。 customerId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index 006955233..ed01cd4b8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -36,7 +36,7 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); } - /** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */ + /** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */ @Test void completedCustomsAndClearanceUploadWaitForActualDeparture() { var repository=mock(WorkflowFactRepository.class); var downstream=mock(LastlegWorkflowClient.class); @@ -45,12 +45,15 @@ class WorkflowBoundaryTest { inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); when(repository.facts(List.of(1L))).thenReturn(List.of( new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); + when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of())); assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); - verifyNoInteractions(downstream); + verify(downstream).read(any(),any()); + clearInvocations(downstream); when(repository.facts(List.of(1L))).thenReturn(List.of( new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); - verifyNoInteractions(downstream); + verify(downstream).read(any(),any()); + clearInvocations(downstream); when(repository.facts(List.of(1L))).thenReturn(List.of( new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true))); when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( @@ -64,4 +67,18 @@ class WorkflowBoundaryTest { verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); } + /** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */ + @Test void outboundBeforeDepartureIsReadAndClassified() { + var repository=mock(WorkflowFactRepository.class); + var downstream=mock(LastlegWorkflowClient.class); + var service=new WorkflowTabService(repository,downstream); + var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L); + inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,false,false))); + when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( + new WorkflowFacts.Overseas(11,1,true,true,false,true)))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("OUTBOUND_DOCUMENT"); + verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index fa85c1fdc..8f91fa72f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -30,12 +30,12 @@ class WorkflowBusinessTest { assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded)) .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); } - /** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */ + /** 出库与派送按尾程事实展示,不受清关资料限制。 */ @Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() { var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false); var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false); - assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE"); - assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("DELIVERING","SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT"); assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING"); assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java index 519b80818..76ff33316 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsQueryMapper.java @@ -25,7 +25,17 @@ public interface TripOperationsQueryMapper { (SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1) ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END) - ELSE NULL END AS transport_facts + ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages', + JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount')) + FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id + WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING' + AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL + AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null' + AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL + OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null') + AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0 + AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') > 0 + ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts FROM ll_fleet_trip t WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to} AND t.warehouse_id=#{q.warehouseId} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java index 889b10a89..c25bfa32d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -55,6 +55,10 @@ public class TripOperationsService { var trip=snapshot.trip(); var load=snapshot.confirmedLoad(); if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),""))) return transport(fact,trip); + // 纯散板照片发运没有仓内实载,只接受查询取得的不可变照片发运依据。 + if(trip!=null && load==null && fact.transportFacts()!=null + && "PHOTO_LOOSEBOARD".equals(json.readTree(fact.transportFacts()).path("taskType").asText())) + return transport(fact,trip); if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty()) throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo()); var externalByPallet=new HashMap(); var money=new TreeMap(); var bookingNumbers=new HashSet(); @@ -111,9 +115,11 @@ public class TripOperationsService { if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2) throw new IllegalStateException("运输发车历史缺少行程或实际时间"); var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts()); - if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据"); + boolean photo=facts!=null && "PHOTO_LOOSEBOARD".equals(facts.path("taskType").asText()); + if(facts==null || (!photo && !Objects.equals(trip.tripPurpose(),facts.path("taskType").asText())))throw new IllegalStateException("运输出车缺少来源依据"); // 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。 var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue(); + if(photo && (packages==null || !packages.isIntegralNumber() || quantity<=0))throw new IllegalStateException("散板照片发运缺少实际箱数"); if(quantity<0)throw new IllegalStateException("运输箱数不合法"); var destinations=new ArrayList(); for(int i=1;i detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { - return ApiResponse.success(service.detail(customer(user), orderNo)); + return ApiResponse.success(service.merchantDetail(customer(user), orderNo)); } @PutMapping("/{orderNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java index 20b76305e..f013422e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java @@ -8,6 +8,40 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface LocalLooseboardOrderMapper extends BaseMapper { + // 客户状态按正式排车与取货事实投影;不改变收货、测量等内部状态门禁。 + String DELIVERY = " FROM ll_inbound_plan p" + + " JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id" + + " JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=p.customer_id" + + " JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'" + + " WHERE p.source_order_id=ll_local_looseboard_order.lastleg_order_id" + + " AND p.customer_id=ll_local_looseboard_order.customer_id"; + String PICKUP = " FROM ll_warehouse_transfer_order w" + + " JOIN ll_fleet_pickup_task t ON t.transfer_order_id=w.id AND t.customer_id=w.customer_id" + + " JOIN ll_fleet_trip f ON f.trip_no=t.trip_no AND f.status IN ('PLANNED','IN_TRANSIT')" + + " WHERE w.customer_id=ll_local_looseboard_order.customer_id" + + " AND w.transfer_no=ll_local_looseboard_order.transfer_no"; + String MERCHANT_STATUS = "(CASE" + + " WHEN ll_local_looseboard_order.status IN ('FUNDING_PENDING','FUNDING_FAILED','PENDING_ACCEPTANCE','COMPLETED','CANCELLED')" + + " THEN ll_local_looseboard_order.status" + + " WHEN ll_local_looseboard_order.status='IN_TRANSIT' OR EXISTS (SELECT 1" + DELIVERY + + " AND d.stage IN ('DELIVERING','DELIVERED')) THEN 'IN_TRANSIT'" + + " WHEN EXISTS (SELECT 1" + PICKUP + + " AND (t.status='PICKED' OR (t.status='ASSIGNED' AND f.status='IN_TRANSIT'))) THEN 'PICKING_UP'" + + " WHEN EXISTS (SELECT 1" + DELIVERY + ") OR EXISTS (SELECT 1" + PICKUP + + " AND t.status='ASSIGNED') THEN 'DISPATCHED'" + + " WHEN ll_local_looseboard_order.status='DISPATCHED' THEN 'DISPATCHED'" + + " ELSE 'PENDING_DISPATCH' END)"; + + /** 批量读取同客户散板的运输状态,与分页筛选使用同一表达式。 */ + @Select("") + java.util.List merchantStatuses(@Param("customerId") String customerId, + @Param("ids") java.util.List ids); + + /** 客户列表与详情共享的只读状态。 */ + record MerchantStatus(Long id, String status) { } + @Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL") int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo); @org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java index cd9a46a30..032122e80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java @@ -33,18 +33,35 @@ public class LocalLooseboardRepository { public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); } /** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */ public Page page(LocalLooseboardRequests.Query query) { + return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, false)); + } + /** 客户按运输状态筛选后分页,确保总数和返回记录使用相同口径。 */ + public Page merchantPage(LocalLooseboardRequests.Query query) { + return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, true)); + } + /** 按认证客户批量装配状态,空页不查询排车事实。 */ + public java.util.Map merchantStatuses(String customer, List ids) { + if (ids.isEmpty()) return java.util.Map.of(); + return orders.merchantStatuses(customer, ids).stream().collect(java.util.stream.Collectors.toMap( + LocalLooseboardOrderMapper.MerchantStatus::id, LocalLooseboardOrderMapper.MerchantStatus::status)); + } + /** 路线与时间筛选共用,仅客户查询采用实际运输进度。 */ + private LambdaQueryWrapper queryWrapper(LocalLooseboardRequests.Query query, boolean merchant) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) .and(StringUtils.hasText(query.routeKeyword()), value -> value .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) - .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, + .eq(!merchant && StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) .ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay()) .lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay()) .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); - return orders.selectPage(Page.of(query.page(), query.size()), wrapper); + if (merchant && StringUtils.hasText(query.status())) { + wrapper.apply(LocalLooseboardOrderMapper.MERCHANT_STATUS + "={0}", query.status().trim().toUpperCase(java.util.Locale.ROOT)); + } + return wrapper; } /** 规范查询文字,缺失值不参与筛选。 */ private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 125b99e82..c286073d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -51,24 +51,40 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. @Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool; public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; + public static final String FUNDING_PENDING = "FUNDING_PENDING"; + public static final String FUNDING_FAILED = "FUNDING_FAILED"; public static final String DISPATCHED = "DISPATCHED"; public static final String IN_TRANSIT = "IN_TRANSIT"; public static final String COMPLETED = "COMPLETED"; public static final String CANCELLED = "CANCELLED"; - private static final Set STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH"); + private static final Set STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH"); + private static final Set MERCHANT_STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, + "PENDING_DISPATCH", DISPATCHED, "PICKING_UP", IN_TRANSIT, COMPLETED, CANCELLED); private static final Map> TRANSITIONS = Map.of( PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), + FUNDING_PENDING, Set.of(CANCELLED), + FUNDING_FAILED, Set.of(CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), IN_TRANSIT, Set.of(COMPLETED)); @Resource private LocalLooseboardRepository repository; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget; - /** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */ + /** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */ @Transactional public LocalLooseboardResponses.Detail create(String customerId, String operatorId, LocalLooseboardRequests.Create request) { + var result = orderBudget.submit(customerId, "looseboard", request.idempotencyKey().trim(), request.budgetAmount(), + StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD", + () -> createPending(customerId, operatorId, request), value -> value.summary().orderNo()); + return detail(customerId, result.summary().orderNo()); + } + + /** 业务单与冻结指令同事务保存,任何创建失败均不留下独立预占。 */ + private LocalLooseboardResponses.Detail createPending(String customerId, String operatorId, + LocalLooseboardRequests.Create request) { String key = request.idempotencyKey().trim(); String requestHash = hash(request); LocalLooseboardOrder replay = repository.findKey(customerId, key); @@ -82,7 +98,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); - order.setStatus(PENDING_ACCEPTANCE); + order.setStatus(FUNDING_PENDING); order.setIdempotencyKey(key); order.setRequestHash(requestHash); order.setCreatedBy(operatorId); @@ -97,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, + history(order, "CREATE", null, FUNDING_PENDING, operatorId, key, null, request.remark(), now); return detail(customerId, order.getOrderNo()); @@ -113,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. result.getSize(), result.getTotal(), result.getPages()); } - /** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */ + /** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, LocalLooseboardRequests.MerchantQuery query) { - return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, - query.orderNo(), query.routeKeyword(), query.status(), - query.requestedFrom(), query.requestedTo())); + var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, + query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo()); + validateQuery(scoped, MERCHANT_STATUSES); + var result = repository.merchantPage(scoped); + var statuses = repository.merchantStatuses(customerId, result.getRecords().stream().map(LocalLooseboardOrder::getId).toList()); + var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList()); + return new PageResult<>(result.getRecords().stream() + .map(o -> summary(o, arrangements.get(poolKey(o)), Objects.requireNonNull(statuses.get(o.getId())))).toList(), + result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + + /** 客户详情与列表共用运输状态投影,不改写作业用原始状态。 */ + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public LocalLooseboardResponses.Detail merchantDetail(String customerId, String orderNo) { + var order = repository.findNo(customerId, orderNo); + if (order == null) throw notFound(); + var status = repository.merchantStatuses(customerId, java.util.List.of(order.getId())).get(order.getId()); + return response(order, Objects.requireNonNull(status)); } /** 按客户与业务单号读取需求及历史。 */ @@ -134,6 +165,14 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. @Transactional public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.Update request) { + return orderBudget.update(customerId, "looseboard", orderNo, request.budgetAmount(), + StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD", + () -> updateLocked(customerId, orderNo, operatorId, request)); + } + + /** 已冻结预算不允许通过普通编辑改价,业务字段仍按订单版本校验。 */ + private LocalLooseboardResponses.Detail updateLocked(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.Update request) { LocalLooseboardOrder order = lock(customerId, orderNo); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!PENDING_ACCEPTANCE.equals(order.getStatus())) { @@ -158,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. @Transactional public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.ChangeStatus request) { + return orderBudget.withOrder(customerId, "looseboard", orderNo, + () -> changeStatusLocked(customerId, orderNo, operatorId, request)); + } + + /** 取消与释放意图同事务,冻结在途回执到达后也会自动释放。 */ + private LocalLooseboardResponses.Detail changeStatusLocked(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardOrder order = lock(customerId, orderNo); String key = request.idempotencyKey().trim(); String requestHash = hash(request); @@ -182,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. touch(order, now); repository.updateOrder(order); syncLastlegOrder(order, next, now); + if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo); LocalLooseboardOperation operation = new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); operation.setOperationType("STATUS"); @@ -196,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. @Transactional public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.Review request) { + return orderBudget.withOrder(customerId, "looseboard", orderNo, + () -> reviewLocked(customerId, orderNo, operatorId, request)); + } + + /** 冻结与审核串行核对,通过后只释放预算,实际费用继续走原费用链。 */ + private LocalLooseboardResponses.Detail reviewLocked(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.Review request) { LocalLooseboardOrder order = lock(customerId, orderNo); String requestHash = hash(request); var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey()); @@ -205,16 +259,19 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. } if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); String decision = request.decision().trim().toUpperCase(); - if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试"); + if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单须资金冻结成功且待审核,请刷新后重试"); if ("REJECT".equals(decision)) { if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因"); - return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark())); + order.setStatus(CANCELLED); + } else { + if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效"); + if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核"); + if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐"); + var created = receiving.approve(order, operatorId); + order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND"); } - if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效"); - if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐"); - var created = receiving.approve(order, operatorId); - order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND"); touch(order, LocalDateTime.now()); repository.updateOrder(order); + orderBudget.releaseAfterReview(customerId, "looseboard", orderNo); history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now()); var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation); @@ -281,7 +338,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. /** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */ private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { - return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), + return response(order, order.getStatus()); + } + + /** 展示状态仅用于响应,原单及历史保持真实作业状态。 */ + private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order, String status) { + return new LocalLooseboardResponses.Detail(summary(order, fleetPool.arrangement(poolKey(order)), status), order.getOriginAddress(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), repository.histories(order.getCustomerId(), order.getId()).stream() .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), @@ -295,16 +357,18 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId()); } /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ - private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { - return summary(order, fleetPool.arrangement(poolKey(order))); - } - /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { + return summary(order, arrangement, order.getStatus()); + } + + /** 按调用方所需口径装配状态,其余原单与承运事实保持一致。 */ + private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, + com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement, String status) { return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(), order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), - order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(), + order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), status, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement); } @@ -383,7 +447,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port. /** 校验状态与取货日期筛选边界。 */ private void validateQuery(LocalLooseboardRequests.Query query) { - if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { + validateQuery(query, STATUSES); + } + + /** 分别限制客户可见运输状态和平台内部作业状态,并校验时间范围。 */ + private void validateQuery(LocalLooseboardRequests.Query query, Set statuses) { + if (StringUtils.hasText(query.status()) && !statuses.contains(norm(query.status()))) { throw badRequest("Unsupported looseboard order status"); } if (query.requestedFrom() != null && query.requestedTo() != null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java index c2915be62..0709f6aa1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingRepository.java @@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.time.LocalDateTime; import org.springframework.stereotype.Repository; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; /** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */ @Repository @@ -21,6 +25,21 @@ public class OrderFundingRepository { private final OverseasProcurementStatusHistoryMapper purchaseHistory; private final UsTruckOrderMapper trucks; private final UsTruckOrderStatusHistoryMapper truckHistory; + private final LocalLooseboardOrderMapper looseboards; + private final LocalLooseboardHistoryMapper looseboardHistory; + + /** 预算锁后按认证客户锁定散板原单。 */ + public LocalLooseboardOrder looseboard(String customer, String number) { return looseboards.lockNo(customer, number); } + /** 保存散板资金状态,不改客户填写的业务资料。 */ + public void save(LocalLooseboardOrder order) { looseboards.updateById(order); } + /** 追加散板冻结结果和重试的不可变历史。 */ + public void history(LocalLooseboardOrder order, String from, String target, String reason, LocalDateTime now) { + var history = new LocalLooseboardHistory(); + 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); + looseboardHistory.insert(history); + } /** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */ public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java index 1cdf53315..2bf38630a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/funding/OrderFundingService.java @@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Set; -/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */ +/** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */ @Service @RequiredArgsConstructor public class OrderFundingService implements OrderFundingPort { @@ -36,6 +36,15 @@ public class OrderFundingService implements OrderFundingPort { order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now); repository.save(order); repository.history(order, from, target, reason, now); + } else if ("looseboard".equals(budget.getKind())) { + var order = repository.looseboard(budget.getCustomerId(), budget.getOrderNo()); + if (order == null) throw new IllegalStateException("资金关联的散板订单不存在"); + String from = order.getStatus(); + if (!changeable(from, target)) return; + String next = "PENDING_REVIEW".equals(target) ? "PENDING_ACCEPTANCE" : target; + order.setStatus(next); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now); + repository.save(order); + repository.history(order, from, next, reason, now); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java index 621e0591d..e09be4556 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/MerchantOrderBudgetController.java @@ -21,26 +21,26 @@ public class MerchantOrderBudgetController { @NotBlank @Pattern(regexp="CNY|USD") String currency) { } /** 按业务创建权限发起预估冻结。 */ @PostMapping("/{kind}/{key}") - @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) { return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency())); } /** 只读资金回执沿同一业务权限。 */ @GetMapping("/{kind}/{key}") - @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))") public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) { return ApiResponse.success(service.read(customer(user),kind,key)); } /** 列表及详情可核对当前客户订单资金状态和失败原因。 */ @GetMapping("/{kind}/orders/{orderNo}") - @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:list'))") public ApiResponse readOrder(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String kind, @PathVariable String orderNo) { return ApiResponse.success(service.readOrder(customer(user), kind, orderNo)); } /** 失败订单按原预算重试;客户身份只取当前会话。 */ @PostMapping("/{kind}/orders/{orderNo}/retry") - @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))") + @PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))") public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String kind, @PathVariable String orderNo) { return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java index a5dffcab9..71c6646dc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java @@ -24,6 +24,7 @@ public interface OrderBudgetMapper extends BaseMapper { (b.order_no IS NULL AND b.created_at < #{expiredBefore}) OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED'))) OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED'))) + OR (b.kind='looseboard' AND EXISTS (SELECT 1 FROM ll_local_looseboard_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED','IN_TRANSIT','CANCELLED','COMPLETED'))) )) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED """) List releasable(@Param("expiredBefore") LocalDateTime expiredBefore, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java index a6a63b045..ff8f9b9d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java @@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler { return change.get(); } - /** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */ + /** 审核及取消先锁预算再锁业务单,与异步资金回执保持相同锁序。 */ + @Transactional + public T withOrder(String customer,String kind,String no,Supplier change) { + requireKind(kind); + repository.lockOrder(customer,kind,no); + return change.get(); + } + + /** 审核通过后立即释放订单预估冻结,后续实际费用按正常余额/授信扣除。 */ @Transactional public void releaseAfterReview(String customer, String kind, String orderNo) { requireKind(kind); @@ -194,7 +202,7 @@ public class OrderBudgetService implements WalletResultHandler { };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)))); } /** 仅开放本轮客户下单业务。 */ - private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); } + private void requireKind(String kind) { if(!Set.of("procurement","trucking","looseboard").contains(kind)) throw bad("不支持的预算业务"); } /** 时间按UTC保存以匹配资金事件。 */ private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } /** 返回可恢复的业务校验失败。 */ diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 1059082a9..13e9cba5a 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -13,7 +13,7 @@ "paths": { "/api/platform/v1/last-leg/fleet/operations": { "get": { - "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总", + "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。", "parameters": [ { "name": "page", diff --git a/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml b/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml index 7bf972c42..0376af6f1 100644 --- a/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/order-budgets-v1.yaml @@ -2,17 +2,17 @@ openapi: 3.0.3 info: title: 海外客户下单预估资金校验 version: 1.0.0 - description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。 + description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。 security: - bearerAuth: [] paths: /api/merchant/v1/last-leg/order-budgets/{kind}/{key}: parameters: - - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}} + - {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}} - {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} post: 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,looseboard需要merchant:cross-border:pallet-shipping:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。 requestBody: required: true content: @@ -34,16 +34,16 @@ paths: '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: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} get: summary: 读取订单资金状态 - description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。 + description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。 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: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}} post: summary: 重试订单资金冻结 diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index ce5568f30..11d0a9c8d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -28,7 +28,7 @@ paths: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review: post: summary: 审核散板并创建唯一入仓履约单 - description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。 + description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } @@ -2754,20 +2754,20 @@ paths: /api/merchant/v1/last-leg/local-looseboard-orders: get: summary: 客户分页查询自己的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId;状态筛选、分页计数和显示共用真实运输进度。PENDING_ACCEPTANCE 为待审核,审核通过尚未排车为 PENDING_DISPATCH(待排车);已排车待取货为 DISPATCHED;取货到仓车次出发或已取货未到仓为 PICKING_UP(取货中),到仓后等待后续排车;直接取货送货取货完成后为 IN_TRANSIT(派送中)。内部收货与合托状态不作为客户筛选项。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: orderNo, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } } - - { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/MerchantLooseboardStatus' } } - { name: requestedFrom, in: query, schema: { type: string, format: date } } - { name: requestedTo, in: query, schema: { type: string, format: date } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } post: summary: 客户创建本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 + description: 权限 merchant:cross-border:pallet-shipping:create;散板金额必填,预算与订单和冻结指令同事务保存;新单进入 FUNDING_PENDING,Finance按余额优先、授信补足冻结,成功进入 PENDING_ACCEPTANCE,失败进入 FUNDING_FAILED,支持原单重试。审核通过、取消或驳回申请释放预占,不自动扣款;客户不能调用平台状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -2775,12 +2775,12 @@ paths: parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] get: summary: 客户查询自己的本土散板订单详情 - description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。 + description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } put: - summary: 客户编辑待接单的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 + summary: 客户编辑待审核的本土散板订单 + description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } @@ -4897,7 +4897,11 @@ components: destinationContactPhone: { type: string, nullable: true } pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 } deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 } - status: { $ref: '#/components/schemas/LooseboardStatus' } + status: + description: 客户读取为运输状态 MerchantLooseboardStatus,平台读取保留内部作业状态 LooseboardStatus。 + anyOf: + - { $ref: '#/components/schemas/LooseboardStatus' } + - { $ref: '#/components/schemas/MerchantLooseboardStatus' } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } @@ -5292,7 +5296,11 @@ components: pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } remark: { type: string, nullable: true, maxLength: 1000 } - LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + LooseboardStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + MerchantLooseboardStatus: + type: string + description: 订单受理中、订单处理失败、待审核、待排车、待取货、取货中、派送中、已完成、已取消;只读运输进度,不作为平台状态推进命令。 + enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, PENDING_DISPATCH, DISPATCHED, PICKING_UP, IN_TRANSIT, COMPLETED, CANCELLED] LooseboardStatusChange: type: object required: [version, status, idempotencyKey] @@ -5303,7 +5311,7 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } LooseboardCreate: type: object - required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, budgetAmount, idempotencyKey] properties: deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } operatingWarehouseId: { type: string, description: 实际收货海外仓ID } @@ -5322,7 +5330,7 @@ components: packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } - budgetAmount: { type: number, nullable: true, minimum: 0 } + budgetAmount: { type: number, minimum: 0, multipleOf: 0.01 } currency: { type: string, minLength: 3, maxLength: 3, default: USD } originContactName: { type: string, minLength: 1, maxLength: 128 } originContactPhone: { type: string, minLength: 1, maxLength: 128 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java index d2d17e462..caaf13d3b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java @@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class TripOperationsServiceTest { + /** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */ + @Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() { + var repo=mock(TripOperationsRepository.class); + var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + String snapshot=""" + {"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z", + "vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}} + """; + var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); + var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}"); + when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null))); + var result=service.report(query,false); + assertThat(result.page().records()).hasSize(2); + assertThat(result.summary().trips()).isEqualTo(2); + assertThat(result.summary().ownPackages()).isEqualTo(17); + assertThat(result.summary().physicalPallets()).isEqualTo(1); + var row=result.page().records().get(0); + assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull(); + assertThat(row.destinations().get(0).label()).isEqualTo("目的仓"); + // 普通车次仍不能因缺少装载而被静默当成照片直送。 + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null))) + .hasMessageContaining("缺少装载"); + for(String quantity:List.of("null","0","-1","1.5","\"6\"")) { + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null, + "{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数"); + } + } static final String DEPARTURE=""" {"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"}, "confirmedLoad":{"cargo":[ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardMerchantStatusTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardMerchantStatusTest.java new file mode 100644 index 000000000..3238287d9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardMerchantStatusTest.java @@ -0,0 +1,220 @@ +package com.baiyue.lastleg.looseboard; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.port.LooseboardDispatches; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; +import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LocalLooseboardMerchantStatusTest { + private JdbcTemplate jdbc; + private LocalLooseboardRepository repository; + private LocalLooseboardService service; + + /** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */ + @BeforeEach + void setUp() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ("); + String ddl = baseline.substring(start, baseline.indexOf(';', start)) + .replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", ""); + ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")"; + jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")")); + jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LocalLooseboardOrderMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + config.addInterceptor(pagination); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class); + repository = new LocalLooseboardRepository(); + ReflectionTestUtils.setField(repository, "orders", mapper); + ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class)); + service = new LocalLooseboardService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class)); + ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class)); + } + + /** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */ + @Test + void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() { + String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND", + "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"}; + for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]); + var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH")); + assertThat(waiting.total()).isEqualTo(4); + assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH")); + for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) { + assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]); + } + assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND"); + } + + /** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */ + @Test + void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() { + order(1, "C1", "WAITING_INBOUND"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')"); + jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')"); + assertStatus(1, "DISPATCHED"); + jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'"); + jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'"); + jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'"); + assertStatus(1, "PICKING_UP"); + jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'"); + assertStatus(1, "PICKING_UP"); + jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'"); + assertStatus(1, "PENDING_DISPATCH"); + plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING"); + assertStatus(1, "DISPATCHED"); + jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'"); + assertStatus(1, "IN_TRANSIT"); + assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND"); + jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'"); + assertStatus(1, "COMPLETED"); + } + + /** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */ + @Test + void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() { + order(1, "C1", "WAITING_INBOUND"); plan(1, "C1"); + dispatch(1, 11, "C1", "CANCELLED", "DELIVERING"); + assertStatus(1, "PENDING_DISPATCH"); + dispatch(1, 12, "C1", "ACTIVE", "LOADING"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')"); + assertStatus(1, "DISPATCHED"); + jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12"); + dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING"); + assertStatus(1, "IN_TRANSIT"); + jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'"); + jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'"); + assertStatus(1, "CANCELLED"); + } + + /** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */ + @Test + void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() { + order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND"); + plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'"); + assertStatus(1, "PENDING_DISPATCH"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')"); + jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')"); + assertStatus(1, "PENDING_DISPATCH"); + assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty(); + assertThatThrownBy(() -> service.merchantDetail("C2", "LB1")) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'"); + assertStatus(1, "IN_TRANSIT"); + } + + /** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */ + @Test + void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() { + order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING"); + order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND"); + var first = service.page("C1", query(1, 1, " pending_dispatch ")); + var second = service.page("C1", query(2, 1, "PENDING_DISPATCH")); + assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2); + assertThat(second.total()).isEqualTo(2); + assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1); + assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id()); + for (var row : List.of(first.records().get(0), second.records().get(0))) { + assertThat(row.customerId()).isEqualTo("C1"); + assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status()); + } + for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) { + assertThatThrownBy(() -> service.page("C1", query(1, 20, status))) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + } + } + + /** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */ + private void order(long id, String customer, String status) { + var order = new LocalLooseboardOrder(); + order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id); + order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000); + order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP"); + order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5)); + order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO); + order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64)); + order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0)); + repository.insertOrder(order); + } + + /** 建立原单到申报托盘的正式链路。 */ + private void plan(long id, String customer) { + jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer); + jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer); + } + + /** 建立可独立取消或推进的分车排车事实。 */ + private void dispatch(long pallet, long id, String customer, String status, String stage) { + jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer); + } + + /** 每个节点同时核对列表筛选总数和客户详情。 */ + private void assertStatus(long id, String status) { + assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status); + var page = service.page("C1", query(1, 20, status)); + assertThat(page.total()).isEqualTo(1); + assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id); + } + + /** 用同一路线和日期边界查询客户运输进度。 */ + private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) { + return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status, + LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index ed3a670bc..1f07b4abb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -48,6 +48,7 @@ class LocalLooseboardServiceTest { @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; @Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches; + @Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets; @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private LocalLooseboardService service; private final Map operationRows = new HashMap<>(); @@ -56,6 +57,13 @@ class LocalLooseboardServiceTest { @BeforeEach void setUp() { service = new LocalLooseboardService(); + ReflectionTestUtils.setField(service, "orderBudget", budgets); + org.mockito.Mockito.lenient().when(budgets.submit(anyString(),anyString(),anyString(),any(),anyString(),any(),any())) + .thenAnswer(call -> ((java.util.function.Supplier)call.getArgument(5)).get()); + org.mockito.Mockito.lenient().when(budgets.update(anyString(),anyString(),anyString(),any(),anyString(),any())) + .thenAnswer(call -> ((java.util.function.Supplier)call.getArgument(5)).get()); + org.mockito.Mockito.lenient().when(budgets.withOrder(anyString(),anyString(),anyString(),any())) + .thenAnswer(call -> ((java.util.function.Supplier)call.getArgument(3)).get()); ReflectionTestUtils.setField(service, "dispatches", dispatches); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); var repository = new LocalLooseboardRepository(); @@ -106,7 +114,7 @@ class LocalLooseboardServiceTest { var detail = service.create("100", "7", create("key-1")); assertEquals("100", detail.summary().customerId()); - assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); + assertEquals(LocalLooseboardService.FUNDING_PENDING, detail.summary().status()); assertEquals("Alice", detail.summary().originContactName()); assertEquals("Bob", detail.summary().destinationContactName()); assertEquals("家居用品", detail.summary().productName()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java index 2580f7002..4c554647b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetAuthorizationTest.java @@ -53,6 +53,18 @@ class OrderBudgetAuthorizationTest { verify(service).retryOrder("9","trucking","KC1"); assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class); } + @Test void looseboardFundingUsesOnlyItsOwnCustomerAndPermissions() { + var controller=context.getBean(MerchantOrderBudgetController.class); + var service=context.getBean(OrderBudgetService.class); + var reader=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:list"); + controller.readOrder(reader,"looseboard","LB1");verify(service).readOrder("9","looseboard","LB1"); + assertThatThrownBy(()->controller.retry(reader,"looseboard","LB1")).isInstanceOf(AccessDeniedException.class); + var writer=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:create"); + controller.retry(writer,"looseboard","LB1");verify(service).retryOrder("9","looseboard","LB1"); + assertThatThrownBy(()->controller.retry(writer,"trucking","LB1")).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.readOrder(writer,"procurement","LB1")).isInstanceOf(AccessDeniedException.class); + } + private AuthenticatedUser login(long customer,String app,String permission) { var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java index d368642a0..c085ed4f4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java @@ -12,6 +12,11 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.UUID; import java.util.regex.Pattern; +import com.baiyue.lastleg.looseboard.mapper.*; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; +import com.baiyue.lastleg.looseboard.port.LooseboardReceiving; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.*; /** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */ @@ -31,7 +36,7 @@ class OrderBudgetPersistenceTest { 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))"); - for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) { + for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history", "ll_local_looseboard_order", "ll_local_looseboard_operation", "ll_local_looseboard_history"}) { var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql); assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group()); } @@ -41,6 +46,9 @@ class OrderBudgetPersistenceTest { config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class); config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class); config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class); + config.addMapper(LocalLooseboardOrderMapper.class); + config.addMapper(LocalLooseboardOperationMapper.class); + config.addMapper(LocalLooseboardHistoryMapper.class); var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); sessions = new SqlSessionTemplate(factory.getObject()); repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class)); @@ -60,10 +68,11 @@ class OrderBudgetPersistenceTest { } /** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */ @Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() { - for (String kind : new String[]{"procurement","trucking"}) { - String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order"; + for (String kind : new String[]{"procurement","trucking","looseboard"}) { + String table = switch(kind) { case "procurement" -> "ll_overseas_procurement_order"; case "trucking" -> "ll_us_truck_order"; default -> "ll_local_looseboard_order"; }; assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> { if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64)); + else if ("looseboard".equals(kind)) jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64)); else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')"); throw new IllegalStateException("订单明细写入失败"); }, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败"); @@ -74,12 +83,113 @@ class OrderBudgetPersistenceTest { } /** 成功受理原子提交并可同键重放,资金指令不能重复。 */ @Test void acceptsAndReplaysWithoutDuplicateFreeze() { - for (String kind : new String[]{"procurement","trucking"}) { + for (String kind : new String[]{"procurement","trucking","looseboard"}) { 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(3); + } + + /** 散板原单、资金回执、审核和释放使用真实Mapper,重复请求不重复占款或建履约。 */ + @Test void looseboardCreationFreezesAndApprovalReleasesTheSameBudget() { + var harness=looseboards();var looseboards=harness.orders();var request=looseboardRequest(); + var created=looseboards.create("9","customer",request); + String number=created.summary().orderNo(); + assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING"); + assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(0L,"APPROVE","early-review",null))) + .hasMessageContaining("冻结成功"); + looseboards.create("9","customer",request); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1); + var budget=repository.lockOrder("9","looseboard",number); + service.handle(truckReceipt(budget,"FREEZE","FROZEN")); + var ready=looseboards.detail("9",number); + assertThat(ready.summary().status()).isEqualTo("PENDING_ACCEPTANCE"); + var review=new LocalLooseboardRequests.Review(ready.summary().version(),"APPROVE","review-key",null); + assertThat(looseboards.review("9",number,"reviewer",review).summary().status()).isEqualTo("WAITING_INBOUND"); + looseboards.review("9",number,"reviewer",review); + org.mockito.Mockito.verify(harness.receiving(),org.mockito.Mockito.times(1)).approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("reviewer")); + budget=repository.lockOrder("9","looseboard",number); + assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");assertThat(budget.getReleaseRequested()).isTrue(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); + service.handle(truckReceipt(budget,"RELEASE","RELEASED")); + assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASED"); + assertThat(looseboards.create("9","customer",request).summary().orderNo()).isEqualTo(number); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); + } + + /** 冻结失败不能审核,重试仍绑定原单;驳回同键重放只释放一次。 */ + @Test void looseboardInsufficientFundsCanRetryAndRejectionReleasesOnce() { + var harness=looseboards();var looseboards=harness.orders(); + String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo(); + var budget=repository.lockOrder("9","looseboard",number); + service.handle(new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(),"FREEZE",9L,OrderBudgetService.BUSINESS_TYPE, + budget.getId().toString(),"FAILED",null,budget.getAmountCny(),null,"可用余额和授信额度不足",java.time.Instant.now(),null,null)); + assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("FUNDING_FAILED"); + assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","early",null))) + .hasMessageContaining("冻结成功"); + assertThat(service.readOrder("9","looseboard",number).message()).contains("授信额度不足"); + service.retryOrder("9","looseboard",number); + budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN")); + var review=new LocalLooseboardRequests.Review(looseboards.detail("9",number).summary().version(),"REJECT","reject-key","路线不可承运"); + looseboards.review("9",number,"reviewer",review);looseboards.review("9",number,"reviewer",review); + assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED"); + assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3); + org.mockito.Mockito.verifyNoInteractions(harness.receiving()); + } + + /** 取消时冻结在途,成功回执只能触发释放,不能将原单重新改成待审核。 */ + @Test void looseboardCancellationReleasesLateFreezeWithoutReopeningOrder() { + var looseboards=looseboards().orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo(); + var cancel=new LocalLooseboardRequests.ChangeStatus(0L,"CANCELLED","cancel-key",null); + looseboards.changeStatus("9",number,"operator",cancel);looseboards.changeStatus("9",number,"operator",cancel); + var budget=repository.lockOrder("9","looseboard",number);assertThat(budget.getReleaseRequested()).isTrue(); + service.handle(truckReceipt(budget,"FREEZE","FROZEN")); + assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING"); + assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); + } + + /** 审核履约创建失败回滚,不提前释放预算或保存已审核状态。 */ + @Test void looseboardApprovalFailureKeepsOriginalHold() { + var harness=looseboards();var looseboards=harness.orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo(); + var budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN")); + org.mockito.Mockito.when(harness.receiving().approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("履约创建失败")); + assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","failed-review",null))) + .hasMessageContaining("履约创建失败"); + assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("PENDING_ACCEPTANCE"); + assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("FROZEN"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1); + } + + private record Looseboards(LocalLooseboardService orders,LooseboardReceiving receiving) {} + /** 散板原单及历史使用生产持久化,仅隔离真正的收货履约入口。 */ + private Looseboards looseboards() { + var orders=sessions.getMapper(LocalLooseboardOrderMapper.class);var history=sessions.getMapper(LocalLooseboardHistoryMapper.class); + var repo=new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository(); + ReflectionTestUtils.setField(repo,"orders",orders);ReflectionTestUtils.setField(repo,"histories",history); + ReflectionTestUtils.setField(repo,"operations",sessions.getMapper(LocalLooseboardOperationMapper.class)); + var looseboards=new LocalLooseboardService();ReflectionTestUtils.setField(looseboards,"repository",repo); + ReflectionTestUtils.setField(looseboards,"objectMapper",new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(looseboards,"orderBudget",service); + ReflectionTestUtils.setField(looseboards,"dispatches",org.mockito.Mockito.mock(com.baiyue.lastleg.looseboard.port.LooseboardDispatches.class)); + ReflectionTestUtils.setField(looseboards,"fleetPool",org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); + var numbers=org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class); + org.mockito.Mockito.when(numbers.next("LOOSEBOARD")).thenReturn("LB-BUDGET-1");ReflectionTestUtils.setField(looseboards,"lastlegOrderNoGenerator",numbers); + var receiving=org.mockito.Mockito.mock(LooseboardReceiving.class);ReflectionTestUtils.setField(looseboards,"receiving",receiving); + org.mockito.Mockito.when(receiving.approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenReturn(new LooseboardReceiving.Created("TRANSFER1",99L)); + var actualFunding=new com.baiyue.lastleg.procurement.funding.OrderFundingService( + new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null,null,null,null,orders,history)); + org.mockito.Mockito.doAnswer(call->{actualFunding.apply(call.getArgument(0),call.getArgument(1),call.getArgument(2));return null;}) + .when(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); + return new Looseboards(looseboards,receiving); + } + /** 明确金额和币种,避免测试把未填金额冒充零价。 */ + private LocalLooseboardRequests.Create looseboardRequest() { + return new LocalLooseboardRequests.Create("9","FBA",null,"WAREHOUSE_PICKUP",null,null,null,null,"LA","Street A","ONT8",null, + "货物",2,10,BigDecimal.TEN,BigDecimal.ONE,new BigDecimal("100.00"),"CNY","发货人","10086","收货人","10010", + LocalDateTime.of(2026,11,2,9,0),LocalDateTime.of(2026,11,3,9,0),"原始备注","looseboard-budget-key"); } /** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */ @@ -99,7 +209,7 @@ class OrderBudgetPersistenceTest { org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1"); org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers); var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService( - new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories)); + new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories, null, null)); org.mockito.Mockito.doAnswer(call -> { actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null; }).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); @@ -158,7 +268,11 @@ class OrderBudgetPersistenceTest { repository.insert(row(4,"FROZEN")); repository.insert(row(5,"FREEZE_PENDING")); var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure); - assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L); + var acceptedLooseboard=row(7,"FROZEN");acceptedLooseboard.setKind("looseboard");acceptedLooseboard.setOrderNo("LB1");repository.insert(acceptedLooseboard); + jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(7,'9','LB1','A','B','WAITING_INBOUND','accepted',?)", "a".repeat(64)); + var pendingLooseboard=row(8,"FROZEN");pendingLooseboard.setKind("looseboard");pendingLooseboard.setOrderNo("LB2");repository.insert(pendingLooseboard); + jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(8,'9','LB2','A','B','PENDING_ACCEPTANCE','pending',?)", "a".repeat(64)); + assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L,7L); } /** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */ private OrderBudget row(long id,String status) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java index e922d65a8..a7ddb8243 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java @@ -68,7 +68,7 @@ class OrderBudgetServiceTest { assertThat(service.read("9","procurement","key").usable()).isFalse(); assertThat(service.read("9","procurement","key").message()).contains("过期"); } - @ParameterizedTest @ValueSource(strings={"procurement","trucking"}) + @ParameterizedTest @ValueSource(strings={"procurement","trucking","looseboard"}) void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) { row.setKind(kind); row.setOrderNo("CG-1"); row.setStatus("FROZEN"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java index af65ec873..af2ff1c34 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderFundingServiceTest.java @@ -15,6 +15,24 @@ class OrderFundingServiceTest { OrderFundingRepository repository = mock(OrderFundingRepository.class); OrderFundingService service = new OrderFundingService(repository); + /** 散板成功后进入原待审核状态,失败可重试;重复或取消后的回执不复活原单。 */ + @Test void looseboardFundingPreservesAuditGateAndIgnoresLateResults() { + var budget = budget("looseboard"); + var order = new com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder(); + order.setId(1L);order.setCustomerId("9");order.setStatus("FUNDING_PENDING");order.setVersion(0L);order.setRemark("客户备注"); + when(repository.looseboard("9","NO1")).thenReturn(order); + service.apply(budget,"FUNDING_FAILED","可用余额和授信额度不足"); + assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED"); + service.apply(budget,"FUNDING_PENDING",null); + service.apply(budget,"PENDING_REVIEW",null); + service.apply(budget,"PENDING_REVIEW",null); + assertThat(order.getStatus()).isEqualTo("PENDING_ACCEPTANCE"); + assertThat(order.getVersion()).isEqualTo(3L);assertThat(order.getRemark()).isEqualTo("客户备注"); + 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 purchaseSuccessIsIdempotentAndPreservesRemark() { var budget = budget("procurement"); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 94efaa141..438635093 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -136,7 +136,8 @@ public class UnifiedOrderQueryService { OrderEntity order = authority.get(row.orderId()); if (order == null || !Objects.equals(order.getTenantId(), row.tenantId()) || !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue; - String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + // 待审核主状态仍由 Order 决定,提前测量提交产生的独立审核任务须保留。 + String status = order.getStatus() == OrderStatus.SUBMITTED && !"MEASUREMENT_REVIEW".equals(row.mainStatus()) ? "PENDING_REVIEW" : order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus(); if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status)); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index ceed15f86..aece2d23b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -421,7 +421,7 @@ paths: 需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。 按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。 订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。 - MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。 + MEASUREMENT_REVIEW 返回独立测量审核任务数量;待审核订单提前测量并提交审核后可同时计入 PENDING_REVIEW 与 MEASUREMENT_REVIEW。 parameters: - $ref: '#/components/parameters/UnifiedTaxMode' - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1743,7 +1743,7 @@ components: name: mainStatus in: query required: false - description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 + description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。 schema: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 52c715994..1a9ed42cc 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -1012,6 +1012,35 @@ class UnifiedOrderQueryServiceTest { }); } + /** 提前测量提交后同时保留订单待审核和测量审核待办,统计与列表一致。 */ + @Test + void submittedOrderRetainsIndependentMeasurementReviewTask() { + var pending = order(OrderStatus.SUBMITTED); + when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending)); + when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending)); + when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending)); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of( + new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"), + new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"), + new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW")))); + var projected = summary("WAIT_MEASURE", null); + when(projected.inbound().processStatus()).thenReturn("MEASURE_PENDING"); + when(firstleg.statusSummaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(projected))); + + var counts = service.statusCounts(10001L, query(null)); + var page = service.page(10001L, query("MEASUREMENT_REVIEW")); + + assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L) + .containsEntry("MEASUREMENT_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L); + assertThat(page.total()).isEqualTo(1L); + assertThat(page.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + assertThat(row.inboundId()).isEqualTo(88L); + }); + } + /** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */ @Test void statusCountsRejectDependencyFailuresAndForeignCustomerRows() { From f4362f6dfde201009a14f01bcfac77adc31144b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 13:59:16 +0800 Subject: [PATCH 4/4] fix(ci): reject TEST revisions without memory protection --- Jenkinsfile.service | 8 +++ ...est-environment-pipeline-contract.test.mjs | 60 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 7693a915d..fd3348739 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -125,6 +125,14 @@ pipeline { git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" + # TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。 + if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then + if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null || + ! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then + echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2 + exit 1 + fi + fi if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 9ad321f05..392624169 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import test from 'node:test' const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) @@ -15,6 +19,60 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +test('TEST rejects old source commits even when the workspace contains newer limits files', { + skip: process.platform === 'win32' && !existsSync('C:/Program Files/Git/bin/bash.exe') +}, async () => { + const directory = await mkdtemp(join(tmpdir(), 'test-memory-source-')) + const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' + const guardStart = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then') + const guardEnd = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then', guardStart) + assert.ok(guardStart >= 0 && guardEnd > guardStart) + assert.ok(guardStart < pipeline.indexOf("stage('Maven 测试与打包')")) + const guard = pipeline.slice(guardStart, guardEnd) + // 在微型 Git 夹具中验证目标提交本身,避免工作区残留文件误放行旧版本。 + const git = (...args) => { + const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + return result.stdout.trim() + } + // 构建提交沿用正常身份设置,但禁止夹具触发全局签名及提交钩子。 + const commit = (message) => { + git('add', '.') + git('-c', 'user.name=Resource Contract', '-c', 'user.email=resource@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-m', message) + return git('rev-parse', 'HEAD') + } + try { + git('init') + await writeFile(join(directory, 'README.md'), 'Source guard fixture\n') + const oldCommit = commit('old source') + await mkdir(join(directory, 'deploy/scripts'), { recursive: true }) + await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n') + await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), '#!/bin/sh\nexit 0\n') + const incompleteCommit = commit('limits file without deployment integration') + await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript) + const protectedCommit = commit('complete memory protection') + git('checkout', '--detach', oldCommit) + await mkdir(join(directory, 'deploy/scripts'), { recursive: true }) + await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n') + await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript) + for (const [environment, sourceCommit, status] of [ + ['test', oldCommit, 1], ['test', incompleteCommit, 1], + ['test', protectedCommit, 0], ['production', oldCommit, 0] + ]) { + const result = spawnSync(bash, ['-c', guard], { + cwd: directory, encoding: 'utf8', timeout: 10000, + env: { ...process.env, RESOLVED_ENVIRONMENT: environment, RESOLVED_SOURCE_COMMIT: sourceCommit } + }) + assert.equal(result.status, status, result.stderr) + if (status !== 0) assert.match(result.stderr, /目标 TEST 提交缺少完整内存保护/) + } + } finally { + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + await rm(directory, { recursive: true, force: true }) + } +}) + test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => { for (const source of [coordinatorPipeline, pipeline]) { assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,