diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index ede9b6852..15000acf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -1,35 +1,65 @@ package com.baiyue.lastleg.dispatch.mapper; -import com.baiyue.lastleg.dispatch.model.DispatchBolData; + import org.apache.ibatis.annotations.*; import java.util.List; -/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ + +/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */ @Mapper public interface DispatchBolMapper { - /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ - default List findByDispatchOrder(String customerId, Long dispatchOrderId) { - return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); - } + record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType, + String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode, + String destinationWarehouseName,String warehouseNo,String fbaShipmentId,String poReferenceNo, + Integer packageCount,Integer itemPackageCount,boolean originalPlan) { } + record Allocation(Long dispatchItemId,Long planPalletId,String batchKey,String customerId,String sourceKey, + String destinationKey,Integer packageCount,Long appointmentId,Long orderId,String confirmationNo) { } - @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + - "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + - "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + - "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + - "MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," + - "COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," + - "COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," + - "GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," + - "GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," + - "CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " + - "WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " + - "ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " + - "FROM ll_dispatch_item di " + - "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + - "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + - "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " + - "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + - "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + - "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + - "WHERE di.dispatch_order_id=#{dispatchOrderId} " + - "GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id") - List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); + /** 一行对应本车一个真实计划来源行;商品明细只聚合展示字段,原调度数量不取部分已拣数。 */ + @Select(""" + SELECT di.id AS dispatchItemId, + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL + AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId, + di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType, + di.delivery_address AS deliveryAddress,di.postal_code AS postalCode, + COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo, + MAX(NULLIF(di.pallet_no,'')) AS palletNo, + COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')), + MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode, + COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName, + COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo, + GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId, + GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo, + CASE WHEN l.id IS NULL THEN MAX(di.package_count) + WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count) + ELSE MAX(a.planned_package_count) END AS packageCount, + MAX(di.package_count) AS itemPackageCount, + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL + AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan + FROM ll_dispatch_item di + LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id + LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id + LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id + AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' + LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id + LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id + LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id + WHERE di.dispatch_order_id=#{dispatchOrderId} + GROUP BY di.id,di.order_id,di.plan_pallet_id,di.customer_id,di.destination_type,di.delivery_address, + di.postal_code,di.source_stage,di.return_receipt_id,di.source_business_type,l.id,l.order_id,a.id + ORDER BY di.id,l.id,a.id + """) + List findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId); + + /** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */ + @Select(""" + SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey, + a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey, + a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo + FROM ll_dispatch_appointment_allocation a + LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) + LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id + AND i.source_key=a.source_key AND CONCAT('FBA:',i.fba_warehouse_code)=a.destination_key + WHERE a.dispatch_id=#{dispatchOrderId} AND a.batch_key LIKE 'FBA_APPOINTMENT:%' + ORDER BY a.dispatch_item_id,a.customer_id,a.source_key,a.destination_key,a.batch_key + """) + List findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java index 869c23a97..016a13577 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -3,13 +3,17 @@ import java.time.LocalDateTime; import java.util.*; /** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, - String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines,Map appointmentNumbers) { - public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, + String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines) { + public record Line(Long dispatchItemId,Long orderId,String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, - String poReferenceNo,int packageCount) { + String poReferenceNo,int packageCount,String appointmentNo) { + /** 外部确认号只去首尾空白,未取得真实约号时保留空。 */ + public Line { appointmentNo=appointmentNo==null || appointmentNo.isBlank()?null:appointmentNo.strip(); } + /** 目的地身份不使用客户作为分组条件。 */ public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + /** 打印展示使用业务名称或真实地址。 */ public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;} - public String groupKey(){return customerId+"\u0000"+destinationKey();} + /** 同目的地同约号跨客户合并,无号也可组成同目的地文档。 */ + public String groupKey(){return destinationKey()+"\u0000"+Objects.requireNonNullElse(appointmentNo,"");} } - public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} } 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 b283e69fb..8c52fba4b 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 @@ -369,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); - if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); @@ -739,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); } + /** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */ + private static boolean canSubmitReview(String reviewState) { + return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState) + || ReviewState.LOADING_REJECTED.name().equals(reviewState); + } + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ Map actions(Actor actor,DispatchAggregate row) { var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); @@ -746,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var action:Action.values()) { boolean state=switch(action) { case CREATE -> actor.mode()==ActorMode.PLATFORM; - case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState()); case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index ff5af7f79..341ab0d03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -32,6 +32,7 @@ public class DispatchBolPrintService { @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; + /** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */ public GeneratedPdf generate(String dispatchNo, String requestId) { DispatchBolData detail = dispatches.read(dispatchNo); if (detail.bolLines() == null || detail.bolLines().isEmpty()) { @@ -54,6 +55,7 @@ public class DispatchBolPrintService { } } + /** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */ private List documents(DispatchBolData detail) { String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); Map> groups = new LinkedHashMap<>(); @@ -72,7 +74,7 @@ public class DispatchBolPrintService { .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), - text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + lines.get(0).appointmentNo() == null ? "" : lines.get(0).appointmentNo().strip(), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); offset = end; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java index 00c2c2d28..4d361fde2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -1,31 +1,92 @@ package com.baiyue.lastleg.dispatch.service; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.time.*; import java.util.*; -import java.util.stream.Collectors; -/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ + +/** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */ @Service public class DispatchBolReadService { - private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; - public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;} + private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json; + private record Source(Long itemId,String customerId,Long orderId,String destinationKey) { } + private record AllocationKey(Long itemId,String batchKey,String customerId,String sourceKey,String destinationKey) { } + /** 打印读取只经Repository,不写预约、调度或库存事实。 */ + public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolRepository facts,ObjectMapper json){this.roots=roots;this.facts=facts;this.json=json;} + + /** 保留原调度总量,未约部分以空约号打印,不按部分拣货进度缩量。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchBolData read(String number) { var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);} - var cargo=lines.findWholeDispatch(row.getId()); - if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); - var confirmations=new LinkedHashMap>(); - for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo()); - var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); + var cargo=new LinkedHashMap();var totals=new HashMap();var pallets=new HashMap(); + for(var raw:facts.cargo(row.getId())) { + int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages); + if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null + || raw.originalPlan() && raw.orderId()==null)throw invalid(); + int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total); + if(prior!=null && prior!=total)throw invalid(); + if(pallets.containsKey(raw.dispatchItemId()) && !Objects.equals(pallets.get(raw.dispatchItemId()),raw.planPalletId()))throw invalid(); + pallets.put(raw.dispatchItemId(),raw.planPalletId()); + if(packages==0)continue; + var key=new Source(raw.dispatchItemId(),raw.customerId(),raw.orderId(),line.destinationKey()); + cargo.merge(key,line,DispatchBolReadService::merge); + } + var sourceTotals=new HashMap(); + cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact)); + if(!sourceTotals.equals(totals))throw invalid(); + var remaining=new LinkedHashMap();cargo.forEach((key,line)->remaining.put(key,line.packageCount())); + var seen=new HashSet();var result=new ArrayList(); + for(var allocation:facts.allocations(row.getId())) { + if(allocation.appointmentId()==null || allocation.orderId()==null || blank(allocation.sourceKey()) + || !Objects.equals(pallets.get(allocation.dispatchItemId()),allocation.planPalletId()))throw invalid(); + var key=new Source(allocation.dispatchItemId(),allocation.customerId(),allocation.orderId(),allocation.destinationKey()); + var source=cargo.get(key);int count=positive(allocation.packageCount()); + if(source==null || !seen.add(new AllocationKey(allocation.dispatchItemId(),allocation.batchKey(),allocation.customerId(),allocation.sourceKey(),allocation.destinationKey())) + || count>remaining.get(key))throw invalid(); + remaining.put(key,remaining.get(key)-count);result.add(copy(source,count,allocation.confirmationNo())); + } + // 每个来源只消费实际分配份额,余额有明确实物依据,保持空ISA而非重复整托。 + remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));}); + var printedTotals=new HashMap();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact)); + if(!printedTotals.equals(totals))throw invalid(); return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(), - row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); + row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result)); } + /** 将数据库原始来源行转换为带真实身份的内部打印行。 */ + private static DispatchBolData.Line line(DispatchBolMapper.Cargo c,String appointment,int packages) { + return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(), + c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment); + } + /** 拆份只改变份额和预约号,真实客户、源订单和排车明细身份不变。 */ + private static DispatchBolData.Line copy(DispatchBolData.Line c,int packages,String appointment) { + return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(), + c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment); + } + /** 同订单多来源行先守恒合并,再让每条预约分配消费一次;展示字符串只去重合并。 */ + private static DispatchBolData.Line merge(DispatchBolData.Line a,DispatchBolData.Line b) { + return new DispatchBolData.Line(a.dispatchItemId(),a.orderId(),a.customerId(),a.destinationType(),a.deliveryAddress(),a.postalCode(),join(a.containerNo(),b.containerNo()),a.palletNo(), + a.fbaWarehouseCode(),a.destinationWarehouseName(),join(a.warehouseNo(),b.warehouseNo()),join(a.fbaShipmentId(),b.fbaShipmentId()),join(a.poReferenceNo(),b.poReferenceNo()),Math.addExact(a.packageCount(),b.packageCount()),null); + } + /** 仅打印展示字段去重,不用逗号字符串表示预约身份。 */ + private static String join(String a,String b) { + var values=new LinkedHashSet();for(var text:Arrays.asList(a,b))if(text!=null)for(var part:text.split(","))if(!part.isBlank())values.add(part.strip()); + return values.isEmpty()?null:String.join(", ",values); + } + /** 数量未知或非正不得伪造打印份额。 */ + private static int positive(Integer value){if(value==null || value<=0)throw invalid();return value;} + /** 已核实为零的源行没有待打印货物,但不以零替代未知数量。 */ + private static int nonnegative(Integer value){if(value==null || value<0)throw invalid();return value;} + /** 来源身份必须来自真实持久事实。 */ + private static boolean blank(String value){return value==null || value.isBlank();} + /** 不完整、跨客户或超额分配不能生成看似守恒的BOL。 */ + private static BizException invalid(){return new BizException(ResultCode.VALIDATION_FAILED,"BOL货物来源、预约归属或分配箱数尚未核实");} } 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 8245d27b3..86c396041 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -874,7 +874,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: get: summary: 生成排车 BOL PDF - description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。 + description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的地与预约号归集BOL,同目的地同预约号跨客户合并,不同预约号分别生成BOL并独立分页;无预约号也能打印且预约栏留空,同目的地空号合并。本次仍返回包含各份BOL的一个固定A4 PDF,浏览器不直接访问内部打印服务,打印不改变货物客户归属。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 3f4290dcf..356a5c095 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest { /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ DriverEvidenceFixture() throws Exception { f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); - var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); + var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class)); org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); } @@ -287,6 +287,64 @@ public class DispatchAggregatePersistenceTest { assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); } + /** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */ + @Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit")); + try { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source)))); + var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING); + assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse(); + var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo())); + var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id"); + assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(), + new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null)))) + .isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交"); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion()); + assertThat(replay.result()).isEqualTo(submitted.result()); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + } finally { security.setAuthentication(previous); } + } + + /** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */ + @Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit", + "platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review")); + try { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source)))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度"))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true); + var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false); + var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载"))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true); + f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); + } finally { security.setAuthentication(previous); } + } + + /** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */ + private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) { + var detail=f.tx.execute(s->f.service.detail(f.actor,number)); + assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed); + } + /** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */ @Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception { var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 488b56e2f..2d3587142 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -10,7 +10,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; import com.baiyue.lastleg.dispatch.model.DispatchBolData; -import java.util.Map; +import java.util.ArrayList; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -33,11 +33,13 @@ class DispatchBolPrintServiceTest { @InjectMocks private DispatchBolPrintService service; @BeforeEach + /** 设置本次受控打印调用的内部身份。 */ void setUp() { ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); } @Test + /** 核对首页十八行及续页二十六行的全部分页边界。 */ void usesDenseFirstAndContinuationPageCapacities() { assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1); @@ -49,10 +51,11 @@ class DispatchBolPrintServiceTest { } @Test + /** 保留从实际起运仓主档解析营业地址的打印行为。 */ void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { String warehouseId = "193000000000200003"; DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, - LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of()); + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1))); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, @@ -74,10 +77,11 @@ class DispatchBolPrintServiceTest { } @Test + /** 无预约号时不同目的仓仍分别生成各自BOL。 */ void createsOneBolDocumentPerFbaWarehouse() { String warehouseId = "193000000000200003"; DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, - LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of()); + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1))); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.read(detail.dispatchNo())).thenReturn(detail); @@ -96,21 +100,91 @@ class DispatchBolPrintServiceTest { .containsExactly("ABE2", "ONT8"); } @Test - void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() { - var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5); - var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second), - Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B")); + /** 同仓同号合并客户,同时保留各自入仓码、托盘与箱数。 */ + void mergesCustomersWithTheSameDestinationAndAppointmentWithoutLosingTheirCargo() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ABE2","IN-B",5,"CONF-SHARED")),1); + assertThat(request.documents()).singleElement().satisfies(document->{ + assertThat(document.isaNumber()).isEqualTo("CONF-SHARED");assertThat(document.totalPackages()).isEqualTo(8); + assertThat(document.pageNumber()).isEqualTo(1);assertThat(document.pageCount()).isEqualTo(1); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B"); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::packageCount).containsExactly(3,5); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::palletNo).containsExactly("PALLET-11","PALLET-12"); + }); + } + @Test + /** 同目的仓的不同预约号不得合成同一份BOL。 */ + void keepsDifferentAppointmentsAtTheSameDestinationInSeparateDocuments() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-A"),cargo(12,"102","ABE2","IN-B",5,"CONF-B")),2); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-B"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5); + assertThat(request.documents().get(0).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A"); + assertThat(request.documents().get(1).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-B"); + } + @Test + /** 相同预约号不能将不同目的仓的货物混入同一份BOL。 */ + void keepsTheSameAppointmentAtDifferentDestinationsInSeparateDocuments() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ONT8","IN-B",5,"CONF-SHARED")),2); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-SHARED","CONF-SHARED"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ONT8"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5); + } + @Test + /** 无号及空白号合并同仓货物,并把ISA作为空串传递。 */ + void mergesMissingAppointmentsAtTheSameDestinationAndLeavesIsaEmpty() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,null),cargo(12,"102","ABE2","IN-B",5," ")),1); + assertThat(request.documents()).singleElement().satisfies(document->{ + assertThat(document.isaNumber()).isEmpty();assertThat(document.totalPackages()).isEqualTo(8); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B"); + }); + } + @Test + /** 两仓各两个预约在一次PDF请求中保留四份独立BOL。 */ + void keepsTwoAppointmentsForEachOfTwoWarehousesInOnePdfRequest() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A1",2,"CONF-A1"),cargo(12,"102","ABE2","IN-A2",3,"CONF-A2"), + cargo(13,"101","ONT8","IN-B1",4,"CONF-B1"),cargo(14,"102","ONT8","IN-B2",5,"CONF-B2")),4); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A1","CONF-A2","CONF-B1","CONF-B2"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ABE2","ONT8","ONT8"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(2,3,4,5); + assertThat(request.downloadFileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf"); + } + @Test + /** 每份BOL重置页码,跨页全部原始货物仅累计一次。 */ + void restartsPaginationPerBolAndCountsEveryCargoRowOnlyOnce() { + var lines=new ArrayList(); + for(int i=0;i<45;i++)lines.add(cargo(100+i,i%2==0?"101":"102","ABE2","IN-A-"+i,i%3+1,"CONF-A")); + for(int i=0;i<19;i++)lines.add(cargo(200+i,"103","ABE2","IN-B-"+i,2,"CONF-B")); + var documents=render(lines,5).documents(); + assertThat(documents).extracting(PrintPdfClient.Document::pageNumber).containsExactly(1,2,3,1,2); + assertThat(documents).extracting(PrintPdfClient.Document::pageCount).containsExactly(3,3,3,2,2); + assertThat(documents.stream().map(d->d.cargoRows().size()).toList()).containsExactly(18,26,1,18,1); + assertThat(documents).extracting(PrintPdfClient.Document::totalPackages).containsExactly(90,90,90,38,38); + assertThat(documents).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-A","CONF-A","CONF-B","CONF-B"); + var rows=documents.stream().flatMap(d->d.cargoRows().stream()).toList();assertThat(rows).hasSize(64); + assertThat(rows.stream().mapToInt(PrintPdfClient.CargoRow::packageCount).sum()).isEqualTo(128); + assertThat(rows).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactlyElementsOf(lines.stream().map(DispatchBolData.Line::warehouseNo).toList()); + } + /** 所有分组仍通过一次远程调用生成同一个PDF,并保留原文件名。 */ + private PrintPdfClient.Request render(List lines,int pages) { + var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,lines); when(dispatches.read(detail.dispatchNo())).thenReturn(detail); var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null); when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of()))); - when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); - service.generate(detail.dispatchNo(),"two-customers"); + when(print.generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count",Integer.toString(pages)).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + var generated=service.generate(detail.dispatchNo(),"grouped-bol"); + assertThat(generated.fileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf");assertThat(generated.pageCount()).isEqualTo(Integer.toString(pages)); + assertThat(generated.content()).isEqualTo("%PDF-".getBytes(StandardCharsets.US_ASCII)); var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class); - verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture()); - assertThat(request.getValue().documents()).hasSize(2); - assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B"); + verify(print).generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),request.capture()); + return request.getValue(); } + /** 新快照携带真实明细及原订单身份,不恢复旧预约号映射。 */ + private static DispatchBolData.Line cargo(long item,String customer,String code,String incoming,int packages,String appointment) { + return new DispatchBolData.Line(item,item+1000,customer,"FBA",null,null,"TSTU3539519","PALLET-"+item,code,code+" Warehouse",incoming, + "SHIP-"+item,"REF-"+item,packages,appointment); + } + /** 为既有仓地址与目的地测试构造没有预约号的真实身份快照。 */ private static DispatchBolData.Line line(String customer,String code,String name,int packages) { - return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages); + return new DispatchBolData.Line(Long.valueOf(customer),Long.parseLong(customer)+1000,customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages,null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java index 098768f5a..1e2e0e22b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -1,41 +1,214 @@ package com.baiyue.lastleg.dispatch.service; + import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository; import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.fasterxml.jackson.databind.ObjectMapper; 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.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; + +/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */ class DispatchBolReadPersistenceTest { + /** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */ @Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception { - var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')"); - f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)"); - f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); - f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5); - var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B"); - assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey()); + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); + var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102"); + assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5); + assertThat(result.bolLines()).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A","SHIP-B"); + assertThat(result.bolLines().get(0).groupKey()).isEqualTo(result.bolLines().get(1).groupKey()); + assertThat(result.bolLines()).allSatisfy(line->assertThat(line.appointmentNo()).isNull()); } + + /** 部分退回仅打印所选箱数,不借原计划已清点/已拣总数放大。 */ @Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception { - var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)"); - f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2); - var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");}); + var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8); + f.db.update("UPDATE ll_dispatch_item SET return_receipt_id=99,destination_type='PRIVATE',delivery_address='Actual receiving street',inbound_code='RETURN-IN',package_count=2 WHERE id=11"); + var result=f.read();assertThat(result.bolLines()).singleElement().satisfies(line->{ + assertThat(line.packageCount()).isEqualTo(2);assertThat(line.destinationLabel()).isEqualTo("Actual receiving street"); + assertThat(line.warehouseNo()).isEqualTo("RETURN-IN");assertThat(line.appointmentNo()).isNull(); + }); } + + /** 换标履约仅采用该逻辑明细,不能复用原计划所有货物。 */ + @Test void relabelSourceDoesNotBorrowOriginalPlanQuantity() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8); + f.db.update("UPDATE ll_dispatch_item SET source_business_type='RELABEL_ORDER',source_stage='RELABEL',package_count=2,inbound_code='RELABEL-IN' WHERE id=11"); + assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.packageCount()).isEqualTo(2);assertThat(line.warehouseNo()).isEqualTo("RELABEL-IN");}); + } + + /** 同实物分成两个预约时只消费各自份额,多条订单商品资料不得重复箱数。 */ + @Test void twoAppointmentsConsumeOnePhysicalSourceExactlyOncePerAllocation() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(1000,'101','SHIP-B','REF-B'),(1000,'101','SHIP-A','REF-A')"); + f.appointment(1," ISA-A ","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2);f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3); + var lines=f.read().bolLines();assertThat(lines).hasSize(2); + assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3); + assertThat(lines).allSatisfy(line->{assertThat(line.dispatchItemId()).isEqualTo(11L);assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.fbaShipmentId()).isEqualTo("SHIP-A, SHIP-B");}); + assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5); + } + + /** 同一明细同计划实托的两个订单各取自己的预约份额,商品JOIN不能将另一订单或整托复制过去。 */ + @Test void oneDispatchItemWithTwoOrdersAllocatesEachOriginalSourceToItsOwnAppointment() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",1); + f.db.update("UPDATE ll_dispatch_item SET package_count=5 WHERE id=11"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(120,'101',10,2000,'ONT8','ONT8','ONT8','IN-2000')"); + f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(121,'101',10,1,120,0,2,2)"); + f.db.update("INSERT INTO ll_order VALUES(2000,'101','CN-2000')"); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A1','REF-A1'),(1000,'101','SHIP-A2','REF-A2'),(1000,'101','SHIP-A1','REF-A1'),(2000,'101','SHIP-B1','REF-B1'),(2000,'101','SHIP-B2','REF-B2')"); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:2000",2000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(11,1,2,"101","ORDER:2000","FBA:ONT8",2); + var lines=f.read().bolLines();assertThat(lines).hasSize(2); + assertThat(lines).extracting(DispatchBolData.Line::dispatchItemId).containsExactly(11L,11L); + assertThat(lines).extracting(DispatchBolData.Line::orderId).containsExactly(1000L,2000L); + assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,2); + assertThat(lines).extracting(DispatchBolData.Line::warehouseNo).containsExactly("IN-1000","IN-2000"); + assertThat(lines).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A1, SHIP-A2","SHIP-B1, SHIP-B2"); + assertThat(lines).extracting(DispatchBolData.Line::poReferenceNo).containsExactly("REF-A1, REF-A2","REF-B1, REF-B2"); + assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5); + } + + /** BOOKING来源通过原预约item的持久order_id关联,不把真实稳定键改拼成ORDER。 */ + @Test void bookingSourceUsesPersistedAppointmentOrderMapping() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"BOOKING-ISA","101","BOOKING:EXT-REAL",1000,"ONT8"); + f.allocate(11,1,1,"101","BOOKING:EXT-REAL","FBA:ONT8",5); + assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.appointmentNo()).isEqualTo("BOOKING-ISA");assertThat(line.packageCount()).isEqualTo(5);}); + } + + /** 同外部约号、同目的地的不同客户保留各自份额,但共用文档键。 */ + @Test void sameAppointmentAcrossCustomersSharesDocumentWithoutLosingOwnership() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0); + f.appointment(1,"SHARED-ISA","101","ORDER:1000",1000,"ONT8");f.appointment(2," SHARED-ISA ","102","ORDER:2000",2000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:ONT8",5); + var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::customerId).containsExactly("101","102"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);assertThat(lines.stream().map(DispatchBolData.Line::groupKey).distinct().count()).isOne(); + } + + /** 同约号不同目的地仍分文档;缺约号不以系统预约单号补充。 */ + @Test void destinationSeparatesSharedNumbersAndBlankConfirmationRemainsPrintable() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"LAX9","RECEIVED",0); + f.appointment(1,"SAME","101","ORDER:1000",1000,"ONT8");f.appointment(2,"SAME","102","ORDER:2000",2000,"LAX9"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:LAX9",5); + assertThat(f.read().bolLines().stream().map(DispatchBolData.Line::groupKey).distinct().count()).isEqualTo(2); + f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no=' '"); + assertThat(f.read().bolLines()).allSatisfy(line->{assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isPositive();}); + } + + /** 未约余量有真实来源数量依据,空ISA保留;不能把全托复制到已有预约。 */ + @Test void unassignedRemainderKeepsItsOwnQuantityAndBlankAppointment() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2); + var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3); + assertThat(lines.get(0).appointmentNo()).isEqualTo("ISA-A");assertThat(lines.get(1).appointmentNo()).isNull(); + } + + /** PLAN用计划箱数,RECEIVED用清点箱数;部分扫码量从不替代原调度份额。 */ + @Test void plannedAndReceivedCountsMatchTheirAuthoritativeSourceStage() throws Exception { + for(var stage:List.of("PLAN","RECEIVED")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8",stage,1); + if(stage.equals("PLAN"))f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=2"); + else f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=9"); + assertThat(f.read().bolLines()).singleElement().satisfies(line->assertThat(line.packageCount()).isEqualTo(5)); + } + } + + /** 来源合计与本车选择量不一致或数量未知时失败,不以整托/扫码常数补齐。 */ + @Test void missingCountsAndChangedSelectedTotalsFailClosed() throws Exception { + for(var change:List.of("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL", + "UPDATE ll_dispatch_item SET package_count=4")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1);f.db.update(change); + assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + } + } + + /** 明细、客户、来源、目的地、原计划与预约item任一不匹配均不能取得打印份额。 */ + @Test void foreignOrIncompleteAllocationCannotBorrowAnotherSource() throws Exception { + for(var fault:List.of("customer","item","plan","source","destination","appointment-customer","order","missing-order","missing-batch")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"ISA","101","BOOKING:REAL",1000,"ONT8");f.allocate(11,1,1,"101","BOOKING:REAL","FBA:ONT8",5); + f.db.update(switch(fault) { + case "customer" -> "UPDATE ll_dispatch_appointment_allocation SET customer_id='FOREIGN'"; + case "item" -> "UPDATE ll_dispatch_appointment_allocation SET dispatch_item_id=99"; + case "plan" -> "UPDATE ll_dispatch_appointment_allocation SET plan_pallet_id=99"; + case "source" -> "UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:1000'"; + case "destination" -> "UPDATE ll_dispatch_appointment_allocation SET destination_key='FBA:LAX9'"; + case "appointment-customer" -> "UPDATE ll_inbound_fba_appointment_item SET customer_id='FOREIGN'"; + case "order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=9999"; + case "missing-order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=NULL"; + default -> "DELETE FROM ll_inbound_fba_appointment"; + }); + assertThatThrownBy(f::read).as(fault).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + } + } + + /** 单份超量和多份累计超量均失败,原货物和分配记录不被读取操作修复。 */ + @Test void individualAndCumulativeOverAllocationFailWithoutChangingFacts() throws Exception { + for(boolean cumulative:List.of(false,true)) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",cumulative?3:6); + if(cumulative){f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3);} + var before=f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key"); + assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key")).isEqualTo(before); + } + } + + /** 测试表仅取完整模块DDL中实际引用列,保留来源/分配唯一键,事务覆盖两个真实Mapper查询。 */ private static final class Fixture { - final JdbcTemplate db;final DispatchBolReadService service; + final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx; + /** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */ Fixture() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds); - db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)"); - db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(128),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),source_stage VARCHAR(32),order_id BIGINT,destination_type VARCHAR(32),delivery_address VARCHAR(1000),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT,counted_package_count INT,UNIQUE(line_id,pallet_id))"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,order_id BIGINT,fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),UNIQUE(plan_id,order_id))"); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(255),fba_tracking_no VARCHAR(255))"); + db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL,plan_pallet_id BIGINT NOT NULL,batch_key VARCHAR(96) NOT NULL,customer_id VARCHAR(64) NOT NULL,source_key VARCHAR(160) NOT NULL,destination_key VARCHAR(160) NOT NULL,package_count INT NOT NULL,PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))"); var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); - var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper()); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row); + service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper()); + tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + } + /** 本地一致事务读取,不接触任何环境或远程文件。 */ + DispatchBolData read(){return tx.execute(status->service.read("DP-ONE"));} + /** 创建一票真实计划来源;picked与原调度/清点数量有意独立。 */ + void source(long item,String customer,long pallet,long order,int packages,String code,String stage,int picked) { + long plan=pallet*10,line=item*10; + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",pallet,plan);db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)",plan,customer,"CN-"+order); + db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?,?,?,?,?)",line,customer,plan,order,code,code,code,"IN-"+order); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?,?,?,?)",line+1,customer,plan,pallet,line,picked,packages,packages); + db.update("INSERT INTO ll_order VALUES(?,?,?)",order,customer,"CN-"+order); + db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,NULL,NULL,?,?, 'FBA',NULL,NULL,?,?,?,?,?,?)",item,customer,pallet,stage,order,"P-"+pallet,code,code,code,"IN-"+order,packages); + } + /** 系统预约业务号与对外确认号分开,稳定来源键原样保存。 */ + void appointment(long id,String confirmation,String customer,String source,long order,String code) { + db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,?)",id,"SYSTEM-APPOINTMENT-"+id,confirmation); + db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(?,?,?,?,?,?)",id,id,source,customer,order,code); + } + /** 按模块实际主键建立本车明细的真实逐票分配,不以客户目的地总量替代。 */ + void allocate(long item,long pallet,long appointment,String customer,String source,String destination,int packages) { + db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,pallet,"FBA_APPOINTMENT:"+appointment,customer,source,destination,packages); } - void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);} } } diff --git a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java index 213c1a826..badf3f26e 100644 --- a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java +++ b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java @@ -64,6 +64,7 @@ public class DispatchBolPdfService { return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents)); } + /** 核对页面容量并规范化打印字段,预约号缺失时保持空白。 */ private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) { int maxRows = value != null && value.pageNumber() == 1 ? MAX_FIRST_PAGE_ROWS : MAX_CONTINUATION_PAGE_ROWS; if (value == null || value.cargoRows() == null || value.cargoRows().isEmpty() || value.cargoRows().size() > maxRows) { @@ -77,15 +78,22 @@ public class DispatchBolPdfService { text(row.warehouseNo()), text(row.fbaShipmentId()), text(row.poReferenceNo()), row.packageCount())); } return new DispatchBolPdfDtos.Document(text(value.shipFromName()), text(value.shipFromAddress()), text(value.carrierName()), - text(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()), + optionalText(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()), text(value.fbaWarehouseCode()), text(value.shipToName()), text(value.shipToAddress()), text(value.dispatchNo()), text(value.fbaShipmentIds()), text(value.poReferenceNos()), value.totalPackages(), value.pageNumber(), value.pageCount(), List.copyOf(rows)); } + /** 普通缺失字段沿用占位符,预约号通过可空文本单独处理。 */ private String text(String value) { + String normalized = optionalText(value); + return normalized.isBlank() ? "—" : normalized; + } + + /** 保留空白语义,同时复用长度、规范化和控制字符校验。 */ + private String optionalText(String value) { String normalized = line(value); if (normalized.codePointCount(0, normalized.length()) > 300 || normalized.codePoints().anyMatch(c -> Character.isISOControl(c))) invalid("document text is invalid"); - return normalized.isBlank() ? "—" : normalized; + return normalized; } private String line(String value) { return value == null ? "" : Normalizer.normalize(value, Normalizer.Form.NFC).strip(); } private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); } diff --git a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java index 7e8aaf050..cc1f744bf 100644 --- a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java @@ -1,6 +1,7 @@ package com.baiyue.print.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.baiyue.print.config.PrintPdfProperties; import com.baiyue.print.dto.DispatchBolPdfDtos; @@ -73,6 +74,45 @@ class DispatchBolPdfServiceGenerationTest { "FBA19TEST", "PO-TEST", 44, pageNumber, pageCount, rows); } + /** 未预约仍能生成真实PDF,预约栏不打印占位符或null。 */ + @Test + void leavesMissingAppointmentNumbersBlankInTheRenderedPdf() throws Exception { + for (String appointment : new String[] { null, "", " " }) { + var generated = service.generate(appointmentRequest(appointment)); + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(1); + assertThat(new PDFTextStripper().getText(pdf)).contains("ISA #:", "BILL OF LADING", "PALLET-01") + .doesNotContain("ISA #: —", "ISA #: null"); + } + } + } + + /** 空值放开不削弱预约文本的长度和控制字符边界。 */ + @Test + void keepsAppointmentTextValidationAndTrimsPresentNumbers() throws Exception { + assertThatThrownBy(() -> service.generate(appointmentRequest("ISA\u0000BAD"))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + assertThatThrownBy(() -> service.generate(appointmentRequest("A".repeat(301)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + var generated = service.generate(appointmentRequest(" ISA-ONE ")); + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(new PDFTextStripper().getText(pdf)).contains("ISA #: ISA-ONE"); + } + } + + /** 只替换预约字段,其他页面参数使用既有真实渲染夹具。 */ + private DispatchBolPdfDtos.Request appointmentRequest(String appointment) { + var original = page(1, 1, rows(1, 1)); + var document = new DispatchBolPdfDtos.Document(original.shipFromName(), original.shipFromAddress(), + original.carrierName(), appointment, original.appointmentTime(), original.trailerNumber(), + original.sealNumber(), original.fbaWarehouseCode(), original.shipToName(), original.shipToAddress(), + original.dispatchNo(), original.fbaShipmentIds(), original.poReferenceNos(), original.totalPackages(), + original.pageNumber(), original.pageCount(), original.cargoRows()); + return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, + new DispatchBolPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), + "appointment-bol.pdf", List.of(document)); + } + private List rows(int start, int end) { List rows = new ArrayList<>(); for (int index = start; index <= end; index++) { diff --git a/scripts/README.md b/scripts/README.md index 08ccd5cd8..d149ea7a5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -273,7 +273,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc |---|---|---| | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | | 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 | -| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | +| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据;客户账单、海外仓自助(退货预报写链路与贴标/转运/废弃/库存/箱号只读)、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办已纳入 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c95e05789..a51961e09 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'dashboard', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -96,6 +96,7 @@ const MODULE_DEPENDENCIES = new Map([ ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents', 'phase5-overseas-fulfillment']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], + ['appointment-work', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-return-receipt', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e8d0c5e26..1df7aa5c5 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -273,7 +273,13 @@ export async function run(context) { | 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed | | Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | +| 预约工作日历与工作事项 | `appointment-work.mjs` | `appointment-work-contract.test.mjs`;默认只读核对月历、汇总、分页、详情与不存在事项拒绝,正向命令待本轮可编辑工作项接入 | +| PDA 版本发布 | `pda-releases.mjs` | `pda-audit-sailing-contract.test.mjs`;只读双端发布列表与空上传/不存在版本拒绝,真实 APK/WGT 发布需人工准备安装包 | +| 平台操作审计 | `audit.mjs` | `pda-audit-sailing-contract.test.mjs`;分页查询、列表不返回快照、详情与不存在日志拒绝 | +| 船期日历 | `sailing-calendar.mjs` | `pda-audit-sailing-contract.test.mjs`;月历、渠道、模板与异常分配只读核对 | | 首页实时指标 | `dashboard.mjs` | `dashboard-contract.test.mjs`;默认只读核对实时柜量/清关指标、今日新运单及启用客户的服务端分页总数 | +| 自营经营分析 | `self-operated-analytics.mjs` | `fleet-analytics-contract.test.mjs`;空参探测看板/货主目录,缺采集账号上下文时记录非阻断缺口,不虚构指标 | +| 车队车次与出车记录 | `fleet-management.mjs` | `fleet-analytics-contract.test.mjs`;只读核对车次列表/详情、出车记录、车队报价与拼车需求,车次写命令待本轮排车资源接入 | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | @@ -442,4 +448,37 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra 下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。 +## 新增模块的授权后定向运行清单 + +以下模块已注册 `CONFIGURED_MODULES`,离线合同通过;真实 HTTP 仍需当前轮测试环境授权和对应业务前置。默认全量已包含它们,定向排查时按模块名运行,依赖仍由总入口展开。 + +| 模块 | 定向命令 | 关键前置 | 报告校验点 | +|---|---|---|---| +| `appointment-work` | `--modules phase4-carrier-pickup-ready,appointment-work` | 本轮柜、船节点或排车产生工作事项 | 日历/汇总/分页/详情结构;不存在事项命令拒绝 | +| `pda-releases` | `--modules pda-releases` | 平台 `platform:pda-release:list` | 双端发布列表结构;空上传与不存在版本拒绝 | +| `audit` | `--modules audit` | 平台 `platform:audit:list`,且本轮已有业务写入 | 分页、列表不返回快照、详情与不存在日志拒绝 | +| `sailing-calendar` | `--modules sailing-calendar` | 当月已有订舱或模板生成的船期 | 月历、渠道、模板、异常分配;无船期时记录非阻断缺口 | +| `self-operated-analytics` | `--modules self-operated-analytics` | 采集账号与仓库上下文 | 空参不可用时记录非阻断缺口,不虚构指标 | +| `fleet-management` | `--modules phase4-overseas-master-data,fleet-management` | 本轮车次或排车样本 | 车次、出车记录、报价、拼车需求只读;无车次时记录缺口 | + +Client 全量为单文件,新增的客户账单、海外仓自助、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办随默认全量执行,不能按模块定向。 + +上述模块与 Client 新增端点已按当前 Controller 静态核对存在,包括 `/api/merchant/v1/customs/pending-summary`、`/orders/{orderId}`、`/first-leg/pricing/channels`、`/api/merchant/v1/warehouses`、`/inbound-plans`、`/delivery-orders`、`/api/platform/v1/files/pda-releases` 和 `fleet/operations|quotes|capacity-offers`;真实响应结构仍以 HTTP 验收为准。所需平台权限(`platform:appointment:list`、`platform:pda-release:list`、`platform:audit:list`、`platform:firstleg:sailing-calendar:list`、`platform:self-operated-analytics:list`、`platform:fleet:*:list`)已在 IAM 与服务权限种子中定义并授予 `PLATFORM_ADMIN`,HTTP 403 不应被误判为脚本失败。 + +已知待验收缺口(不计作已覆盖通过): + +- `phase4-overseas-dispatch` 要求两个 FBA 目的地及同目的地双实托;默认主链当前只产生单目的地(9/23 证据 `destinations=ABE4`),前置不足按 `notRun` 阻断,报告中 `dispatchStatus=NOT_RUN`,并连带 `phase5-overseas-fulfillment` 阻断。补齐主链多目的仓样本后重跑。 +- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID。 +- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次。 +- `fleet-management` 车次写命令需要本轮排车、车辆与司机资源。 +- Client 海外仓贴标、仓储转运、货物废弃和退货换标的创建链路需要平台海外 PDA 链产出的库存;当前只覆盖客户范围列表和退货预报写链路。 +- `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。 + +## 本地离线合同运行边界 + +- 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。 +- `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。 +- `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。 +- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。 + 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs index a202b246b..dbd698b8c 100644 --- a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -65,3 +65,9 @@ test('未部署时能力探测后记录部署前置,不借历史业务数据', assert.match(gaps[0][3], /部署后重跑完整链/) assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false) }) + +test('同参导出只合并进行中任务,已完成任务允许按同参重新导出', () => { + assert.match(source, /60 秒同参请求合并/) + assert.match(source, /String\(originalStatus\) === 'SUCCEEDED'/) + assert.match(source, /File 只合并 PENDING\/RUNNING 的同参任务/) +}) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 2f45e3a45..71b91494f 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -127,8 +127,13 @@ async function createTask(context, type, parameters, suffix, expectedHeaders, ex const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body, context.token, { 'Idempotency-Key': idempotencyKey }) const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) - context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, - `original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) + const originalRead = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`) + const originalStatus = originalRead.body?.data?.status + context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, + // File 只合并 PENDING/RUNNING 的同参任务;已完成任务允许按同参重新导出。 + id(duplicate?.id) === taskId || String(originalStatus) === 'SUCCEEDED', + `original=${taskId}, originalStatus=${originalStatus || 'missing'}, duplicate=${id(duplicate?.id) || 'missing'}`, + OPTIONS) const listResponse = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=100&exportType=${type}`) const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index 16ec5aa51..a98dc4500 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -101,6 +101,8 @@ export function markerCandidates(context, candidates) { } export function notRun(context, proof, reason) { proof.status = 'NOT_RUN'; proof.reason = reason + // 未运行时保留既有终态;否则显式标记 NOT_RUN,便于下游区分前置不足与代码失败。 + proof.finalStatus ??= 'NOT_RUN' context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true }) return proof } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6f9b19c34..36215c2d6 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -706,7 +706,7 @@ test('container overview always follows phase3 loading in default and custom mod const entry = source('../admin-test-env-acceptance.mjs') assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/) assert.match(entry, /const modules = normalizeModuleOrder\(/) - assert.match(entry, /return \{[\s\S]{0,500}modules,/) + assert.match(entry, /return \{[\s\S]{0,1200}modules,/) }) test('overview does not fall back to a container fixture when loading evidence was requested', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 5603063c8..e2ddb9e05 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -311,3 +311,11 @@ test('platform scope zero completes the same detail and manual-node lifecycle as assert.ok(fixture.assertions.every((check) => check.passed)) assert.ok(fixture.steps.every((check) => check.passed)) }) + +test('还柜终态拆分为节点覆盖与停止语义,人工节点下限扣除出仓自动节点和拖车回写节点', () => { + assert.match(source, /拖车业务回写后覆盖十标准实际节点/) + assert.match(source, /还柜终态停止语义与本轮货柜关联/) + assert.match(source, /STANDARD_NODES\.length - 2/) + assert.match(source, /stopReason=\$\{finalTask\.stopReason/) + assert.doesNotMatch(source, /覆盖十标准实际节点且EMPTY_RETURNED停止/) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index fe9d53590..e1e7ad0d9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -348,16 +348,23 @@ export async function run(context) { // PICKED_UP; the immutable carrier history therefore contains the eight // pre-pickup nodes plus EMPTY_RETURNED. const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP') - context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, - carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' - && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' + context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点', detailResponse, + carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)), + `actualNodes=${[...actualNodes].join(',')}, missing=${carrierNodesWithoutInferredPickup.filter((node) => !actualNodes.has(node)).join(',')}`, + OPTIONS) + context.assert(MODULE, '还柜终态停止语义与本轮货柜关联', detailResponse, + finalTask.status === 'STOPPED' && finalTask.latestActualNode === 'EMPTY_RETURNED' + && finalTask.stopReason === '已还柜' && id(finalTask.containerBookingId) === id(drayage.containerBookingId), - `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) + `status=${finalTask.status}, latest=${finalTask.latestActualNode || 'none'}, ` + + `stopReason=${finalTask.stopReason || 'none'}, taskBooking=${id(finalTask.containerBookingId)}, ` + + `drayageBooking=${id(drayage.containerBookingId)}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) - && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 1, + // 首个标准节点由国内出仓自动写入,PICKED_UP 由码头拖车业务回写,人工补录最多 8 条。 + && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 2, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) } diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 49f14f071..0f2a739c1 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -41,7 +41,13 @@ $requiredTests = @( "firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java", "firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java", "warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java", - "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java" + "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java", + "audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java", + "file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java" ) $mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test") diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 2b8daf794..6474dd4a3 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -504,3 +504,77 @@ test('F04 still rejects a numeric error row pointing to the successful row', asy const { r } = importHarness('wrong-error-row') await assert.rejects(() => r.orderExcelImportModule(), /actual Excel error row/) }) + +test('F01 client bills module covers customer-only read APIs and rejects unknown documents', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /async function merchantFinanceBillsModule\(\)/) + assert.match(source, /await merchantFinanceBillsModule\(\)/) + const callOrder = [ + source.indexOf('await orderSubmissionChannelGateModule()'), + source.indexOf('await merchantFinanceBillsModule()'), + source.indexOf('await notificationInboxModule()'), + ] + assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], + 'bills module must run after channel gate and before notifications') + assert.match(source, /const base = '\/api\/client\/finance\/v1'/) + for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /customerId=0&tenantId=0/) + assert.match(source, /customer bills ignore caller-supplied customer scope/) + assert.match(source, /cross-customer bill detail stays outside the other enterprise/) + assert.match(source, /unknown statement confirmation is rejected with idempotency key/) + assert.match(source, /customer receipts are confirmed-only and customer-scoped/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + +test('F02 client overseas self-service covers customer-scoped lists and the return forecast write chain', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /async function merchantOverseasWarehouseModule\(\)/) + assert.match(source, /await merchantOverseasWarehouseModule\(\)/) + const callOrder = [ + source.indexOf('await merchantFinanceBillsModule()'), + source.indexOf('await merchantOverseasWarehouseModule()'), + source.indexOf('await notificationInboxModule()'), + ] + assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], + 'overseas self-service must run after bills and before notifications') + for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications', + '/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary', + '/return-inventory/cartons']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /create return forecast with marker-owned payload/) + assert.match(source, /same idempotency key replays the same return order/) + assert.match(source, /return order appears in the customer list/) + assert.match(source, /cross-customer return order is rejected/) + assert.match(source, /overseasWarehouseReadOnlyGaps/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + +test('F03 client cross-border, warehouse orders and pricing/customs cover current customer pages', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + for (const name of ['merchantCrossBorderModule', 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule']) { + assert.match(source, new RegExp(`async function ${name}\\(\\)`), name) + assert.match(source, new RegExp(`await ${name}\\(\\)`), name) + } + const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule', + 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule', 'notificationInboxModule'] + .map((name) => source.indexOf(`await ${name}()`)) + assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]), + 'new client modules must run in order before notifications') + for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements', + '/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary', + '/customs/orders/']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /same idempotency key replays the same US truck order/) + assert.match(source, /same idempotency key replays the same looseboard order/) + assert.match(source, /same idempotency key replays the same procurement order/) + assert.match(source, /customer inbound plans are customer-scoped/) + assert.match(source, /customer delivery orders are customer-scoped/) + assert.match(source, /customer customs pending summary is readable/) + assert.match(source, /crossBorderLimitations/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 51fd75776..24090611b 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -108,6 +108,11 @@ try { await orderMutationEvidenceModule() await orderExcelImportModule() await orderSubmissionChannelGateModule() + await merchantFinanceBillsModule() + await merchantOverseasWarehouseModule() + await merchantCrossBorderModule() + await merchantWarehouseOrdersModule() + await merchantPricingCustomsModule() await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) @@ -1490,6 +1495,520 @@ async function isolationModule() { }, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) } +async function merchantFinanceBillsModule() { + const module = 'merchant-bills' + const base = '/api/client/finance/v1' + const customerId = String(report.resources.tenantId) + const requireArray = (json, label) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data), `${label} must be an array`) + return json.data + } + const bills = await apiStep(module, 'customer bills ignore caller-supplied customer scope', { + path: `${base}/bills?customerId=0&tenantId=0`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'bills') + for (const row of rows) { + assertEqual(String(row.customerId), customerId, 'bill owner must be the authenticated customer') + assertPresent(row.billNo, 'billNo') + assertPresent(row.customerName, 'customerName') + assertTrue(['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(row.status), `unexpected visible bill status ${row.status}`) + assertTrue(Number.isSafeInteger(Number(row.version)), 'bill version required') + assertTrue(['NOT_REQUIRED', 'PENDING_CONFIRMATION', 'CONFIRMED'].includes(row.customerConfirmationStatus), + 'customer confirmation status required') + } + }, ({ json }) => ({ customerId, count: json.data?.length ?? null, scope: 'authenticated-enterprise-only' })) + const statements = await apiStep(module, 'customer statements expose the same visible bills', { + path: `${base}/statements`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'statements') + assertEqual(rows.length, bills.json.data.length, 'bills and statements must expose the same visible set') + for (const row of rows) assertEqual(String(row.customerId), customerId, 'statement owner must be the authenticated customer') + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'customer receipts are confirmed-only and customer-scoped', { + path: `${base}/receipts`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'receipts') + for (const row of rows) { + assertPresent(row.receiptNo, 'receiptNo') + assertTrue(Number.isFinite(Number(row.receivedAmount)), 'receipt amount required') + assertEqual(row.status, 'CONFIRMED', 'only confirmed receipts are customer-visible') + } + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'customer receivable settlements are readable', { + path: `${base}/receivable-settlements`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'receivable settlements') + for (const row of rows) { + assertPresent(row.settlementNo, 'settlementNo') + assertEqual(String(row.customerId), customerId, 'settlement owner must be the authenticated customer') + assertTrue(Number.isFinite(Number(row.receivableAmount)), 'receivable amount required') + } + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'unknown bill id is rejected without cross-customer disclosure', { + path: `${base}/bills/999999999999999`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown bill must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + await apiStep(module, 'unknown statement confirmation is rejected with idempotency key', { + method: 'POST', path: `${base}/statements/999999999999999/confirm`, token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('bill-confirm-negative') }, + body: { expectedVersion: 1 }, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown statement must not confirm, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + if (existingClientToken) { + await apiStep(module, 'cross-customer bill detail stays outside the other enterprise', { + path: `${base}/bills/999999999999999`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise bill must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer bill detail stays outside the other enterprise', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + report.merchantFinanceBills = { + customerId, + bills: bills.json.data.length, + statements: statements.json.data.length, + scope: 'read-only; statement confirmation with real issued bills is covered when Admin finance chain runs first', + } +} + +async function merchantOverseasWarehouseModule() { + const module = 'merchant-overseas-warehouse' + const base = '/api/merchant/v1/last-leg' + const page = 'page=1&size=20' + const listStep = async (label, path, validateRow) => { + const response = await apiStep(module, label, { path, token: merchantToken }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) + assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) + for (const row of json.data.records) validateRow?.(row) + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + return response.json.data + } + + const warehouseResponse = await apiStep(module, 'customer reads active overseas warehouses', { + path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'at least one active overseas warehouse required') + assertTrue(json.data.records.every((row) => row.status === 'ACTIVE' && row.warehouseType === 'OVERSEAS'), + 'customer warehouse list must contain only active overseas warehouses') + }, ({ json }) => ({ count: json.data?.records?.length ?? null })) + const warehouse = warehouseResponse.json.data.records[0] + + await listStep('return forecasts are customer-scoped', `${base}/return-orders?${page}`, (row) => { + assertPresent(row.returnNo, 'returnNo') + assertPresent(row.status, 'return status') + }) + await listStep('relabel applications are customer-scoped', `${base}/relabel-applications?${page}`, (row) => { + assertPresent(row.relabelNo, 'relabelNo') + assertTrue(Number.isFinite(Number(row.totalPackageCount)), 'relabel package count required') + }) + await listStep('warehouse transfers are customer-scoped', `${base}/warehouse-transfers?${page}`, (row) => { + assertPresent(row.transferNo, 'transferNo') + assertPresent(row.status, 'transfer status') + }) + await listStep('cargo discards are customer-scoped', `${base}/cargo-discards?${page}`, (row) => { + assertPresent(row.discardNo, 'discardNo') + assertTrue(Number.isFinite(Number(row.totalPackages)), 'discard package count required') + }) + await listStep('return inventory is customer-scoped', `${base}/return-inventory?${page}`, (row) => { + assertPresent(row.cartonMark, 'cartonMark') + assertTrue(Number.isFinite(Number(row.quantity)), 'return inventory quantity required') + }) + await listStep('return inventory summary is customer-scoped', `${base}/return-inventory/summary?${page}`, (row) => { + assertPresent(row.skuCode, 'summary skuCode') + assertTrue(Number.isFinite(Number(row.availableQuantity)), 'available quantity required') + }) + await listStep('return cartons are customer-scoped', `${base}/return-inventory/cartons?${page}`, (row) => { + assertPresent(row.cartonMark, 'cartonMark') + assertPresent(row.status, 'carton status') + }) + + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-20) + const sku = `ACCEPT-${suffix}` + const createPayload = { + overseasWarehouseId: String(warehouse.id), + warehouseContactName: '验收联系人', + warehouseContactPhone: '13800000000', + customerReferenceNo: `RET-${suffix}`, + needRelabel: false, + remark: `${marker} client overseas self-service acceptance`, + idempotencyKey: newIdempotencyKey('return-order'), + items: [{ skuCode: sku, productNameZh: '验收测试货物', expectedQuantity: 1 }], + } + const created = await apiStep(module, 'create return forecast with marker-owned payload', { + method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.order?.returnNo, 'returnNo') + assertEqual(String(json.data.order.overseasWarehouseId), String(warehouse.id), 'return order warehouse') + assertTrue(Array.isArray(json.data.items) && json.data.items.length === 1 + && json.data.items[0].skuCode === sku && Number(json.data.items[0].expectedQuantity) === 1, + 'return order items echo the marker payload') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, status: json.data?.order?.status ?? null })) + const returnNo = created.json.data.order.returnNo + report.resources.returnOrderNos = [returnNo] + + await apiStep(module, 'same idempotency key replays the same return order', { + method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.returnNo, returnNo, 'replayed returnNo must match the original') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null })) + + await apiStep(module, 'return order detail keeps the marker payload', { + path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.returnNo, returnNo, 'detail returnNo') + assertTrue(Array.isArray(json.data.items) && json.data.items.some((item) => item.skuCode === sku), + 'detail keeps the submitted sku') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, items: json.data?.items?.length ?? null })) + + await apiStep(module, 'return order appears in the customer list', { + path: `${base}/return-orders?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(json.data.records.some((row) => String(row.returnNo) === String(returnNo)), + 'new return order must appear in the first customer page') + }, ({ json }) => ({ total: json.data?.total ?? null, returnNo })) + + await apiStep(module, 'unknown return order is rejected', { + path: `${base}/return-orders/${encodeURIComponent(`RET-MISSING-${suffix}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown return order must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + + if (existingClientToken) { + await apiStep(module, 'cross-customer return order is rejected', { + path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise return order must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer return order is rejected', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + + report.overseasWarehouseReadOnlyGaps = [ + 'Relabel, transfer and cargo-discard creation require inventory produced by the platform overseas PDA chain; this run only covers their customer-scoped lists and the return forecast write chain.', + 'Return relabel creation, completion confirmation and document downloads require received return cartons produced by the PDA return receipt chain.', + ] +} + +async function merchantCrossBorderModule() { + const module = 'merchant-cross-border' + const truckPath = '/api/merchant/v1/last-leg/us-truck-orders' + const looseboardPath = '/api/merchant/v1/last-leg/local-looseboard-orders' + const procurementPath = '/api/merchant/v1/last-leg/overseas-procurements' + const page = 'page=1&size=20' + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-18) + const localTime = (days) => { + const date = new Date(Date.now() + days * 86_400_000) + return date.toISOString().slice(0, 19) + } + const listStep = async (label, path, validateRow) => apiStep(module, label, { path, token: merchantToken }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) + assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) + for (const row of json.data.records) validateRow?.(row) + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + await listStep('customer procurement orders are customer-scoped', `${procurementPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'procurement orderNo') + assertPresent(row.status, 'procurement status') + }) + await listStep('customer US truck orders are customer-scoped', `${truckPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'truck orderNo') + assertPresent(row.status, 'truck status') + }) + await listStep('customer local looseboard orders are customer-scoped', `${looseboardPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'looseboard orderNo') + assertPresent(row.status, 'looseboard status') + }) + + const truckPayload = { + vehicleType: 'PALLET_15', + origin: `验收起点-${suffix}`, + destination: `验收终点-${suffix}`, + packageCount: 2, + totalWeightKg: 12.5, + totalVolumeCbm: 0.8, + budgetAmount: 100, + currency: 'USD', + originContactName: '验收发货联系人', + originContactPhone: '13800000001', + destinationContactName: '验收收货联系人', + destinationContactPhone: '13800000002', + pickupAt: localTime(2), + deliveryAt: localTime(3), + remark: `${marker} client US truck acceptance`, + idempotencyKey: newIdempotencyKey('us-truck'), + } + const truck = await apiStep(module, 'customer creates a US truck order', { + method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.summary?.orderNo, 'truck orderNo') + assertTrue(['PENDING_REVIEW', 'SUBMITTED', 'DRAFT'].includes(json.data.summary.status), + `unexpected truck status ${json.data.summary.status}`) + assertTrue(Number(json.data.summary.version) >= 0, 'truck version required') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) + const truckNo = truck.json.data.summary.orderNo + report.resources.crossBorderOrderNos = [truckNo] + + await apiStep(module, 'same idempotency key replays the same US truck order', { + method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, truckNo, 'replayed truck orderNo') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + await apiStep(module, 'US truck order detail keeps the marker payload', { + path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, truckNo, 'truck detail orderNo') + assertEqual(json.data?.summary?.vehicleType, 'PALLET_15', 'truck vehicle type') + assertEqual(Number(json.data?.summary?.packageCount), 2, 'truck package count') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + const looseboardPayload = { + origin: `散板起点-${suffix}`, + destination: `散板终点-${suffix}`, + productName: '验收散板货物', + estimatedPalletCount: 1, + packageCount: 3, + totalWeightKg: 20, + totalVolumeCbm: 1.2, + budgetAmount: 80, + currency: 'USD', + originContactName: '验收发货联系人', + originContactPhone: '13800000001', + destinationContactName: '验收收货联系人', + destinationContactPhone: '13800000002', + pickupAt: localTime(2), + deliveryAt: localTime(4), + remark: `${marker} client looseboard acceptance`, + idempotencyKey: newIdempotencyKey('looseboard'), + } + const looseboard = await apiStep(module, 'customer creates a local looseboard order', { + method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.summary?.orderNo, 'looseboard orderNo') + assertTrue(Number(json.data.summary.estimatedPalletCount) === 1, 'looseboard pallet count') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) + const looseboardNo = looseboard.json.data.summary.orderNo + report.resources.crossBorderOrderNos.push(looseboardNo) + + await apiStep(module, 'same idempotency key replays the same looseboard order', { + method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, looseboardNo, 'replayed looseboard orderNo') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + const procurementPayload = { + customerName: companyName, + purchaseCategory: '验收测试', + productName: `验收采购样品-${suffix}`, + productSpecification: '1 件/箱', + purchaseQuantity: 1, + budgetUnitPrice: 10, + currency: 'CNY', + receivingAddress: '广东省深圳市南山区验收路 1 号', + contactName: '验收采购联系人', + contactPhone: '13800000003', + remark: `${marker} client procurement acceptance`, + idempotencyKey: newIdempotencyKey('procurement'), + } + const procurement = await apiStep(module, 'customer creates an overseas procurement order', { + method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.order?.orderNo, 'procurement orderNo') + assertTrue(Number(json.data.order.purchaseQuantity) === 1, 'procurement quantity') + }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null, status: json.data?.order?.status ?? null })) + const procurementNo = procurement.json.data.order.orderNo + report.resources.crossBorderOrderNos.push(procurementNo) + + await apiStep(module, 'same idempotency key replays the same procurement order', { + method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.orderNo, procurementNo, 'replayed procurement orderNo') + }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null })) + + await apiStep(module, 'created cross-border orders appear in customer lists', { + path: `${truckPath}?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(json.data.records.some((row) => String(row.orderNo) === String(truckNo)), + 'new truck order must appear in the customer list') + }, ({ json }) => ({ total: json.data?.total ?? null, truckNo })) + for (const [label, path] of [['truck', truckPath], ['looseboard', looseboardPath], ['procurement', procurementPath]]) { + await apiStep(module, `unknown ${label} order is rejected`, { + path: `${path}/${encodeURIComponent(`CB-MISSING-${suffix}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown ${label} order must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } + + if (existingClientToken) { + await apiStep(module, 'cross-customer truck order is rejected', { + path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise truck order must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer truck order is rejected', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + + report.crossBorderLimitations = [ + 'Customer creation stops at the submitted/pending platform review state; platform review, pricing, purchase execution, receipts and delivery are covered by Admin/fleet specialty entries.', + ] +} + +async function merchantWarehouseOrdersModule() { + const module = 'merchant-warehouse-orders' + const base = '/api/merchant/v1/last-leg' + const page = 'page=1&size=20' + const inbound = await apiStep(module, 'customer inbound plans are customer-scoped', { + path: `${base}/inbound-plans?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), + 'inbound plan page required') + for (const row of json.data.records) { + assertPresent(row.planNo, 'planNo') + assertPresent(row.inboundCode, 'inboundCode') + assertTrue(Number.isFinite(Number(row.packageCount)), 'inbound packageCount') + assertPresent(row.status, 'inbound status') + } + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + const delivery = await apiStep(module, 'customer delivery orders are customer-scoped', { + path: `${base}/delivery-orders?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), + 'delivery page required') + for (const row of json.data.records) { + assertPresent(row.deliveryNo, 'deliveryNo') + assertPresent(row.dispatchNo, 'dispatchNo') + assertPresent(row.status, 'delivery status') + assertTrue(Number.isFinite(Number(row.packageCount)), 'delivery packageCount') + } + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + const planNo = inbound.json.data.records[0]?.planNo + if (planNo) { + await apiStep(module, 'inbound plan detail keeps plan identity and lines', { + path: `${base}/inbound-plans/${encodeURIComponent(planNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.planNo, planNo, 'planNo') + assertPresent(json.data?.status, 'plan status') + assertTrue(Array.isArray(json.data?.lines) && Array.isArray(json.data?.pallets), 'plan lines and pallets required') + }, ({ json }) => ({ planNo: json.data?.planNo ?? null, lines: json.data?.lines?.length ?? null })) + } else { + recordSkipped(module, 'inbound plan detail keeps plan identity and lines', + 'this run customer has no inbound plan yet') + } + + const deliveryNo = delivery.json.data.records[0]?.deliveryNo + if (deliveryNo) { + await apiStep(module, 'delivery order detail keeps customer-visible fees and skus', { + path: `${base}/delivery-orders/${encodeURIComponent(deliveryNo)}?page=1&size=200`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.deliveryNo, deliveryNo, 'deliveryNo') + assertTrue(Array.isArray(json.data?.skus) && Array.isArray(json.data?.fees), 'delivery skus and fees required') + assertPresent(json.data?.order?.status, 'delivery status') + }, ({ json }) => ({ deliveryNo: json.data?.order?.deliveryNo ?? null, skus: json.data?.skus?.length ?? null })) + } else { + recordSkipped(module, 'delivery order detail keeps customer-visible fees and skus', + 'this run customer has no delivery order yet') + } + + for (const [label, path] of [['inbound plan', `${base}/inbound-plans`], ['delivery order', `${base}/delivery-orders`]]) { + await apiStep(module, `unknown ${label} is rejected`, { + path: `${path}/${encodeURIComponent(`WO-MISSING-${marker}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown ${label} must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } +} + +async function merchantPricingCustomsModule() { + const module = 'merchant-pricing-customs' + const warehouseResponse = await apiStep(module, 'customer reads active warehouses for pricing', { + path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'active warehouse required') + assertTrue(json.data.records.every((row) => row.status === 'ACTIVE'), 'only active warehouses allowed') + }, ({ json }) => ({ count: json.data?.records?.length ?? null })) + const domestic = warehouseResponse.json.data.records.find((row) => row.warehouseType !== 'OVERSEAS') + if (domestic) { + await apiStep(module, 'customer reads active pricing channels', { + path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(String(domestic.id))}`, + token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data), 'pricing channels must be an array') + for (const row of json.data) { + assertPresent(row.channelCode, 'channelCode') + assertPresent(row.priceModel, 'priceModel') + assertTrue(Array.isArray(row.pricingTaxModes), 'pricingTaxModes required') + assertTrue(Number.isFinite(Number(row.priceBookVersionId)), 'priceBookVersionId required') + } + }, ({ json }) => ({ count: json.data?.length ?? null, warehouseId: String(domestic.id) })) + } else { + recordSkipped(module, 'customer reads active pricing channels', + 'this customer has no domestic origin warehouse') + } + + await apiStep(module, 'customer customs pending summary is readable', { + path: '/api/merchant/v1/customs/pending-summary', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number.isFinite(Number(json.data?.pendingCount)), 'pendingCount required') + assertTrue(Number(json.data.pendingCount) >= 0, 'pendingCount must be non-negative') + }, ({ json }) => ({ pendingCount: json.data?.pendingCount ?? null })) + + const orderId = report.resources.orderIds[0] + if (orderId) { + await apiStep(module, 'order customs documents stay customer-scoped', { + path: `/api/merchant/v1/customs/orders/${encodeURIComponent(String(orderId))}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + if (String(json?.code) === '200') { + assertTrue(json.data && typeof json.data === 'object', 'customs payload required') + } else { + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), + `unexpected customs code ${json?.code}`) + } + }, ({ json, status }) => ({ http: status, code: json?.code ?? null, orderId: String(orderId) })) + } else { + recordSkipped(module, 'order customs documents stay customer-scoped', 'no current-run order id') + } +} + async function notificationInboxModule() { const module = 'notification-inbox' const templateBase = '/api/platform/v1/notification-templates' diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index ea0370761..4bb5e9958 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -190,3 +190,13 @@ test('Finance-only HTTP entry expands producer prerequisites and runs downloads 'finance-direct-paid-isolation', 'finance-downloads', ]) }) + +test('default Admin HTTP entry registers current-code appointment, PDA release, audit and sailing calendar modules', () => { + for (const module of ['appointment-work', 'pda-releases', 'audit', 'sailing-calendar', + 'self-operated-analytics', 'fleet-management']) { + assert.match(adminAcceptance, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's'), + `${module} must be registered in CONFIGURED_MODULES`) + assert.ok(existsSync(resolve(scriptsDir, 'admin-test-env-modules', `${module}.mjs`)), + `${module} module file is missing`) + } +})