This commit is contained in:
2026-10-20 14:22:53 +08:00
parent 19745e46c1
commit bf2508b6d6
24 changed files with 1222 additions and 88 deletions
@@ -1,35 +1,65 @@
package com.baiyue.lastleg.dispatch.mapper; package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */
@Mapper @Mapper
public interface DispatchBolMapper { public interface DispatchBolMapper {
/** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType,
default List<com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine> findByDispatchOrder(String customerId, Long dispatchOrderId) { String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode,
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(); 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," + @Select("""
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," + SELECT di.id AS dispatchItemId,
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," + AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId,
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," + di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType,
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," + di.delivery_address AS deliveryAddress,di.postal_code AS postalCode,
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," + COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo,
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," + MAX(NULLIF(di.pallet_no,'')) AS palletNo,
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " + COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')),
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " + MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode,
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " + COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName,
"FROM ll_dispatch_item di " + COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo,
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId,
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo,
"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' " + CASE WHEN l.id IS NULL THEN MAX(di.package_count)
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count)
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + ELSE MAX(a.planned_package_count) END AS packageCount,
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + MAX(di.package_count) AS itemPackageCount,
"WHERE di.dispatch_order_id=#{dispatchOrderId} " + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id") AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); 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<Cargo> 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<Allocation> findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId);
} }
@@ -3,13 +3,17 @@ import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ /** 打印服务内部一致快照,不恢复旧排车JSON协议。 */
public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName,
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines,Map<String,String> appointmentNumbers) { String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines) {
public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, 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 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 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 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());}
} }
@@ -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 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(); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion()); 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); verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row); 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("仅装车前且未扫码的有效排车可以调整货物"); 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<Action,Gate> actions(Actor actor,DispatchAggregate row) { Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); var gates=new EnumMap<Action,Gate>(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()) { for(var action:Action.values()) {
boolean state=switch(action) { boolean state=switch(action) {
case CREATE -> actor.mode()==ActorMode.PLATFORM; 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 DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
@@ -32,6 +32,7 @@ public class DispatchBolPrintService {
@Resource private DomesticWarehouseDirectoryClient warehouses; @Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
/** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */
public GeneratedPdf generate(String dispatchNo, String requestId) { public GeneratedPdf generate(String dispatchNo, String requestId) {
DispatchBolData detail = dispatches.read(dispatchNo); DispatchBolData detail = dispatches.read(dispatchNo);
if (detail.bolLines() == null || detail.bolLines().isEmpty()) { if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
@@ -54,6 +55,7 @@ public class DispatchBolPrintService {
} }
} }
/** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */
private List<PrintPdfClient.Document> documents(DispatchBolData detail) { private List<PrintPdfClient.Document> documents(DispatchBolData detail) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>(); Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>();
@@ -72,7 +74,7 @@ public class DispatchBolPrintService {
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), .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(); text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), 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), text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
total, page + 1, pages, rows)); total, page + 1, pages, rows));
offset = end; offset = end;
@@ -1,31 +1,92 @@
package com.baiyue.lastleg.dispatch.service; package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*; import org.springframework.transaction.annotation.*;
import java.time.*; import java.time.*;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ /** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */
@Service @Service
public class DispatchBolReadService { public class DispatchBolReadService {
private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json;
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=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) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchBolData read(String number) { public DispatchBolData read(String number) {
var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); 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);} 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()); var cargo=new LinkedHashMap<Source,DispatchBolData.Line>();var totals=new HashMap<Long,Integer>();var pallets=new HashMap<Long,Long>();
if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); for(var raw:facts.cargo(row.getId())) {
var confirmations=new LinkedHashMap<String,LinkedHashSet<String>>(); int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages);
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()); if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null
var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); || raw.originalPlan() && raw.orderId()==null)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(), int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total);
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); 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<Long,Integer>();
cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact));
if(!sourceTotals.equals(totals))throw invalid();
var remaining=new LinkedHashMap<Source,Integer>();cargo.forEach((key,line)->remaining.put(key,line.packageCount()));
var seen=new HashSet<AllocationKey>();var result=new ArrayList<DispatchBolData.Line>();
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<Long,Integer>();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(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<String>();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货物来源、预约归属或分配箱数尚未核实");}
} }
@@ -874,7 +874,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol:
get: get:
summary: 生成排车 BOL PDF 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: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
responses: responses:
@@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest {
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
DriverEvidenceFixture() throws Exception { DriverEvidenceFixture() throws Exception {
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); 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); 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"); 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("核平"); 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"); 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 { @Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
@@ -10,7 +10,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.PrintPdfClient; import com.baiyue.lastleg.client.PrintPdfClient;
import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import java.util.Map; import java.util.ArrayList;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -33,11 +33,13 @@ class DispatchBolPrintServiceTest {
@InjectMocks private DispatchBolPrintService service; @InjectMocks private DispatchBolPrintService service;
@BeforeEach @BeforeEach
/** 设置本次受控打印调用的内部身份。 */
void setUp() { void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); ReflectionTestUtils.setField(service, "internalToken", "internal-test-token");
} }
@Test @Test
/** 核对首页十八行及续页二十六行的全部分页边界。 */
void usesDenseFirstAndContinuationPageCapacities() { void usesDenseFirstAndContinuationPageCapacities() {
assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1);
assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1); assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1);
@@ -49,10 +51,11 @@ class DispatchBolPrintServiceTest {
} }
@Test @Test
/** 保留从实际起运仓主档解析营业地址的打印行为。 */
void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() {
String warehouseId = "193000000000200003"; String warehouseId = "193000000000200003";
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, 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", var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null); null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
@@ -74,10 +77,11 @@ class DispatchBolPrintServiceTest {
} }
@Test @Test
/** 无预约号时不同目的仓仍分别生成各自BOL。 */
void createsOneBolDocumentPerFbaWarehouse() { void createsOneBolDocumentPerFbaWarehouse() {
String warehouseId = "193000000000200003"; String warehouseId = "193000000000200003";
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, 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, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
@@ -96,21 +100,91 @@ class DispatchBolPrintServiceTest {
.containsExactly("ABE2", "ONT8"); .containsExactly("ABE2", "ONT8");
} }
@Test @Test
void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() { /** 同仓同号合并客户,同时保留各自入仓码、托盘与箱数。 */
var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5); void mergesCustomersWithTheSameDestinationAndAppointmentWithoutLosingTheirCargo() {
var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second), var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ABE2","IN-B",5,"CONF-SHARED")),1);
Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B")); 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<DispatchBolData.Line>();
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<DispatchBolData.Line> lines,int pages) {
var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,lines);
when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null); 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(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))); when(print.generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
service.generate(detail.dispatchNo(),"two-customers"); .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); var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class);
verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture()); verify(print).generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),request.capture());
assertThat(request.getValue().documents()).hasSize(2); return request.getValue();
assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B");
} }
/** 新快照携带真实明细及原订单身份,不恢复旧预约号映射。 */
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) { 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);
} }
} }
@@ -1,41 +1,214 @@
package com.baiyue.lastleg.dispatch.service; package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; 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.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; 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.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource; 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 java.util.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */
class DispatchBolReadPersistenceTest { class DispatchBolReadPersistenceTest {
/** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */
@Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception { @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')"); 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_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_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')");
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')"); var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5); assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,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()).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A","SHIP-B");
assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey()); 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 { @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)"); var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8);
f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2); 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.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 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 { private static final class Fixture {
final JdbcTemplate db;final DispatchBolReadService service; final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;
/** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */
Fixture() throws Exception { Fixture() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds); 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_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,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_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 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);}
} }
} }
@@ -64,6 +64,7 @@ public class DispatchBolPdfService {
return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents)); return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents));
} }
/** 核对页面容量并规范化打印字段,预约号缺失时保持空白。 */
private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) { private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) {
int maxRows = value != null && value.pageNumber() == 1 ? MAX_FIRST_PAGE_ROWS : MAX_CONTINUATION_PAGE_ROWS; 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) { 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())); text(row.warehouseNo()), text(row.fbaShipmentId()), text(row.poReferenceNo()), row.packageCount()));
} }
return new DispatchBolPdfDtos.Document(text(value.shipFromName()), text(value.shipFromAddress()), text(value.carrierName()), 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.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)); text(value.fbaShipmentIds()), text(value.poReferenceNos()), value.totalPackages(), value.pageNumber(), value.pageCount(), List.copyOf(rows));
} }
/** 普通缺失字段沿用占位符,预约号通过可空文本单独处理。 */
private String text(String value) { private String text(String value) {
String normalized = optionalText(value);
return normalized.isBlank() ? "—" : normalized;
}
/** 保留空白语义,同时复用长度、规范化和控制字符校验。 */
private String optionalText(String value) {
String normalized = line(value); String normalized = line(value);
if (normalized.codePointCount(0, normalized.length()) > 300 || normalized.codePoints().anyMatch(c -> Character.isISOControl(c))) invalid("document text is invalid"); 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 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); } private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
@@ -1,6 +1,7 @@
package com.baiyue.print.service; package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat; 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.config.PrintPdfProperties;
import com.baiyue.print.dto.DispatchBolPdfDtos; import com.baiyue.print.dto.DispatchBolPdfDtos;
@@ -73,6 +74,45 @@ class DispatchBolPdfServiceGenerationTest {
"FBA19TEST", "PO-TEST", 44, pageNumber, pageCount, rows); "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<DispatchBolPdfDtos.CargoRow> rows(int start, int end) { private List<DispatchBolPdfDtos.CargoRow> rows(int start, int end) {
List<DispatchBolPdfDtos.CargoRow> rows = new ArrayList<>(); List<DispatchBolPdfDtos.CargoRow> rows = new ArrayList<>();
for (int index = start; index <= end; index++) { for (int index = start; index <= end; index++) {
+1 -1
View File
@@ -273,7 +273,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
|---|---|---| |---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 | | 测试环境 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-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
+2 -1
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import process from 'node:process' 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', 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-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-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']], ['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-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']],
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
['appointment-work', ['phase4-carrier-pickup-ready']],
['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda', ['phase4-tail-order-controls']],
['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']], ['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
+39
View File
@@ -273,7 +273,13 @@ export async function run(context) {
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed | | 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 | | Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-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`;默认只读核对实时柜量/清关指标、今日新运单及启用客户的服务端分页总数 | | 首页实时指标 | `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` | | 消息中心管理 | `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 真实前置、对账、结算与下载中心 | `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`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | | 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` 校验失败不继续下载及文件内容验证,不访问环境。 下载中心导出共用内部助手:`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` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
@@ -65,3 +65,9 @@ test('未部署时能力探测后记录部署前置,不借历史业务数据',
assert.match(gaps[0][3], /部署后重跑完整链/) assert.match(gaps[0][3], /部署后重跑完整链/)
assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false) 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 的同参任务/)
})
@@ -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, const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body,
context.token, { 'Idempotency-Key': idempotencyKey }) context.token, { 'Idempotency-Key': idempotencyKey })
const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS)
context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, const originalRead = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`)
`original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) 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 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) const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS)
@@ -101,6 +101,8 @@ export function markerCandidates(context, candidates) {
} }
export function notRun(context, proof, reason) { export function notRun(context, proof, reason) {
proof.status = 'NOT_RUN'; proof.reason = 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 }) context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true })
return proof return proof
} }
@@ -706,7 +706,7 @@ test('container overview always follows phase3 loading in default and custom mod
const entry = source('../admin-test-env-acceptance.mjs') const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/) assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
assert.match(entry, /const modules = normalizeModuleOrder\(/) 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', () => { test('overview does not fall back to a container fixture when loading evidence was requested', () => {
@@ -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.assertions.every((check) => check.passed))
assert.ok(fixture.steps.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停止/)
})
@@ -348,16 +348,23 @@ export async function run(context) {
// PICKED_UP; the immutable carrier history therefore contains the eight // PICKED_UP; the immutable carrier history therefore contains the eight
// pre-pickup nodes plus EMPTY_RETURNED. // pre-pickup nodes plus EMPTY_RETURNED.
const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP') const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP')
context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点', detailResponse,
carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)),
&& finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' `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), && 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 historyResponse = await context.request('GET', `${BASE}/${queryId}/history`)
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse,
Array.isArray(history) && history.every(validHistory) 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) `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
} }
+7 -1
View File
@@ -41,7 +41,13 @@ $requiredTests = @(
"firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java", "firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.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/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") $mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test")
@@ -504,3 +504,77 @@ test('F04 still rejects a numeric error row pointing to the successful row', asy
const { r } = importHarness('wrong-error-row') const { r } = importHarness('wrong-error-row')
await assert.rejects(() => r.orderExcelImportModule(), /actual Excel 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)
})
+519
View File
@@ -108,6 +108,11 @@ try {
await orderMutationEvidenceModule() await orderMutationEvidenceModule()
await orderExcelImportModule() await orderExcelImportModule()
await orderSubmissionChannelGateModule() await orderSubmissionChannelGateModule()
await merchantFinanceBillsModule()
await merchantOverseasWarehouseModule()
await merchantCrossBorderModule()
await merchantWarehouseOrdersModule()
await merchantPricingCustomsModule()
await notificationInboxModule() await notificationInboxModule()
await accountSecurityModule() await accountSecurityModule()
report.success = report.steps.every((step) => step.passed) report.success = report.steps.every((step) => step.passed)
@@ -1490,6 +1495,520 @@ async function isolationModule() {
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) }, ({ 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() { async function notificationInboxModule() {
const module = 'notification-inbox' const module = 'notification-inbox'
const templateBase = '/api/platform/v1/notification-templates' const templateBase = '/api/platform/v1/notification-templates'
@@ -190,3 +190,13 @@ test('Finance-only HTTP entry expands producer prerequisites and runs downloads
'finance-direct-paid-isolation', 'finance-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`)
}
})