Compare commits

...
94 changed files with 4462 additions and 338 deletions
@@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort {
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
List<AllocationOption> options,List<String> blockReasons) { }
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
@@ -27,7 +33,7 @@ public interface AppointmentWorkDispatchPort {
String departureBlockReason(String dispatchNo);
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
List<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId);
@@ -53,6 +53,18 @@ public class DispatchAggregateController {
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
}
/** 本车货物逐票读取正式预约选项及当前分配。 */
@GetMapping("/{dispatchNo}/appointment-allocations")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo));
}
/** 原车版本化关联正式预约,不新建排车或修改库存。 */
@PutMapping("/{dispatchNo}/appointment-allocations")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) {
return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 读取实际起点和卸货顺序。 */
@GetMapping("/{dispatchNo}/route")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
@@ -11,6 +11,16 @@ import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
/** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */
@Select("<script>SELECT DISTINCT d.dispatch_no,d.trip_id,d.id FROM ll_dispatch_order d "
+ "JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id "
+ "WHERE d.status='ACTIVE' AND i.plan_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
+ "ORDER BY d.trip_id,d.id</script>")
List<ReceiptDispatch> receiptDispatches(@Param("ids") List<Long> planIds);
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@@ -21,6 +21,24 @@ public interface DispatchSourceMapper {
record OrderReference(Long orderId,String customerId) { }
record ItemPicked(Long itemId,long packages) { }
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
@Select("""
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
""")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
@@ -14,6 +14,14 @@ public final class DispatchWorkModels {
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
public record Stop(int index,String name,String address,String destinationKey) { }
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
}
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
@@ -42,6 +42,10 @@ public class DispatchAggregateRepository {
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
/** 按整车读取全部真实客户明细。 */
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
/** 只读取本次确认入库的原计划关联排车。 */
public List<String> receiptDispatches(List<Long> planIds) {
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
}
/** 保存已核对真实客户与仓库的来源快照。 */
public int insertItem(DispatchItem item) { return items.insert(item); }
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
@@ -66,6 +66,8 @@ public class DispatchSourceRepository {
public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); }
/** 取得已有普通计划候选,保留原来源资格SQL。 */
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
/** 取得已有真实退回候选。 */
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
/** 同托真实来源订单组成。 */
@@ -174,9 +174,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
var fact=type!=SourceType.INBOUND_PLAN || row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
reasons.addAll(group.blockReasons());
if(type==SourceType.INBOUND_PLAN && "FBA".equals(destination.type()) && fact==null)reasons.add("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
if(fact!=null) {
reasons.addAll(fact.blockReasons());
// 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。
@@ -380,6 +381,53 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason());
}
/** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) {
requirePlatform(actor);var row=required(actor,number,false);
var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId());
var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(),
options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())),
current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList();
boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty();
return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable,
!permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约"));
}
/** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) {
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限");
var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireEditing(row,request.recordVersion());
if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配");
var ids=request.dispatchItemIds();
if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细");
if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配");
var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList();
if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物");
// 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。
appointments.lock(repository.batches(row.getId()),request.batchAllocations());
sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched);
appointments.bind(row.getId(),touched,request.batchAllocations());
String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason);
return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
}
/** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */
private List<DispatchItem> appointmentItems(DispatchAggregate row) {
return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType())
&& !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList();
}
/** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */
private boolean appointmentEditable(DispatchAggregate row) {
return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage())
&& n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0
&& repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status()))
&& repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status()));
}
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
@@ -645,9 +693,30 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
else {
var batches=json.valueToTree(repository.batches(row.getId()));
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));
}
return hashes.source(facts);
}
/** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */
boolean currentReceiptBasis(DispatchAggregate row) {
return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row));
}
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
long version=row.getRecordVersion();
if(preserveReview)row.setReviewBasisHash(basis(row));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1)
throw conflict("排车实收交接历史未保存");
}
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
@@ -811,6 +880,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var source:resolved) {
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
var c=source.candidate();
if(source.selection().sourceType()==SourceType.INBOUND_PLAN && "FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
sources.expire(c.customerId(),source.sourceKey(),at);
if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用");
try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");}
@@ -22,6 +22,14 @@ public class DispatchAppointmentAllocations {
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
/** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>> options(long dispatch,List<DispatchItem> items) {
var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList();
if(identities.isEmpty())return Map.of();
var result=provider.allocationOptions(dispatch,identities);
if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取");
return result;
}
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
@@ -113,7 +113,9 @@ public class DispatchDraftService implements DispatchPlanningPort {
var resolved=lockedIndex.get(member.sourceKey());
if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId())
|| resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化");
next.add(candidate(resolved,group));keys.add(member.sourceKey());
var projected=candidate(resolved,group);
if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons()));
next.add(projected);keys.add(member.sourceKey());
}
requireCapacity(container.getCapacity(),keys,index(next));
var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream()
@@ -421,14 +423,18 @@ public class DispatchDraftService implements DispatchPlanningPort {
.map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));}
/** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */
private Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> readCandidateBatches(List<Candidate> candidates) {
var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
var identities=candidates.stream().filter(c->c.selection().sourceType()==DispatchTypes.SourceType.INBOUND_PLAN && c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
for(int offset=0;offset<identities.size();offset+=1000)result.putAll(appointmentProvider.candidateBatches(identities.subList(offset,Math.min(offset+1000,identities.size()))));
return result;
}
/** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */
private Candidate projectCandidate(Candidate c,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches) {
if(c.selection().planPalletId()==null)return c;
if(c.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN)return c;
if(c.selection().planPalletId()==null) {
if(!"FBA".equals(c.destination().type()))return c;
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup());
}
var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId()));
var reasons=new LinkedHashSet<String>();
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
@@ -0,0 +1,111 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.transaction.support.*;
import java.time.LocalDateTime;
import java.util.*;
/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */
@Service
@Transactional(propagation=Propagation.MANDATORY)
public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway {
private final DispatchAggregateRepository repository;
private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;
private final DispatchPhysicalClaimService physicalClaims;
private final DispatchAggregateService workflow;
private final FleetTripExecutionService fleet;
private final InboundDispatchReceiptGateway.Appointments appointments;
private final Object locksKey=new Object();
/** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */
public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
DispatchPickingRepository picking,
DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet,
InboundDispatchReceiptGateway.Appointments appointments) {
this.repository=repository;this.sources=sources;this.picking=picking;
this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments;
}
/** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */
@Override public void lock(List<Long> planIds) {
var numbers=repository.receiptDispatches(planIds);
@SuppressWarnings("unchecked") var previous=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
if(previous!=null) {
if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库");
return;
}
if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers);
for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo());
appointments.lock(numbers);
TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 事务完成后清除本次收货持锁范围。 */
@Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); }
});
}
/** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */
@Override public void complete(List<Long> planIds, String operator, LocalDateTime at) {
var numbers=repository.receiptDispatches(planIds);
@SuppressWarnings("unchecked") var locked=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库");
for(var number:numbers) {
var row=repository.lock(number);
if(row==null || !"ACTIVE".equals(row.getStatus()))continue;
var items=repository.cargo(row.getId());
var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList();
if(pending.isEmpty())continue;
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
boolean preserveReview=workflow.currentReceiptBasis(row);
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
for(var item:pending) {
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
received.put(item,actual);
}
for(var entry:received.entrySet()) {
entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId());
entry.getKey().setPackageCount(entry.getValue().packageCount());
}
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
}
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
for(var entry:received.entrySet()) {
var item=entry.getKey();var actual=entry.getValue();
if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)
throw conflict("原排车托盘占用已变化,入库交接未保存");
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
}
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
if(total!=row.getPackageCount()) {
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(number,total,stops);
}
row.setPackageCount(total);
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
workflow.saveReceivedCargo(row,operator,at,preserveReview);
}
}
/** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
}
@@ -16,8 +16,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
@Service
public class DispatchPdaQueryService {
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
@jakarta.annotation.Resource private DispatchService sourceRules;
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
@@ -30,7 +28,7 @@ public class DispatchPdaQueryService {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
}
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
@@ -61,8 +59,6 @@ public class DispatchPdaQueryService {
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
sourcePositions=receivedPlanPositions(actor,item,items);
if(normalRelabel(item)) {
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
@@ -95,14 +91,6 @@ public class DispatchPdaQueryService {
}
return List.copyOf(result);
}
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
if(received.isEmpty())return List.of();
// 只修改本次投影对象,不在GET更新排车、版本或库存。
item.setPackageCount(received.get().source().packageCount());
return received.get().positions();
}
/** 仓和操作者均来自受信身份,不接收请求自报。 */
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
/** 缺失计数不作为业务标识使用。 */
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
@@ -24,10 +23,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service
public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
@@ -53,7 +50,8 @@ public class DispatchPickingService {
var currentItems=repository.cargo(row.getId());
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
materialize(row,item,actor,currentItems);
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems));
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
if(task==null) {
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
@@ -103,45 +101,6 @@ public class DispatchPickingService {
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
long dispatch=row.getId();
if(item.getWarehousePalletId()!=null) {
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
}
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
.ifPresent(fact->received.put(candidate,fact));
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
for(var entry:received.entrySet()) {
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
}
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
}
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
for(var entry:received.entrySet()) {
var candidate=entry.getKey();var actual=entry.getValue().source();
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
}
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
}
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
/** 不可变扫码证据序列化失败随根命令回滚。 */
@@ -2,45 +2,23 @@ package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */
final class DispatchReceivedPlan {
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
DispatchSourceRepository sources, DispatchService rules) {
/** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */
static Optional<DispatchPalletCandidate> resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) {
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)return Optional.empty();
var actual=rows.get(0);
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(original.isEmpty() || !new HashSet<>(original).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
return Optional.of(actual);
}
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
@@ -394,6 +394,18 @@ public class DispatchService {
if(!pallets.isEmpty())palletSerial.lock(pallets);
}
/** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockAppointmentSources(String warehouse,List<DispatchItem> items) {
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
var orders=executionOrders(warehouse,items,pallets);
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
palletSerial.lock(List.copyOf(pallets));
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
}
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import java.time.LocalDateTime;
import java.util.List;
/** 收货确认在原事务内交接真实托盘,预排估算不进入出货执行。 */
public interface InboundDispatchReceiptGateway {
/** 收货锁来源前,按固定顺序锁齐已有车辆、排车及预约。 */
void lock(List<Long> planIds);
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
record Item(long itemId, String customerId, long planPalletId, int packages) { }
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
interface Appointments {
/** 在库存来源锁之前锁齐全部原预约。 */
void lock(List<String> dispatchNumbers);
/** 逐票采用已确认实收,禁止对多批次来源猜测分配。 */
void complete(long dispatchId, List<Item> items);
}
}
@@ -37,6 +37,13 @@ public class PdaWorkflowRepository {
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
public InboundPlan findPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
}
/** 父运输锁完成后锁定原客户入仓计划。 */
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
@@ -55,6 +55,7 @@ public class InboundPdaWorkflowService {
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@@ -544,16 +545,21 @@ public class InboundPdaWorkflowService {
return result;
}
/** 单票确认入库也先锁运输父记录,再复用整柜实收交接事务。 */
@Transactional
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan original = repository.findPlan(customerId, planNo);
if (original == null) throw conflict("入仓计划不存在");
dispatchReceipts.lock(List.of(original.getId()));
InboundPlan plan = requirePlan(customerId, planNo);
return completeContainer(List.of(plan)).get(0);
}
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
/** 整柜确认实收、库存及原预排交接,并且仅发布一次柜级卸货事件。 */
@Transactional
public List<PdaResponses.Task> completeContainer(List<InboundPlan> containerPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
String containerNo = orderedPlans.get(0).getContainerNo();
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
@@ -569,6 +575,8 @@ public class InboundPdaWorkflowService {
LocalDateTime now = LocalDateTime.now();
for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now);
// 实收库存和原预排一起交接,出货时不再补写数量。
dispatchReceipts.complete(orderedPlans.stream().map(InboundPlan::getId).toList(), contexts.get(0).task().getOperatorId(), now);
String sourceContainerBookingId = sourceContainerBookingId(contexts);
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
.max(LocalDateTime::compareTo).orElseThrow();
@@ -587,6 +595,7 @@ public class InboundPdaWorkflowService {
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
@@ -606,6 +615,7 @@ public class InboundPdaWorkflowService {
allTasks.add(task);
}
}
if (!reconciledPlans.isEmpty()) dispatchReceipts.complete(reconciledPlans.stream().map(InboundPlan::getId).toList(), allTasks.get(0).getOperatorId(), now);
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
@@ -678,6 +688,7 @@ public class InboundPdaWorkflowService {
return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons);
}
/** 按确认的逐托实收入库,完成状态与运输交接属于同一事务。 */
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
@@ -84,6 +84,7 @@ public class ReceivingContainerService {
"一个托盘最多只能收66箱,请拆分到其他托盘";
@Resource private ReceivingCandidateRepository candidateRepository;
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@@ -594,11 +595,15 @@ public class ReceivingContainerService {
return result;
}
/** 确认拆柜入库前先锁原运输安排;实际数量在本次收货事务完成交接。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
// 在收货会话、订单及库存来源加锁之前,先锁齐原运输父记录。
ReceivingContainerSession original = requireCurrent(operatorKey, false);
dispatchReceipts.lock(requirePlans(original.getSourceContainerBookingId(), original.getContainerNo()).stream().map(InboundPlan::getId).toList());
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
Totals totals = totals(session, containerPlans);
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
import org.springframework.stereotype.Service;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** 为本车已有货物提供逐票原预约选项,读取不会释放占用或修改预约。 */
@Service
public class AppointmentDispatchAllocationReader {
private final AppointmentDispatchAllocationRepository repository;
private final AppointmentSourceService sources;
private final AppointmentDispatchAvailability availability;
/** 注入本域只读仓储、来源规范化和正式确认资格。 */
public AppointmentDispatchAllocationReader(AppointmentDispatchAllocationRepository repository,AppointmentSourceService sources,AppointmentDispatchAvailability availability) {
this.repository=repository;this.sources=sources;this.availability=availability;
}
/** 本车来源身份由上层核对;只返回明确已确认且仍有可分配数量的原批次。 */
public Map<CandidatePhysical,List<AllocationSource>> read(long dispatch,List<CandidatePhysical> items) {
if(dispatch<=0 || items==null || items.size()>1000 || items.stream().anyMatch(Objects::isNull))throw bad("本车预约实物范围无效");
if(items.isEmpty())return Map.of();
var physical=repository.physical(dispatch,items);
if(physical.size()>20000)throw unavailable("本车逐票实物超过完整读取上限");
var orders=physical.stream().map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).toList();
var canonical=new HashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList();
for(int offset=0;offset<distinct.size();offset+=100)canonical.putAll(AppointmentWorkPhysicalSources.canonical(distinct.subList(offset,Math.min(offset+100,distinct.size())),sources));
var refs=orders.stream().filter(p->p.orderId()!=null && p.destinationKey()!=null && p.destinationKey().startsWith("FBA:"))
.map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():repository.batches(dispatch,refs,orders);
if(batches.size()>20000)throw unavailable("本车原预约选项超过完整读取上限");
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
var result=new LinkedHashMap<CandidatePhysical,List<AllocationSource>>();
for(var identity:items) {
var rows=new ArrayList<AllocationSource>();
for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) {
var reasons=new ArrayList<String>();var options=new ArrayList<AllocationOption>();
if(p.orderId()==null || p.destinationKey()==null || p.packages()==null || p.packages()<=0)throw conflict("本车逐票实物来源或箱数尚未核实");
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).sourceKey();
if(p.destinationKey().startsWith("FBA:")) {
for(var b:batches)if(identity.customerId().equals(b.customerId()) && source.equals(b.sourceKey()) && p.destinationKey().equals(b.destinationKey())) {
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey());
long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated();
if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) {
var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status())
&& a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow();
var details=agreement.details();
options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(),
details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone()));
}
}
if(options.stream().mapToLong(AllocationOption::availablePackages).sum()<p.packages())reasons.add("请先在预约列表确认本票FBA送仓时间及覆盖箱数,再返回刷新");
}
rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons)));
}
if(rows.isEmpty())throw conflict("本车托盘缺少真实逐票货物来源");
result.put(identity,List.copyOf(rows));
}
return result;
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 已有排车关联预约的本域只读持久化边界。 */
@Repository
public class AppointmentDispatchAllocationRepository {
private final AppointmentWorkDispatchMapper mapper;
/** 复用权威实物与预约Mapper,不新增分配存储。 */
public AppointmentDispatchAllocationRepository(AppointmentWorkDispatchMapper mapper) {this.mapper=mapper;}
/** 按服务层已核实的本车实物读取逐票业务标识及数量。 */
public List<AppointmentWorkDispatchMapper.AllocationPhysical> physical(long dispatch,List<CandidatePhysical> items) {return mapper.allocationPhysical(dispatch,items);}
/** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */
public List<AppointmentWorkDispatchMapper.CandidateBatch> batches(long dispatch,List<SourceRef> refs,List<AppointmentWorkDispatchMapper.Physical> orders) {
return mapper.candidateBatches(refs,orders,dispatch);
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */
@Service
public class AppointmentDispatchAvailability {
private final FbaReceivingStore receiving;
/** 只消费原收货确认权威,不使用车辆补充预约号。 */
public AppointmentDispatchAvailability(FbaReceivingStore receiving) {this.receiving=receiving;}
/** 按既有200批上限分块批读,避免逐托查询预约。 */
public Map<String,BatchReceiving> snapshots(List<String> keys) {
var unique=keys.stream().distinct().sorted().toList();var result=new HashMap<String,BatchReceiving>();
for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot);
return result;
}
/** 未确认、待仓库回复、时间无效或覆盖身份不唯一时均不可作为排车依据。 */
public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) {
if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0;
var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey())
&& a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList();
if(agreements.size()!=1)return 0;
var agreement=agreements.get(0);var details=agreement.details();
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0;
var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset());
if(start==null)return 0;
if(details.appointmentEndAt()!=null) {
var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset());
if(end==null || end.isBefore(start))return 0;
}
var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1)return 0;
var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList();
return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0;
}
/** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */
private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) {
try {
var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);
if(offsets.isEmpty() || offset==null && offsets.size()!=1)return null;
var selected=offset==null?offsets.get(0):ZoneOffset.of(offset);
return offsets.contains(selected)?at.toInstant(selected):null;
} catch(DateTimeException|NullPointerException error){return null;}
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.conflict;
/** 实收沿原逐票预约归属更新数量,绝不按托数均分货物或新选批次。 */
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceiptGateway.Appointments {
private final AppointmentReceiptRepository repository;
private final AppointmentWorkDispatchBinding binding;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
/** 复用原批次锁和真实来源;收货记录不依赖预约是否已满足出货资格。 */
public AppointmentReceiptDispatchAdapter(AppointmentReceiptRepository repository, AppointmentWorkDispatchBinding binding,
InboundFbaAppointmentService appointments, AppointmentSourceService sources) {
this.repository=repository;this.binding=binding;this.appointments=appointments;this.sources=sources;
}
/** 收货前按业务批次顺序锁齐原有效预约,避免库存与预约反向取锁。 */
@Override public void lock(List<String> numbers) {
var versions=new TreeMap<String,Long>();
for(var number:numbers) for(var row:repository.bindings(number))
if(!row.batchKey().startsWith("DISPATCH:"))versions.computeIfAbsent(row.batchKey(),this::version);
binding.lockBatches(versions);
}
/** 实收来源必须与原绑定唯一对应,多预约分摊不明确时拒绝猜测。 */
@Override public void complete(long dispatch, List<InboundDispatchReceiptGateway.Item> items) {
var previous=repository.bindings(dispatch);
for(var item:items) {
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
var physical=repository.physical(item.planPalletId(),item.customerId());
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var quantities=new LinkedHashMap<List<String>,Integer>();
for(var part:physical) {
if(part.countedPackages()==null || part.countedPackages()<=0)throw conflict("收货逐票实物箱数尚未确认");
var key=List.of(AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey());
quantities.merge(key,part.countedPackages(),Math::addExact);
}
var originalKeys=new HashSet<List<String>>();
for(var row:original)originalKeys.add(List.of(row.sourceKey(),row.destinationKey()));
if(!quantities.keySet().equals(originalKeys))throw conflict("实收货物来源与原排车预约不一致,请核对原逐票安排");
if(quantities.values().stream().mapToLong(Integer::longValue).sum()!=item.packages())throw conflict("逐票实收与托盘确认箱数不一致");
for(var entry:quantities.entrySet()) {
var matches=original.stream().filter(r->r.sourceKey().equals(entry.getKey().get(0)) && r.destinationKey().equals(entry.getKey().get(1))).toList();
if(matches.size()!=1)throw conflict("同一实收来源关联多个预约批次,请明确逐票分配,不能自动均分");
var row=matches.get(0);
if(!row.customerId().equals(item.customerId()) || row.palletId()!=item.planPalletId())throw conflict("原预约客户或托盘归属已变化");
if(row.packages()!=entry.getValue() && repository.receiveQuantity(row,entry.getValue())!=1)throw conflict("原预约逐托分配已变化,收货交接未保存");
}
}
binding.receiptQuantitiesChanged();
}
/** 使用同事务已锁定的原预约版本,不沿用客户端旧快照。 */
private long version(String batch) {
var value=appointments.batchContext(batch).appointment().version();
if(value==null || value<0)throw conflict("原预约批次版本缺失,无法确认收货交接");
return value.longValue();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.work;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 收货交接只读取原排车预约绑定和已确认逐票来源。 */
@Repository
public class AppointmentReceiptRepository {
private final AppointmentWorkDispatchMapper mapper;
/** 复用权威预约与托盘分配Mapper。 */
public AppointmentReceiptRepository(AppointmentWorkDispatchMapper mapper) { this.mapper=mapper; }
/** 读取本车已选择的批次,不选择新预约。 */
public List<AppointmentWorkDispatchMapper.Bound> bindings(String number) { return mapper.bindings(number); }
/** 读取原车逐托绑定,供实收来源逐项匹配。 */
public List<AppointmentWorkDispatchMapper.Bound> bindings(long id) { return mapper.byDispatch(id); }
/** 真实逐票数量以收货确认的分配记录为准。 */
public List<AppointmentWorkDispatchMapper.Physical> physical(long pallet, String customer) { return mapper.physical(pallet,customer); }
/** 原绑定身份及旧数量同时参与更新,防止覆盖并发改量。 */
public int receiveQuantity(AppointmentWorkDispatchMapper.Bound original,int actual) { return mapper.receiveQuantity(original,actual); }
}
@@ -38,6 +38,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
private final AppointmentWorkChanges changes;
private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
private final AppointmentWorkDispatchCandidates candidates;
@jakarta.annotation.Resource private AppointmentDispatchAllocationReader allocationReader;
private final Object prelockedBatchesKey=new Object();
private final Object pendingInvalidationKey=new Object();
/** 注入原预约、真实实物和收货确认权威。 */
@@ -48,6 +49,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
/** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> input) {return candidates.batchReferences(input);}
/** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items) {return allocationReader.read(dispatchId,items);}
/** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */
public void lockBatches(Map<String,Long> expectedBatchVersions) {
if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效");
@@ -115,9 +119,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
var appointment=appointments.batchContext(batch).appointment();
if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选");
if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次");
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList())
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) {
requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId()))
.map(a->allocation(a,appointment.version().longValue())).toList());
// FBA确认前不能把货物加入车辆;提交审核仍复验运输时间。
requireReceiving(batch,customer,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())).toList());
}
}
// 所有校验结束后才按明确item删除;没有整车清空操作。
for(var item:touched) mapper.removeItem(dispatch,item);
@@ -230,7 +237,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复");
var agreement=receiving.currentAgreement(batch,customer);
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus())
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车");
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先预约成功并明确送仓时间,再加入排车或发车");
receivingInstant(agreement.details().appointmentAt(),agreement.timezone(),agreement.details().startOffset());
var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对");
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) {
@@ -260,7 +268,11 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车");
}
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
@@ -16,8 +16,9 @@ public class AppointmentWorkDispatchCandidates {
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
private final AppointmentDispatchAvailability availability;
/** 注入唯一原批次、实物和来源规范化边界。 */
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; }
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { this.mapper=mapper; this.appointments=appointments; this.sources=sources;this.availability=availability; }
/** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */
@Transactional(readOnly=true)
public Map<CandidatePhysical,CandidateBatches> batchReferences(List<CandidatePhysical> input) {
@@ -32,8 +33,9 @@ public class AppointmentWorkDispatchCandidates {
var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限");
var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList();
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders);
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders,-1);
if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限");
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
var output=new LinkedHashMap<CandidatePhysical,CandidateBatches>();
for(var identity:identities) {
var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList();
@@ -46,15 +48,16 @@ public class AppointmentWorkDispatchCandidates {
if(!row.destinationKey().startsWith("FBA:")) continue;
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey();
var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList();
if(matched.isEmpty()) {unassigned=true;continue;}
if(matched.isEmpty()) {unassigned=true;reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");continue;}
related=true;long available=0;var seen=new HashSet<String>();
for(var b:matched) {
if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;}
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;}
int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining);
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey());
int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining);
if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count)));
}
if(available<count) reasons.add("关联批次逐票剩余量不足以覆盖本托实物");
if(available<count) reasons.add("FBA已确认预约及逐票剩余量不足,请先确认送仓时间和本票箱数再排车");
}
if(related && unassigned) reasons.add("托盘含未归属当前预约批次的货物,请先核对逐票分配");
var result=references.entrySet().stream().map(e->new BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList();
@@ -77,6 +80,7 @@ public class AppointmentWorkDispatchCandidates {
// Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility.
if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物"));
var candidates=mapper.candidates(appointment.id());
var confirmation=availability.snapshots(List.of(batch)).get(batch);
if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批");
var output=new ArrayList<Candidate>();
for(var candidate:candidates) {
@@ -93,6 +97,7 @@ public class AppointmentWorkDispatchCandidates {
if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; }
long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1);
if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");
if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失");
allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count));
}
@@ -18,6 +18,26 @@ public interface AppointmentWorkDispatchMapper {
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
record AllocationPhysical(long planPalletId,String customerId,Long orderId,String orderNo,String inboundCode,String destinationKey,Integer packages) { }
/** 来源展示使用业务编号;逐票数量按本车原计划或实收阶段读取。 */
@Select("""
<script>
SELECT i.plan_pallet_id,i.customer_id,l.order_id,o.order_no,o.inbound_code,
CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
CASE WHEN i.source_stage='PLAN' THEN
CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END
ELSE CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END END packages
FROM ll_dispatch_item i
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
WHERE i.dispatch_order_id=#{dispatchId} AND
(<foreach collection='items' item='p' separator=' OR '>(i.plan_pallet_id=#{p.planPalletId} AND i.customer_id=#{p.customerId})</foreach>)
GROUP BY i.plan_pallet_id,i.customer_id,i.source_stage,l.order_id,o.order_no,o.inbound_code,l.destination_type,l.fba_warehouse_code
ORDER BY i.plan_pallet_id,l.order_id LIMIT 20001
</script>
""")
List<AllocationPhysical> allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical> items);
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
@Select("""
<script>
@@ -43,7 +63,7 @@ public interface AppointmentWorkDispatchMapper {
i.customer_id,i.order_id,i.source_key,CONCAT('FBA:',i.fba_warehouse_code) destination_key,i.package_count packages,
(SELECT COALESCE(SUM(a.package_count),0) FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) AND a.customer_id=i.customer_id AND a.source_key=i.source_key
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED') allocated
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED' AND d.id&lt;&gt;#{excludedDispatchId}) allocated
FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment p ON p.id=i.appointment_id
WHERE i.active=1 AND (p.replacement_keys_json IS NULL OR p.replacement_keys_json='[]') AND
(<foreach collection='refs' item='r' separator=' OR '>(i.customer_id=#{r.customerId} AND i.source_key=#{r.sourceKey} AND CONCAT('FBA:',i.fba_warehouse_code)=#{r.destinationKey})</foreach>
@@ -51,7 +71,7 @@ public interface AppointmentWorkDispatchMapper {
ORDER BY p.id,i.customer_id,i.source_key,i.fba_warehouse_code LIMIT 20001
</script>
""")
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders);
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders,@Param("excludedDispatchId") long excludedDispatchId);
@Select("""
<script>
SELECT a.dispatch_id,a.batch_key,CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.dispatch_no ELSE p.appointment_no END batch_no,
@@ -115,5 +135,14 @@ public interface AppointmentWorkDispatchMapper {
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
List<Bound> bindings(String number);
/** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */
@Update("""
UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual}
WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId}
AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey}
AND destination_key=#{row.destinationKey} AND package_count=#{row.packages}
""")
int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual);
@Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List<Bound> byDispatch(long id);
}
@@ -1798,6 +1798,53 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/appointment-allocations:
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
get:
summary: 读取本车逐票正式预约选项及当前分配
description: 权限 platform:overseas-warehouse:dispatch:list。只读本车真实货物;已确认时段和覆盖来自正式收货预约。本车原占用不扣减本车可重分配量,其他有效车辆占用仍扣减。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchAppointmentAllocations'
put:
summary: 在原排车关联或调整正式预约分配
description: 权限 platform:overseas-warehouse:dispatch:edit。主单及原批次版本必填,0合法;每个触及实物必须按客户/来源/目的仓完整分配。仅装车前零真实扫码且无有效下游单据,成功增加主版本并使两级审核失效,不新建排车、不改变货物或库存。失败原子回滚;同键同载荷恢复原结果,同键异载荷拒绝。
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchAppointmentAllocationCommand'
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchCommandResult'
/api/platform/v1/last-leg/dispatches/{dispatchNo}/route:
get:
summary: 读取实际起点与既有卸货站序
@@ -5927,7 +5974,7 @@ components:
required:
- idempotencyKey
- warehouseId
description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。
description: 同一起运仓,允许空单或多客户、多目的仓。普通入仓FBA货物须先取得覆盖本客户、来源和箱数的正式预约时间才能选入,不能等提交审核才检查;换标和退货独立来源保留原流程。客户和来源归属由服务端解析,临时资源不生成假档案。
DispatchAddCargo:
type: object
x-java-record: DispatchCommands.AddCargo
@@ -6951,6 +6998,94 @@ components:
destinationKey:
type: string
nullable: true
DispatchAppointmentAllocations:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocations
required: [dispatchNo, recordVersion, items, edit]
properties:
dispatchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
items:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentItem'
edit:
$ref: '#/components/schemas/DispatchGate'
DispatchAppointmentItem:
type: object
x-java-record: DispatchWorkModels.AppointmentItem
required: [dispatchItemId, planPalletId, customerId, palletNo, sources, allocations]
properties:
dispatchItemId: {type: string}
planPalletId: {type: string}
customerId: {type: string}
customerName: {type: string, nullable: true}
palletNo: {type: string}
sources:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentSource'
allocations:
type: array
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchAppointmentSource:
type: object
required: [sourceKey, destinationKey, options, blockReasons]
properties:
sourceKey: {type: string}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
destinationKey: {type: string}
packageCount: {type: integer, nullable: true, minimum: 1}
options:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentOption'
blockReasons:
type: array
items: {type: string}
DispatchAppointmentOption:
type: object
required: [batchKey, batchNo, recordVersion, availablePackages, appointmentAt, timezone]
properties:
batchKey: {type: string}
batchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
availablePackages: {type: integer, minimum: 1}
confirmationNo: {type: string, nullable: true}
appointmentAt:
type: string
description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。
appointmentEndAt: {type: string, nullable: true}
timezone: {type: string}
DispatchAppointmentAllocationCommand:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocationCommand
required: [recordVersion, idempotencyKey, dispatchItemIds, batchAllocations]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey: {type: string}
dispatchItemIds:
type: array
minItems: 1
maxItems: 1000
uniqueItems: true
items: {type: string}
batchAllocations:
type: array
maxItems: 1000
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchRoute:
type: object
x-java-record: DispatchWorkModels.Route
@@ -58,8 +58,13 @@ class Phase4BrowserApiBoundaryTest {
assertTrue(condition != null);
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
assertFalse(condition.matchIfMissing());
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3);
Set<String> readPaths = Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class))
.flatMap(method -> {
String[] values = method.getAnnotation(GetMapping.class).value();
return values.length == 0 ? java.util.stream.Stream.of("") : Arrays.stream(values);
}).collect(Collectors.toSet());
assertEquals(Set.of("", "/capacity-demands", "/{dispatchNo}", "/{dispatchNo}/prints/bol"), readPaths);
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
@@ -33,7 +33,7 @@ public final class DispatchPublicJsonContract {
DispatchCommands.Depart.class, ArrangeDelivery.class,
DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class,
DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class,
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,DispatchWorkModels.AppointmentAllocationCommand.class,
BatchAllocation.class,BatchReference.class
}) {
rejectsMissing(requests, request, "{}");
@@ -77,7 +77,7 @@ public final class DispatchPublicJsonContract {
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
System.out.println("DispatchPublicJsonContract: PASS (17 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
}
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.*;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 原车关联命令使用真实主单、版本CAS、历史与命令账本事务。 */
class DispatchAppointmentLinkPersistenceTest {
/** 每项测试分别授权列表与维护,避免缺权限造成伪业务拦截。 */
@BeforeEach void authorize() {SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));}
/** 不把测试身份传给后续其他用例。 */
@AfterEach void clear() {SecurityContextHolder.clearContext();}
/** 补分配保留原车、同车其他客户与来源占用,作废旧审批且支持原键恢复。 */
@Test void linksExistingCargoAndInvalidatesReviewWithoutRecreatingTruckOrClaims() throws Exception {
var s=new Scenario();s.f.db.update("UPDATE ll_dispatch_order SET review_state='DISPATCH_PENDING',review_basis_hash='old-basis'");
long claims=s.count("ll_dispatch_pallet_lock"),history=s.count("ll_dispatch_history");
var cargo=s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
var saved=s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original")));
assertThat(saved.dispatchNo()).isEqualTo(s.no);assertThat(saved.recordVersion()).isEqualTo(s.version+1);
assertThat(s.f.repository.find(s.no).getReviewState()).isEqualTo("DRAFT");assertThat(s.f.repository.find(s.no).getReviewBasisHash()).isNull();
assertThat(s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(cargo);
assertThat(s.count("ll_dispatch_pallet_lock")).isEqualTo(claims);assertThat(s.count("ll_dispatch_order")).isOne();assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
verify(s.provider).bindItems(eq(s.root),eq(List.of(new AppointmentWorkDispatchPort.PhysicalItem(s.item,"101",1L,"RECEIVED",10))),eq(List.of(new AppointmentWorkDispatchPort.Allocation(1L,"FBA_APPOINTMENT:9",0L,"101","ORDER:9001","FBA:ACY2",10))));
assertThat(s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original"))).replayed()).isTrue();
verify(s.provider,times(1)).bindItems(anyLong(),anyList(),anyList());assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"stale-new-key")))).hasMessageContaining("版本");
}
/** 真实扫码优先于头部零计数,外车明细与只读权限都不能进入写入。 */
@Test void rejectsActualPicksForeignItemsAndReadOnlyActorBeforeProviderMutation() throws Exception {
var s=new Scenario();
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,new DispatchWorkModels.AppointmentAllocationCommand(s.version,"foreign",List.of(s.item+999),s.allocations())))).hasMessageContaining("本车");
s.f.picked(1,"101",501,1);
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"picked")))).hasMessageContaining("实际扫码");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"read-only")))).hasMessageContaining("权限");
verifyNoInteractions(s.provider);assertThat(s.f.repository.find(s.no).getRecordVersion()).isEqualTo(s.version);
}
/** 原批次拒绝时,主单版本、审核和账本都保留修改前事实。 */
@Test void providerFailureRollsBackVersionReviewAndCommandLedger() throws Exception {
var s=new Scenario();var before=s.f.json.writeValueAsString(s.f.repository.find(s.no));long history=s.count("ll_dispatch_history"),commands=s.count("ll_dispatch_command");
doThrow(new IllegalStateException("预约版本已变化")).when(s.provider).bindItems(anyLong(),anyList(),anyList());
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"reject-batch")))).hasMessageContaining("预约版本");
assertThat(s.f.json.writeValueAsString(s.f.repository.find(s.no))).isEqualTo(before);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);
}
/** 候选必须属于本车真实实物,原版本零与本车分配对称返回。 */
@Test void readsOnlyOwnedPhysicalOptionsAndDisablesWriteAfterActualPick() throws Exception {
var s=new Scenario();
when(s.provider.allocationOptions(eq(s.root),anyList())).thenAnswer(call->{
List<AppointmentWorkDispatchPort.CandidatePhysical> identities=call.getArgument(1);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>>();
for(var identity:identities)result.put(identity,List.of(new AppointmentWorkDispatchPort.AllocationSource("ORDER:9001","WC-1","IN-1","FBA:ACY2",10,List.of(),List.of())));return result;
});
var result=s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no));
assertThat(result.dispatchNo()).isEqualTo(s.no);assertThat(result.items()).hasSize(2);assertThat(result.edit().allowed()).isTrue();
s.f.picked(1,"101",501,1);
assertThat(s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no)).edit().allowed()).isFalse();
}
private static class Scenario {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class);
final String no;final long root,item,version;
/** 先建已有车和两客户货物,模拟后续新增正式预约,不能靠重建车完成补关联。 */
Scenario() throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("original-before-appointment",List.of(f.source(1,"101",501,"ACY2",10),f.source(2,"202",502,"ONT8",5)))));
no=created.dispatchNo();version=created.recordVersion();root=f.repository.find(no).getId();
item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class);
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
}
/** 本次只触及第一客户原有托盘,不重发选货请求。 */
DispatchWorkModels.AppointmentAllocationCommand request(long at,String key) {return new DispatchWorkModels.AppointmentAllocationCommand(at,key,List.of(item),allocations());}
/** 正式预约显式提交当前权威版本零。 */
List<DispatchContracts.BatchAllocation> allocations() {return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:9","101","ORDER:9001","FBA:ACY2",10,0L));}
/** 统计真实持久化记录,验证失败和重放均不产生额外副作用。 */
long count(String table) {return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
}
}
@@ -120,6 +120,27 @@ class DispatchDraftPersistenceTest {
return result(no,request.idempotencyKey(),request.cargo().size());});
}
/** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */
@Test void unconfirmedFbaCannotBeSelectedIntoDraftOrLeaveClaims() {
var state=empty(1);
when(appointmentProvider.candidateBatches(anyList())).thenReturn(Map.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",1L),
new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("FBA货物须先预约成功并明确送仓时间"),false)));
assertThatThrownBy(()->select(state,0,1)).hasMessageContaining("先预约成功");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero();
assertThat(service.drafts(owner).get(0).sources()).isEmpty();
}
/** 本轮门禁限普通入仓,独立换标和退货FBA保留原排车流程。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value=SourceType.class,names={"RELABEL","RETURN_RECEIPT"})
void independentFbaKeepsExistingDraftFlow(SourceType sourceType) {
var state=empty(1);var choice=sourceType==SourceType.RELABEL
?new DispatchCommands.CargoSelection(sourceType,null,null,"RL-1",1001L,"version-1")
:new DispatchCommands.CargoSelection(sourceType,null,1L,null,1001L,"version-1");choices.put(1L,choice);
doAnswer(call->{var resolved=whole(choice);resolved.candidate().destinationType("FBA");return List.of(resolved);}).when(sources).lockDraftSources(anyString(),anyList());
assertThat(select(state,0,1).sources()).hasSize(1);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isOne();
verify(appointmentProvider,never()).candidateBatches(anyList());
}
/** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */
@Test void restartRecoversOriginalResultsAndRejectsChangedActorVersionOrPayload(){
var request=new CreateDraft("create","92");var draft=service.createDraft(owner,request);
@@ -0,0 +1,211 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
import com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.receiving.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.work.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
class DispatchInboundReceiptPersistenceTest {
/** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new org.springframework.jdbc.core.JdbcTemplate(ds);
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)");
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')");
db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')");
for(int n=1;n<=2;n++) {
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n);
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11);
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11);
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
}
/** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */
@Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception {
var f=new Fixture();f.confirm();
assertThat(f.quantities()).containsExactly(9,11);
assertThat(f.allocations()).containsExactly(9,11);
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();
assertThat(f.p.f.db.queryForObject("SELECT picked_package_count FROM ll_dispatch_order",Integer.class)).isZero();
verifyNoInteractions(f.p.inventory);
var before=f.version();
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(before,"receipt-nine",11L,9,0,null,null)));
assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException();
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"receipt-eleven",12L,11,0,null,null)));
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
assertThat(f.binding.departureBlockReason(f.p.number)).isNull();
assertThat(f.quantities()).containsExactly(9,11);
assertThat(f.allocations()).containsExactly(9,11);
verify(f.p.inventory,times(2)).ship(eq("101"),eq("picker"),any());
}
/** 重复交接不修改版本、重复占用托盘或新增收货历史。 */
@Test void confirmedReceiptIsIdempotent() throws Exception {
var f=new Fixture();f.confirm();long version=f.version();
f.p.f.tx.executeWithoutResult(s->{f.receipt.lock(List.of(1L,2L));f.receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now());});
assertThat(f.version()).isEqualTo(version);
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='RECEIVING_COMPLETE'",Integer.class)).isEqualTo(1);
}
/** 已因其他事实变化失效的审核,不能在确认入库时被重新放行。 */
@Test void receiptDoesNotApproveAnAlreadyStaleReview() throws Exception {
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash='stale-review'");
f.confirm();
assertThat(f.p.f.db.queryForObject("SELECT review_basis_hash FROM ll_dispatch_order",String.class)).isEqualTo("stale-review");
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效");
}
/** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */
@Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception {
var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true);
f.confirm();
assertThat(f.quantities()).containsExactly(9,11);
assertThat(f.allocations()).containsExactly(9,11);
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认");
}
/** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */
@Test void receiptWithoutActiveDispatchDoesNotRequireTransportResources() throws Exception {
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
assertThatCode(f::confirm).doesNotThrowAnyException();
assertThat(f.quantities()).containsExactly(10,10);
}
/** 客户仓沿本车逐票绑定交接9+11,首托扫码递增公共版本不能使第二托审核失效。 */
@Test void privateWarehouseReceiptAndBothPicksKeepTheSameReview() throws Exception {
var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId();
f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'");
f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'");
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch);
f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE"));
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
f.confirm();
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(f.version(),"private-nine",11L,9,0,null,null)));
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"private-eleven",12L,11,0,null,null)));
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
assertThat(f.allocations()).containsExactly(9,11);
}
/** 预约逐票交接失败时,收货数量、库存及排车实托绑定必须整体回滚。 */
@Test void ambiguousBatchAllocationRollsBackTheWholeReceipt() throws Exception {
var f=new Fixture();
f.p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_id,dispatch_item_id,plan_pallet_id,'FBA_APPOINTMENT:2',customer_id,source_key,destination_key,1 FROM ll_dispatch_appointment_allocation WHERE plan_pallet_id=1");
f.p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(2,0,'AP-2')");
when(f.appointments.batchContext("FBA_APPOINTMENT:2")).thenReturn(new FbaAppointmentBatchContext(f.snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
assertThatThrownBy(f::confirm).hasMessageContaining("不能自动均分");
assertThat(f.quantities()).containsExactly(10,10);
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
assertThat(f.p.f.db.queryForObject("SELECT SUM(counted_package_count) FROM ll_inbound_plan_pallet_allocation",Integer.class)).isZero();
}
/** 不能借收货确认接管其他排车已经占用的实托。 */
@Test void occupiedPhysicalPalletRejectsReceiptWithoutPartialUpdates() throws Exception {
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
assertThatThrownBy(f::confirm).hasMessageContaining("占用");
assertThat(f.quantities()).containsExactly(10,10);
assertThat(f.allocations()).containsExactly(10,10);
}
/** 单客户同票两托的真实预约提供者,避免用恒定放行的预约Mock掩盖数量冲突。 */
private static final class Fixture {
final DispatchPickingPersistenceTest.Picking p=new DispatchPickingPersistenceTest.Picking(true);
final InboundFbaAppointmentService appointments=mock(InboundFbaAppointmentService.class);
final FbaAppointmentResponse snapshot=mock(FbaAppointmentResponse.class);
final FbaReceivingStore receiving=mock(FbaReceivingStore.class);
final AppointmentWorkDispatchBinding binding;
final DispatchInboundReceiptService receipt;
/** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */
Fixture() throws Exception {
p.receivedPlans(9,11);
p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'");
p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0");
p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'");
p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)");
p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8");
p.f.candidates.get(2L).destinationWarehouseCode("ONT8");
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
for(long id=1;id<=2;id++) {
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
doReturn(id).when(p.sources).lockPlan("101",id);
doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id);
doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any());
}
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))");
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')");
for(long id=1;id<=2;id++) {
p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id);
p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,'FBA_APPOINTMENT:1','101','ORDER:9001','FBA:ONT8',10 FROM ll_dispatch_item WHERE plan_pallet_id=?",id);
}
p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(1,0,'AP-1')");
// 与生产一致,排车和预约Mapper共用同一SqlSession,更新后一起失效本地查询缓存。
p.f.session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
var mapper=p.f.session.getMapper(AppointmentWorkDispatchMapper.class);
var sources=mock(AppointmentSourceService.class);
when(sources.canonicalOrders(anyList())).thenAnswer(call->{List<AppointmentExternalSourceGateway.OrderRef> refs=call.getArgument(0);return refs.stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList();});
when(snapshot.version()).thenReturn(0);when(snapshot.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(snapshot.replacementBatchKeys()).thenReturn(List.of());
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("101");when(item.sourceKey()).thenReturn("ORDER:9001");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(20);when(snapshot.items()).thenReturn(List.of(item));
when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.now(ZoneOffset.UTC).plusDays(3),null,"SUCCESS",null,null,null);
var member=new FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",0,"101",List.of(new FbaReceivingContracts.Source("ORDER:9001","FBA:ONT8",20,"v1",1,"IN-1")),null);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","101")).thenReturn(new FbaReceivingContracts.Agreement("AG-1",0,"ACTIVE","FBA:ONT8",details,"UTC",List.of(member),"确认收货"));
binding=new AppointmentWorkDispatchBinding(mapper,appointments,sources,mock(AppointmentWorkChanges.class),receiving,null);
var bridge=new AppointmentReceiptDispatchAdapter(new AppointmentReceiptRepository(mapper),binding,appointments,sources);
var picking=(DispatchPickingRepository)ReflectionTestUtils.getField(p.service,"picking");
receipt=new DispatchInboundReceiptService(p.f.repository,p.sources,picking,(DispatchPhysicalClaimService)ReflectionTestUtils.getField(p.f.service,"physicalClaims"),p.f.service,p.f.fleet,bridge);
ReflectionTestUtils.setField(p.f.service,"appointments",new DispatchAppointmentAllocations(binding));
p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(p.f.service,"basis",p.f.repository.find(p.number)));
doAnswer(call->{var pick=(com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick)call.getArgument(1);long receiptId=pick.getInventoryPositionId()==11?1:2;
p.f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,'101',?,9001,?,?,0)",pick.getId(),pick.getId(),receiptId,pick.getPackages());return List.of(new com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService.OrderPackages(9001L,pick.getPackages()));
}).when(p.attribution).attribute(eq("101"),any(),isNull());
}
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
void confirm() {
p.f.tx.executeWithoutResult(s->{
receipt.lock(List.of(1L,2L));
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
});
}
/** 读取持久化实收箱数,不能只检查响应投影。 */
List<Integer> quantities() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY plan_pallet_id",Integer.class); }
/** 读取实际预约分配,覆盖本次遗漏的第二套账本。 */
List<Integer> allocations() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_appointment_allocation ORDER BY plan_pallet_id",Integer.class); }
/** 后续扫码必须使用收货交接后的公共版本。 */
long version() { return p.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class); }
}
}
@@ -259,7 +259,7 @@ class DispatchMergedRelabelPersistenceTest {
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class));
ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins);
picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json);
ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins);
balance(601,"101",5);position(101,601,"101",crossCustomer?2:5);
if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);}
@@ -34,44 +34,16 @@ class DispatchPickingPersistenceTest {
verify(fixture.inventory,never()).ship(any(),any(),any());
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
}
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
/** 未在收货事务完成交接的原计划不能在出货时临时修数量。 */
@Test void pickingCannotMaterializeAnUnconfirmedReceipt() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
verify(fixture.inventory,never()).ship(any(),any(),any());
new DispatchCommands.Pick(fixture.version,"receipt-not-confirmed",11L,9,0,null,null))))
.hasMessageContaining("收货交接");
verifyNoInteractions(fixture.inventory);
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
}
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(replay.replayed()).isTrue();
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
}
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(8,11);
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
}
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
@@ -113,7 +85,7 @@ class DispatchPickingPersistenceTest {
}
/** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */
private static final class Picking {
static final class Picking {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
@@ -147,8 +119,7 @@ class DispatchPickingPersistenceTest {
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
}
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
void receivedPlans(int first,int second) {
@@ -33,62 +33,30 @@ class DispatchPlanPdaReadPersistenceTest {
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
}
}
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
var f=new Fixture();var before=f.dbState();
var task=f.read();
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
assertThat(f.dbState()).isEqualTo(before);
verify(f.sources).stock("101",501L,false);
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
}
/** 在途和来源身份变化均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
/** PDA查询只读取确认入库时已经交接的事实,不在GET补写或投影修正计划数量。 */
@Test void pendingReceiptDoesNotExposeStockOrReplaceEstimates() throws Exception {
var f=new Fixture(true);var before=f.dbState();
var task=f.read();
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(10L,10L);
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isEmpty());
assertThat(f.dbState()).isEqualTo(before);
verifyNoInteractions(f.sources,f.rules);
}
/** 收货已保存9+11时,PDA直接显示9+11并提供实际托盘仓位。 */
@Test void confirmedReceiptUsesPersistedNineAndEleven() throws Exception {
var f=new Fixture(true);
for(int n=1;n<=2;n++) {
int count=n==1?9:11;
f.f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=?,source_stage='RECEIVED',package_count=? WHERE plan_pallet_id=?",500+n,count,n);
f.f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages) VALUES(?,'101',?,?,'9',?,0,0,0)",10+n,20+n,500+n,count);
}
var before=f.dbState();var task=f.read();
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
assertThat(task.counts().packageCount()).isEqualTo(20);
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).hasSize(1));
assertThat(f.dbState()).isEqualTo(before);
}
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
assertThat(f.read().items().get(0).positions()).isEmpty();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
var f=new Fixture();
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
assertThat(f.read().items().get(0).positions()).isEmpty();
}
}
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
@@ -109,7 +77,6 @@ class DispatchPlanPdaReadPersistenceTest {
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
@@ -33,11 +33,13 @@ class InboundPdaContainerCompletionTest {
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService outbox;
@Mock LastlegOrderMapper orders;
@Mock com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
private InboundPdaWorkflowService service;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatchReceipts);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
@@ -74,6 +76,14 @@ class InboundPdaContainerCompletionTest {
any(Instant.class), any(Instant.class), anyLong());
verify(repository, times(2)).update(any(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class));
// 运输父锁必须早于收货来源锁;两票库存落账后才交接原排车,并且只交接一次。
var sequence=org.mockito.Mockito.inOrder(dispatchReceipts,repository,stockReceipts);
sequence.verify(dispatchReceipts).lock(List.of(2L,1L));
sequence.verify(repository).lockTask("100",1L);
sequence.verify(stockReceipts).stockIn(eq("100"),any(),any(),any(),any(),any(),any());
sequence.verify(stockReceipts).stockIn(eq("200"),any(),any(),any(),any(),any(),any());
sequence.verify(dispatchReceipts).complete(eq(List.of(1L,2L)),org.mockito.ArgumentMatchers.isNull(),any());
verify(dispatchReceipts,times(1)).complete(any(),any(),any());
}
private static InboundPlan plan(long id, String planNo) {
@@ -71,6 +71,7 @@ class InboundPdaWorkflowServiceTest {
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
@@ -91,6 +92,7 @@ class InboundPdaWorkflowServiceTest {
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
lenient().when(repository.findPlan("100", "LIP1")).thenReturn(plan);
LastlegOrder sourceOrder = new LastlegOrder();
sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1");
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
@@ -83,6 +83,7 @@ class ReceivingContainerServiceBehaviorTest {
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository);
org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation ->
sessions.findActiveByOperator(invocation.getArgument(0)));
@@ -1166,6 +1167,7 @@ class ReceivingContainerServiceBehaviorTest {
order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A");
order.setInboundCode("IN-1");
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
org.mockito.Mockito.lenient().when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line));
org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order));
@@ -20,7 +20,7 @@ import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class AppointmentWorkPreArrangementPersistenceTest {
@Test void siblingPlanChangeCannotGrantOrRemovePhysicalPreArrangementAndNeverAuthorizesDeparture() {
@Test void siblingPlanChangeNeverAuthorizesUnconfirmedFbaCargoToEnterDispatch() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var sources=context.getBean(AppointmentSourceService.class);
var cargo=List.of(source("B",1,60),source("D",2,40));
@@ -40,17 +40,17 @@ class AppointmentWorkPreArrangementPersistenceTest {
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources,new AppointmentDispatchAvailability(store));
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates);
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertBindable(tx,binding,mapper,before);
assertFalse(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertNotBindable(tx,binding,mapper,before);
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons());
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertBindable(tx,binding,mapper,after);
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertNotBindable(tx,binding,mapper,after);
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
doReturn(List.of(bound)).when(mapper).bindings("DP-B");
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
@@ -65,13 +65,11 @@ class AppointmentWorkPreArrangementPersistenceTest {
assertTrue(mapper.byDispatch(20).isEmpty());
}
}
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
tx.executeWithoutResult(status->{
private static void assertNotBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(status->{
binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion()));
binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),candidate.allocations());
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
});
}));
assertTrue(mapper.byDispatch(20).isEmpty());
}
private static FbaAppointmentDetails plan(String status) {return new FbaAppointmentDetails(LocalDate.of(2026,10,25),null,null,null,null,null,status,"仓库暂无确认",null,null);}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */
class AppointmentDispatchAvailabilityTest {
private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class));
/** 预约头成功不能替代当前客户的有效确认,取消及无时间均不放行。 */
@Test void onlyCurrentConfirmedCustomerSourceAndDestinationCanEnterDispatch() {
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isEqualTo(20);
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"B"),"A")).isZero();
assertThat(count(snapshot("CANCELLED","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
assertThat(count(snapshot("ACTIVE","PENDING","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",null,"A"),"A")).isZero();
var confirmed=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A");
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).isZero();
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:1","FBA:ONT8")).isZero();
}
/** 夏令时歧义及未维护时区不能猜成一个可排时间。 */
@Test void invalidOrAmbiguousLocalTimeCannotAuthorizeDispatch() {
assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A"),"A")).isZero();
var before=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A");var old=before.agreements().get(0);
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.of(2026,11,1,1,30),LocalDateTime.of(2026,11,1,1,15),"SUCCESS",null,"-04:00","-05:00");
var explicit=new Agreement(old.agreementKey(),old.version(),old.status(),old.destinationKey(),details,old.timezone(),old.members(),old.warehouseEvidence());
assertThat(count(new BatchReceiving(before.batchKey(),List.of(explicit),List.of(),List.of()),"A")).isEqualTo(20);
}
/** 批读分块不丢客户确认,不逐托重复查询。 */
@Test void batchSnapshotReadHonorsExistingProviderChunkLimit() {
var store=mock(FbaReceivingStore.class);var reader=new AppointmentDispatchAvailability(store);
when(store.snapshots(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().map(key->new BatchReceiving(key,List.of(),List.of(),List.of())).toList();});
var keys=java.util.stream.IntStream.rangeClosed(1,401).mapToObj(i->"FBA_APPOINTMENT:"+i).toList();
assertThat(reader.snapshots(keys)).hasSize(401);verify(store,times(3)).snapshots(anyList());
}
/** 使用与页面相同的稳定身份检查来源覆盖。 */
private int count(BatchReceiving snapshot,String customer) {return policy.confirmedPackages(snapshot,customer,"ORDER:1","FBA:ACY2");}
/** 构造独立客户的正式收货确认,避免靠预约头状态模拟成功。 */
private BatchReceiving snapshot(String status,String result,String timezone,LocalDateTime at,String customer) {
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",at,null,result,null,null,null);
var member=new Member("FBA_APPOINTMENT:1","YY-1",0,customer,List.of(new Source("ORDER:1","FBA:ACY2",20,"v1",1,"IN-1")),"客户甲");
return new BatchReceiving("FBA_APPOINTMENT:1",List.of(new Agreement("RA-1",0,status,"FBA:ACY2",details,timezone,List.of(member),"确认回复")),List.of(),List.of());
}
}
@@ -51,6 +51,7 @@ class AppointmentWorkDispatchPersistenceTest {
private AppointmentWorkDispatchCandidates candidates;
private InboundFbaAppointmentService appointments;
private AppointmentSourceService sources;
private FbaReceivingStore receiving;
private final String batch="FBA_APPOINTMENT:1";
/** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */
@@ -78,8 +79,21 @@ class AppointmentWorkDispatchPersistenceTest {
when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of()));
when(sources.canonicalOrders(anyList())).thenAnswer(c->{List<AppointmentExternalSourceGateway.OrderRef> orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();});
doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged();
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources);
var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
receiving=mock(FbaReceivingStore.class);
when(receiving.currentAgreement(eq(batch),anyString())).thenAnswer(call->confirmed(call.getArgument(1)));
when(receiving.snapshots(anyList())).thenAnswer(call->List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(batch,List.of(confirmed("101"),confirmed("202")),List.of(),List.of())));
var availability=new AppointmentDispatchAvailability(receiving);
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources,availability);
var target=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,candidates);
org.springframework.test.util.ReflectionTestUtils.setField(target,"allocationReader",new AppointmentDispatchAllocationReader(new AppointmentDispatchAllocationRepository(mapper),sources,availability));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
}
/** 成功场景明确提供本客户原来源的正式收货时间,不借其他客户确认通过。 */
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement confirmed(String customer) {
var details=new FbaAppointmentDetails(null,null,null,"CONF-"+customer,java.time.LocalDateTime.of(2026,10,26,10,0),null,"SUCCESS",null,null,null);
var source=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Source("101".equals(customer)?"ORDER:1":"BOOKING:real-2","FBA:ONT8","101".equals(customer)?80:160,"v1",1,"IN-"+customer);
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member(batch,"AP-1",0,customer,List.of(source),null);
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-"+customer,0,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库明确确认");
}
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
private void seed(long item,String customer,long order,int count,String destination) {
@@ -157,4 +171,36 @@ class AppointmentWorkDispatchPersistenceTest {
int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
} finally {pool.shutdownNow();}
}
/** 同一票两托在原车补齐预约分配,数量仍按各托真实来源核验。 */
@Test void lateConfirmedAppointmentCanBindBothExistingPalletsWithoutReplacingCargo() {
jdbc.update("UPDATE ll_dispatch_item SET package_count=40 WHERE id=11");
jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=40,counted_package_count=40 WHERE pallet_id=111");
seed(12,"101",1,40,"FBA");
var original=jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
tx.executeWithoutResult(s->bind(11,12));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"));
}
/** 正式确认未完成时,候选和写入都阻止提前排车,不留下部分分配。 */
@Test void unconfirmedAppointmentBlocksBothCandidateAndBindingBeforeReview() {
when(receiving.currentAgreement(batch,"101")).thenReturn(null);
when(receiving.snapshots(anyList())).thenReturn(List.of());
assertFalse(candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L)).blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
}
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),order_no VARCHAR(40),inbound_code VARCHAR(40))");
jdbc.update("INSERT INTO ll_order VALUES(1,'101','WC-1','IN-1'),(2,'202','WC-2','IN-2'),(3,'303','WC-3','IN-3')");
tx.executeWithoutResult(s->bind(11));
var identity=new CandidatePhysical("101",111L);
var option=binding.allocationOptions(1,List.of(identity)).get(identity).get(0);
assertEquals("WC-1",option.orderNo());assertEquals("IN-1",option.inboundCode());assertEquals(80,option.options().get(0).availablePackages());
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(2,99,999,'FBA_APPOINTMENT:1','101','ORDER:1','FBA:ONT8',5)");
assertEquals(75,binding.allocationOptions(1,List.of(identity)).get(identity).get(0).options().get(0).availablePackages());
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
}
}
+34
View File
@@ -4,6 +4,39 @@ import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
/** 首页八个业务入口的脚本审计;登记范围不是运行证据。 */
export function describeHomepageBusinessCoverage() {
const scope = describeAcceptanceScope()
return [
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment'],
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
covered: '退货预报、审核、签收、合箱、换标、客户确认及换标后出库',
remaining: '换标、客户确认及换标后出库已接入默认后续模块;固定客户跨客户边界仍缺资源,真实HTTP待验' },
{ title: '美国本土卡', route: '/cross-border/domestic-trucking/create', modules: ['phase4-overseas-business'],
covered: '创建、编辑、审核及原单实收、两托部分至全部分配、派车、运输完成,逐步幂等重放', remaining: '独立本土卡车正常及部分分配链已接入;超托审核与真实HTTP待验,不能替代车队签收证据' },
{ title: '美本土散板', route: '/cross-border/pallet-shipping/create', modules: ['phase4-overseas-business', 'phase5-overseas-fulfillment'],
covered: '原单创建、需求池接车、PDA实收、原车加货及共享装车签收,核对原单终态与数量', remaining: '正常链已接入默认编排;异常履约、完整费用边界及真实HTTP待验' },
{ title: '海外采购', route: '/cross-border/procurement/create', modules: ['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
covered: '创建、备注编辑、审核驳回;固定客户正常样品采购至寄客户及资金核对已接入默认链',
remaining: '固定客户已接入正常样品采购、收货、核费及寄客户;异常、退款及其他采购类型未全覆盖,真实HTTP待验' },
{ title: '贴标申请', route: '/overseas-warehouse/labeling/create', modules: ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
covered: '客户原申请审核、PDA换标、客户确认、同单新箱唛出库签收、车次释放及原费用结算回查', remaining: '异常边界与真实HTTP仍待验' },
{ title: '仓储转运', route: '/overseas-warehouse/storage-transfer/create', modules: ['phase4-overseas-client-services'],
covered: '客户申请、提交、审核与拒绝、取消边界;已有库存原单分配、关联换标、出库、异仓收货及唯一库存流水与结算终态核验', remaining: '在途转运入库计量、多来源及部分收货异常矩阵仍待补;真实HTTP待验' },
{ title: '货物废弃', route: '/overseas-warehouse/cargo-disposal/create', modules: ['phase4-overseas-client-services'],
covered: '申请、拒绝释放、批准锁箱、PDA照片与实际扣库、客户确认及终态重放', remaining: '已接入同一申请公共库存总账核对;独立箱/SKU流水证据、多来源边界及真实HTTP仍待验' },
].map(business => ({ ...business,
defaultModules: business.modules.filter(name => scope.defaultModules.includes(name)),
additionalModules: (business.additionalModules || []).map(name => ({ name,
defaultSelected: scope.defaultModules.includes(name),
skippedWithFixedResources: scope.fixedResourceSkippedModules.includes(name) })),
endToEndCoverage: 'INCOMPLETE', currentVersionVerified: false,
}))
}
/** 只输出白名单统计和模块名,不转抄报告中的账号、地址、凭据或业务载荷。 */
export function summarizeReport(report) {
if (!report || !Array.isArray(report.steps) || report.steps.length === 0
@@ -53,6 +86,7 @@ async function main(argv) {
const result = {
purpose: '离线验收范围总览,不执行HTTP,不证明当前部署通过',
admin: { entry: 'scripts/admin-test-env-acceptance.mjs', ...scope },
homepageBusinesses: describeHomepageBusinessCoverage(),
otherEntries: [
{ entry: 'scripts/client-test-env-acceptance.mjs', scope: 'Client全量HTTP,独立执行' },
{ entry: 'scripts/client-phase2-test-env-acceptance.mjs', scope: '测量计费专项,不替代Client全量' },
+46 -3
View File
@@ -1,13 +1,56 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { summarizeReport } from './acceptance-overview.mjs'
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
import { summarizeReport, describeHomepageBusinessCoverage } from './acceptance-overview.mjs'
import { readFileSync } from 'node:fs'
import { describeAcceptanceScope, normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
test('业务覆盖清单逐项对应客户端首页八个入口,未闭环不可标记通过', () => {
const homepage = readFileSync(new URL('../../apps/client-web/src/modules/dashboard/views/DashboardView.vue', import.meta.url), 'utf8')
const actions = [...homepage.matchAll(/\{ title: '([^']+)'[^\n]+route: '([^']+)'/g)]
.map(match => ({ title: match[1], route: match[2] }))
const businesses = describeHomepageBusinessCoverage()
assert.equal(actions.length, 8)
assert.deepEqual(businesses.map(({ title, route }) => ({ title, route })), actions)
const scope = describeAcceptanceScope()
const registered = new Set([...scope.defaultModules, ...scope.optionalModules])
for (const business of businesses) {
assert.ok(business.modules.every(name => registered.has(name)))
assert.deepEqual(business.defaultModules, business.modules, `${business.title}正常链必须默认执行`)
assert.ok(business.modules.every(name => !scope.fixedResourceSkippedModules.includes(name)), `${business.title}不能因固定客户跳过`)
assert.ok(business.additionalModules.every(({ name }) => registered.has(name)))
assert.ok(business.remaining)
assert.equal(business.currentVersionVerified, false)
assert.equal(business.endToEndCoverage, 'INCOMPLETE')
}
assert.ok(businesses.find(item => item.title === '海外采购').modules.includes('phase4-overseas-wallet-and-pallet-cost'))
assert.ok(businesses.find(item => item.title === '退货换标').modules.includes('phase4-overseas-return-followups'))
})
test('八大业务定向执行保留正常履约的直接前置顺序', () => {
const dependencies = [
['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
['phase4-overseas-client-services', 'phase4-overseas-dispatch'],
['phase4-overseas-business', 'phase5-overseas-fulfillment'],
['phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
]
for (const [producer, consumer] of dependencies) {
const planned = normalizeModuleOrder([consumer])
assert.ok(planned.includes(producer), `${consumer}缺少${producer}`)
assert.ok(planned.indexOf(producer) < planned.indexOf(consumer))
assert.equal(planned.filter(name => name === producer).length, 1)
}
})
test('注册表返回独立副本,默认与可选不重复', () => {
const scope = describeAcceptanceScope()
assert.ok(scope.defaultModules.includes('appointment-work'))
assert.ok(scope.optionalModules.includes('phase4-overseas-return-followups'))
assert.ok(scope.defaultModules.includes('phase4-overseas-return-followups'))
assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name)))
assert.ok(scope.fixedResourceSkippedModules.includes('customers'))
assert.ok(!scope.fixedResourceSkippedModules.includes('suppliers'))
scope.fixedResourceSkippedModules.length = 0
assert.ok(describeAcceptanceScope().fixedResourceSkippedModules.length > 0)
scope.defaultModules.length = 0
assert.ok(describeAcceptanceScope().defaultModules.length > 0)
})
+8 -6
View File
@@ -5,14 +5,16 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', '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 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-return-followups', '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', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['customers', 'supplemental-boundaries', 'system', 'master-data',
'notifications', 'pricing', 'phase4-overseas-master-data'])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -106,8 +108,8 @@ const MODULE_DEPENDENCIES = new Map([
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']],
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations', 'phase4-overseas-client-services']],
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch', 'phase4-overseas-business']],
['phase5-terminal-drayage', ['phase4-overseas-pda']],
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']],
@@ -291,6 +293,7 @@ export function describeAcceptanceScope() {
return {
defaultModules: [...DEFAULT_MODULES],
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_MODULES],
}
}
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
@@ -821,8 +824,7 @@ export class AcceptanceRun {
}
async executeModule(name, handler) {
if (this.options.fixedCustomerId && new Set(['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications',
'pricing', 'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']).has(name)) {
if (this.options.fixedCustomerId && FIXED_RESOURCE_SKIPPED_MODULES.has(name)) {
this.addCoverageGap(name, 'fixed-resource-lifecycle', '固定资源模式排除新增主档及账户生命周期',
'业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化', 'not-implemented', { blocking: false })
this.modules.push({ name, success: false, skipped: true, passed: 0, failed: 0,
+45 -3
View File
@@ -1,5 +1,47 @@
# Admin 测试环境 HTTP 模块
样品采购履约每次聚合回查均核对原客户、原采购单号和有效版本;创建重放及客户最终详情同样校验身份,串单立即停止后续写入。`procurement-sample-fulfillment-contract.test.mjs` 离线走完整个分次收货、双箱打包、两段交寄及 1 元货款、2 元运费冻结、1.5 元实际运费结算链,另以串单和重放重复扣款验证失败路径;这不代表真实钱包或部署已验收。
货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。
本土卡车履约每次请求前后保存检查点:总编排提供 `checkpoint` 时沿用其证据持久化,否则独占创建 `reports/us-truck-checkpoints/<marker>.json`。同批次文件已存在即拒绝重跑,不覆盖旧证据;落盘失败不发送请求,发送后未知结果停止后续命令。记录原请求体、拒绝预期、诊断和最终状态,不记录会话凭据。对应磁盘失败、未知响应及新执行覆盖拒绝均有离线合同。
本土卡车默认履约在正式实收和分配前,各用原单增加一条无效命令:逐托箱数超过请求总箱数、重复选同一托盘。必须明确返回参数错误并带诊断标识,随后回查原单数量、状态、历史和版本完全不变,才继续正常链。两托原单仅分配首托时,还尝试进入待派车,必须返回 409/A0409 状态冲突并保留诊断标识,整个原单回查不变后才继续分配第二托;未知结果、无关拒绝或意外接受均停止。此边界不增加客户或业务单据,不替代超托审核。
客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运链存在源码待回归风险:PDA 完工调用 `settlePdaCompletion`,而 `completeWarehouseTransferFulfillment` 当前只接受 `FROZEN/NOT_REQUIRED`;脚本保留正式收货及结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。
`warehouse-transfer-receipt.mjs` 在默认原转运出库后复用异仓既有公共空闲托盘,要求目的 FBA 一致、主档启用及库位可预留;不足时失败,不创建或修改公共资源。按原出库仓位正式收货,核对完成命令的权威目标、目标库存位置、唯一新 SKU 入库一箱流水、原键重放及客户详情,再有限轮询关联换标 `COMPLETED/SETTLED`。逐请求保存 `reports/warehouse-transfer-receipt-checkpoints/<marker>.json`;未知结果停写,不覆盖同批次,不直接修改资金状态。离线合同为 `warehouse-transfer-receipt-contract.test.mjs`;只补正常已有库存转运链,在途、多来源、部分收货与异常矩阵仍未全覆盖,真实 HTTP 待运行。
`warehouse-transfer-outbound.mjs` 由默认客户业务链在原转运关联换标确认后调用,仅接受登记的新 SKU 实托预留。出库前核对原单和零 SHIP 流水;出库后独立核对同客户、同仓、原入仓码、新 SKU、实际托盘号的唯一扣一箱流水,原键重放后单据及流水均不变,客户回查一致且关联换标同步 `OUTBOUNDED`。不另建转运单或客户;未知结果停写,证据保存于 `reports/warehouse-transfer-outbound-checkpoints/<marker>.json`,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-outbound-contract.test.mjs`;收货及关联换标结算由 warehouse-transfer-receipt.mjs 继续核验,尚未运行真实 HTTP。
已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。检查点独立保存 `reports/transfer-relabel-checkpoints/<marker>.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。
`warehouse-transfer-allocation.mjs` 由默认 `phase4-overseas-client-services.mjs` 在本轮已有库存转运审核通过后调用。仅分配原入仓单、同 SKU、同客户同仓的真实可用仓位,核对关联换标申请唯一、同键重放及客户详情一致,达到 `WAIT_RELABEL`。不另建客户或替代转运单;请求前后保存 `reports/warehouse-transfer-checkpoints/<marker>.json`,未知结果停写,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-allocation-contract.test.mjs`;关联换标完工与转运出库已衔接,收货已由后续助手衔接,尚未运行真实 HTTP。
客户首页贴标原申请已接入默认 `phase4-overseas-dispatch.mjs` 的 `createClientRelabelDispatch`:总入口显式展开 `phase4-overseas-client-services`,从其 `relabelFulfillment` 读取本轮已确认申请;按 RELABEL 来源、申请号、客户、仓库及全部目标托盘精确选货,创建独立原生排车并核验幂等与逐行明细。不能复用 `phase4OverseasRelabel` 另一份样本的通过结果。检查点同时保存客户贴标来源和排车请求;创建后交由默认 phase5-overseas-fulfillment 串行执行装车、同单新箱唛出库核验、正式签收及车次资源释放;先释放主车,再分别履约旧样本和客户原申请,避免固定资源池重叠占用。deliveryExecution 保存独立终态且随 phase5 检查点落盘;签收后由 verifyClientRelabelSettlement 独立只读轮询原申请资金结算;必须同来源、同仓、原测试费用0.01且 SETTLED,真实 HTTP 未运行。合同复用 `phase4-overseas-dispatch-contract.test.mjs`。
`looseboard-fulfillment.mjs` 已由默认 `phase5-overseas-fulfillment` 调用;总入口将 `phase4-overseas-business` 展开为前置,保留同一张待接车散板单。串接余位发布、原单绑定、PDA 实收、独立 STOCK_IN 数量及唯一性核验、原车加货和重新两级审核,再由原有主链核验全部客户库存、SHIP、装车发车和签收,最后回查原散板的 booking、来货、车次、派送和签收箱数达到 COMPLETED。使用同一固定客户、车队资源池及 PDA;测试约定单价25/托,逐托测量明确为合成 TEST 样本。检查点沿用 `reports/phase5-checkpoints/<marker>.json` 并包含散板证据,未知结果停写。离线合同为 `looseboard-fulfillment-contract.test.mjs`;当前只补脚本,真实 HTTP、异常履约及页面仍未验收。
`looseboard-pool-dispatch.mjs` 接受散板原单已运营确认的实收证据,原车零扫码时显式退回审核,经需求池 `arrange-dispatch` 正式排入本车唯一主单,重放核对唯一性并逐行核对原货不丢失、新增实托与箱数一致。保留原容量计划,仅追加本次实收托盘及测量;使用共享排车写入边界,未知结果全批停写。终态为 `BOUND_AWAITING_REVIEW`,由默认 looseboard-fulfillment.mjs 继续重新审核、装车、签收与流水核验。离线合同为 `looseboard-pool-dispatch-contract.test.mjs`。
`looseboard-pool-receipt.mjs` 衔接散板原单的已确认 booking,登记唯一来货,按调用方明确提供的逐托测量经 PDA 草稿、生成、绑定、合成测试照片和完成打托,最后运营确认实收并核验重放不变。检查同客户、同仓、同目的地、原托身份及逐托箱数;请求前后写总入口 checkpoint,未知结果停止,不创建客户或主档,不取消现场。此助手仅达到 `RECEIVED_READY_FOR_DISPATCH`,默认 looseboard-fulfillment.mjs 已衔接独立入库流水核验、原车加货及后续共享履约。离线合同为 `looseboard-pool-receipt-contract.test.mjs`,不代表真实 HTTP 通过。
`looseboard-pool-preparation.mjs` 是 `looseboard-pool-binding.mjs` 的车次前置编排:仅接受本轮已核验、同仓同车同司机、原排车尚未扫码发运的车次;已有有效容量计划直接复用,缺计划才依据本轮真实实托生成明确标记为非实测的受控预计计划。随后创建/发布余位,分别同键重放核验,调用原散板单绑定助手。过程经总入口 checkpoint 保存原请求和诊断,未知结果停止,不创建车辆、司机或客户。离线合同为 `looseboard-pool-preparation-contract.test.mjs`。此编排由默认 looseboard-fulfillment.mjs 调用;脚本接入不代表真实 HTTP 已通过。
`looseboard-pool-binding.mjs` 由默认 looseboard-fulfillment.mjs 经余位准备调用,负责原散板单接车。输入必须是本轮登记的待接单原单、本轮车次及其 `looseboardOffer` 发布证据;核对客户、版本、同仓目的地和容量后,携带 `sourceOrderNo/sourceOrderVersion` 创建询价及确认承运,重放核对唯一性,再回查原单进入待入仓且关联同一 booking/trip。约定单价由调用方明确提供,不使用历史订单或自动创建客户。逐请求通过总入口 checkpoint 保存诊断,未知结果停止,不自动取消。离线用例位于 `looseboard-pool-binding-contract.test.mjs`;完整编排和运行限制见 looseboard-fulfillment.mjs 条目;此助手单独不代表签收。
`us-truck-fulfillment.mjs` 由默认 `phase4-overseas-business.mjs` 衔接本轮已审核的独立本土卡车原单,调用正式实收、分配、待派车、派车、运输和完成接口。原单至少两箱时分为两托(1箱与余量),依次验证部分分配及全部分配;单箱仅实收一托并明确未覆盖部分分配。逐托箱数取自原订单;每步核对客户、版本、托盘身份、数量及历史,并执行同键重放和详情回查。不再在 finally 取消这张卡车单,失败保留现场,未知写入不重发;请求诊断与结果放在总入口报告的 `phase4OverseasBusiness.truck.fulfillment`。不创建客户、车辆或司机;订单实收托盘属于业务数据。当前只补脚本,尚未真实 HTTP 运行;该独立订单完成不代表车队 POD 签收,超托审核仍待补;部分分配已在两托原单默认链覆盖。离线合同位于 `phase4-overseas-business-contract.test.mjs`。
`client-relabel-fulfillment.mjs` 由默认 `phase4-overseas-client-services.mjs` 衔接客户刚创建的同一张单箱贴标申请:审核与资金冻结、PDA箱标/SKU核验、原箱换标组托、合成完工图片、正常完工和客户确认,达到 `READY_FOR_OUTBOUND/WAIT_PRE_DISPATCH`。来源严格匹配本轮入仓码、仓库、余额和实际托盘;固定客户先核验既有余额,不补款。新目标托盘属于本轮换标业务产物。逐请求写入 `reports/client-relabel-checkpoints/<marker>.json`,未知结果停写,同批次不覆盖重跑,完成后保存 `relabelFulfillment.targetPalletIds` 等供同单后续排车使用。此助手只到待排车;默认排车及 phase5 后续已衔接同一申请到签收和资源释放,资金结算仍单独核验。离线合同不代表真实HTTP通过。
`cargo-discard-fulfillment.mjs` 为默认 `phase4-overseas-client-services.mjs` 的内部后续:仅衔接本批已批准的单箱一件代发废弃申请,复用同仓 PDA,扫码解析、上传合成测试图片、确认实际废弃、核对公共余额减一箱及唯一 DISCARD 总账,再由客户确认完成,重放两端完成命令核对库存、历史和时间不变。每次请求前保存 `reports/cargo-discard-checkpoints/<marker>.json`,未知结果停写,不自动重跑或清理;不创建客户或公共资源。多来源/多箱边界及独立箱、SKU、箱级流水底层取证仍保留缺口,不能用公共总账替代底层证据。真实 HTTP 以后执行;离线合同只验证编排与断言。
固定客户模式下 `phase4-overseas-wallet-and-pallet-cost.mjs` 不再整模块跳过:默认链调用既有 `procurement-sample-fulfillment.mjs` 完成样品采购、分次海外收货、装箱与面单、预估冻结、国际交寄、国内收货、实际核费及寄客户。使用本轮已核验固定客户,再次核对会话归属,收货仓从正式采购候选复用;既有可用现金须至少 3 元,正常样本实扣 2.50 元,不充值、不修改授信、不创建客户或仓库。仍按原依赖展开;定向入口为 `--modules phase4-overseas-wallet-and-pallet-cost`。凭据来自显式环境文件,失败保留报告及 `reports/procurement-wallet-checkpoints/<marker>.json`,未知结果停写,同批次不自动重跑。合成图片仅作测试凭证,客户会话在 finally 退出。授信充值、贴标资金、托盘汇总及拆柜成本综合场景继续列为覆盖缺口;该分支不代表采购异常、退款与全部类型已验收。离线检查不代表已实际执行 TEST HTTP。
固定客户模式下 `suppliers` 执行既有供应商四类启用筛选、分页、统计、详情身份与平台作用域拒绝的只读检查,不再整模块跳过;缺少某类样本时保留详情覆盖缺口。新增、编辑、停用生命周期仍明确未验收,不创建或修改供应商。`acceptance-overview.mjs` 从执行入口直接输出 `fixedResourceSkippedModules`,区分默认注册与固定资源模式实际跳过的模块。
客户端首页八大业务的脚本覆盖审计统一通过 `node scripts/acceptance-overview.mjs` 的 `homepageBusinesses` 查看,逐项对应首页实际标题及路由,并由离线测试检查八个入口无遗漏。输出区分默认模块、补充模块、固定资源跳过项、已写场景与剩余闭环;这是源码覆盖审计,不是当前 HTTP 或页面通过证明。审核通过、其他样本的 PDA 作业和独立专项通过均不能替代同一业务单的后续履约闭环。
固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。
国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。
@@ -14,7 +56,7 @@
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
@@ -146,7 +188,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。
`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。
`phase4-overseas-return-followups.mjs` 已接入默认全量,在退货签收合箱后执行换标、客户确认及普通/换标后出库;显式只选签收模块仍不运行后续。前置必须是同批次、同客户、主链断言通过的退货资源,仓库严格沿用实际退货收货仓并核对当前启用状态,不回退其他仓。固定客户模式保留跨客户、跨仓和并发边界缺口,不创建第二客户;后续复用既有 PDA 池,两个模块均退出本轮客户会话。写请求网络结果未知不自动重试。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;当前只补脚本,真实 HTTP 及页面仍待执行。
退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。
@@ -329,7 +371,7 @@ export async function run(context) {
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外业务 HTTP:采购创建/备注/审核边界,独立卡车原单履约完成,保留散板原单供默认主链接车 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` |
@@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
function harness(fault = '') {
const calls = [], persisted = []
let discarded = false, confirmed = false, repeats = 0, invalidPhotoRejected = false
const proof = { approvedNo: 'CD202610220001', customerId: '101', warehouseId: '201', inboundCode: 'RK-TEST', skuCode: 'SKU-TEST',
status: 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING' }
const task = { discardNo: proof.approvedNo, customerId: '101', overseasWarehouseId: '201', palletNo: 'PALLET-TEST',
status: 'PENDING', skus: [{ itemId: '301', skuCode: proof.skuCode, quantityUnit: '箱', plannedPackages: 1 }], cartonMarks: ['BOX-TEST'] }
const context = { marker: 'DISCARD-TEST', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { marker: 'DISCARD-TEST' } },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
let data
if (method === 'GET') data = [{ ...task, customerId: fault === 'wrong customer' ? '999' : '101' }]
else if (path.endsWith('/resolve')) data = task
else if (path.endsWith('/confirm-completion')) { confirmed = true; data = {} }
else if (path.endsWith('/confirm')) {
if (body.photoFileIds.length === 2) {
invalidPhotoRejected = true
return { http: 200, requestId: 'request-test', traceId: fault === 'photo missing trace' ? null : 'trace-test',
body: { code: fault === 'photo accepted' ? '200' : fault === 'photo wrong code' ? 'A0400' : 'A0409', data: null } }
}
if (fault === 'unknown confirm') throw new Error('network unknown')
if (discarded) repeats++
discarded = true
data = { ...task, status: 'COMPLETED', skus: [{ ...task.skus[0], actualPackages: 1 }] }
} else throw new Error(`unexpected ${path}`)
return { http: 200, body: { code: '200', data } }
},
async requestMultipart(method, path, form, token) {
calls.push({ method, path, token })
assert.ok(form.get('file'))
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
return { http: 200, body: { code: '200', data: { fileId: '401', palletNo: task.palletNo } } }
},
}
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret',
async checkpoint() {
if (fault === 'disk') throw new Error('disk full')
persisted.push(structuredClone(proof.fulfillment))
},
async snapshot() {
const quantity = discarded ? fault === 'no deduction' ? 4 : fault === 'replay deduction' && repeats ? 2 : 3 : 4
const ledger = discarded ? [{ businessOrderNo: proof.approvedNo, businessType: 'DISCARD', deltaPackages: -1,
id: '501', customerId: proof.customerId, overseasWarehouseId: proof.warehouseId, palletNo: task.palletNo,
beforePackages: 4, afterPackages: 3,
skuCode: proof.skuCode, inboundCode: proof.inboundCode }] : []
if (discarded && fault === 'duplicate ledger') ledger.push({ ...ledger[0] })
if (discarded && fault === 'ledger customer') ledger[0].customerId = '999'
if (discarded && fault === 'ledger warehouse') ledger[0].overseasWarehouseId = '999'
if (discarded && fault === 'ledger pallet') ledger[0].palletNo = 'OTHER-PALLET'
if (discarded && fault === 'ledger identity') delete ledger[0].id
if (discarded && fault === 'ledger arithmetic') ledger[0].afterPackages = 4
return { balances: [{ inboundCode: proof.inboundCode, skuCode: proof.skuCode, totalPackages: quantity, availablePackages: quantity }],
ledger, detail: { summary: { discardNo: proof.approvedNo, customerId: proof.customerId, overseasWarehouseId: proof.warehouseId,
status: confirmed ? 'COMPLETED' : discarded ? 'PDA_COMPLETED' : 'APPROVED', totalPackages: 1,
actualPackages: discarded || invalidPhotoRejected && fault === 'photo drift' ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
} }
return { context, inputs, calls, persisted }
}
test('discard executes same application through PDA and customer completion with zero-drift replays', async () => {
const h = harness()
const result = await verifyCargoDiscardFulfillment(h.context, h.inputs)
assert.equal(result.status, 'HTTP_VERIFIED')
assert.equal(h.inputs.proof.status, 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING')
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 4)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/photos/files')).length, 1)
assert.ok(!JSON.stringify(h.persisted).includes('client-secret'))
assert.ok(!JSON.stringify(h.persisted).includes('pda-secret'))
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /已批准申请|不自动重跑/)
})
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk',
'ledger customer', 'ledger warehouse', 'ledger pallet', 'ledger identity', 'ledger arithmetic',
'photo accepted', 'photo wrong code', 'photo missing trace', 'photo drift']) {
test(`discard stops on ${fault}`, async () => {
const h = harness(fault)
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs))
assert.equal(h.inputs.proof.fulfillment.status, 'FAILED')
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
if (fault === 'wrong customer') assert.equal(h.calls.length, 1)
if (fault === 'disk') assert.equal(h.calls.length, 0)
if (fault === 'unknown confirm') {
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 2)
assert.equal(h.inputs.proof.fulfillment.requests.at(-1).status, 'UNKNOWN_RESULT')
}
})
}
test('foreign batch or customer fails before HTTP or checkpoint', async () => {
const h = harness()
h.inputs.proof.customerId = '999'
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /本轮已批准申请/)
assert.equal(h.calls.length, 0)
assert.equal(h.persisted.length, 0)
})
@@ -0,0 +1,127 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-client-services'
const PDA = '/api/pda/v1/last-leg/cargo-discards'
const CLIENT = '/api/merchant/v1/last-leg/cargo-discards'
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
const integer = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
/** 仅衔接本轮单箱一件代发废弃申请;所有写入前保存请求,未知结果不重试。 */
export async function verifyCargoDiscardFulfillment(context, { proof, merchantToken, pdaToken, snapshot, checkpoint }) {
if (!proof?.approvedNo || proof.status !== 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
|| !merchantToken || !pdaToken || typeof snapshot !== 'function'
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| context.createdResources.phase4OverseasClientServices?.marker !== context.marker) {
throw new Error('废弃履约缺少本轮已批准申请、客户或PDA会话')
}
if (proof.fulfillment) throw new Error('已有废弃履约证据,不自动重跑')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('废弃批次标识不合法')
const evidence = proof.fulfillment = { marker: context.marker, status: 'RUNNING', requests: [], snapshots: {} }
let initialized = false
const directory = new URL('../../reports/cargo-discard-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const require = async (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'cargo-discard-fulfillment', deploymentBlocker: true })
if (!passed) throw new Error(name)
}
async function command(method, path, body, token, upload = false, expectedConflict = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, expectedConflict, status: 'SENDING' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
: await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
if (expectedConflict) {
await require('废弃重复照片明确冲突且保留诊断', [200, 409].includes(response?.http)
&& ['409', 'A0409'].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
return response.body.data
}
await require(`废弃履约 ${method} ${path}`, ok(response))
return response.body.data
}
const capture = async name => {
const state = await snapshot()
evidence.snapshots[name] = structuredClone(state); await persist(); return state
}
const matchTask = task => task?.discardNo === proof.approvedNo && task?.customerId === proof.customerId
&& task?.overseasWarehouseId === proof.warehouseId && typeof task?.palletNo === 'string' && task.palletNo.length > 0
try {
await persist()
const before = await capture('before')
await require('废弃执行前仍为同客户同仓已批准单箱申请', before.detail?.summary?.discardNo === proof.approvedNo
&& before.detail.summary.customerId === proof.customerId && before.detail.summary.overseasWarehouseId === proof.warehouseId
&& before.detail.summary.status === 'APPROVED' && Number(before.detail.summary.totalPackages) === 1)
const tasks = await command('GET', PDA, undefined, pdaToken)
const selected = Array.isArray(tasks) ? tasks.filter(task => task.discardNo === proof.approvedNo) : []
await require('本轮废弃任务唯一且客户仓库一致', selected.length === 1 && matchTask(selected[0]))
const task = selected[0]
await require('单箱废弃任务明细和真实箱唛完整', task.status === 'PENDING' && task.skus?.length === 1
&& task.skus[0].skuCode === proof.skuCode && task.skus[0].quantityUnit === '箱'
&& Number(task.skus[0].plannedPackages) === 1 && /^[1-9]\d*$/.test(String(task.skus[0].itemId))
&& task.cartonMarks?.length === 1 && typeof task.cartonMarks[0] === 'string' && task.cartonMarks[0].length > 0)
const resolved = await command('POST', `${PDA}/resolve`, { discardNo: proof.approvedNo, palletNo: task.palletNo }, pdaToken)
await require('PDA扫码解析保持原任务及明细', stable(resolved) === stable(task))
const root = `${PDA}/${encodeURIComponent(proof.approvedNo)}/pallets/${encodeURIComponent(task.palletNo)}`
const form = new FormData()
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')],
{ type: 'image/png' }), `${context.marker}-synthetic-discard.png`)
const photo = await command('POST', `${root}/photos/files`, form, pdaToken, true)
await require('废弃图片绑定当前托盘', /^[1-9]\d*$/.test(String(photo?.fileId)) && photo.palletNo === task.palletNo)
const body = { lines: [{ itemId: String(task.skus[0].itemId), actualPackages: 1 }], photoFileIds: [String(photo.fileId)],
scannedCartonMarks: [...task.cartonMarks], confirmAllCartons: false }
const beforeInvalid = await capture('beforeDuplicatePhoto')
await command('POST', `${root}/confirm`, { ...body, photoFileIds: [String(photo.fileId), String(photo.fileId)] }, pdaToken, false, true)
await require('重复照片拒绝后申请与库存零漂移', stable(await capture('duplicatePhotoRejected')) === stable(beforeInvalid))
const completedTask = await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成保留原任务身份和实际数量', matchTask(completedTask) && completedTask.palletNo === task.palletNo
&& completedTask.status === 'COMPLETED' && completedTask.skus?.length === 1
&& String(completedTask.skus[0].itemId) === String(task.skus[0].itemId)
&& Number(completedTask.skus[0].actualPackages) === 1)
const completed = await capture('pdaCompleted')
await require('客户详情等待客户确认且实际废弃数量为一箱', completed.detail?.summary?.status === 'PDA_COMPLETED'
&& completed.detail.summary.discardNo === proof.approvedNo && Number(completed.detail.summary.actualPackages) === 1)
const scopedBalances = state => state.balances.filter(row => row.inboundCode === proof.inboundCode && row.skuCode === proof.skuCode)
const prior = scopedBalances(before), after = scopedBalances(completed)
await require('废弃库存快照包含同一来源余额', prior.length > 0 && prior.length === after.length
&& [...prior, ...after].every(row => integer(row.totalPackages) && integer(row.availablePackages)))
const sum = (rows, field) => rows.reduce((value, row) => value + Number(row[field]), 0)
await require('PDA废弃只扣本来源一箱总量和可用量', sum(prior, 'totalPackages') - sum(after, 'totalPackages') === 1
&& sum(prior, 'availablePackages') - sum(after, 'availablePackages') === 1)
const movements = completed.ledger.filter(row => row.businessOrderNo === proof.approvedNo)
await require('本申请只有一笔正确废弃总账', !before.ledger.some(row => row.businessOrderNo === proof.approvedNo)
&& movements.length === 1 && movements[0].businessType === 'DISCARD' && Number(movements[0].deltaPackages) === -1
&& /^[1-9]\d*$/.test(String(movements[0].id))
&& movements[0].customerId === proof.customerId && movements[0].overseasWarehouseId === proof.warehouseId
&& movements[0].palletNo === task.palletNo
&& movements[0].skuCode === proof.skuCode && movements[0].inboundCode === proof.inboundCode
&& integer(movements[0].beforePackages) && integer(movements[0].afterPackages)
&& Number(movements[0].beforePackages) - Number(movements[0].afterPackages) === 1)
await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成重放库存与单据零漂移', stable(await capture('pdaReplay')) === stable(completed))
const clientPath = `${CLIENT}/${encodeURIComponent(proof.approvedNo)}/confirm-completion`
await command('POST', clientPath, undefined, merchantToken)
const confirmed = await capture('customerCompleted')
await require('客户确认完成且库存不再扣减', confirmed.detail?.summary?.status === 'COMPLETED'
&& Boolean(confirmed.detail.summary.customerConfirmedAt)
&& stable(confirmed.balances) === stable(completed.balances) && stable(confirmed.ledger) === stable(completed.ledger))
await command('POST', clientPath, undefined, merchantToken)
await command('POST', `${root}/confirm`, body, pdaToken)
await require('客户及PDA终态重放保持原完成事实', stable(await capture('terminalReplay')) === stable(confirmed))
evidence.status = 'HTTP_VERIFIED'; proof.status = 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING'
proof.remainingEvidence = '公共库存总账已核对;独立箱、SKU及箱级流水底层证据仍待验证。'
await persist()
return evidence
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
}
}
@@ -0,0 +1,126 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
function harness(fault = '', transfer = false) {
const calls = [], saved = [], commands = new Set()
const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201',
sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' }
let status = 'PENDING_REVIEW', moved = false, scanned = false, version = 0, taskStatus = 'AVAILABLE'
const item = { itemId: '401', expectedPackages: 1, cartonMark: 'BOX-TEST', fbaCartonMark: 'FBA-TEST',
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'SKU', newSkuCode: 'NEW', quantity: 1 }] }
const detail = () => ({ application: { relabelNo: proof.relabelNo, customerOrderNo: proof.sourceOrderNo,
lastlegOrderId: '1001', reviewChannel: transfer ? 'RELABEL_ORDER' : 'TAIL_ORDER',
overseasWarehouseId: fault === 'wrong warehouse' ? '999' : '201', status,
walletStatus: fault === 'no funds' ? 'PENDING' : 'FROZEN', fulfillmentStatus: status === 'READY_FOR_OUTBOUND' ? transfer && fault !== 'wrong fulfillment' ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH' : null },
items: [{ sourceInventoryPositionId: '301', cartonMark: 'BOX-TEST' }] })
const task = () => ({ relabelNo: proof.relabelNo, taskStatus, version, applicationStatus: status,
relabelledPackages: scanned ? 1 : 0, skippedPackages: 0, exceptionPackages: 0, items: [item] })
const context = { marker: proof.marker, token: 'admin-token', context: { merchantQualificationFixture: { tenantId: '101' } },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
const repeated = body?.idempotencyKey && commands.has(body.idempotencyKey)
if (body?.idempotencyKey) commands.add(body.idempotencyKey)
let data
if (path.endsWith('/tenant/profile')) data = { id: fault === 'wrong customer' ? '999' : '101', status: 'ACTIVE' }
else if (path.endsWith('/status') || path.includes('/relabel-review?')) {
assert.equal(path.endsWith('/status'), transfer)
status = 'PENDING_PROCESSING'; data = detail()
}
else if (path.endsWith('/claim')) { taskStatus = 'PROCESSING'; if (!repeated) version++; data = task() }
else if (path.endsWith('/cartons/scan')) { if (!repeated) version++; data = task() }
else if (path.endsWith('/units')) {
if (fault === 'unknown scan') throw new Error('unknown scan response')
scanned = true; if (!repeated) version++; data = task()
if (fault === 'scan replay drift' && repeated) data.relabelledPackages = 2
} else if (path.endsWith('/relabel-pallet-label')) data = { palletId: '501', palletCode: 'TARGET' }
else if (path.endsWith('/pallets/resolve')) {
const source = body.code === 'SOURCE'
data = { palletCode: body.code, palletVersion: moved ? 1 : 0,
totalPackages: source ? moved ? fault === 'no source deduction' ? 3 : 2 : 3 : moved ? 1 : 0 }
} else if (path.endsWith('/relabel-disassemble')) { moved = true; data = { status: 'COMPLETED', operationId: '601', replayed: Boolean(repeated) } }
else if (path.endsWith('/confirm-completion')) { status = 'READY_FOR_OUTBOUND'; data = detail() }
else if (path.endsWith('/complete')) { status = 'CUSTOMER_CONFIRM_PENDING'; taskStatus = 'WAIT_REVIEW'; data = task() }
else if (path.includes('/relabel-tasks/')) data = task()
else if (path.includes('/warehouse-transfers/')) data = { transferNo: proof.sourceOrderNo, status: 'ALLOCATED', relabelNo: proof.relabelNo,
relabelApplicationId: '801', allocations: [{ status: 'RESERVED', packageCount: 1, palletId: fault === 'wrong target' ? '999' : '501',
inboundCode: 'IN-1', inventoryPositionId: '901', skuCode: 'NEW' }] }
else data = detail()
return { http: 200, body: { code: '200', data } }
},
async requestMultipart(method, path, body, token) {
calls.push({ method, path, token })
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
return { http: 200, body: { code: '200', data: { fileId: '701' } } }
} }
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret', delay: async () => {},
checkpoint: async () => { if (fault === 'disk') throw new Error('disk full'); saved.push(structuredClone(proof.execution)) } }
if (transfer) {
inputs.transferProof = { ...proof, transferNo: proof.sourceOrderNo, status: 'WAIT_RELABEL', inboundCode: 'IN-1',
relabelApplicationId: '801', allocation: { status: 'ALLOCATED_WAIT_RELABEL' }, relabelFulfillment: proof }
context.createdResources = { phase4OverseasClientServices: { transferFulfillment: inputs.transferProof } }
}
return { context, inputs, calls, saved }
}
test('original customer relabel reaches ready for dispatch through real command sequence and exact replays', async () => {
const h = harness()
const result = await verifyClientRelabelFulfillment(h.context, h.inputs)
assert.equal(result.status, 'READY_FOR_OUTBOUND')
assert.deepEqual(result.targetPalletIds, ['501'])
assert.equal(result.execution.status, 'HTTP_VERIFIED_READY_FOR_DISPATCH')
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/relabel-disassemble')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
assert.ok(!JSON.stringify(h.saved).includes('client-secret'))
assert.ok(!JSON.stringify(h.saved).includes('pda-secret'))
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /不自动重跑/)
})
for (const fault of ['wrong customer', 'wrong warehouse', 'no funds', 'unknown scan', 'scan replay drift', 'no source deduction', 'disk']) {
test(`relabel stops before completion on ${fault}`, async () => {
const h = harness(fault)
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
assert.equal(h.inputs.proof.execution.status, 'FAILED')
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
if (fault === 'no funds') assert.equal(h.calls.some(call => call.path.endsWith('/claim')), false)
if (fault === 'unknown scan') {
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 1)
assert.equal(h.inputs.proof.execution.requests.at(-1).status, 'UNKNOWN_RESULT')
}
if (fault === 'disk') assert.equal(h.calls.length, 0)
})
}
test('foreign-batch relabel is rejected before any HTTP', async () => {
const h = harness()
h.inputs.proof.marker = 'OLD'
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /本轮客户申请/)
assert.equal(h.calls.length, 0)
})
test('已有库存转运原关联换标完成后回到原转运新SKU预留', async () => {
const h = harness('', true)
await verifyClientRelabelFulfillment(h.context, h.inputs)
assert.equal(h.inputs.transferProof.status, 'ALLOCATED')
assert.equal(h.inputs.proof.execution.status, 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND')
assert.equal(h.inputs.transferProof.outboundAllocations[0].palletId, '501')
})
for (const fault of ['wrong fulfillment', 'wrong target']) {
test(`转运关联换标拒绝 ${fault}`, async () => {
const h = harness(fault, true)
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
assert.equal(h.inputs.transferProof.status, 'WAIT_RELABEL')
assert.equal(h.inputs.proof.execution.status, 'FAILED')
})
}
test('未登记转运分配证据不得操作关联换标', async () => {
const h = harness('', true)
h.inputs.transferProof = { ...h.inputs.transferProof }
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /原分配证据/)
assert.equal(h.calls.length, 0)
})
@@ -0,0 +1,158 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-client-services'
const PALLETS = '/api/pda/v1/last-leg/pallet-operations'
const validId = value => /^[1-9]\d*$/.test(String(value))
const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 原申请单箱贴标作业链;独立贴标准备排车,关联转运恢复原单预留。 */
export async function verifyClientRelabelFulfillment(context, { proof, transferProof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
// 已有库存转运的换标必须回到原转运预留,不进入普通贴标排车链。
if (transferProof && (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== transferProof
|| transferProof.relabelFulfillment !== proof || transferProof.marker !== context.marker
|| transferProof.status !== 'WAIT_RELABEL' || transferProof.allocation?.status !== 'ALLOCATED_WAIT_RELABEL'
|| transferProof.relabelNo !== proof?.relabelNo || transferProof.transferNo !== proof?.sourceOrderNo
|| transferProof.customerId !== proof?.customerId || transferProof.warehouseId !== proof?.warehouseId
|| transferProof.sourcePositionId !== proof?.sourcePositionId || transferProof.sourcePalletNo !== proof?.sourcePalletNo)) {
throw new Error('转运关联换标缺少原分配证据')
}
if (proof?.marker !== context.marker || !proof?.relabelNo || !merchantToken || !pdaToken
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| !validId(proof.warehouseId) || !validId(proof.sourcePositionId) || !proof.sourcePalletNo) {
throw new Error('贴标后续缺少本轮客户申请、来源仓位及会话')
}
if (proof.execution) throw new Error('已有贴标执行证据,不自动重跑')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('贴标批次标识不合法')
const evidence = proof.execution = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL(transferProof ? '../../reports/transfer-relabel-checkpoints/' : '../../reports/client-relabel-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(transferProof || proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true })
if (!passed) throw new Error(name)
}
const key = action => `${context.marker}-CLIENT-RELABEL-${action}`
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}`
async function command(method, path, body, token, upload = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
: await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
require(`贴标后续 ${method} ${path}`, response?.http === 200 && String(response?.body?.code) === '200')
return response.body.data
}
const read = () => command('GET', platform, undefined, context.token)
const sameApplication = detail => detail?.application?.relabelNo === proof.relabelNo
&& detail.application.customerOrderNo === proof.sourceOrderNo
&& String(detail.application.overseasWarehouseId) === proof.warehouseId
const resolve = palletCode => command('POST', `${PALLETS}/pallets/resolve`, { code: palletCode }, pdaToken)
try {
await persist()
const profile = await command('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken)
require('贴标当前客户会话身份有效', String(profile?.id) === proof.customerId && profile?.status === 'ACTIVE')
const owned = await command('GET', merchant, undefined, merchantToken)
require('客户可读取同一贴标申请', sameApplication(owned))
const original = await read()
require('贴标原申请客户、仓库、来源仓位与待审核状态一致', sameApplication(original)
&& original.application.status === 'PENDING_REVIEW' && original.items?.length === 1
&& String(original.items[0].sourceInventoryPositionId) === proof.sourcePositionId)
require('贴标审核渠道与业务来源一致', original.application.reviewChannel === (transferProof ? 'RELABEL_ORDER' : 'TAIL_ORDER')
&& validId(original.application.lastlegOrderId))
const reviewPath = transferProof ? `${platform}/status`
: `/api/platform/v1/last-leg/orders/${original.application.lastlegOrderId}/relabel-review?customerId=${encodeURIComponent(proof.customerId)}`
const reviewed = await command('PUT', reviewPath, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
require('贴标审核推进同一申请', sameApplication(reviewed) && reviewed.application.status === 'PENDING_PROCESSING')
let funded
for (let attempt = 0; attempt < 31; attempt++) {
funded = await read()
require('贴标等待资金期间申请身份不变', sameApplication(funded))
if (funded.application.walletStatus === 'FROZEN') break
if (attempt < 30) await delay()
}
require('贴标费用已冻结后才开始PDA作业', funded.application.walletStatus === 'FROZEN')
const claim = { idempotencyKey: key('claim') }
let task = await command('POST', `${work}/claim`, claim, pdaToken)
const replay = await command('POST', `${work}/claim`, claim, pdaToken)
require('认领原键重放不改变任务', stable(task) === stable(replay))
require('PDA原申请单箱任务及版本有效', task.relabelNo === proof.relabelNo && task.taskStatus === 'PROCESSING'
&& count(task.version) && task.items?.length === 1 && Number(task.items[0].expectedPackages) === 1)
const item = task.items[0]
require('PDA箱唛及SKU校验来自原申请', validId(item.itemId) && Boolean(item.cartonMark)
&& ['CARTON_AND_SKU', 'SKU_LABEL'].includes(item.operationType) && item.skuItems?.length > 0
&& item.skuItems.every(sku => sku.skuCode && sku.newSkuCode && count(sku.quantity) && Number(sku.quantity) > 0))
if (item.operationType === 'CARTON_AND_SKU') task = await command('POST', `${work}/cartons/scan`,
{ cartonMark: item.cartonMark, expectedVersion: Number(task.version), idempotencyKey: key('carton') }, pdaToken)
const scan = { itemId: String(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark || item.newSkuCode,
skuVerifications: item.skuItems.map(sku => ({ skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity) })),
expectedVersion: Number(task.version), idempotencyKey: key('unit') }
task = await command('POST', `${work}/units`, scan, pdaToken)
const scannedReplay = await command('POST', `${work}/units`, scan, pdaToken)
require('单箱贴标完成且扫描重放不重复计数', task.relabelNo === proof.relabelNo && Number(task.relabelledPackages) === 1
&& Number(task.skippedPackages) === 0 && Number(task.exceptionPackages) === 0 && stable(task) === stable(scannedReplay))
const detail = await read()
require('组托前来源位置和箱唛仍属于原申请', sameApplication(detail) && detail.items?.length === 1
&& String(detail.items[0].sourceInventoryPositionId) === proof.sourcePositionId && detail.items[0].cartonMark === item.cartonMark)
const target = await command('POST', `${PALLETS}/relabel-pallet-label`, { relabelNo: proof.relabelNo, idempotencyKey: key('label') }, pdaToken)
require('换标生成本次业务目标托盘', validId(target?.palletId) && Boolean(target.palletCode) && target.palletCode !== proof.sourcePalletNo)
const from = await resolve(proof.sourcePalletNo), to = await resolve(target.palletCode)
require('源目标托盘数量版本有效', from?.palletCode === proof.sourcePalletNo && to?.palletCode === target.palletCode
&& [from, to].every(value => count(value?.totalPackages) && count(value?.palletVersion)))
const binding = { relabelNo: proof.relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, cartonMarks: [item.cartonMark],
reason: context.marker, idempotencyKey: key('bind') }
const bound = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const boundReplay = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const fromAfter = await resolve(proof.sourcePalletNo), toAfter = await resolve(target.palletCode)
require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true
&& String(bound.operationId) === String(boundReplay.operationId)
&& Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1)
task = await command('GET', work, undefined, pdaToken)
const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true)
require('换标完工图片返回正式文件引用', validId(photo?.fileId))
const complete = { expectedVersion: Number(task.version), photoFileIds: [String(photo.fileId)], manualCompletion: false,
idempotencyKey: key('complete'), remark: context.marker }
const completed = await command('POST', `${work}/complete`, complete, pdaToken)
const completedReplay = await command('POST', `${work}/complete`, complete, pdaToken)
require('PDA完工及重放等待客户确认', completed.relabelNo === proof.relabelNo && completed.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
&& stable(completed) === stable(completedReplay))
const confirm = { idempotencyKey: key('confirm'), remark: context.marker }
const confirmed = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
const finalReplay = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
require('客户确认原申请进入对应出库流程且原键重放零漂移', sameApplication(confirmed)
&& confirmed.application.status === 'READY_FOR_OUTBOUND'
&& confirmed.application.fulfillmentStatus === (transferProof ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH')
&& stable(confirmed) === stable(finalReplay))
if (transferProof) {
const transfer = await command('GET', `/api/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferProof.transferNo)}?customerId=${encodeURIComponent(proof.customerId)}`, undefined, context.token)
const active = transfer?.allocations?.filter(row => row.status === 'RESERVED') || []
require('关联换标完成后原转运保留新SKU实托唯一预留', transfer?.transferNo === transferProof.transferNo
&& transfer.status === 'ALLOCATED' && transfer.relabelNo === proof.relabelNo
&& String(transfer.relabelApplicationId) === transferProof.relabelApplicationId
&& active.length === 1 && active[0].packageCount === 1 && String(active[0].palletId) === String(target.palletId)
&& active[0].inboundCode === transferProof.inboundCode && validId(active[0].inventoryPositionId)
&& active[0].skuCode === item.skuItems[0].newSkuCode)
Object.assign(transferProof, { status: 'ALLOCATED', outboundAllocations: active })
}
Object.assign(proof, { status: 'READY_FOR_OUTBOUND', targetPalletIds: [String(target.palletId)],
sourceCartonMarks: [item.cartonMark], relabelCartonMarks: [item.fbaCartonMark].filter(Boolean) })
evidence.status = transferProof ? 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' : 'HTTP_VERIFIED_READY_FOR_DISPATCH'
await persist(); return proof
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
}
}
@@ -37,7 +37,8 @@ export async function dispatchEvidenceContext(context) {
scoped.checkpoint = async () => {
const resources = context.createdResources
const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary,
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch })
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch,
clientRelabel: resources.phase4OverseasClientServices?.relabelFulfillment })
await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
await rename(`${path}.tmp`, path)
}
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
function fixture(fault = '') {
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', finalStatus: 'PENDING_ACCEPTANCE' }
const tripProof = { marker: 'FT-independent', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12', stops: [{}, { destinationKey: 'FBA:TEST' }] }
const calls = [], stages = []; let reviewed = false, complete = false
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, label, _r, ok) { assert.ok(ok, label) }, async checkpoint() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.includes('/local-looseboard-orders/')) data = { summary: { orderNo: 'LB-1', customerId: '101',
status: complete ? 'COMPLETED' : 'PENDING_ACCEPTANCE', estimatedPalletCount: 2, packageCount: 3,
fleetArrangement: complete ? { fulfillmentStage: 'COMPLETED', bookingNo: 'BK-1', tripNo: 'TR-1', dispatchNo: 'DP-1',
incomingNo: 'IN-1', deliveryNo: 'DE-1', signedPackages: fault === 'signed quantity' ? 2 : 3 } : null } }
else if (path.includes('/inventory/ledger?')) data = { total: 1, records: [{ id: '91', businessOrderNo: 'IN-1', businessType: 'STOCK_IN',
customerId: '101', overseasWarehouseId: '42', deltaPackages: fault === 'ledger' ? 4 : 3 }] }
else if (method === 'POST') {
stages.push(path.split('/').at(-1))
reviewed = stages.length === 3
data = { recordVersion: stages.length + 1 }
} else data = { dispatchNo: 'DP-1', stage: reviewed ? 'LOADING' : 'BEFORE_LOADING', recordVersion: 1, counts: { pickedPackageCount: 0 },
reviewState: 'APPROVED', reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } },
vehicleInfo: { vehicle: { vehicleId: fault === 'vehicle' ? '99' : '11' }, driver: { driverId: '12' } } }
return { http: 200, body: { code: '200', data }, requestId: 'req', traceId: 'trace' }
} }
const helpers = {
async prepare(ctx, args) {
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
demand.poolBinding = { bookingNo: 'BK-1', createRequest: { cargo: { packageCount: 3 } } }
},
async receive(ctx, args) {
assert.equal(args.pdaToken, 'pda'); assert.deepEqual(args.measurements.map(m => m.cartonCount), [2, 1])
demand.receipt = { incomingNo: 'IN-1' }
},
async arrange() { return { status: 'BOUND_AWAITING_REVIEW', dispatchNo: 'DP-1', addedItems: [{ sourceKey: 'NEW-1' }, { sourceKey: 'NEW-2' }] } },
}
return { context, tripProof, demand, helpers, calls, stages, markCompleted: () => { complete = true } }
}
test('default orchestration uses original demand and existing trip, then verifies original signed order', async () => {
const f = fixture()
const result = await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
assert.equal(result.proof.status, 'READY_FOR_SHARED_LOADING')
assert.deepEqual(f.stages, ['submit-review', 'dispatch-review', 'loading-review'])
assert.deepEqual(result.proof.sourceKeys, ['NEW-1', 'NEW-2'])
f.markCompleted()
await verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }])
assert.equal(f.demand.finalStatus, 'COMPLETED')
assert.equal(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
})
for (const fault of ['ledger', 'vehicle']) test(`orchestration stops on ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers))
assert.equal(f.demand.fulfillment.status, 'FAILED')
if (fault === 'ledger') assert.equal(f.stages.length, 0)
})
test('whole vehicle signed does not substitute for original looseboard signed quantity', async () => {
const f = fixture('signed quantity')
await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
f.markCompleted()
await assert.rejects(verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }]))
assert.notEqual(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
})
@@ -0,0 +1,85 @@
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
import { dispatchIo, version } from './grouped-dispatch-acceptance.mjs'
/** 默认主链复用原散板原车;测量仅为明确TEST样本,不代表生产实测。 */
export async function attachLooseboardToTrip(context, tripProof, pdaToken,
helpers = { prepare: prepareLooseboardPool, receive: receiveLooseboardPool, arrange: arrangeLooseboardPool }) {
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard
if (!demand || demand.marker !== context.marker || demand.finalStatus !== 'PENDING_ACCEPTANCE'
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !pdaToken || demand.fulfillment) throw Error('默认散板履约缺少本轮待接车原单或已执行')
const proof = demand.fulfillment = { marker: context.marker, status: 'ATTACHING', requests: [] }
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-MAIN')
const check = (name, ok) => io.check(name, null, Boolean(ok))
try {
const original = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '读取原散板货量')
const row = original?.summary
check('原散板身份和货量可供受控实收', row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId)
&& row.status === 'PENDING_ACCEPTANCE' && Number.isSafeInteger(row.estimatedPalletCount) && row.estimatedPalletCount > 0
&& Number.isSafeInteger(row.packageCount) && row.packageCount >= row.estimatedPalletCount)
const destinationKey = tripProof.stops?.[1]?.destinationKey
check('原车存在明确卸货目的地', typeof destinationKey === 'string' && destinationKey.length > 0)
// 当前受控样本以每实托100×100×90cm、40kg执行,原单箱数均分且总数守恒。
const measurements = Array.from({ length: row.estimatedPalletCount }, (_, index) => ({
cartonCount: Math.floor(row.packageCount / row.estimatedPalletCount) + (index < row.packageCount % row.estimatedPalletCount ? 1 : 0),
lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
}))
proof.measurements = { kind: 'CONTROLLED_TEST_MEASUREMENT', measurements }
await helpers.prepare(context, { demand, tripProof, destinationKey, unitPrice: 25 })
await helpers.receive(context, { demand, tripProof, pdaToken, measurements })
const ledger = await io.command('GET', `/api/platform/v1/last-leg/inventory/ledger?${new URLSearchParams({
customerId: String(demand.customerId), overseasWarehouseId: String(tripProof.warehouseId),
businessOrderNo: demand.receipt.incomingNo, businessType: 'STOCK_IN', page: '1', size: '200',
})}`, undefined, '独立核验原散板来货入库流水')
check('散板实收仅产生足量且唯一的本票入库流水', Array.isArray(ledger?.records) && ledger.records.length > 0
&& Number(ledger.total) === ledger.records.length && new Set(ledger.records.map(r => String(r.id))).size === ledger.records.length
&& ledger.records.every(r => /^[1-9]\d*$/.test(String(r.id)) && r.businessOrderNo === demand.receipt.incomingNo
&& r.businessType === 'STOCK_IN' && String(r.customerId) === String(demand.customerId)
&& String(r.overseasWarehouseId) === String(tripProof.warehouseId)
&& Number.isSafeInteger(Number(r.deltaPackages)) && Number(r.deltaPackages) > 0)
&& ledger.records.reduce((sum, r) => sum + Number(r.deltaPackages), 0) === row.packageCount)
proof.stockInLedger = ledger.records
const arranged = await helpers.arrange(context, { demand, tripProof })
check('原散板全部实收货物已经加入本车', arranged?.status === 'BOUND_AWAITING_REVIEW' && arranged.dispatchNo === tripProof.dispatchNo
&& arranged.addedItems?.length === measurements.length && new Set(arranged.addedItems.map(r => r.sourceKey)).size === measurements.length)
const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
let current = await io.command('GET', path, undefined, '散板加入后重新审核原车')
check('加货后仅在原车装车前重新审核', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING'
&& Number(current.counts?.pickedPackageCount) === 0)
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
current = await io.command('POST', `${path}/${action}`, { recordVersion: version(current.recordVersion), idempotencyKey: io.key(action),
...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }, `散板加货后${action}`)
}
const reviewed = await io.command('GET', path, undefined, '核对加货后两级审核及原车资源')
check('加货审核后原车资源不变且进入装车', reviewed?.stage === 'LOADING' && reviewed.reviewState === 'APPROVED'
&& reviewed.reviews?.dispatchReview?.status === 'APPROVED' && reviewed.reviews?.loadingReview?.status === 'APPROVED'
&& String(reviewed.vehicleInfo?.vehicle?.vehicleId) === String(tripProof.vehicleId)
&& String(reviewed.vehicleInfo?.driver?.driverId) === String(tripProof.driverId))
proof.status = 'READY_FOR_SHARED_LOADING'
proof.sourceKeys = arranged.addedItems.map(row => row.sourceKey)
proof.incomingNo = demand.receipt.incomingNo
await context.checkpoint()
return { proof, detail: reviewed }
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error
}
}
/** 同车正常签收后回查原散板商业单,不能用整车状态代替原单履约。 */
export async function verifyLooseboardDelivered(context, tripProof, deliveries) {
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard, proof = demand?.fulfillment
if (demand?.marker !== context.marker || proof?.status !== 'READY_FOR_SHARED_LOADING') throw Error('缺少本轮原散板装车前证据')
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-COMPLETE')
const detail = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '签收后回查原散板终态')
const row = detail?.summary, arrangement = row?.fleetArrangement
const passed = row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId) && row.status === 'COMPLETED'
&& arrangement?.fulfillmentStage === 'COMPLETED' && arrangement.bookingNo === demand.poolBinding.bookingNo
&& arrangement.tripNo === tripProof.tripNo && arrangement.dispatchNo === tripProof.dispatchNo
&& arrangement.incomingNo === demand.receipt.incomingNo && Number(arrangement.signedPackages) === demand.poolBinding.createRequest.cargo.packageCount
&& deliveries.some(d => d.deliveryNo === arrangement.deliveryNo && String(d.customerId) === String(demand.customerId))
io.check('原散板通过真实同车签收完成且数量守恒', null, Boolean(passed))
Object.assign(proof, { status: 'COMPLETED_HTTP_VERIFIED', deliveryNo: arrangement.deliveryNo })
demand.finalStatus = 'COMPLETED'
await context.checkpoint()
}
@@ -0,0 +1,87 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
// 有状态模拟保留原单与独立询价,重放返回原响应,故障必须在后续写入前停止。
function fixture(fault = '') {
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', version: 2, finalStatus: 'PENDING_ACCEPTANCE' }
const tripProof = { tripNo: 'TR-1', warehouseId: '42', looseboardOffer: { marker: 'fixture', offerNo: 'OF-1' } }
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', warehouseId: '42', status: 'OPEN', tripRecordVersion: 1, recordVersion: 0,
canCreateInquiry: true, canConfirm: true, capacityKnown: true,
destinations: [{ destinationKey: 'FBA:TEST', remaining: { palletSpaces: fault === 'capacity' ? 0 : 4, weightKg: 400, volumeCbm: 4 } }] }
const original = { remark: 'fixture-original', summary: { ...demand, status: 'PENDING_ACCEPTANCE', estimatedPalletCount: 2,
packageCount: 3, totalWeightKg: 80, totalVolumeCbm: 1.8, productName: 'TEST', currency: 'USD', originContactName: 'TEST', originContactPhone: '19900000000' } }
let created, confirmed, writes = 0
const calls = [], snapshots = []
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
async checkpoint() {
if (fault === 'disk') throw Error('disk unavailable')
snapshots.push(structuredClone(demand.poolBinding))
},
async request(method, path, body) {
calls.push({ method, path, body: structuredClone(body) })
if (method === 'POST') writes += 1
let data
if (path.includes('/local-looseboard-orders/')) {
data = structuredClone(original)
if (fault === 'customer') data.summary.customerId = '102'
if (confirmed) {
data.summary.status = 'WAITING_INBOUND'; data.summary.version += 1
data.summary.fleetArrangement = { businessOrderNo: 'LB-1', bookingNo: 'BK-1', tripNo: 'TR-1' }
if (fault === 'binding') data.summary.fleetArrangement.tripNo = 'OTHER'
}
} else if (method === 'GET' && path.endsWith('/OF-1')) data = { offer }
else if (method === 'GET' && path.endsWith('/BK-1')) data = created.inquiry
else if (method === 'POST' && path.endsWith('/OF-1/inquiries')) {
assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(body.sourceOrderVersion, 2)
assert.equal(body.customerId, '101'); assert.equal(body.expectedPalletCount, 2)
assert.equal(body.cargo.packageCount, 3)
if (fault === 'network') return { http: 0 }
if (!created) {
created = { inquiry: { ...body, offerNo: 'OF-1', tripNo: 'TR-1', bookingNo: 'BK-1', status: 'DRAFT', recordVersion: 0 }, offer }
data = created
} else {
data = { ...structuredClone(created), replayed: true }
if (fault === 'replay') data.inquiry.bookingNo = 'BK-2'
}
} else if (method === 'POST' && path.endsWith('/BK-1/confirm')) {
if (!confirmed) {
confirmed = { inquiry: { ...created.inquiry, status: 'CONFIRMED', recordVersion: 1, agreedAmount: fault === 'price' ? 25 : 50,
businessOrderNo: fault === 'new order' ? 'LB-2' : 'LB-1' }, offer }
data = confirmed
} else data = { ...confirmed, replayed: true }
} else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
} }
return { context, args: { demand, tripProof, offerNo: 'OF-1', destinationKey: 'FBA:TEST', unitPrice: 25 }, calls, snapshots, writes: () => writes }
}
test('pool binding preserves original demand and confirms only one commercial order', async () => {
const f = fixture()
const proof = await bindLooseboardDemand(f.context, f.args)
assert.equal(proof.status, 'CONFIRMED_ORIGINAL_ORDER')
assert.equal(f.args.demand.finalStatus, 'WAITING_INBOUND')
assert.equal(f.writes(), 4)
assert.equal(f.snapshots.at(-1).status, 'CONFIRMED_ORIGINAL_ORDER')
await assert.rejects(bindLooseboardDemand(f.context, f.args), /本轮原单/)
assert.equal(f.writes(), 4)
})
for (const [fault, maxWrites] of [['customer', 0], ['capacity', 0], ['network', 1], ['replay', 2], ['new order', 3], ['price', 3], ['binding', 4], ['disk', 0]]) {
test(`pool binding stops on ${fault} without another order or automatic retry`, async () => {
const f = fixture(fault)
await assert.rejects(bindLooseboardDemand(f.context, f.args))
assert.equal(f.args.demand.poolBinding.status, 'FAILED')
assert.equal(f.writes(), maxWrites)
assert.equal(f.calls.some(c => /cancel|\/iam\/|\/auth\/register/.test(c.path)), false)
})
}
test('foreign batch or unregistered trip is rejected before HTTP', async () => {
for (const alter of [f => { f.args.demand.marker = 'other' }, f => { f.context.createdResources.fleetTrips = [] }]) {
const f = fixture(); alter(f)
await assert.rejects(bindLooseboardDemand(f.context, f.args))
assert.equal(f.calls.length, 0)
}
})
@@ -0,0 +1,100 @@
const BASE = '/api/platform/v1/last-leg/fleet/capacity-offers'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'looseboard-pool-binding', deploymentBlocker: true }
const version = value => Number.isSafeInteger(value) && value >= 0
/** 将本轮散板原单绑定本轮已发布车次余位,禁止无来源另建商业订单。 */
export async function bindLooseboardDemand(context, { demand, tripProof, offerNo, destinationKey, unitPrice }) {
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo
|| tripProof.looseboardOffer?.marker !== context.marker || tripProof.looseboardOffer?.offerNo !== offerNo
|| !destinationKey || !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
throw Error('散板匹配缺少本轮原单、车次、发布证据、目的地、约定价格或检查点')
}
const proof = demand.poolBinding = { marker: context.marker, offerNo, tripNo: tripProof.tripNo, status: 'CHECKING', requests: [] }
const check = (name, ok) => {
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${demand.orderNo}`, OPTIONS)
if (!ok) throw Error(name)
}
const request = async (method, path, body) => {
const log = { method, path, status: 'SENDING' }; proof.requests.push(log)
await context.checkpoint()
const response = await context.request(method, path, body)
Object.assign(log, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
const success = response?.http === 200 && String(response?.body?.code) === '200'
log.status = success ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED'
await context.checkpoint()
context.apiSuccess(MODULE, `散板原单接车 ${method}`, response, JSON.stringify(log), OPTIONS)
if (!success) throw Error('散板接车请求失败,保留原单与请求证据,不自动重试')
return response.body.data
}
const originalPath = `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`
const validateInquiry = (row, status) => check('询价归属本轮原车与原客户', row?.tripNo === tripProof.tripNo && row.offerNo === offerNo
&& String(row.customerId) === String(demand.customerId) && row.businessType === 'LOCAL_LOOSEBOARD'
&& row.status === status && row.bookingNo && (!proof.bookingNo || row.bookingNo === proof.bookingNo)
&& version(row.recordVersion) && row.cargo?.destinationKey === destinationKey)
try {
const original = await request('GET', originalPath)
const summary = original?.summary
check('散板原单仍可接车且无已有承运', summary?.orderNo === demand.orderNo && String(summary.customerId) === String(demand.customerId)
&& summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && summary.version === demand.version
&& !summary.fleetArrangement && original.remark?.startsWith(context.marker))
const offer = (await request('GET', `${BASE}/${encodeURIComponent(offerNo)}`))?.offer
const destination = offer?.destinations?.find(row => row.destinationKey === destinationKey)
check('余位为本轮同仓开放车次且容量明确', offer?.offerNo === offerNo && offer.tripNo === tripProof.tripNo
&& String(offer.warehouseId) === String(tripProof.warehouseId) && offer.status === 'OPEN'
&& offer.canCreateInquiry && offer.canConfirm && offer.capacityKnown && destination
&& version(offer.tripRecordVersion) && version(offer.recordVersion))
const pallets = Number(summary.estimatedPalletCount), packages = Number(summary.packageCount)
const weight = Number(summary.totalWeightKg), volume = Number(summary.totalVolumeCbm)
check('接车货量沿用原散板单且不超过余位', Number.isSafeInteger(pallets) && pallets > 0 && Number.isSafeInteger(packages) && packages > 0
&& Number.isFinite(weight) && weight > 0 && Number.isFinite(volume) && volume > 0
&& destination.remaining?.palletSpaces >= pallets && destination.remaining?.weightKg >= weight && destination.remaining?.volumeCbm >= volume)
const body = proof.createRequest = { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId: demand.customerId, businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
sourceOrderNo: demand.orderNo, sourceOrderVersion: summary.version, expectedPalletCount: pallets,
estimatedArrivalAt: new Date(Date.now() + 900000).toISOString(), idempotencyKey: `${context.marker}-looseboard-bind`,
cargo: { destinationKey, cargoName: summary.productName, packageCount: packages, palletSpaces: pallets,
weightKg: weight, volumeCbm: volume, priceBasis: 'PALLET', unitPrice, currency: summary.currency,
contactName: summary.originContactName, contactPhone: summary.originContactPhone, remark: context.marker } }
const created = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
validateInquiry(created?.inquiry, 'DRAFT')
proof.bookingNo = created.inquiry.bookingNo
const replay = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
check('原单询价重放保持唯一承运与版本', replay.replayed === true
&& JSON.stringify(replay.inquiry) === JSON.stringify(created.inquiry) && JSON.stringify(replay.offer) === JSON.stringify(created.offer))
const path = `${BASE}/inquiries/${encodeURIComponent(proof.bookingNo)}`
const latest = await request('GET', path)
validateInquiry(latest, 'DRAFT')
check('确认承运使用完整即时版本', version(latest.tripRecordVersion) && version(latest.offerRecordVersion))
const confirmBody = proof.confirmRequest = { tripRecordVersion: latest.tripRecordVersion, offerRecordVersion: latest.offerRecordVersion,
recordVersion: latest.recordVersion, idempotencyKey: `${context.marker}-looseboard-confirm`, reason: context.marker }
const confirmed = await request('POST', `${path}/confirm`, confirmBody)
validateInquiry(confirmed?.inquiry, 'CONFIRMED')
check('原单约定货量和运费没有漂移', confirmed.inquiry.cargo?.packageCount === packages
&& confirmed.inquiry.cargo?.palletSpaces === pallets && confirmed.inquiry.cargo?.currency === summary.currency
&& Number(confirmed.inquiry.cargo?.unitPrice) === unitPrice
&& Math.round(Number(confirmed.inquiry.agreedAmount) * 100) === Math.round(unitPrice * pallets * 100))
check('承运复用原散板单且尚未伪造来货或排车', confirmed.inquiry.businessOrderNo === demand.orderNo
&& !confirmed.inquiry.incomingNo && !confirmed.inquiry.dispatchNo)
const confirmReplay = await request('POST', `${path}/confirm`, confirmBody)
check('承运确认重放未生成第二单或重复占位', confirmReplay.replayed === true
&& JSON.stringify(confirmReplay.inquiry) === JSON.stringify(confirmed.inquiry) && JSON.stringify(confirmReplay.offer) === JSON.stringify(confirmed.offer))
const result = await request('GET', originalPath)
const arrangement = result?.summary?.fleetArrangement
check('原散板单回写同一承运并进入待入仓', result?.summary?.orderNo === demand.orderNo
&& String(result.summary.customerId) === String(demand.customerId) && result.summary.status === 'WAITING_INBOUND'
&& arrangement?.businessOrderNo === demand.orderNo && arrangement.bookingNo === proof.bookingNo && arrangement.tripNo === tripProof.tripNo
&& !arrangement.incomingNo && !arrangement.dispatchNo)
Object.assign(demand, { finalStatus: 'WAITING_INBOUND', version: result.summary.version, retained: true })
proof.status = 'CONFIRMED_ORIGINAL_ORDER'
await context.checkpoint()
return proof
} catch (error) {
for (const log of proof.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint()
throw error
}
}
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
function fixture(fault = '') {
const tripProof = { tripNo: 'TR-1', dispatchNo: 'DP-1' }
const pallet = { warehousePalletId: '72', palletNo: 'P-2', packages: 3 }
const receipt = { status: 'RECEIVED_READY_FOR_DISPATCH', incomingNo: 'IN-1', pallets: [pallet],
acceptedReceipt: { source: { receiptHash: 'hash', pallets: [{ ...pallet, weightKg: 80, volumeCbm: 1.8 }] } } }
const demand = { marker: 'fixture', customerId: '101', receipt, poolBinding: {
bookingNo: 'BK-1', tripNo: 'TR-1', createRequest: { cargo: { destinationKey: 'FBA:TEST' } } } }
const oldRow = { dispatchItemId: '1', sourceKey: 'OLD', customerId: '101', warehousePalletId: '71', inboundCode: 'OLD', scheduledPackages: 2, pickedPackages: 0 }
const newRow = { dispatchItemId: '2', sourceKey: 'NEW', customerId: '101', ...pallet, inboundCode: 'IN-1', scheduledPackages: 3, pickedPackages: 0 }
const planPallet = { warehousePalletId: '71', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', incomingNo: 'IN-1', status: 'RECEIVED', recordVersion: 2, receipt: receipt.acceptedReceipt }
let stage = 'LOADING', arranged = false, arrangeCalls = 0, returnCalls = 0
const calls = []
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, async checkpoint() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage, recordVersion: 1,
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
else if (path.endsWith('/items')) {
data = { dispatchNo: 'DP-1', items: arranged ? [oldRow, newRow] : [oldRow] }
data = structuredClone(data)
if (arranged && fault === 'lost original') data.items.shift()
if (arranged && fault === 'wrong boxes') data.items[1].scheduledPackages = 4
} else if (path.endsWith('/return-to-review')) {
returnCalls += 1; stage = 'BEFORE_LOADING'
data = { dispatchNo: 'DP-1', action: 'RETURN_TO_REVIEW', replayed: returnCalls > 1 }
} else if (path.endsWith('/capacity-plan')) {
data = { planValid: !arranged, recordVersion: 1, manifestHash: 'hash', capacityPlan: { pallets: [planPallet] }, cargo: [{ dispatchNo: 'DP-1' }] }
if (method === 'POST') {
assert.deepEqual(body.pallets[0], planPallet)
assert.deepEqual(body.pallets[1], { warehousePalletId: '72', palletSpaces: 1, weightKg: 80, volumeCbm: 1.8, deliveryIndex: 1 })
data.planValid = true
}
} else if (path.endsWith('/bookings')) data = { tripRecordVersion: 2, records: [booking] }
else if (path.endsWith('/arrange-dispatch')) {
arrangeCalls += 1
assert.equal('dispatchNo' in body, false)
if (fault === 'network') return { http: 0 }
arranged = true
data = { tripRecordVersion: 3, booking: { ...booking, status: 'BOUND', dispatchNo: fault === 'wrong main' ? 'OTHER' : 'DP-1' }, replayed: arrangeCalls > 1 }
if (fault === 'replay' && arrangeCalls > 1) data.tripRecordVersion += 1
} else if (path.endsWith('/trips/TR-1')) data = { trip: { tripNo: 'TR-1', stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] } }
else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
} }
return { context, args: { demand, tripProof }, calls, counts: () => arrangeCalls }
}
test('original looseboard receipt joins same main preserving existing rows and planning', async () => {
const f = fixture()
const result = await arrangeLooseboardPool(f.context, f.args)
assert.equal(result.status, 'BOUND_AWAITING_REVIEW')
assert.equal(f.counts(), 2)
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
assert.equal(f.counts(), 2)
})
for (const fault of ['picked', 'network', 'wrong main', 'replay', 'lost original', 'wrong boxes']) {
test(`join original main rejects ${fault} and preserves evidence`, async () => {
const f = fixture(fault)
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
assert.equal(f.args.demand.receipt.dispatch.status, 'FAILED')
assert.equal(f.calls.some(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')), false)
if (fault === 'network') {
assert.equal(f.counts(), 1)
assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true)
}
})
}
@@ -0,0 +1,86 @@
import { dispatchIo, replay, version } from './grouped-dispatch-acceptance.mjs'
const API = '/api/platform/v1/last-leg'
const id = value => String(value ?? '')
const rowFact = row => JSON.stringify([id(row.dispatchItemId), row.sourceKey, id(row.customerId), id(row.warehousePalletId),
row.inboundCode, Number(row.scheduledPackages), Number(row.pickedPackages)])
/** 把原散板实收货加入本车唯一主单,并核对既有货物和新增实托守恒。 */
export async function arrangeLooseboardPool(context, { demand, tripProof }) {
const receipt = demand?.receipt, binding = demand?.poolBinding
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|| receipt?.status !== 'RECEIVED_READY_FOR_DISPATCH' || !receipt.incomingNo || receipt.dispatch
|| !receipt.pallets?.length || typeof context.checkpoint !== 'function') throw Error('散板排入原车缺少本轮确认实收证据')
const proof = receipt.dispatch = { marker: context.marker, status: 'ARRANGING', requests: [], dispatchNo: tripProof.dispatchNo }
const io = dispatchIo(context, 'phase4-overseas-business', proof, `LOOSEBOARD-${binding.bookingNo}`)
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const dispatchPath = `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
const check = (name, ok) => io.check(name, null, Boolean(ok))
try {
let current = await io.command('GET', dispatchPath, undefined, '散板加货前原车详情')
const before = await io.command('GET', `${dispatchPath}/items`, undefined, '冻结原车既有货物')
check('原车仍未扫码且原货完整', current?.dispatchNo === tripProof.dispatchNo && current.status === 'ACTIVE'
&& ['BEFORE_LOADING', 'LOADING'].includes(current.stage) && Number(current.counts?.pickedPackageCount) === 0
&& Number(current.counts?.shippedPackageCount) === 0 && before?.dispatchNo === tripProof.dispatchNo
&& Array.isArray(before.items) && before.items.length > 0
&& before.items.every(row => row.inboundCode !== receipt.incomingNo && Number(row.pickedPackages) === 0))
const originalRows = before.items.map(rowFact)
const originalPlan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '冻结原车容量计划')
check('本车加货前容量计划有效', originalPlan?.planValid === true && !originalPlan.blockReason && originalPlan.capacityPlan?.pallets?.length > 0)
if (current.stage === 'LOADING') {
const body = { recordVersion: version(current.recordVersion), idempotencyKey: io.key('return-review'), reason: '本轮零扫码明确退回,加入散板原单实际实收货物' }
const result = await io.command('POST', `${dispatchPath}/return-to-review`, body, '原车零扫码退回审核')
await replay(io, `${dispatchPath}/return-to-review`, body, result, '退回审核同键重放')
current = await io.command('GET', dispatchPath, undefined, '回查原车可加货阶段')
}
check('原车已处装车前', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING')
const list = await io.command('GET', `${tripPath}/bookings`, undefined, '读取原散板承运最新版本')
const rows = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || [], booking = rows[0]
check('仅把原承运原来货排入本车', rows.length === 1 && booking.tripNo === tripProof.tripNo
&& id(booking.customerId) === id(demand.customerId) && booking.incomingNo === receipt.incomingNo
&& booking.status === 'RECEIVED' && !booking.dispatchNo
&& booking.receipt?.source?.receiptHash === receipt.acceptedReceipt?.source?.receiptHash)
const body = proof.arrangeRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(booking.recordVersion), idempotencyKey: io.key('arrange') }
const path = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}/arrange-dispatch`
const arranged = await io.command('POST', path, body, '正式接口排入本车唯一主单')
check('排入结果绑定同一主单和来源', arranged?.booking?.status === 'BOUND' && arranged.booking.bookingNo === binding.bookingNo
&& arranged.booking.tripNo === tripProof.tripNo && arranged.booking.dispatchNo === tripProof.dispatchNo
&& arranged.booking.incomingNo === receipt.incomingNo && id(arranged.booking.customerId) === id(demand.customerId))
const again = await replay(io, path, body, arranged, '排入本车同键重放')
check('排入重放明确返回原结果', again?.replayed === true)
const after = await io.command('GET', `${dispatchPath}/items`, undefined, '加货后回查全部真实货物')
const added = after?.items?.filter(row => row.inboundCode === receipt.incomingNo) || []
check('加货不删除或重复既有货物', after?.dispatchNo === tripProof.dispatchNo && after.items?.length === before.items.length + receipt.pallets.length
&& originalRows.every(fact => after.items.filter(row => rowFact(row) === fact).length === 1))
check('新增行精确对应原散板实收各托箱数', added.length === receipt.pallets.length
&& receipt.pallets.every(p => added.filter(row => id(row.warehousePalletId) === id(p.warehousePalletId)
&& id(row.customerId) === id(demand.customerId) && row.palletNo === p.palletNo
&& Number(row.scheduledPackages) === p.packages && Number(row.pickedPackages) === 0).length === 1))
const plan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '新增实收后读取容量依据')
const detail = await io.command('GET', tripPath, undefined, '读取原车真实卸货站')
check('容量货物仅属于本车唯一主单', plan?.planValid === false && plan.cargo?.every(row => row.dispatchNo === tripProof.dispatchNo))
const destinationKey = binding.createRequest.cargo.destinationKey
const stopIndexes = detail?.trip?.stops?.map((stop, index) => stop.destinationKey === destinationKey ? index : -1).filter(index => index > 0) || []
check('实收目的地对应唯一卸货站', detail?.trip?.tripNo === tripProof.tripNo && stopIndexes.length === 1)
const pallets = structuredClone(originalPlan.capacityPlan.pallets)
for (const p of receipt.pallets) {
const measured = receipt.acceptedReceipt?.source?.pallets?.find(row => id(row.warehousePalletId) === id(p.warehousePalletId))
check('新增容量仅用实际实托及实测', measured && Number(measured.weightKg) > 0 && Number(measured.volumeCbm) > 0
&& !pallets.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)))
pallets.push({ warehousePalletId: p.warehousePalletId, palletSpaces: 1,
weightKg: Number(measured.weightKg), volumeCbm: Number(measured.volumeCbm), deliveryIndex: stopIndexes[0] })
}
const refreshed = await io.command('POST', `${tripPath}/capacity-plan`, { recordVersion: version(plan.recordVersion), manifestHash: plan.manifestHash,
pallets, reason: '保留原货预计占用,追加散板实际实收容量,仍须独立完成装车确认' }, '重核原车加货后的容量计划')
check('原车加货后容量有效', refreshed?.planValid === true && !refreshed.blockReason)
proof.status = 'BOUND_AWAITING_REVIEW'
proof.pallets = receipt.pallets
proof.addedItems = added
await context.checkpoint()
return proof
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint(); throw error
}
}
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
// 模拟原车资源及容量版本;记录所有写入以验证不会创建主档或自动补发。
function fixture(fault = '', validPlan = false) {
const tripProof = { marker: 'fixture', preparation: 'VERIFIED', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12' }
const demand = { marker: 'fixture', finalStatus: 'PENDING_ACCEPTANCE' }
const trip = { ...tripProof, status: 'PLANNED', recordVersion: 1,
stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] }
const plan = { planValid: validPlan, recordVersion: 1, manifestHash: 'a'.repeat(64), availableSegments: [{}],
cargo: [{ dispatchNo: 'DP-1', warehousePalletId: '71', palletNo: 'P-1', destinationKey: 'FBA:TEST' }] }
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 0, tripRecordVersion: 1 }
const calls = [], checkpoints = []; let creates = 0, publishes = 0, bindCalls = 0
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, name, _r, passed) { assert.ok(passed, name) }, apiSuccess() {},
async checkpoint() { if (fault === 'disk') throw Error('disk'); checkpoints.push(structuredClone(tripProof.looseboardOffer)) },
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage: 'LOADING',
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
else if (path.endsWith('/trips/TR-1')) data = { trip: { ...trip, driverId: fault === 'driver' ? '13' : '12' }, dispatches: [{ dispatchNo: 'DP-1' }] }
else if (path.endsWith('/capacity-plan')) {
if (method === 'POST') {
assert.equal(body.pallets[0].warehousePalletId, '71')
assert.equal(body.pallets[0].deliveryIndex, 1)
plan.planValid = true
}
data = structuredClone(plan)
if (fault === 'foreign cargo') data.cargo[0].dispatchNo = 'OTHER'
} else if (path.endsWith('/loading')) data = { confirmedLoad: fault === 'loaded' ? {} : null }
else if (path.endsWith('/capacity-offers') && method === 'POST') {
creates += 1
if (fault === 'network') return { http: 0 }
data = { offer: { ...offer }, replayed: creates > 1 }
if (fault === 'draft replay' && creates > 1) data.offer.offerNo = 'OF-2'
} else if (path.endsWith('/OF-1/publish')) {
publishes += 1
data = { offer: { ...offer, status: 'OPEN', capacityKnown: true, canCreateInquiry: true, canConfirm: true }, replayed: publishes > 1 }
if (fault === 'publish replay' && publishes > 1) data.offer.recordVersion = 9
} else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
} }
const bind = async (ctx, args) => {
bindCalls += 1
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
assert.equal(args.offerNo, 'OF-1'); assert.equal(args.destinationKey, 'FBA:TEST')
assert.equal(tripProof.looseboardOffer.status, 'OPEN')
return { status: 'CONFIRMED_ORIGINAL_ORDER', tripNo: 'TR-1', offerNo: 'OF-1' }
}
return { context, args: { demand, tripProof, destinationKey: 'FBA:TEST', unitPrice: 25 }, bind, calls, checkpoints, counts: () => ({ creates, publishes, bindCalls }) }
}
for (const valid of [false, true]) test(`prepare owned pool and pass original demand to binding; existing plan=${valid}`, async () => {
const f = fixture('', valid)
await prepareLooseboardPool(f.context, f.args, f.bind)
assert.deepEqual(f.counts(), { creates: 2, publishes: 2, bindCalls: 1 })
assert.equal(f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')).length, valid ? 0 : 1)
assert.equal(f.args.tripProof.looseboardOffer.status, 'BOUND_ORIGINAL_DEMAND')
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
assert.equal(f.calls.some(c => /\/vehicles|\/drivers|\/iam/.test(c.path)), false)
})
for (const fault of ['picked', 'driver', 'foreign cargo', 'loaded', 'network', 'draft replay', 'publish replay', 'disk']) {
test(`pool preparation fails closed on ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
assert.equal(f.args.tripProof.looseboardOffer.status, 'FAILED')
assert.equal(f.counts().bindCalls, 0)
if (fault === 'network') assert.equal(f.counts().creates, 1)
if (fault === 'disk') assert.equal(f.calls.length, 0)
assert.equal(f.calls.some(c => /cancel|close|delete/.test(c.path)), false)
})
}
@@ -0,0 +1,97 @@
import { testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs'
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
const API = '/api/platform/v1/last-leg'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'looseboard-pool-preparation', deploymentBlocker: true }
const validVersion = value => Number.isSafeInteger(value) && value >= 0
/** 复用本轮已审核且未扫码车次,发布可核验余位并衔接散板原单。 */
export async function prepareLooseboardPool(context, { demand, tripProof, destinationKey, unitPrice }, bind = bindLooseboardDemand) {
if (!context.createdResources?.fleetTrips?.includes(tripProof)
|| tripProof.preparation !== 'VERIFIED' || !tripProof.tripNo || !tripProof.dispatchNo || tripProof.looseboardOffer
|| context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding || !destinationKey
|| !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
throw Error('散板余位准备缺少本轮原单、已审核未占用车次、约定目的地/价格或检查点')
}
const proof = tripProof.looseboardOffer = { marker: context.marker, status: 'PREPARING', requests: [] }
const check = (name, condition) => {
context.assert(MODULE, name, null, Boolean(condition), `tripNo=${tripProof.tripNo}`, OPTIONS)
if (!condition) throw Error(name)
}
const request = async (method, path, body) => {
const log = { method, path, ...(body ? { body: structuredClone(body) } : {}), status: 'PREPARED' }
proof.requests.push(log)
await context.checkpoint()
let response
try { log.status = 'SENDING'; response = await context.request(method, path, body) }
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
Object.assign(log, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
const ok = response?.http === 200 && String(response?.body?.code) === '200'
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED'
await context.checkpoint()
context.apiSuccess(MODULE, `散板余位准备 ${method}`, response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
if (!ok) throw Error('散板余位准备失败;不得换键重造或自动重试')
return response.body.data
}
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const pool = `${API}/fleet/capacity-offers`
try {
const dispatch = await request('GET', `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`)
check('仅在原排车未扫码未发运时接入散板', dispatch?.dispatchNo === tripProof.dispatchNo && dispatch.status === 'ACTIVE'
&& ['BEFORE_LOADING', 'LOADING'].includes(dispatch.stage)
&& Number(dispatch.counts?.pickedPackageCount) === 0 && Number(dispatch.counts?.shippedPackageCount) === 0)
let detail = await request('GET', tripPath)
check('散板接车复用本轮同仓同车同司机', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'PLANNED'
&& String(detail.trip.warehouseId) === String(tripProof.warehouseId)
&& String(detail.trip.vehicleId) === String(tripProof.vehicleId) && String(detail.trip.driverId) === String(tripProof.driverId)
&& detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
&& detail.trip.stops?.filter(stop => stop.destinationKey === destinationKey).length === 1)
let plan = await request('GET', `${tripPath}/capacity-plan`)
check('容量依据来自本轮原车真实货物', Array.isArray(plan?.cargo) && plan.cargo.length > 0
&& plan.cargo.every(row => row.dispatchNo === tripProof.dispatchNo) && validVersion(plan.recordVersion)
&& typeof plan.manifestHash === 'string' && /^[0-9a-f]{64}$/.test(plan.manifestHash))
if (plan.planValid !== true) {
const pallets = testPlannedCapacity(plan.cargo, detail.trip.stops)
proof.planningBasis = { actual: false, kind: 'CONTROLLED_TEST_PLAN', pallets }
plan = await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets, reason: '仅本轮真实实托的受控预计占用;不得作为装车实测或回执' })
}
check('车次容量计划有效且无阻断', plan?.planValid === true && !plan.blockReason && plan.availableSegments?.length > 0)
const loading = await request('GET', `${tripPath}/loading`)
check('预计容量未冒充实载确认', loading?.confirmedLoad == null)
detail = await request('GET', tripPath)
check('发布前车次仍未发车且版本有效', detail?.trip?.status === 'PLANNED' && validVersion(detail.trip.recordVersion))
const draftBody = { tripNo: tripProof.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000',
remark: context.marker, idempotencyKey: `${context.marker}-looseboard-offer` }
const draft = await request('POST', pool, draftBody)
check('余位草稿属于原车且版本完整', draft?.offer?.tripNo === tripProof.tripNo && draft.offer.status === 'DRAFT'
&& typeof draft.offer.offerNo === 'string' && draft.offer.offerNo.length > 0
&& validVersion(draft.offer.tripRecordVersion) && validVersion(draft.offer.recordVersion))
proof.offerNo = draft.offer.offerNo
const replay = await request('POST', pool, draftBody)
check('余位发布前草稿重放不生成第二份', replay?.replayed === true && JSON.stringify(replay.offer) === JSON.stringify(draft.offer))
const publishBody = { tripRecordVersion: draft.offer.tripRecordVersion, offerRecordVersion: draft.offer.recordVersion,
idempotencyKey: `${context.marker}-looseboard-publish` }
const publishPath = `${pool}/${encodeURIComponent(proof.offerNo)}/publish`
const published = await request('POST', publishPath, publishBody)
check('原车余位已开放且可登记承运', published?.offer?.offerNo === proof.offerNo && published.offer.tripNo === tripProof.tripNo
&& published.offer.status === 'OPEN' && published.offer.capacityKnown && published.offer.canCreateInquiry && published.offer.canConfirm)
const publishedAgain = await request('POST', publishPath, publishBody)
check('余位发布重放不重复推进版本', publishedAgain?.replayed === true && JSON.stringify(publishedAgain.offer) === JSON.stringify(published.offer))
proof.status = 'OPEN'
await context.checkpoint()
const binding = await bind(context, { demand, tripProof, offerNo: proof.offerNo, destinationKey, unitPrice })
check('散板原单绑定已确认且指向原车余位', binding?.status === 'CONFIRMED_ORIGINAL_ORDER'
&& binding.tripNo === tripProof.tripNo && binding.offerNo === proof.offerNo)
proof.status = 'BOUND_ORIGINAL_DEMAND'
await context.checkpoint()
return binding
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint()
throw error
}
}
@@ -0,0 +1,89 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
function fixture(fault = '') {
const cargo = { destinationKey: 'FBA:TEST', packageCount: 3, unitPrice: 25 }
const tripProof = { tripNo: 'TR-1', warehouseId: '42' }
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', poolBinding: {
tripNo: 'TR-1', bookingNo: 'BK-1', status: 'CONFIRMED_ORIGINAL_ORDER', createRequest: { cargo, expectedPalletCount: 2 } } }
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', status: 'CONFIRMED', recordVersion: 1, cargo }
const pallets = [], calls = [], snapshots = []; let incomingWrites = 0, receiptWrites = 0, palletized = false
const source = () => ({ incomingNo: 'IN-1', warehouseId: fault === 'warehouse' ? '99' : '42', destinationKey: cargo.destinationKey,
status: palletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING', receiptHash: 'hash',
receivedPackages: fault === 'quantity' ? 4 : 3,
pallets: palletized ? pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletNo: p.palletNo, packages: p.cartonCount })) : [] })
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
async checkpoint() { if (fault === 'disk') throw Error('disk'); snapshots.push(structuredClone(demand.receipt)) },
async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body) })
let data
if (path.endsWith('/bookings')) data = { tripRecordVersion: 1, records: [booking] }
else if (path.endsWith('/incoming') && method === 'POST') {
incomingWrites += 1
if (fault === 'network') return { http: 0 }
data = { tripRecordVersion: 2, booking: { ...booking, incomingNo: 'IN-1' }, replayed: incomingWrites > 1 }
if (fault === 'replay' && incomingWrites > 1) data.booking.incomingNo = 'IN-2'
} else if (path.endsWith('/fulfillment')) data = { preview: { tripRecordVersion: 2, recordVersion: 2, incoming: source() } }
else if (path.endsWith('/pallets/drafts')) {
assert.equal(token, 'pda'); assert.equal(body.cartonCount, pallets.length ? 1 : 2)
pallets.push({ id: String(pallets.length + 1), cartonCount: body.cartonCount, status: 'DRAFT' })
data = { pallets }
} else if (path.endsWith('/generate')) {
const pallet = pallets.find(p => path.includes(`/pallets/${p.id}/`))
Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)) })
data = { pallets }
} else if (path.endsWith('/bind')) data = { pallets }
else if (path.endsWith('/palletize')) {
assert.deepEqual(body.photoFileIds, ['9'])
palletized = true; pallets.forEach(p => { p.status = 'COMPLETED' })
data = { status: 'WAIT_CUSTOMER_CONFIRMATION', pallets }
} else if (path.endsWith('/receipt-confirmation')) {
receiptWrites += 1
assert.deepEqual(body.pallets, [{ warehousePalletId: '71', palletSpaces: 1 }, { warehousePalletId: '72', palletSpaces: 1 }])
assert.equal(body.unitPrice, 25)
data = { tripRecordVersion: 3, booking: { ...booking, status: 'RECEIVED', incomingNo: 'IN-1',
receipt: { source: { ...source(), status: 'READY_FOR_DISPATCH' } } }, replayed: receiptWrites > 1 }
} else throw Error(`Unexpected ${method} ${path}`)
if (path.includes('/warehouse-transfers/')) data.transferNo = fault === 'wrong incoming' ? 'OTHER' : 'IN-1'
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
},
async requestMultipart(method, path, file, token) {
assert.equal(token, 'pda'); assert.equal(file.type, 'image/png'); assert.ok(file.content.length > 0)
calls.push({ method, path })
return { http: 200, body: { code: '200', data: { fileId: fault === 'file' ? null : '9' } } }
} }
return { context, calls, snapshots, counts: () => ({ incomingWrites, receiptWrites }),
args: { demand, tripProof, pdaToken: 'pda', measurements: [2, 1].map(cartonCount => ({ cartonCount, lengthCm: 100, widthCm: 100,
heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40 })) } }
}
test('original looseboard booking receives two pallets three boxes and confirms once with replay', async () => {
const f = fixture()
const result = await receiveLooseboardPool(f.context, f.args)
assert.equal(result.status, 'RECEIVED_READY_FOR_DISPATCH')
assert.deepEqual(result.pallets.map(p => p.packages), [2, 1])
assert.deepEqual(f.counts(), { incomingWrites: 2, receiptWrites: 2 })
assert.equal(JSON.stringify(f.snapshots).includes('pdaToken'), false)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
})
for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming']) {
test(`receipt stops on ${fault} before operational confirmation`, async () => {
const f = fixture(fault)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
assert.equal(f.args.demand.receipt.status, 'FAILED')
assert.equal(f.counts().receiptWrites, 0)
if (fault === 'network') assert.equal(f.counts().incomingWrites, 1)
if (fault === 'disk') assert.equal(f.calls.length, 0)
})
}
test('wrong batch and inconsistent measured totals are blocked before HTTP', async () => {
for (const change of [f => { f.args.demand.marker = 'other' }, f => { f.args.measurements[0].cartonCount = 3 }]) {
const f = fixture(); change(f)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
assert.equal(f.calls.length, 0)
}
})
@@ -0,0 +1,108 @@
const API = '/api/platform/v1/last-leg'
const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true }
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const validVersion = value => Number.isSafeInteger(value) && value >= 0
const id = value => String(value ?? '')
/** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */
export async function receiveLooseboardPool(context, { demand, tripProof, pdaToken, measurements }) {
const binding = demand?.poolBinding, cargo = binding?.createRequest?.cargo
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|| binding?.status !== 'CONFIRMED_ORIGINAL_ORDER' || demand.receipt || !pdaToken
|| typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length
|| measurements.length !== binding.createRequest.expectedPalletCount
|| measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0
|| ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0))
|| measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) {
throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据')
}
const proof = demand.receipt = { marker: context.marker, status: 'RECEIVING', bookingNo: binding.bookingNo, requests: [], pallets: [] }
const check = (name, condition) => {
context.assert('phase4-overseas-business', name, null, Boolean(condition), `orderNo=${demand.orderNo}`, OPTIONS)
if (!condition) throw Error(name)
}
const io = async (method, path, body, token, multipart = false) => {
const log = { method, path, status: 'PREPARED', ...(body && !multipart ? { body: structuredClone(body) } : {}) }
proof.requests.push(log); await context.checkpoint()
let response
try {
log.status = 'SENDING'
response = multipart ? await context.requestMultipart(method, path, body, token) : await context.request(method, path, body, token)
} catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
const ok = response?.http === 200 && id(response?.body?.code) === '200'
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await context.checkpoint()
context.apiSuccess('phase4-overseas-business', '散板原承运实收', response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
if (!ok) throw Error('散板实收请求失败;未知结果不自动重发')
return response.body.data
}
const key = action => `${context.marker}-looseboard-${action}`
const pool = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}`
const trip = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const bookingMatches = row => row?.bookingNo === binding.bookingNo && row.tripNo === tripProof.tripNo && id(row.customerId) === id(demand.customerId)
try {
const list = await io('GET', `${trip}/bookings`)
const matches = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || []
const booking = matches[0]
check('来货前保持原车原客户唯一承运且未登记来货', matches.length === 1 && bookingMatches(booking)
&& booking.status === 'CONFIRMED' && !booking.incomingNo && !booking.dispatchNo
&& validVersion(list.tripRecordVersion) && validVersion(booking.recordVersion)
&& JSON.stringify(booking.cargo) === JSON.stringify(cargo))
const body = { tripRecordVersion: list.tripRecordVersion, recordVersion: booking.recordVersion,
estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(), deliveryInstructions: `${context.marker} TEST自送`, idempotencyKey: key('incoming') }
const created = await io('POST', `${pool}/incoming`, body)
check('登记来货保留原承运身份', bookingMatches(created?.booking) && created.booking.status === 'CONFIRMED'
&& typeof created.booking.incomingNo === 'string' && created.booking.incomingNo.length > 0 && !created.booking.receipt)
proof.incomingNo = created.booking.incomingNo
const replay = await io('POST', `${pool}/incoming`, body)
check('来货重放没有产生第二单', replay?.replayed === true && JSON.stringify(replay.booking) === JSON.stringify(created.booking)
&& replay.tripRecordVersion === created.tripRecordVersion)
const initial = (await io('GET', `${pool}/fulfillment`))?.preview?.incoming
check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId)
&& initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0)
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
for (const [index, measurement] of measurements.entries()) {
const draft = await io('POST', `${pda}/pallets/drafts`, { ...measurement, idempotencyKey: key(`measure-${index}`) }, pdaToken)
const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || []
check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === measurement.cartonCount
&& /^[1-9]\d*$/.test(id(newPallets[0].id)))
const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}`
const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken)
const pallet = generated?.pallets?.find(row => id(row.id) === id(palletId))
check('本托生成真实唯一托盘资源', generated?.transferNo === proof.incomingNo && pallet?.palletNo && /^[1-9]\d*$/.test(id(pallet.warehousePalletId))
&& !proof.pallets.some(row => row.palletNo === pallet.palletNo || id(row.warehousePalletId) === id(pallet.warehousePalletId)))
await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken)
proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount })
}
const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true)
check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId)))
const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken)
check('本次PDA完成全部原实托', received?.transferNo === proof.incomingNo && received?.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets?.length === proof.pallets.length
&& proof.pallets.every(p => received.pallets.some(row => id(row.id) === id(p.id) && row.palletNo === p.palletNo
&& id(row.warehousePalletId) === p.warehousePalletId && row.cartonCount === p.packages && row.status === 'COMPLETED')))
const preview = (await io('GET', `${pool}/fulfillment`))?.preview, source = preview?.incoming
check('运营确认依据为同仓同目的地同箱数的实际实收', source?.incomingNo === proof.incomingNo && id(source.warehouseId) === id(tripProof.warehouseId)
&& source.destinationKey === cargo.destinationKey && source.status === 'WAIT_CUSTOMER_CONFIRMATION'
&& source.receiptHash && source.receivedPackages === cargo.packageCount && source.pallets?.length === proof.pallets.length
&& proof.pallets.every(p => source.pallets.some(row => id(row.warehousePalletId) === p.warehousePalletId && row.packages === p.packages))
&& validVersion(preview.tripRecordVersion) && validVersion(preview.recordVersion))
const receiptBody = { tripRecordVersion: preview.tripRecordVersion, recordVersion: preview.recordVersion, receiptHash: source.receiptHash,
pallets: proof.pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletSpaces: 1 })), unitPrice: cargo.unitPrice, idempotencyKey: key('receipt') }
const accepted = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
check('运营确认回写原承运并达到可排车', bookingMatches(accepted?.booking) && accepted.booking.status === 'RECEIVED'
&& accepted.booking.incomingNo === proof.incomingNo && accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH')
const again = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
check('实收确认重放保持原结果和版本', again?.replayed === true && JSON.stringify(again.booking) === JSON.stringify(accepted.booking)
&& again.tripRecordVersion === accepted.tripRecordVersion)
proof.status = 'RECEIVED_READY_FOR_DISPATCH'
proof.acceptedReceipt = accepted.booking.receipt
await context.checkpoint()
return proof
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint(); throw error
}
}
@@ -52,7 +52,9 @@ test('focused matrix execution injects real container lifecycle prerequisites an
'phase4-tail-order-controls',
'phase4-overseas-pda',
'phase4-overseas-pda-operations',
'phase4-overseas-client-services',
'phase4-overseas-dispatch',
'phase4-overseas-business',
'phase5-overseas-fulfillment',
'manual-state-matrix',
]
@@ -85,7 +87,9 @@ test('focused splitting and manual groups resolve producers before consumers wit
'phase4-tail-order-controls',
'phase4-overseas-pda',
'phase4-overseas-pda-operations',
'phase4-overseas-client-services',
'phase4-overseas-dispatch',
'phase4-overseas-business',
'phase5-overseas-fulfillment',
'manual-state-matrix',
]
@@ -74,12 +74,12 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing',
'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) {
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
'phase4-overseas-master-data']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 9)
assert.equal(runner.modules.length, 7)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
@@ -27,7 +27,9 @@ test('review helper refuses stale fixture before any request', async () => {
test('transfer review integration covers both source types and known blank-reason failure', async () => {
const source = await read('warehouse-transfer-review.mjs')
const caller = await read('phase4-overseas-client-services.mjs')
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
assert.match(caller, /if \(!await verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)\) return false/)
assert.ok(caller.indexOf('await allocateWarehouseTransfer(') > caller.indexOf('if (!await verifyWarehouseTransferReview('))
assert.ok(caller.indexOf('await receiveWarehouseTransfer(') > caller.indexOf('await outboundWarehouseTransfer('))
for (const value of ['WAIT_PDA_RECEIVING', 'INBOUND_MEASURING', 'CANCELLED',
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
@@ -1,9 +1,11 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
test('fixed partner warehouse is reused and never retired', async () => {
const calls = []
@@ -21,6 +23,149 @@ test('fixed partner warehouse is reused and never retired', async () => {
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
test('卡车请求先持久化,正常与失败响应均留有证据', async () => {
const context = fixture(), original = context.request.bind(context)
context.request = async (method, path, ...args) => {
if (context.createdResources.phase4OverseasBusiness?.truck?.fulfillment) {
const last = context.savedTruckEvidence.at(-1)?.requests.at(-1)
if (path.includes('/us-truck-orders/TK1')) {
assert.equal(last?.status, 'PREPARED')
assert.equal(last?.method, method)
}
}
return original(method, path, ...args)
}
await run(context)
assert.equal(context.savedTruckEvidence.at(-1).status, 'HTTP_VERIFIED')
assert.ok(context.savedTruckEvidence.some(e => e.requests.some(r => r.expectedRejection && r.status === 'REJECTED')))
})
test('卡车检查点写入失败不发送请求,不能误记未知网络结果', async () => {
const proof = { marker: 'BATCH', orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
let calls = 0
const context = { marker: 'BATCH', createdResources: { phase4OverseasBusiness: { truck: proof } },
request: async () => { calls++; throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } }
await assert.rejects(verifyUsTruckFulfillment(context, proof), /disk full/)
assert.equal(calls, 0)
assert.equal(proof.fulfillment.requests[0].status, 'PREPARED')
assert.equal(proof.fulfillment.status, 'FAILED')
})
test('卡车默认落盘禁止新进程用同批次覆盖失败证据', async () => {
const marker = `TRUCK-OFFLINE-${randomUUID()}`
const file = new URL(`../../reports/us-truck-checkpoints/${marker}.json`, import.meta.url)
let calls = 0
const make = () => {
const proof = { marker, orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
return { proof, context: { marker, createdResources: { phase4OverseasBusiness: { truck: proof } },
request: async () => { calls++; throw Error('connection lost') } } }
}
try {
const first = make()
await assert.rejects(verifyUsTruckFulfillment(first.context, first.proof), /connection lost/)
const saved = fs.readFileSync(file, 'utf8')
assert.equal(JSON.parse(saved).fulfillment.requests[0].status, 'UNKNOWN_RESULT')
const second = make()
await assert.rejects(verifyUsTruckFulfillment(second.context, second.proof), /EEXIST/)
assert.equal(calls, 1)
assert.equal(fs.readFileSync(file, 'utf8'), saved)
} finally {
if (fs.existsSync(file)) fs.unlinkSync(file)
}
})
for (const action of ['receive-pallets', 'allocate-pallets']) {
for (const fault of ['accepted', 'unknown', 'wrong rejection', 'drift']) {
test(`卡车 ${action} 无效命令拒绝校验: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let rejected = false
context.request = async (method, path, body, ...args) => {
if (method === 'POST' && path.includes(`/${action}?`) && body?.idempotencyKey?.endsWith('-invalid')) {
rejected = true
if (fault === 'accepted') return response({})
if (fault === 'unknown') return { http: 0 }
if (fault === 'wrong rejection') return response(null, 'A0403')
}
const result = await original(method, path, body, ...args)
if (fault === 'drift' && rejected && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
result.body.data.summary.version++
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.method === 'POST' && c.path.includes(`/${action}?`) && !c.body.idempotencyKey.endsWith('-invalid')), false)
})
}
}
for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay drift']) {
test(`truck fulfillment stops and retains original order on ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let receipts = 0
context.request = async (method, path, ...args) => {
if (method === 'POST' && path.includes('/receive-pallets?') && !args[0]?.idempotencyKey?.endsWith('-invalid')) {
receipts += 1
if (fault === 'network') return { http: 0 }
const result = structuredClone(await original(method, path, ...args))
if (fault === 'wrong customer') result.body.data.summary.customerId = 'other'
if (fault === 'wrong pallet count') result.body.data.pallets.push({ ...result.body.data.pallets[0] })
if (fault === 'replay drift' && receipts === 2) result.body.data.summary.version += 1
return result
}
return original(method, path, ...args)
}
await assert.rejects(run(context))
const proof = context.createdResources.phase4OverseasBusiness.truck
assert.equal(proof.fulfillment.status, 'FAILED')
assert.equal(proof.retained, true)
assert.equal(receipts, fault === 'replay drift' ? 2 : 1)
assert.equal(context.calls.some(c => c.path.includes('/allocate-pallets?') || c.path.includes('/TK1/status?')), false)
await assert.rejects(verifyUsTruckFulfillment(context, proof), /本轮未执行/)
})
}
for (const fault of ['accepted', 'wrong code', 'missing trace', 'detail drift']) {
test(`卡车部分分配待派车拒绝检查失败即停止: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let rejected = false
context.request = async (method, path, body, ...args) => {
const result = await original(method, path, body, ...args)
if (body?.idempotencyKey?.endsWith('prepare-dispatch-partial')) {
rejected = true
if (fault === 'accepted') result.body.code = 200
if (fault === 'wrong code') result.body.code = 'A0400'
if (fault === 'missing trace') delete result.traceId
} else if (rejected && fault === 'detail drift' && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
result.body.data.summary.version++
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
})
}
for (const fault of ['premature complete', 'unselected allocated', 'selected unchanged']) {
test(`卡车部分分配拒绝错误结果: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
context.request = async (method, path, body, ...args) => {
const result = await original(method, path, body, ...args)
if (method === 'POST' && path.includes('/allocate-pallets?') && body?.idempotencyKey?.endsWith('-0')) {
if (fault === 'premature complete') result.body.data.summary.status = 'ALLOCATED'
if (fault === 'unselected allocated') result.body.data.pallets[1].status = 'ALLOCATED'
if (fault === 'selected unchanged') result.body.data.pallets[0].status = 'RECEIVED'
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.path.includes('/prepare-dispatch?')), false)
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
})
}
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
const context = fixture(), request = context.request.bind(context), pages = []
context.options = { fixedCustomerId: '10001' }
@@ -63,11 +208,16 @@ function fixture(options = {}) {
}
function purchaseCustomer() { return { order: state.purchase, fees: [], progress: [], boxes: [], shipments: [], exceptionSummaries: [] } }
function purchasePlatform() { return { customerView: purchaseCustomer(), internalRemark: state.purchase?.remark ?? null, reviewHistory: state.purchaseHistories } }
function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } }
function truckDetail() { return structuredClone({ summary: state.truck, pallets: state.truckPallets || [], remark: state.truck?.remark ?? null, histories: state.truckHistories }) }
function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } }
const context = {
marker, calls, steps, gaps, blockers,
savedTruckEvidence: [],
async checkpoint() {
if (this.createdResources.phase4OverseasBusiness?.truck?.fulfillment)
this.savedTruckEvidence.push(structuredClone(this.createdResources.phase4OverseasBusiness.truck.fulfillment))
},
context: { merchantQualificationFixture: credentials },
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
async request(...args) {
@@ -122,7 +272,7 @@ function fixture(options = {}) {
const hash = JSON.stringify(body)
if (state.truck) return hash === state.truckHash ? response(truckDetail()) : response(null, 'A0409')
state.truckHash = hash
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', receivedPalletCount: 0, allocatedPalletCount: 0, status: 'PENDING_REVIEW', version: 0, ...body }
state.truckHistories.push({ fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
return response(truckDetail())
}
@@ -144,6 +294,43 @@ function fixture(options = {}) {
state.truckReviews.set(body.idempotencyKey, { hash, result })
return response(result)
}
const stages = { 'receive-pallets': ['WAITING_INBOUND', 'WAITING_CONSOLIDATION'],
'allocate-pallets': ['WAITING_CONSOLIDATION', 'ALLOCATED'], 'prepare-dispatch': ['ALLOCATED', 'PENDING_DISPATCH'],
dispatch: ['PENDING_DISPATCH', 'DISPATCHED'], 'start-transit': ['DISPATCHED', 'IN_TRANSIT'], complete: ['IN_TRANSIT', 'COMPLETED'] }
const action = requestPath.match(/\/us-truck-orders\/TK1\/([^?]+)\?/)?.[1]
if (method === 'POST' && stages[action]) {
if (action === 'prepare-dispatch' && state.truck.status === 'PARTIALLY_ALLOCATED') {
assert.equal(body.version, state.truck.version)
return response(null, 'A0409')
}
const existing = state.truckStatuses.get(body.idempotencyKey)
if (existing) return response(truckDetail())
let [from, to] = stages[action]
if (action === 'allocate-pallets') {
from = state.truck.allocatedPalletCount === 0 ? 'WAITING_CONSOLIDATION' : 'PARTIALLY_ALLOCATED'
to = state.truck.allocatedPalletCount === 0 ? 'PARTIALLY_ALLOCATED' : 'ALLOCATED'
}
assert.equal(state.truck.status, from)
assert.equal(body.version, state.truck.version)
if (action === 'receive-pallets') {
if (body.palletPackageCounts.reduce((sum, count) => sum + count, 0) !== body.packageCount) return response(null, 'A0400')
assert.deepEqual(body.palletPackageCounts, [1, 1])
assert.equal(body.actualPalletCount, 2)
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 1, status: 'RECEIVED' },
{ id: '702', palletNo: 'TK1-P002', packageCount: 1, status: 'RECEIVED' }]
state.truck.receivedPalletCount = 2
}
if (action === 'allocate-pallets') {
if (new Set(body.palletIds).size !== body.palletIds.length) return response(null, 'A0400')
assert.deepEqual(body.palletIds, [state.truck.allocatedPalletCount === 0 ? '701' : '702'])
state.truckPallets.find(row => row.id === body.palletIds[0]).status = 'ALLOCATED'
state.truck.allocatedPalletCount++
}
state.truck.status = to; state.truck.version += 1
state.truckHistories.push({ fromStatus: from, toStatus: to, idempotencyKey: body.idempotencyKey })
state.truckStatuses.set(body.idempotencyKey, true)
return response(truckDetail())
}
if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') {
assert.equal(body.status, 'CANCELLED')
assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true)
@@ -272,7 +459,9 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey)
const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders'))
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 7)
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 24)
assert.equal(truckCalls.filter(call => call.body?.idempotencyKey?.endsWith('prepare-dispatch-partial')).length, 1)
assert.equal(truckCalls.filter(call => call.method === 'POST' && call.body?.idempotencyKey?.endsWith('-invalid')).length, 2)
assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?'))
assert.equal(truckReviews.length, 4)
@@ -281,7 +470,7 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.equal(new Set(truckReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
assert.notEqual(truckReviews[3].body.idempotencyKey, truckReviews[0].body.idempotencyKey)
const truckStatuses = truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
assert.equal(truckStatuses.length, 1)
assert.equal(truckStatuses.length, 0)
assert.ok(truckStatuses.every((call) => call.body.status === 'CANCELLED'
&& typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0))
@@ -290,20 +479,23 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2)
const looseboardStatuses = looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
assert.equal(looseboardStatuses.length, 5)
assert.equal(looseboardStatuses.length, 4)
assert.ok(looseboardStatuses.every((call) => typeof call.body.idempotencyKey === 'string'
&& call.body.idempotencyKey.length > 0))
assert.equal(new Set(looseboardStatuses.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 2)
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 1)
assert.ok(looseboardStatuses.slice(3).every((call) => call.body.idempotencyKey !== looseboardStatuses[0].body.idempotencyKey))
const registry = context.createdResources.phase4OverseasBusiness
assert.equal(registry.purchase.finalStatus, 'REJECTED')
assert.equal(registry.purchase.retained, false)
assert.equal(registry.truck.finalStatus, 'CANCELLED')
assert.equal(registry.truck.retained, false)
assert.equal(registry.looseboard.finalStatus, 'CANCELLED')
assert.equal(registry.looseboard.retained, false)
assert.equal(registry.truck.finalStatus, 'COMPLETED')
assert.equal(registry.truck.retained, true)
assert.equal(registry.truck.fulfillment.status, 'HTTP_VERIFIED')
assert.equal(registry.truck.fulfillment.partialAllocationVerified, true)
assert.equal(registry.truck.fulfillment.pallets.length, 2)
assert.equal(registry.looseboard.finalStatus, 'PENDING_ACCEPTANCE')
assert.equal(registry.looseboard.retained, true)
assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE')
assert.equal(registry.partnerWarehouse.retained, false)
})
@@ -1,3 +1,5 @@
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
const MODULE = 'phase4-overseas-business'
const SCENARIO = 'phase4-overseas-business-write-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -426,8 +428,10 @@ async function truckFlow(context, customerId) {
&& histories.some((item) => String(item?.remark || '').includes(context.marker)),
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}; ${trace(history.response)}`, OPTIONS)
Object.assign(registry.truck, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
await verifyUsTruckFulfillment(context, registry.truck)
} finally {
await cancelMarkerOrder(context, registry.truck, base, ['PENDING_REVIEW', 'WAITING_INBOUND'], '卡车')
// 原单保留到履约完成;失败也保留现场,避免取消掩盖未知写入结果。
registry.truck.retained = true
}
}
@@ -497,11 +501,12 @@ async function looseboardFlow(context, customerId) {
records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId
&& Number(row.version) === Number(historySummary?.version)),
trace(pending.response), OPTIONS)
context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
'本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture')
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
'本模块保留原单待接车;后续由 phase5-overseas-fulfillment 复用本轮原车完成实收与签收,单独执行本模块不代表全链完成。', 'fixture')
Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
} finally {
await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板')
// 后续用原单接入本轮车次,失败保留现场,不另建替代散板单。
registry.looseboard.retained = true
}
}
@@ -2,11 +2,38 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { verifyDiscardApproval } from './phase4-overseas-client-services.mjs'
import { verifyDiscardApproval, submitClientTransfer } from './phase4-overseas-client-services.mjs'
import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
for (const fault of ['', 'network', 'server', 'rejected', 'wrong order', 'wrong status', 'throws']) {
test(`transfer submit sends once and stops on uncertain result: ${fault || 'success'}`, async () => {
const calls = []
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: {} },
apiSuccess(_m, _n, response) { return response?.body?.data },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
if (fault === 'throws') throw new Error('connection closed')
return { http: fault === 'network' ? 0 : fault === 'server' ? 503 : 200,
requestId: 'request', traceId: 'trace', body: { code: fault === 'rejected' ? 'A0409' : '200',
data: { transferNo: fault === 'wrong order' ? 'OTHER' : 'WT-1',
status: fault === 'wrong status' ? 'DRAFT' : 'PENDING_REVIEW' } } }
} }
if (fault) await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'))
else assert.equal((await submitClientTransfer(context, 'merchant', 'WT-1')).status, 'PENDING_REVIEW')
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'POST')
assert.equal(calls[0].path, '/api/merchant/v1/last-leg/warehouse-transfers/WT-1/submit')
const proof = context.createdResources.phase4OverseasClientServices.transferSubmission
assert.equal(proof.status, !fault ? 'PENDING_REVIEW' : ['network', 'server', 'throws'].includes(fault)
? 'UNKNOWN_RESULT' : fault === 'rejected' ? 'REJECTED' : 'UNEXPECTED_RESULT')
assert.equal(JSON.stringify(proof).includes('merchant'), false)
await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'), /禁止重复提交/)
assert.equal(calls.length, 1)
})
}
test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => {
assert.match(source, /Approval retains the existing reservation without deducting stock/)
assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/)
@@ -70,7 +97,10 @@ test('module records marker-owned resources without forbidden fulfillment cleanu
assert.match(source, /relabelNo = relabelNo/)
assert.match(source, /transferNo = transferNo/)
assert.match(source, /discardNo = discardNo/)
assert.match(source, /no outbound, dispatch or delivery action is invoked/)
assert.match(source, /不自动删除业务数据/)
assert.match(source, /transfer: transferDone \? context\.createdResources\.phase4OverseasClientServices\.transferFulfillment\?\.status \?\? null : null/)
assert.match(source, /await verifyCargoDiscardFulfillment\(context/)
assert.match(source, /snapshot: \(\) => discardSnapshot\(context, token, proof, proof.approvedNo\)/)
})
test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => {
@@ -1,4 +1,11 @@
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
const MODULE = 'phase4-overseas-client-services'
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
@@ -51,9 +58,9 @@ async function loginMerchant(context) {
loginId: fixture.username, password: fixture.password,
}, '')
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
return nonBlank(auth?.accessToken) ? auth.accessToken : null
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
async function currentOrders(context, token) {
@@ -98,13 +105,17 @@ async function verifyRelabel(context, token, order) {
}))
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
&& item?.skuCode === cargo.skuCode
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
&& item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode
&& id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId)
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0
&& nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND'))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0
&& nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND')
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
Boolean(source && position),
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
if (!source || !position) return false
if (!await fundRelabelWallet(context, id(context.context.merchantQualificationFixture.tenantId))) return false
const body = {
customerOrderNo: order.row.orderNo,
operationWarehouseId: order.row.overseasWarehouseId,
@@ -120,9 +131,11 @@ async function verifyRelabel(context, token, order) {
productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker,
operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL',
skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }],
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
cartonMark: `CM-${String(context.marker).slice(-32)}`,
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
}],
}
@@ -150,9 +163,52 @@ async function verifyRelabel(context, token, order) {
&& Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_REVIEW'),
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo
const proof = context.createdResources.phase4OverseasClientServices.relabelFulfillment = {
marker: context.marker, relabelNo, customerId: id(context.context.merchantQualificationFixture.tenantId),
sourceOrderNo: order.row.orderNo, warehouseId: id(order.row.overseasWarehouseId),
sourcePositionId: id(position.positionId), sourcePalletNo: position.palletNo, status: 'PENDING_REVIEW',
}
const access = await resolvePickerAccess(context)
if (!access) throw new Error('贴标后续缺少有效PDA角色')
const picker = await createPicker(context, 94, proof.warehouseId, access)
try {
if (!picker?.token) throw new Error('贴标后续未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token })
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
'当前定向范围仅到客户确认;完整默认链继续使用本申请targetPalletIds排车并签收,不能借其他换标单结果',
'fixture', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 94)
}
return true
}
// 提交只发送一次,未知结果保留单号并停止,不能用详情回查掩盖失败。
export async function submitClientTransfer(context, token, transferNo) {
if (!nonBlank(transferNo)) throw new Error('仓储转运提交缺少单号')
if (context.createdResources.phase4OverseasClientServices.transferSubmission) throw new Error('本轮仓储转运已有提交证据,禁止重复提交')
const proof = { transferNo, marker: context.marker, status: 'SUBMITTING' }
context.createdResources.phase4OverseasClientServices.transferSubmission = proof
try {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
Object.assign(proof, { http: response?.http, code: codeOf(response), requestId: response?.requestId, traceId: response?.traceId })
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
if (!success(response)) {
proof.status = !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
throw new Error(`仓储转运提交未成功,停止后续写入: ${transferNo}; ${trace(response)}`)
}
if (id(data?.transferNo) !== transferNo || data?.status !== 'PENDING_REVIEW') {
proof.status = 'UNEXPECTED_RESULT'
throw new Error(`仓储转运提交返回单号或状态不符: ${transferNo}`)
}
proof.status = 'PENDING_REVIEW'
return data
} catch (error) {
if (proof.status === 'SUBMITTING') proof.status = 'UNKNOWN_RESULT'
throw error
}
}
async function verifyTransfer(context, token, orders) {
const order = orders.find((value) => nonBlank(value.row?.inboundCode)
&& nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value))
@@ -184,26 +240,7 @@ async function verifyTransfer(context, token, orders) {
const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body)
const transferNo = id(created.data?.transferNo)
if (transferNo && created.data?.status === 'DRAFT') {
// The overseas gateway occasionally drops a connection while the submit
// request is still being processed. Retry transport failures only, then
// reconcile from the authoritative detail before asserting the state.
let submitted = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
if (response?.http === 0 && attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
continue
}
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
submitted = { response, data }
break
}
if (submitted?.data) created.data = submitted.data
if (created.data?.status !== 'PENDING_REVIEW') {
const reconciled = await merchant(context, token, 'GET', '提交后回查仓储转运申请',
`${TRANSFER}/${encodeURIComponent(transferNo)}`)
if (reconciled.data) created.data = reconciled.data
}
created.data = await submitClientTransfer(context, token, transferNo)
}
context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response,
Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW'
@@ -226,7 +263,30 @@ async function verifyTransfer(context, token, orders) {
&& detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0,
`status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.transferNo = transferNo
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
if (!await verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)) return false
const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = {
marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId),
transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED',
}
await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
const relabelProof = transferProof.relabelFulfillment = {
marker: context.marker, relabelNo: transferProof.relabelNo, customerId: transferProof.customerId,
warehouseId: transferProof.warehouseId, sourceOrderNo: transferNo,
sourcePositionId: transferProof.sourcePositionId, sourcePalletNo: transferProof.sourcePalletNo,
status: 'PENDING_REVIEW',
}
const access = await resolvePickerAccess(context)
if (!access) throw new Error('转运换标后续缺少有效PDA角色')
const picker = await createPicker(context, 96, transferProof.warehouseId, access)
try {
if (!picker?.token) throw new Error('转运换标未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof: relabelProof, transferProof, merchantToken: token, pdaToken: picker.token })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 96)
}
await outboundWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
await receiveWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
return true
}
export async function verifyDiscard(context, token, markerOrders) {
@@ -275,7 +335,21 @@ export async function verifyDiscard(context, token, markerOrders) {
&& Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'),
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.discardNo = discardNo
try { await verifyDiscardApproval(context, token, body, discardNo, proof) }
try {
await verifyDiscardApproval(context, token, body, discardNo, proof)
const access = await resolvePickerAccess(context)
if (!access) throw new Error('废弃执行缺少有效PDA角色')
const picker = await createPicker(context, 93, proof.warehouseId, access)
try {
if (!picker?.token) throw new Error('废弃执行未取得本仓PDA会话')
await verifyCargoDiscardFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token,
snapshot: () => discardSnapshot(context, token, proof, proof.approvedNo) })
context.addCoverageGap(MODULE, 'discard-carton-storage-evidence', '废弃底层箱、SKU和箱级流水独立取证',
'已接入PDA执行、客户完成、公共库存总账和终态重放;未执行底层独立取证', 'prerequisite', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 93)
}
}
catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error }
return true
}
@@ -363,13 +437,22 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
}
export async function run(context) {
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
context.apiSuccess(MODULE, '退出本轮客户服务会话', logout, trace(logout), OPTIONS)
}
}
async function runWithSession(context, token) {
context.createdResources.phase4OverseasClientServices = {
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
finalStatuses: { relabel: null, transfer: null, discard: null },
cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch or delivery action is invoked',
cleanup: '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行',
}
const token = await loginMerchant(context)
if (!token) return
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
const denied = await context.request('GET', query(path, { page: 1, size: 1 }))
@@ -390,8 +473,8 @@ export async function run(context) {
const transferDone = await verifyTransfer(context, token, orders)
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'PENDING_REVIEW' : null,
transfer: transferDone ? 'APPROVED' : null,
discard: discardDone ? 'APPROVED' : null,
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
transfer: transferDone ? context.createdResources.phase4OverseasClientServices.transferFulfillment?.status ?? null : null,
discard: discardDone ? 'COMPLETED' : null,
}
}
@@ -4,8 +4,8 @@ import { readFileSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, basename } from 'node:path'
import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs'
import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
import { DISPATCH_BASE as BASE, dispatchEvidenceContext, dispatchIo } from './grouped-dispatch-acceptance.mjs'
import { run, createClientRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
const clone = value => structuredClone(value)
const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
@@ -46,7 +46,8 @@ function server(fault = '', initial = false) {
if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) }
if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE'))
if (path === `${BASE}/candidates`) {
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
assert.equal(params.get('warehouseId'), '44')
assert.equal(params.get('sourceStage'), params.get('orderNo') === 'RL-CLIENT' ? 'RELABEL' : 'RECEIVED')
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
@@ -97,6 +98,37 @@ function server(fault = '', initial = false) {
return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } }
}
function addClientRelabel(h) {
const candidate = clone(rows()[0])
Object.assign(candidate, { sourceKey: 'RELABEL:RL-CLIENT:91', availablePackages: 1 })
candidate.selection = { sourceType: 'RELABEL', relabelNo: 'RL-CLIENT', warehousePalletId: '91', sourceVersion: candidate.sourceVersion }
h.candidates.push(candidate)
h.context.createdResources.phase4OverseasClientServices = { relabelFulfillment: { marker: 'CURRENT', relabelNo: 'RL-CLIENT',
status: 'READY_FOR_OUTBOUND', execution: { status: 'HTTP_VERIFIED_READY_FOR_DISPATCH' }, customerId: '55', warehouseId: '44', targetPalletIds: ['91'] } }
}
test('client original relabel gets its own exact dispatch and refuses repeat execution', async () => {
const h = server(); addClientRelabel(h)
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
await createClientRelabelDispatch(h.context, io, proof)
assert.equal(proof.clientRelabel.status, 'CREATED_BEFORE_LOADING')
assert.equal(h.context.createdResources.phase4OverseasClientServices.relabelFulfillment.dispatchNo, 'DS-1')
assert.deepEqual(proof.clientRelabel.sourceKeys, ['RELABEL:RL-CLIENT:91'])
const writes = h.calls.filter(c => c.method === 'POST').length
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
assert.equal(h.calls.filter(c => c.method === 'POST').length, writes)
})
for (const fault of ['customer', 'pallet', 'missing']) test(`client relabel excludes ${fault} candidates before writes`, async () => {
const h = server(); addClientRelabel(h)
if (fault === 'customer') h.candidates.at(-1).customerId = '99'
if (fault === 'pallet') h.candidates.at(-1).selection.warehousePalletId = '99'
if (fault === 'missing') h.candidates.pop()
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
assert.equal(h.calls.some(c => c.method === 'POST'), false)
})
test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => {
const c = rows(), selected = selectDispatchRouteFixture(c)
assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23')
@@ -139,7 +171,7 @@ test('phase4 missing current marker or real multi-destination fixture is explici
test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => {
const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-'))
try {
const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
const h = server(); addClientRelabel(h); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' }
const originalRequest = h.context.request
h.context.request = async (method, path, body) => {
@@ -151,6 +151,39 @@ async function createRelabel(context, io, proof) {
Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data })
proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' }
}
/** 客户首页贴标原申请独立排车;不可借用旧PDA换标样本的通过结果。 */
export async function createClientRelabelDispatch(context, io, proof) {
const fixture = context.createdResources?.phase4OverseasClientServices?.relabelFulfillment
io.check('客户贴标排车前置为本轮已确认原申请', null, fixture?.marker === context.marker
&& fixture.status === 'READY_FOR_OUTBOUND' && fixture.execution?.status === 'HTTP_VERIFIED_READY_FOR_DISPATCH'
&& fixture.relabelNo && fixture.customerId && fixture.warehouseId && fixture.targetPalletIds?.length > 0
&& new Set(fixture.targetPalletIds.map(id)).size === fixture.targetPalletIds.length && !fixture.dispatchNo && !proof.clientRelabel)
const evidence = proof.clientRelabel = { marker: context.marker, relabelNo: fixture.relabelNo, status: 'CREATING' }
try {
const candidates = (await candidatePage(io, fixture.warehouseId,
{ orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] }))
.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo
&& id(c.customerId) === id(fixture.customerId) && id(c.warehouseId) === id(fixture.warehouseId))
io.check('客户原申请目标托盘候选完整且唯一', null, candidates.length === fixture.targetPalletIds.length
&& new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length
&& candidates.every(c => fixture.targetPalletIds.map(id).includes(id(c.selection.warehousePalletId))))
const body = createBody(context.marker, 'CLIENT-RELABEL', candidates)
evidence.createRequest = body
const created = await io.command('POST', BASE, body, '客户贴标原申请创建独立排车')
io.check('客户原申请排车返回业务单号', null, typeof created?.dispatchNo === 'string' && created.dispatchNo.length > 0)
evidence.dispatchNo = created.dispatchNo
await replay(io, BASE, body, created, '客户原贴标申请排车创建重放')
const current = await readDispatch(io, created.dispatchNo)
io.check('客户贴标排车逐行匹配原申请实托与箱数', null, dispatchMatches(current, candidates))
Object.assign(fixture, { dispatchNo: created.dispatchNo, dispatchStage: current.data.stage })
Object.assign(evidence, { status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey) })
await context.checkpoint()
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message
await context.checkpoint(); throw error
}
}
export async function run(context) {
context.createdResources ??= {}
if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建')
@@ -193,6 +226,7 @@ export async function run(context) {
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
await createRelabel(context, io, proof)
await createClientRelabelDispatch(context, io, proof)
await context.checkpoint()
} catch (error) {
if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message)
@@ -47,18 +47,16 @@ async function overseas(context, token, method, name, path, body = undefined) {
return { response, data: success(response) ? response.body?.data : null }
}
async function resolveWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
const response = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = records(response?.body?.data)
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
const warehouse = rows.find((item) => id(item?.id) === existing)
|| rows.find((item) => fixedIds.has(id(item?.id)))
|| rows[0]
export async function resolveReturnFollowupWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasReturnReceipt?.warehouseId)
if (!/^[1-9]\d*$/.test(existing)) throw new Error('缺少本轮退货实际收货仓,禁止回退其他仓库')
const response = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(existing)}?tenantId=0`)
const warehouse = response?.body?.data?.warehouse
const valid = success(response) && id(warehouse?.id) === existing
&& warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE'
add(context, '读取可执行退货签收的海外仓', response,
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
valid, `warehouseId=${id(warehouse?.id) || 'missing'}`)
return valid ? { id: existing, name: warehouse.warehouseName || warehouse.name } : null
}
async function loginMerchant(context) {
@@ -70,9 +68,10 @@ async function loginMerchant(context) {
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
const auth = success(response) ? response.body?.data : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
function itemMap(items) {
@@ -81,12 +80,23 @@ function itemMap(items) {
export async function run(context) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker !== context.marker || !source?.returnNo) {
if (source?.marker !== context.marker || !source?.returnNo || source?.mainChainStatus !== 'READY_FOR_FOLLOWUPS'
|| id(source?.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)) {
add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置')
return
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, source, auth.accessToken)
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
add(context, '退出本轮退货后续客户会话', logout, success(logout))
}
}
async function runWithSession(context, source, merchantToken) {
const warehouse = await resolveReturnFollowupWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
const picker = await createPicker(context, 92, warehouse.id, access)
@@ -381,8 +391,11 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0
&& task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL'))
for (const item of task.items) {
require('退货换标逐项具有真实箱唛和SKU及有效数量', nonBlank(item.cartonMark) && nonBlank(item.skuCode)
&& Number.isSafeInteger(Number(item.expectedPackages)) && Number(item.expectedPackages) > 0)
task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, {
itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version),
cartonMark: item.cartonMark, skuCode: item.skuCode,
idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`),
})
}
@@ -3,8 +3,8 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } from './phase4-overseas-return-followups.mjs'
import { normalizeModuleOrder, describeAcceptanceScope } from '../admin-test-env-acceptance.mjs'
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound, resolveReturnFollowupWarehouse, run as runFollowups } from './phase4-overseas-return-followups.mjs'
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
@@ -177,13 +177,17 @@ test('relabelled outbound shares accounting and strict PDA scan checks', async (
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
const calls = []
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
async request(method,path,body,token,_headers) {
calls.push({ method, path, body, token })
let data = task
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
if (path.endsWith('/return-skus/complete')) {
assert.equal(body.cartonMark, 'BOX')
assert.equal(body.skuCode, 'SKU-1')
}
if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }]
if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' }
if (path.endsWith('/resolve')) {
@@ -215,6 +219,25 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant')
})
for (const missing of ['cartonMark', 'skuCode', 'expectedPackages']) {
test(`return relabel blocks incomplete task before quantity write: ${missing}`, async () => {
const calls = []
const item = { itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', expectedPackages: 1, operationType: 'RETURN_BULK_SKU_LABEL' }
delete item[missing]
const context = { marker: 'batch', token: 'admin', addStep() {}, assert() {},
async request(method, path) {
calls.push(path)
const data = path.endsWith('/status')
? { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
: { relabelNo: 'RL-1', version: 1, items: [item] }
return { http: 200, body: { code: '200', data } }
} }
await assert.rejects(completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1'), /真实箱唛和SKU/)
assert.equal(calls.length, 2)
assert.ok(calls.every(path => !path.endsWith('/return-skus/complete')))
})
}
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
const source = await readFile(acceptanceUrl, 'utf8')
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
@@ -260,12 +283,55 @@ test('HTTP acceptance uses injected clients and never bypasses APIs with databas
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
})
test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => {
test('receipt-only scope remains focused while default full plan includes ordered return followups', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/)
const ordered = normalizeModuleOrder(['phase4-overseas-return-followups'])
assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups'))
assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups'))
const defaults = describeAcceptanceScope().defaultModules
assert.ok(defaults.includes('phase4-overseas-return-followups'))
assert.ok(defaults.indexOf('phase4-overseas-return-receipt') < defaults.indexOf('phase4-overseas-return-followups'))
})
test('return followups read only the actual receipt warehouse and reject drift', async () => {
for (const patch of [{}, { id: '202' }, { status: 'INACTIVE' }, { warehouseType: 'DOMESTIC' }]) {
const calls = [], checks = []
const context = { createdResources: { phase4OverseasReturnReceipt: { warehouseId: '201' } },
addStep(module, name, response, passed) { checks.push(passed) },
async request(method, path) {
calls.push({ method, path })
return { http: 200, body: { code: '200', data: { warehouse: { id: '201', status: 'ACTIVE', warehouseType: 'OVERSEAS', ...patch } } } }
} }
const warehouse = await resolveReturnFollowupWarehouse(context)
assert.equal(Boolean(warehouse), Object.keys(patch).length === 0)
assert.equal(calls.length, 1)
assert.equal(calls[0].path, '/api/platform/v1/warehouse-admin/201?tenantId=0')
}
await assert.rejects(resolveReturnFollowupWarehouse({ createdResources: {} }), /实际收货仓/)
})
test('return followups stop before login when main chain failed or customer changed', async () => {
for (const patch of [{ mainChainStatus: 'FAILED' }, { customerId: '102' }, { marker: 'OLD' }]) {
const checks = []
await runFollowups({ marker: 'CURRENT', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasReturnReceipt: { marker: 'CURRENT', returnNo: 'TH202610220001',
mainChainStatus: 'READY_FOR_FOLLOWUPS', customerId: '101', ...patch } },
addStep(module, name, response, passed) { checks.push(passed) },
request() { throw new Error('must not send HTTP') } })
assert.deepEqual(checks, [false])
}
})
test('fixed return main chain excludes customer provisioning and write transport retries', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.match(source, /context.options.returnMainChainOnly \|\| context.options.fixedCustomerId/)
assert.match(source, /addCoverageGap\(MODULE, 'return-isolation-boundaries'/)
assert.match(source, /method === 'GET' && attempt < 3/)
assert.match(source, /mainChainStatus = context.steps.slice\(start\).every/)
for (const url of [moduleUrl, new URL('./phase4-overseas-return-followups.mjs', import.meta.url)]) {
assert.match(await readFile(url, 'utf8'), /finally \{\s*const logout = await context.request\('POST', '\/api\/auth\/logout'/)
}
})
test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => {
@@ -56,7 +56,7 @@ function add(context, name, response, passed, evidence = '') {
async function overseas(context, token, method, name, path, body = undefined) {
let response = await context.request(method, path, body, token, {})
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
for (let attempt = 1; method === 'GET' && attempt < 3 && response?.http === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
response = await context.request(method, path, body, token, {})
}
@@ -87,9 +87,10 @@ async function loginMerchant(context) {
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
const auth = success(response) ? response.body?.data : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
function itemMap(items) {
@@ -123,14 +124,38 @@ export async function approveReturnFixture(context, merchantToken, created) {
}
export async function run(context) {
const start = context.steps.length
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} catch (error) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker) source.mainChainStatus = 'FAILED'
throw error
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
add(context, '退出本轮退货客户会话', logout, success(logout))
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker && context.steps.slice(start).some(step => !step.passed)) {
source.mainChainStatus = 'FAILED'
}
}
}
async function runWithSession(context, merchantToken) {
const start = context.steps.length
context.createdResources.phase4OverseasReturnReceipt = {
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
Object.assign(context.createdResources.phase4OverseasReturnReceipt, {
warehouseId: warehouse.id, customerId: id(context.context?.merchantQualificationFixture?.tenantId),
mainChainStatus: 'RUNNING',
})
const picker = await createPicker(context, 91, warehouse.id, access)
if (!picker?.token) {
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
@@ -477,11 +502,16 @@ export async function run(context) {
trace(merchantSummary.response), OPTIONS)
}
if (context.options.returnMainChainOnly) {
if (context.options.returnMainChainOnly || context.options.fixedCustomerId) {
context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios';
context.addCoverageGap(MODULE, 'return-isolation-boundaries', '跨客户、跨仓及并发退货扩展',
'主链复用固定客户;跨客户扩展仍需独立固定资源,不创建第二客户,也不计边界通过',
'prerequisite', { blocking: false })
} else {
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
}
context.createdResources.phase4OverseasReturnReceipt.mainChainStatus = context.steps.slice(start).every(step => step.passed)
? 'READY_FOR_FOLLOWUPS' : 'FAILED'
} finally {
await disablePicker(context, picker, 91)
}
@@ -1,12 +1,87 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { beijingDate, matchesProcurementNo } from './phase4-overseas-wallet-and-pallet-cost.mjs'
import { beijingDate, matchesProcurementNo, runFixedCustomerProcurement, run } from './phase4-overseas-wallet-and-pallet-cost.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const source = readFileSync(new URL('./phase4-overseas-wallet-and-pallet-cost.mjs', import.meta.url), 'utf8')
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), 'utf8')
function fixedHarness({ warehouse = true, profileId = '101', logoutCode = '200' } = {}) {
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: 'TEN101', quiet: true })
context.marker = 'PROC-FIXED-TEST'
context.context.merchantQualificationFixture = { marker: context.marker, tenantId: '101',
tenantName: 'QA', reused: true, username: 'fixture-user', password: 'fixture-password' }
const calls = []
context.request = async (method, path) => {
calls.push({ method, path })
let data, code = '200'
if (method === 'GET' && path.endsWith('/warehouse-options')) data = warehouse ? [{ warehouseId: '201' }] : []
else if (method === 'POST' && path === '/api/auth/login') data = { accessToken: 'test-token', refreshToken: 'test-refresh' }
else if (method === 'GET' && path === '/api/merchant/v1/iam/tenant/profile') data = { id: profileId, status: 'ACTIVE' }
else if (method === 'POST' && path === '/api/auth/logout') code = logoutCode
else throw new Error(`unexpected request ${method} ${path}`)
return { http: 200, body: { code, data } }
}
return { context, calls }
}
test('固定客户采购进入默认模块,仅复用候选仓与会话,不初始化钱包', async () => {
const { context, calls } = fixedHarness()
let invoked = 0
await context.executeModule('phase4-overseas-wallet-and-pallet-cost', () => runFixedCustomerProcurement(context,
async (actualContext, fixture, token, warehouseId) => {
invoked++
assert.equal(actualContext, context)
assert.equal(fixture.tenantId, '101')
assert.equal(token, 'test-token')
assert.equal(warehouseId, '201')
}))
assert.equal(invoked, 1)
assert.equal(context.modules[0].skipped, undefined)
assert.equal(context.modules[0].success, true)
assert.equal(context.coverageGaps.length, 1)
assert.equal(calls.at(-1).path, '/api/auth/logout')
assert.match(source, /if \(context.options\?\.fixedCustomerId\) return runFixedCustomerProcurement\(context\)/)
})
test('错误客户及旧批次资格在任何网络请求前阻断默认入口', async () => {
for (const patch of [{ tenantId: '102' }, { marker: 'OLD' }, { reused: false }]) {
const { context, calls } = fixedHarness()
Object.assign(context.context.merchantQualificationFixture, patch)
await assert.rejects(run(context), /固定客户资格/)
assert.equal(calls.length, 0)
}
})
test('缺少候选仓不建仓;登录身份漂移不执行采购且退出会话', async () => {
for (const options of [{ warehouse: false }, { profileId: '102' }]) {
const { context, calls } = fixedHarness(options)
let invoked = false
await assert.rejects(runFixedCustomerProcurement(context, async () => { invoked = true }), /合作仓|身份不匹配/)
assert.equal(invoked, false)
if (options.profileId) assert.equal(calls.at(-1).path, '/api/auth/logout')
else assert.equal(calls.length, 1)
}
})
test('采购执行失败仍退出会话,退出失败保留失败断言', async () => {
const { context, calls } = fixedHarness()
await assert.rejects(runFixedCustomerProcurement(context, async () => { throw new Error('unknown result') }), /unknown result/)
assert.equal(calls.at(-1).path, '/api/auth/logout')
const failedLogout = fixedHarness({ logoutCode: 'A0409' })
await runFixedCustomerProcurement(failedLogout.context, async () => {})
assert.ok(failedLogout.context.steps.some(step => !step.passed))
})
test('已存在采购证据不重复创建样本', async () => {
const { context, calls } = fixedHarness()
context.createdResources.procurementSample = { status: 'FAILED' }
await assert.rejects(runFixedCustomerProcurement(context), /不自动重跑/)
assert.equal(calls.length, 0)
})
test('采购单号按北京时间日期执行精确合同', () => {
const instant = new Date('2026-09-05T16:30:00.000Z')
assert.equal(beijingDate(instant), '20260906')
@@ -1,5 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
@@ -314,7 +315,41 @@ async function verifyDestuffCosts(context, completedFacts) {
return evidence
}
/** 固定客户只执行采购正常链,不初始化余额、授信或修改公共资源。 */
export async function runFixedCustomerProcurement(context, fulfill = procurementFlow) {
const fixture = context.context?.merchantQualificationFixture
const customerId = normalizeFixedCustomerId(context.options?.fixedCustomerId)
if (fixture?.marker !== context.marker || id(fixture?.tenantId) !== customerId
|| fixture?.reused !== true || !fixture?.username || !fixture?.password) {
throw new Error('采购正向链缺少本轮固定客户资格,禁止创建或借用其他客户')
}
if (context.createdResources.procurementSample) throw new Error('已有采购验收证据,不自动重跑或覆盖')
context.addCoverageGap(MODULE, 'fixed-wallet-lifecycle', '授信充值、贴标资金及拆柜成本综合场景',
'本分支仅执行既有余额上的采购正向履约;未执行钱包初始化及原综合模块其他场景',
'not-implemented', { blocking: false })
const warehousesResponse = await context.request('GET', `${PROCUREMENT}/warehouse-options`)
const candidates = success(warehousesResponse) ? warehousesResponse.body.data : null
const warehouse = Array.isArray(candidates) ? candidates.find(row => /^[1-9]\d*$/.test(id(row?.warehouseId))) : null
assert(context, '采购正向链复用正式候选收货仓', warehousesResponse, Boolean(warehouse))
if (!warehouse) throw new Error('缺少现有采购合作仓,不自动创建')
const auth = await merchantLogin(context, fixture)
if (!auth?.accessToken || !auth?.refreshToken) throw new Error('采购客户会话或退出凭据缺失')
try {
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
const profile = success(profileResponse) ? profileResponse.body.data : null
const matched = id(profile?.id) === customerId && profile?.status === 'ACTIVE'
assert(context, '采购当前会话属于指定固定客户', profileResponse, matched)
if (!matched) throw new Error('采购客户会话身份不匹配,停止业务写入')
// 共享助手在创建订单前核验现金可用余额至少3元,并逐请求持久化;不足时直接失败。
return await fulfill(context, fixture, auth.accessToken, id(warehouse.warehouseId))
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
assert(context, '退出本轮固定采购客户会话', logout, success(logout))
}
}
export async function run(context) {
if (context.options?.fixedCustomerId) return runFixedCustomerProcurement(context)
const start = context.steps.length
const fixture = context.context?.merchantQualificationFixture
const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || []
@@ -1,7 +1,7 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -10,6 +10,80 @@ import { fileURLToPath } from 'node:url'
import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resource held', 'settlement failed']) {
test(`client original relabel delivery includes receipt and resource release: ${fault || 'normal'}`, async () => {
const relabel = { marker: 'batch', relabelNo: 'RL-CLIENT', dispatchNo: 'DP-CLIENT', warehouseId: '42', customerId: '101',
sourceCartonMarks: ['OLD'], relabelCartonMarks: ['NEW'] }
const calls = []
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } },
checkpoint: async () => {}, assert(_m, name, _r, ok) { assert.ok(ok, name) },
apiSuccess(_m, _name, response) { return response.body.data },
async request(method, path) {
calls.push('outbound')
assert.equal(method, 'GET'); assert.ok(path.endsWith('/outbound-orders/OUT-1'))
return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101',
status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } }
} }
const helpers = {
async prepare(_c, _m, dispatch, warehouse) { calls.push('prepare'); assert.equal(dispatch, relabel.dispatchNo); assert.equal(warehouse, '42'); return { tripNo: 'TR-1' } },
async pick() { calls.push('pick'); return true }, async load() { calls.push('load') },
async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1' }],
deliveries: [{ customerId: fault === 'foreign customer' ? '102' : '101', outboundNo: 'OUT-1', deliveryNo: 'DE-1', packageCount: 1 }] } },
async sign() { calls.push('sign'); return { order: { status: fault === 'sign failure' ? 'DELIVERING' : 'DELIVERED',
deliveryNo: 'DE-1', customerId: '101', outboundNo: 'OUT-1' } } },
async finish() { calls.push('finish'); return { status: fault === 'resource held' ? 'CHECKING' : 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
async settle() { calls.push('settle'); return { status: fault === 'settlement failed' ? 'FAILED' : 'SETTLED_HTTP_VERIFIED' } },
}
if (fault) {
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
assert.equal(relabel.deliveryExecution.status, 'FAILED')
if (['foreign customer', 'old marks'].includes(fault)) assert.equal(calls.includes('sign'), false)
if (fault === 'sign failure') assert.equal(calls.includes('finish'), false)
} else {
await verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers)
assert.equal(relabel.deliveryExecution.status, 'DELIVERED_HTTP_VERIFIED')
assert.deepEqual(calls, ['prepare', 'pick', 'load', 'read', 'outbound', 'sign', 'finish', 'settle'])
}
const count = calls.length
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
assert.equal(calls.length, count)
})
}
for (const fault of ['', 'foreign order', 'wrong amount', 'pending', 'rejected']) {
test(`原贴标结算只读回查: ${fault || 'settled'}`, async () => {
const relabel = { marker: 'batch', relabelNo: 'RL-1', sourceOrderNo: 'WC-1', customerId: '101', warehouseId: '42', outboundNo: 'OUT-1',
deliveryExecution: { closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } } }
let calls = 0
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } }, checkpoint: async () => {}, assert() {},
apiSuccess(_m, _n, response) { return response.body.data },
async request(method, path) {
assert.equal(method, 'GET'); assert.ok(path.endsWith('/relabel-applications/RL-1')); calls++
return { http: 200, body: { code: '200', data: { application: { relabelNo: 'RL-1',
customerOrderNo: fault === 'foreign order' ? 'OTHER' : 'WC-1', overseasWarehouseId: '42',
chargeAmount: fault === 'wrong amount' ? 1 : 0.01, chargeCurrency: 'USD', customerConfirmedAt: '2026-10-22T01:00:00',
walletStatus: fault === 'pending' ? 'SETTLE_PENDING' : fault === 'rejected' ? 'SETTLE_FAILED' : calls === 1 ? 'SETTLE_PENDING' : 'SETTLED' } } } }
} }
if (fault) {
await assert.rejects(verifyClientRelabelSettlement(context, relabel, async () => {}))
assert.equal(relabel.settlement.status, 'FAILED')
assert.equal(calls, fault === 'pending' ? 31 : 1)
} else {
await verifyClientRelabelSettlement(context, relabel, async () => {})
assert.equal(relabel.settlement.status, 'SETTLED_HTTP_VERIFIED'); assert.equal(calls, 2)
}
})
}
test('relabel fleets run after main resource release and retain both original proofs in checkpoints', () => {
const start = source.indexOf('export async function run(context)')
const run = source.slice(start)
assert.ok(run.indexOf('await finishRelabelDeliveries(') > run.indexOf('await finishFleetTrip('))
assert.equal(run.includes('await verifyRelabelDispatchOutbound('), false)
assert.match(source, /clientRelabel: context.createdResources.phase4OverseasClientServices\?\.relabelFulfillment/)
})
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
@@ -53,15 +127,24 @@ test('native route conserves source keys and destinations while same destination
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/)
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/)
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch', 'phase4-overseas-business'\]/)
const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize',
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) {
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch', 'phase4-overseas-business']) {
assert.ok(modules.indexOf(prerequisite) >= 0
&& modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite)
}
})
test('default looseboard attaches before stock snapshots and verifies its original order after signature', () => {
const start = source.indexOf('export async function run(context)')
const run = source.slice(start)
assert.ok(run.indexOf('await attachLooseboardToTrip(') < run.indexOf('const sourceTask ='))
assert.ok(run.indexOf('await verifyLooseboardDelivered(') > run.indexOf('const completedDeliveries ='))
assert.match(source, /looseboard: context.createdResources.phase4OverseasBusiness\?\.looseboard/)
assert.match(run, /sourceKeys: \[\.\.\.source.dispatch.sourceKeys, \.\.\.looseboard.proof.sourceKeys\]/)
})
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds',
@@ -2,6 +2,7 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
import { mkdir, writeFile, rename, readFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { createHash } from 'node:crypto'
@@ -45,6 +46,9 @@ export async function fulfillmentEvidenceContext(context) {
await mkdir(dirname(path), { recursive: true })
await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence,
fulfillment: context.createdResources.phase5OverseasFulfillment,
looseboard: context.createdResources.phase4OverseasBusiness?.looseboard,
clientRelabel: context.createdResources.phase4OverseasClientServices?.relabelFulfillment,
pdaRelabel: context.createdResources.phase4OverseasRelabel,
fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8')
await rename(`${path}.tmp`, path)
}
@@ -842,26 +846,107 @@ export function relabelOutboundMarksMatch(detail, relabel) {
&& Number(detail?.outbound?.packageCount) === expected.length
}
async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) {
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
return
// 客户原贴标费用只读等待结算回执,不以派送成功推定扣费成功。
export async function verifyClientRelabelSettlement(context, relabel, delay = () => new Promise(resolve => setTimeout(resolve, 1000))) {
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment !== relabel
|| relabel?.marker !== context.marker || !nonBlank(relabel.sourceOrderNo)
|| id(relabel.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)
|| relabel.deliveryExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|| !nonBlank(relabel.outboundNo)) throw Error('贴标结算缺少本轮原申请已交付证据')
const evidence = relabel.settlement = { status: 'POLLING', observations: [] }
try {
for (let attempt = 0; attempt < 31; attempt++) {
await context.checkpoint()
const result = await platform(context, 'GET', '客户原贴标申请结算回查',
`${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`)
const application = result.data?.application
const same = success(result.response) && application?.relabelNo === relabel.relabelNo
&& application.customerOrderNo === relabel.sourceOrderNo && id(application.overseasWarehouseId) === id(relabel.warehouseId)
&& Number(application.chargeAmount) === 0.01 && nonBlank(application.chargeCurrency)
&& Boolean(application.customerConfirmedAt)
context.assert(MODULE, '结算回执属于原贴标申请及原费用', result.response, same, `relabelNo=${relabel.relabelNo}`, OPTIONS)
if (!same) throw Error('原贴标申请结算身份或金额不一致')
evidence.observations.push({ walletStatus: application.walletStatus, chargeAmount: application.chargeAmount,
chargeCurrency: application.chargeCurrency, requestId: result.response.requestId, traceId: result.response.traceId })
await context.checkpoint()
if (application.walletStatus === 'SETTLED') { evidence.status = 'SETTLED_HTTP_VERIFIED'; await context.checkpoint(); return evidence }
if (!['FROZEN', 'SETTLE_PENDING'].includes(application.walletStatus)) throw Error(`贴标结算异常状态: ${application.walletStatus}`)
if (attempt < 30) await delay()
}
throw Error('原贴标费用结算回执等待超时')
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await context.checkpoint(); throw error }
}
// 原换标申请逐车完成,必须等前车释放复用资源,不能只出库不签收。
export async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId, helpers = {
prepare: prepareFleetTrip, pick: pickAll, load: confirmFleetLoading, read: readAutomaticDispatchDelivery,
sign: saveReceiptAndComplete, finish: finishFleetTrip, settle: verifyClientRelabelSettlement,
}) {
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo) || !token || relabel.deliveryExecution
|| ![context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment].includes(relabel)
|| id(relabel.warehouseId ?? relabel.overseasWarehouseId) !== id(warehouseId)) throw Error('缺少本轮原换标排车、同仓PDA或履约已执行')
const evidence = relabel.deliveryExecution = { status: 'RUNNING', marker: context.marker }
const require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabel.relabelNo}`, OPTIONS)
if (!passed) throw Error(name)
}
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
if (!await pickAll(context, token, relabel.dispatchNo)) return
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
if (!transferred) return
try {
const proof = await helpers.prepare(context, MODULE, relabel.dispatchNo, warehouseId)
evidence.tripNo = proof.tripNo
require('原换标排车实际扫码完成', await helpers.pick(context, token, relabel.dispatchNo))
await helpers.load(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await helpers.read(context, relabel.dispatchNo)
const customerId = id(relabel.customerId)
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return }
require('原换标排车生成唯一客户出库且派送无外来客户', transferred?.outbounds?.length === 1
&& id(transferred.outbounds[0].customerId) === customerId && nonBlank(transferred.outbounds[0].outboundNo)
&& transferred.deliveries?.length > 0 && new Set(transferred.deliveries.map(row => row.deliveryNo)).size === transferred.deliveries.length
&& transferred.deliveries.every(row => id(row.customerId) === customerId && row.outboundNo === transferred.outbounds[0].outboundNo)
&& transferred.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === relabel.relabelCartonMarks.length)
const outboundNo = transferred.outbounds[0].outboundNo
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`)
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱',
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
&& id(detail.data?.outbound?.customerId) === customerId && detail.data?.outbound?.status === 'OUTBOUNDED'
&& relabelOutboundMarksMatch(detail.data, relabel))
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
for (const [index, delivery] of transferred.deliveries.entries()) {
const signed = await helpers.sign(context, delivery, customerId, index)
require('原换标货物经正式签收完成派送', signed?.order?.status === 'DELIVERED'
&& signed.order.deliveryNo === delivery.deliveryNo && id(signed.order.customerId) === customerId
&& signed.order.outboundNo === outboundNo)
}
evidence.closure = await helpers.finish(context, MODULE, proof)
require('原换标车签收后车辆司机均已释放', evidence.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment === relabel) {
const settled = await helpers.settle(context, relabel)
require('客户原贴标申请费用已结算', settled?.status === 'SETTLED_HTTP_VERIFIED')
}
evidence.status = 'DELIVERED_HTTP_VERIFIED'
await context.checkpoint()
return evidence
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message
await context.checkpoint(); throw error
}
}
// 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。
async function finishRelabelDeliveries(context, mainTrip, warehouseId) {
if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约')
const access = await resolvePickerAccess(context)
const picker = access ? await createPicker(context, 95, warehouseId, access) : null
try {
if (!picker?.token) throw Error('换标完整履约缺少可复用同仓PDA')
for (const original of [context.createdResources.phase4OverseasRelabel, context.createdResources.phase4OverseasClientServices?.relabelFulfillment]) {
await verifyRelabelDispatchOutbound(context, original, picker.token, warehouseId)
}
} finally {
await disablePicker(context, picker, 95)
if (picker) picker.token = null
await context.checkpoint()
}
}
async function loginMerchant(context, customerId) {
@@ -1058,7 +1143,8 @@ export async function run(context) {
if (!merchantToken) return
context = await fulfillmentEvidenceContext(context)
const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
let beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
let fulfillmentDispatch = source.dispatch
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes,
@@ -1079,6 +1165,12 @@ export async function run(context) {
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
return
}
const looseboard = await attachLooseboardToTrip(context, tripProof, picker.token)
fulfillmentDispatch = { ...source.dispatch, sourceKeys: [...source.dispatch.sourceKeys, ...looseboard.proof.sourceKeys] }
beforeEdit = { ...beforeEdit, data: looseboard.detail }
if (!dispatchDestinationConservation(beforeEdit.data, fulfillmentDispatch)) throw new Error('散板加货后原车来源或目的地不守恒')
Object.assign(routeProof, { sourceKeys: fulfillmentDispatch.sourceKeys, beforeEdit: beforeEdit.data,
looseboardIncomingNo: looseboard.proof.incomingNo })
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return
@@ -1118,8 +1210,8 @@ export async function run(context) {
deliveries = transferred.deliveries; outbounds = transferred.outbounds
routeProof.deliveries = deliveries
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries)) return
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
if (!ready) return
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
@@ -1135,8 +1227,6 @@ export async function run(context) {
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
if (!unchanged) return
}
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId)
} finally {
try { await disablePicker(context, picker, 5) }
finally { if (picker) picker.token = null; await context.checkpoint() }
@@ -1192,6 +1282,7 @@ export async function run(context) {
success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS)
if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return
context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data
await verifyLooseboardDelivered(context, tripProof, deliveries)
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
removedComplete?.http === 404 || removedComplete?.http === 405
@@ -1256,6 +1347,7 @@ export async function run(context) {
// 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。
if (context.context?.phase5TailVerification?.runReturnScenario !== true) {
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await finishRelabelDeliveries(context, tripProof, warehouseId)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'
@@ -1305,6 +1397,7 @@ export async function run(context) {
exceptionNo: returnExceptionNo, outboundNo })
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await finishRelabelDeliveries(context, tripProof, warehouseId)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
@@ -48,3 +48,95 @@ test('failure to persist the request prevents network dispatch', async () => {
await assert.rejects(verifyProcurementSample(context, inputs), /disk write failed/)
assert.equal(events.length, 0)
})
function completeFixture(fault = '') {
const { context, inputs } = fixture()
const calls = [], seen = new Map()
let spent = 0, frozen = 0, creations = 0
const order = { orderNo: 'CG-TEST-1', customerId: '101', status: 'PENDING_REVIEW', version: 0, updatedAt: '2026-10-22T12:00:00' }
const fees = [], physical = { receipts: [], cartons: [], packingVersion: 0 }
const transport = { overseasLabels: { version: 0 }, domesticLabels: { version: 0 }, shipments: [] }
const detail = () => structuredClone({ customerView: { order, fees }, physical, transport, fees })
inputs.upload = async () => ({ fileId: '501' })
inputs.readWallet = async () => ({ actualBalance: ((1000 - spent) / 100).toFixed(2), availableBalance: ((1000 - spent - frozen) / 100).toFixed(2),
frozenBalance: (frozen / 100).toFixed(2), creditUsed: '0.00' })
context.request = async (method, path, body) => {
calls.push({ method, path, body: structuredClone(body) })
const reply = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req-test', traceId: 'trace-test' })
if (method === 'GET') {
if (path.startsWith('/api/merchant/')) return reply({ order: { ...order, ...(fault === 'customer final order' ? { orderNo: 'OTHER' } : {}) } })
const result = detail()
if (fault === 'aggregate customer') result.customerView.order.customerId = '999'
if (fault === 'aggregate order') result.customerView.order.orderNo = 'OTHER'
if (fault === 'aggregate version') result.customerView.order.version = null
return reply(result)
}
if (path === '/api/merchant/v1/last-leg/overseas-procurements') {
creations++
return reply({ order: { ...order, ...(creations === 2 && fault === 'create replay customer' ? { customerId: '999' } : {}) } })
}
assert.equal(method, 'POST')
const action = path.split('/CG-TEST-1/')[1]?.split('?')[0]
assert.ok(action, path)
if (seen.has(body.idempotencyKey)) {
assert.deepEqual(body, seen.get(body.idempotencyKey))
if (fault === 'replay deducts' && action === 'purchase') spent += 100
return reply(detail())
}
assert.equal(body.version, order.version)
seen.set(body.idempotencyKey, structuredClone(body))
switch (action) {
case 'review': order.status = 'APPROVED'; break
case 'purchase':
spent += 100
fees.push({ active: true, purpose: 'SAMPLE_PAYMENT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-1' })
break
case 'overseas-receipts': case 'domestic-receipts':
physical.receipts.push({ id: String(601 + physical.receipts.length), leg: body.leg, active: true, goodQuantity: body.goodQuantity }); break
case 'overseas-receipts/complete': case 'domestic-receipts/complete': break
case 'packing':
physical.cartons = body.cartons.map((row, index) => ({ ...row, id: String(701 + index) })); physical.packingVersion++; break
case 'packing/confirm': assert.equal(body.packingVersion, physical.packingVersion); break
case 'labels': transport[body.leg === 'OVERSEAS' ? 'overseasLabels' : 'domesticLabels'].version++; break
case 'international-estimate':
frozen = 200; fees.push({ active: true, purpose: 'INTERNATIONAL_ESTIMATE', walletStatus: 'FROZEN' }); break
case 'labels/apply': break
case 'overseas-shipment': case 'customer-shipment':
transport.shipments.push({ leg: body.leg })
if (action === 'customer-shipment') order.status = 'SHIPPED_TO_CUSTOMER'
break
case 'charges/confirm':
spent += 150; frozen = 0
fees.find(row => row.purpose === 'INTERNATIONAL_ESTIMATE').walletStatus = 'RELEASED'
fees.push({ active: true, purpose: 'INTERNATIONAL_FREIGHT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-2' })
break
default: assert.fail(`Unexpected operation ${action}`)
}
order.version++
return reply(detail())
}
return { context, inputs, calls, seen, physical, transport }
}
test('sample procurement traverses receipt, packing, both shipments and exact wallet settlement with same-key replays', async () => {
const h = completeFixture()
const proof = await verifyProcurementSample(h.context, h.inputs)
assert.equal(proof.status, 'PASSED')
assert.equal(proof.settledAmountCny, '2.50')
assert.deepEqual(proof.walletBusinessNos, ['PAY-1', 'PAY-2'])
assert.equal(h.physical.receipts.length, 4)
assert.equal(h.physical.cartons.length, 2)
assert.deepEqual(h.transport.shipments.map(row => row.leg), ['OVERSEAS', 'DOMESTIC'])
assert.equal(proof.walletChecks.length, 4)
assert.ok(proof.checks.every(check => check.passed))
})
for (const fault of ['aggregate customer', 'aggregate order', 'aggregate version', 'create replay customer', 'customer final order', 'replay deducts']) {
test(`procurement stops rather than passing ${fault}`, async () => {
const h = completeFixture(fault)
await assert.rejects(verifyProcurementSample(h.context, h.inputs))
assert.equal(h.context.createdResources.procurementSample.status, 'FAILED')
if (fault.startsWith('aggregate') || fault === 'create replay customer') assert.equal(h.seen.size, 0)
if (fault === 'replay deducts') assert.equal(h.physical.receipts.length, 0)
})
}
@@ -10,7 +10,7 @@ const cents = value => {
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
}
/** Internal scenario; the TEST coordinator owns isolated customer creation, funding, sessions and file upload. */
/** Internal scenario; the coordinator supplies the configured customer, existing balance, session and upload. */
export async function verifyProcurementSample(context, inputs) {
if (!context.marker || typeof context.checkpoint !== 'function' || typeof context.request !== 'function'
|| inputs?.marker !== context.marker || !/^[1-9]\d*$/.test(String(inputs.customerId))
@@ -44,7 +44,13 @@ export async function verifyProcurementSample(context, inputs) {
await check(label, response?.http === 200 && String(response?.body?.code) === expectedCode)
return response.body.data
}
async function read() { detail = await request('GET', path(), undefined, '回查采购聚合详情'); return detail }
async function read() {
detail = await request('GET', path(), undefined, '回查采购聚合详情')
await check('采购聚合仍属于原客户原单且版本有效', detail?.customerView?.order?.orderNo === proof.orderNo
&& detail.customerView.order.customerId === customerId
&& Number.isSafeInteger(detail.customerView.order.version) && detail.customerView.order.version >= 0)
return detail
}
async function command(operation, values = {}, replay = false) {
const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
await request('POST', path(operation), body, operation)
@@ -76,7 +82,7 @@ export async function verifyProcurementSample(context, inputs) {
proof.orderNo = created?.order?.orderNo; await context.checkpoint()
await check('客户创建响应包含本轮订单', typeof proof.orderNo === 'string' && created.order.customerId === customerId)
const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken)
await check('重放没有新建采购单', repeated.order.orderNo === proof.orderNo)
await check('重放没有新建采购单', repeated?.order?.orderNo === proof.orderNo && repeated.order.customerId === customerId)
await read()
await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
await read()
@@ -115,7 +121,9 @@ export async function verifyProcurementSample(context, inputs) {
await command('customer-shipment', { leg: 'DOMESTIC', labelVersion: domesticLabels, cartonIds, shippedAt: at() }, true)
await check('寄客户正常终态及单次交寄', detail.customerView.order.status === 'SHIPPED_TO_CUSTOMER' && detail.transport.shipments.filter(row => row.leg === 'DOMESTIC').length === 1)
const customer = await request('GET', `${MERCHANT}/${encodeURIComponent(proof.orderNo)}`, undefined, '客户最终详情', inputs.customerToken)
await check('客户可见终态且没有平台内部数据', customer.order.status === 'SHIPPED_TO_CUSTOMER' && !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
await check('客户可见终态且没有平台内部数据', customer?.order?.orderNo === proof.orderNo
&& customer.order.customerId === customerId && customer.order.status === 'SHIPPED_TO_CUSTOMER'
&& !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
await wallet('重复交寄不再次扣款', 250n, 0n)
proof.walletBusinessNos = [...new Set(detail.fees.filter(row => row.walletStatus === 'SETTLED').map(row => row.walletBusinessNo))]
await check('每笔成功消费保留权威钱包业务引用', proof.walletBusinessNos.length === 2 && proof.walletBusinessNos.every(value => typeof value === 'string' && value.length > 0))
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { run } from './suppliers.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8')
@@ -18,3 +20,57 @@ test('supplier OpenAPI exposes the internal active category validation contract
assert.doesNotMatch(openapi,/\bscope\b/)
assert.doesNotMatch(openapi,/platform:supplier:view/)
})
function existingHarness({ empty = false, wrongDetail = false, failStats = false } = {}) {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
const calls = []
const categories = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
runner.request = async (method, path) => {
calls.push({ method, path })
assert.equal(method, 'GET', 'fixed suppliers must never mutate master data')
const url = new URL(path, 'http://offline.invalid')
let data
let code = '200'
if (url.searchParams.get('tenantId') === '1') code = 'A0400'
else if (url.pathname.endsWith('/stats')) {
data = { booking: 1, customs: 1, trucking: 1, certificate: 1 }
if (failStats) code = 'S0500'
} else if (url.searchParams.has('category')) {
const category = url.searchParams.get('category')
data = { records: empty ? [] : [{ id: String(categories.indexOf(category) + 1), category, status: 'ACTIVE' }],
page: 1, size: 20, total: empty ? 0 : 1, pages: empty ? 0 : 1 }
} else {
const id = url.pathname.split('/').at(-1)
data = { id, tenantId: wrongDetail ? '88' : '0', category: categories[Number(id) - 1], status: 'ACTIVE' }
}
return { http: 200, body: { code, data }, requestId: 'request', traceId: 'trace' }
}
return { runner, calls }
}
test('fixed supplier module runs through orchestrator using only existing read-only records', async () => {
const { runner, calls } = existingHarness()
await runner.executeModule('suppliers', () => run(runner))
assert.equal(calls.length, 10)
assert.equal(runner.modules[0].skipped, undefined)
assert.equal(runner.modules[0].success, true)
assert.equal(runner.coverageGaps.length, 1)
assert.equal(runner.coverageGaps[0].scenario, 'fixed-resource-lifecycle')
})
test('empty supplier lists retain detail gaps and never create replacements', async () => {
const { runner, calls } = existingHarness({ empty: true })
await run(runner)
assert.equal(calls.length, 6)
assert.equal(runner.coverageGaps.length, 5)
assert.ok(runner.steps.every(step => step.passed))
})
test('supplier detail scope drift and failed statistics remain failures', async () => {
for (const options of [{ wrongDetail: true }, { failStats: true }]) {
const { runner } = existingHarness(options)
await runner.executeModule('suppliers', () => run(runner))
assert.equal(runner.modules[0].success, false)
assert.ok(runner.steps.some(step => !step.passed))
}
})
@@ -16,6 +16,7 @@ function query(values = {}) { const p = new URLSearchParams(); for (const [k,v]
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
export async function run(context) {
if (context.options?.fixedCustomerId) return runExistingSuppliers(context)
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20}))
const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS)
@@ -67,3 +68,40 @@ export async function run(context) {
context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true}
}
}
/** 固定资源模式只读现有主档,生命周期缺口仍单独保留。 */
async function runExistingSuppliers(context) {
context.addCoverageGap(MODULE, 'fixed-resource-lifecycle', '供应商新增、编辑、停用生命周期',
'固定资源模式仅验收现有供应商列表、筛选、统计、详情和作用域拒绝,不修改公共主档',
'not-implemented', { blocking: false })
const statsResponse = await context.request('GET', `${BASE}/stats?tenantId=0`)
const stats = context.apiSuccess(MODULE, '查询四类供应商统计', statsResponse, '', OPTIONS)
const nonNegativeInteger = value => value !== null && value !== undefined && value !== ''
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0
context.assert(MODULE, '统计字段完整', statsResponse,
['booking', 'customs', 'trucking', 'certificate'].every(key => nonNegativeInteger(stats?.[key])), '', OPTIONS)
for (const category of ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']) {
const response = await context.request('GET', query({ tenantId: 0, category, status: 'ACTIVE', page: 1, size: 20 }))
const page = context.apiSuccess(MODULE, `查询已有${category}供应商`, response, '', OPTIONS)
const rows = records(page)
context.assert(MODULE, `${category}分页和启用类别筛选正确`, response,
Array.isArray(page?.records) && Number(page.page) === 1 && Number(page.size) === 20
&& nonNegativeInteger(page.total) && Number(page.pages) === Math.ceil(Number(page.total) / 20)
&& rows.length === Math.min(20, Number(page.total))
&& rows.every(row => id(row.id) && row.category === category && row.status === 'ACTIVE'), '', OPTIONS)
if (!rows.length) {
context.addCoverageGap(MODULE, `existing-${category}`, `${category}供应商详情`,
'没有启用的既有样本;未创建供应商,详情未验收', 'prerequisite', { blocking: false })
continue
}
const supplierId = id(rows[0].id)
if (!supplierId) continue
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, `回查已有${category}供应商详情`, detailResponse, '', OPTIONS)
context.assert(MODULE, `${category}详情身份、类别及平台归属一致`, detailResponse,
id(detail?.id) === supplierId && id(detail?.tenantId) === '0' && detail?.category === category
&& detail?.status === 'ACTIVE' && !Object.hasOwn(detail || {}, 'scope'), '', OPTIONS)
}
const denied = await context.request('GET', query({ tenantId: 1, page: 1, size: 20 }))
context.assert(MODULE, 'tenantId非0被拒绝', denied, code(denied, 'A0400') && traced(denied), '', OPTIONS)
}
@@ -0,0 +1,120 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) {
if (!proof || proof.marker !== context.marker || !proof.orderNo || !proof.customerId
|| context.createdResources?.phase4OverseasBusiness?.truck !== proof || proof.fulfillment
|| proof.finalStatus !== 'WAITING_INBOUND') throw Error('卡车履约必须使用本轮未执行的待入仓原单')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw Error('卡车批次标识不合法')
const evidence = proof.fulfillment = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/us-truck-checkpoints/', import.meta.url)
// 总编排提供检查点时沿用其持久化边界;否则独占创建批次文件,拒绝覆盖旧执行。
const persist = checkpoint ? () => checkpoint.call(context) : async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
}
const base = `/api/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(proof.orderNo)}`
const suffix = `?customerId=${encodeURIComponent(proof.customerId)}`
const check = (name, ok) => {
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${proof.orderNo}`, OPTIONS)
if (!ok) throw Error(name)
}
const request = async (method, action, body, expectedRejection = false) => {
const log = { method, action, body, expectedRejection, status: 'PREPARED' }
evidence.requests.push(log)
await persist()
log.status = 'SENDING'
const response = await context.request(method, `${base}${action}${suffix}`, body)
Object.assign(log, { http: response?.http, code: response?.body?.code,
requestId: response?.requestId, traceId: response?.traceId })
const ok = response?.http === 200 && String(response?.body?.code) === '200'
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
await persist()
if (expectedRejection) {
check('卡车无效命令按预期错误码拒绝且保留诊断', [200, expectedRejection].includes(response?.http)
&& [String(expectedRejection), `A0${expectedRejection}`].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
return response.body.data
}
context.apiSuccess(MODULE, `本土卡车${method} ${action || '详情'}`, response,
`HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, OPTIONS)
if (!ok) throw Error(`卡车履约请求失败,不自动重发: ${action}`)
return response.body.data
}
const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo
&& String(detail.summary.customerId) === String(proof.customerId)
&& Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0)
try {
let current = await request('GET', '')
identity(current)
check('卡车实收前仍为本轮待入仓单', current.summary.status === 'WAITING_INBOUND'
&& current.remark?.startsWith(context.marker) && Number(current.summary.receivedPalletCount) === 0)
const packages = Number(current.summary.packageCount)
check('卡车实收箱数来自原订单且为有效正整数', Number.isSafeInteger(packages) && packages > 0)
const packageCounts = packages >= 2 ? [1, packages - 1] : [packages]
const palletCount = packageCounts.length
const stages = [
['receive-pallets', 'WAITING_CONSOLIDATION'],
...(palletCount === 2 ? [['allocate-pallets', 'PARTIALLY_ALLOCATED', 0], ['allocate-pallets', 'ALLOCATED', 1]] : [['allocate-pallets', 'ALLOCATED', 0]]),
['prepare-dispatch', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'],
['start-transit', 'IN_TRANSIT'], ['complete', 'COMPLETED'],
]
let pallets
const allocatedIds = new Set()
for (const [action, state, allocationIndex] of stages) {
if (action === 'allocate-pallets' && allocationIndex === 1) {
await request('POST', '/prepare-dispatch', {
version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
}, 409)
const unchanged = await request('GET', '')
check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current))
}
const beforeVersion = current.summary.version
const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker }
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts })
if (action === 'allocate-pallets') {
body.palletIds = [pallets[allocationIndex].id]
body.idempotencyKey += `-${allocationIndex}`
}
// 拒绝分支使用同一原单与独立键,回查整个聚合证明未偷偷收托或分配。
if (action === 'receive-pallets' || action === 'allocate-pallets' && allocationIndex === 0) {
const invalid = { ...body, idempotencyKey: `${body.idempotencyKey}-invalid`,
...(action === 'receive-pallets' ? { palletPackageCounts: packageCounts.map((n, i) => n + (i === 0 ? 1 : 0)) } : { palletIds: [body.palletIds[0], body.palletIds[0]] }) }
await request('POST', `/${action}`, invalid, 400)
const unchanged = await request('GET', '')
check(`卡车${action}拒绝后数量状态历史版本均不变`, JSON.stringify(unchanged) === JSON.stringify(current))
}
const next = await request('POST', `/${action}`, body)
identity(next)
check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1)
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount
&& new Set(next.pallets.map(row => String(row.id))).size === palletCount
&& next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index]
&& /^[1-9]\d*$/.test(String(row.id)) && typeof row.palletNo === 'string' && row.palletNo.startsWith(`${proof.orderNo}-P`)))
pallets ??= next.pallets.map(row => ({ id: String(row.id), palletNo: row.palletNo, packageCount: Number(row.packageCount) }))
if (action === 'allocate-pallets') allocatedIds.add(String(body.palletIds[0]))
check('卡车履约复用原实收托盘且只分配本次选择', next.pallets.every((row, index) => String(row.id) === pallets[index].id
&& row.palletNo === pallets[index].palletNo && row.status === (allocatedIds.has(String(row.id)) ? 'ALLOCATED' : 'RECEIVED'))
&& Number(next.summary.allocatedPalletCount) === allocatedIds.size)
check('卡车履约写入对应操作历史', next.histories?.some(row => row.idempotencyKey === body.idempotencyKey
&& row.toStatus === state && row.fromStatus === current.summary.status))
const replay = await request('POST', `/${action}`, body)
check('卡车同键重放不重复收托或推进版本', JSON.stringify(replay) === JSON.stringify(next))
current = await request('GET', '')
check('卡车履约状态已持久化且回查一致', JSON.stringify(current) === JSON.stringify(next))
}
Object.assign(proof, { finalStatus: 'COMPLETED', version: current.summary.version, retained: true })
Object.assign(evidence, { status: 'HTTP_VERIFIED', pallets, packages, partialAllocationVerified: palletCount === 2 })
await persist()
} catch (error) {
for (const log of evidence.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
evidence.status = 'FAILED'
evidence.failure = error.message
try { await persist() } catch (checkpointError) { evidence.checkpointFailure = checkpointError.message }
throw error
}
}
@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
// 用独立来源快照验证隔离、幂等与未知结果停写,不访问测试环境。
function fixture(fault = '') {
const proof = { marker: 'batch', customerId: '101', warehouseId: '201', transferNo: 'WT-1', inboundCode: 'IN-1', skuCode: 'SKU-1', status: 'APPROVED' }
const calls = [], snapshots = []
const allocation = { inventoryPositionId: '301', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', packageCount: 1 }
const allocated = { transferNo: 'WT-1', status: 'WAIT_RELABEL', relabelNo: 'RL-1', relabelApplicationId: '601', allocations: [allocation] }
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } },
assert() {}, apiSuccess() {},
async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body), token })
let data
if (method === 'POST') {
if (fault === 'network') throw Error('connection closed')
if (fault === 'server') return { http: 503, body: { code: '503' }, requestId: 'request-1' }
data = structuredClone(allocated)
if (fault === 'wrong allocation') data.allocations[0].inboundOrderId = '999'
if (fault === 'replay' && calls.filter(c => c.method === 'POST').length === 2) data.relabelNo = 'RL-2'
} else if (path.includes('inventory-candidates')) {
data = [{ inventoryBalanceId: '701', inboundOrderId: '401', inboundCode: 'IN-1', overseasWarehouseId: '201', skuCode: 'SKU-1', positions: [{ positionId: '301', palletNo: 'P-1', availablePackages: 1 }] }]
if (fault === 'old stock') data[0].inboundCode = 'IN-OLD'
} else if (path.includes('/positions')) {
data = [{ id: '301', balanceId: '701', customerId: '101', overseasWarehouseId: '201', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', availablePackages: 1 }]
if (fault === 'foreign customer') data[0].customerId = '999'
if (fault === 'foreign inbound') data[0].inboundOrderId = '999'
if (fault === 'no stock') data[0].availablePackages = 0
} else if (path.startsWith('/api/merchant')) {
data = structuredClone(allocated)
if (fault === 'merchant drift') data.relabelApplicationId = '999'
} else data = { transferNo: 'WT-1', status: 'APPROVED', operatingWarehouseId: '201', needRelabel: true, packageCount: 1, allocations: [], sourceOrders: [{ inboundOrderId: '401', inboundCode: 'IN-1' }] }
return { http: 200, body: { code: '200', data } }
} }
const checkpoint = async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk unavailable') }
return { proof, context, calls, snapshots, options: { proof, merchantToken: 'secret-token', checkpoint } }
}
test('原转运分配同批库存、重放并由客户回查唯一换标申请', async () => {
const f = fixture()
await allocateWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'WAIT_RELABEL')
assert.equal(f.proof.allocation.status, 'ALLOCATED_WAIT_RELABEL')
assert.equal(f.proof.sourcePositionId, '301')
const writes = f.calls.filter(c => c.method === 'POST')
assert.equal(writes.length, 2)
assert.deepEqual(writes[0].body, writes[1].body)
assert.equal(writes[0].body.allocations[0].packageCount, 1)
assert.equal(f.snapshots[0].allocation.requests[0].status, 'PREPARED')
assert.equal(JSON.stringify(f.snapshots).includes('secret-token'), false)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options), /或已执行/)
assert.equal(f.calls.length, 6)
})
for (const fault of ['old stock', 'foreign customer', 'foreign inbound', 'no stock', 'network', 'server', 'wrong allocation', 'replay', 'merchant drift', 'disk']) {
test(`转运分配遇到 ${fault} 停止且保留证据`, async () => {
const f = fixture(fault)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.allocation.status, 'FAILED')
const writes = f.calls.filter(c => c.method === 'POST')
assert.equal(writes.length, ['replay', 'merchant drift'].includes(fault) ? 2 : ['network', 'server', 'wrong allocation'].includes(fault) ? 1 : 0)
if (['network', 'server'].includes(fault)) assert.equal(f.proof.allocation.requests.at(-1).status, 'UNKNOWN_RESULT')
if (fault === 'disk') assert.equal(f.calls.length, 0)
})
}
test('未登记的同形证明不得触发请求', async () => {
const f = fixture()
f.options.proof = structuredClone(f.proof)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, 0)
})
@@ -0,0 +1,81 @@
import { mkdir, writeFile } from 'node:fs/promises'
const PLATFORM = '/api/platform/v1/last-leg'
const MODULE = 'phase4-overseas-client-services'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
/** 已有库存转运按本轮原入仓单分配真实仓位,保留关联换标申请供后续使用。 */
export async function allocateWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
if (proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| !proof.transferNo || !proof.inboundCode || !proof.skuCode || !validId(proof.warehouseId)
|| proof.status !== 'APPROVED' || !merchantToken || proof.allocation) throw Error('转运库存分配缺少本轮原单和客户来源,或已执行')
const evidence = proof.allocation = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), `transferNo=${proof.transferNo}`, { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
if (!passed) throw Error(name)
}
const request = async (method, path, body, token) => {
const log = { method, path, body, status: 'PREPARED' }; evidence.requests.push(log); await persist()
let response
try { log.status = 'SENDING'; response = await context.request(method, path, body, token) }
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
const ok = response?.http === 200 && id(response?.body?.code) === '200'
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
context.apiSuccess(MODULE, '转运原库存分配', response, JSON.stringify(log), { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
if (!ok) throw Error('转运库存请求失败,保留原单且不自动重发')
return response.body.data
}
const base = `${PLATFORM}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
try {
const transfer = await request('GET', base + scope)
check('转运原单已通过且待分配换标库存', transfer?.transferNo === proof.transferNo && transfer.status === 'APPROVED'
&& id(transfer.operatingWarehouseId) === proof.warehouseId && transfer.needRelabel === true
&& transfer.packageCount === 1 && transfer.allocations?.length === 0
&& transfer.sourceOrders?.some(row => row.inboundCode === proof.inboundCode))
const candidates = await request('GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?${new URLSearchParams({ operationWarehouseId: proof.warehouseId, inboundCode: proof.inboundCode })}`, undefined, merchantToken)
const eligible = (Array.isArray(candidates) ? candidates : []).filter(c => c.inboundCode === proof.inboundCode && c.skuCode === proof.skuCode
&& id(c.overseasWarehouseId) === proof.warehouseId && validId(c.inventoryBalanceId)
&& transfer.sourceOrders.some(s => id(s.inboundOrderId) === id(c.inboundOrderId)))
let source, position
for (const candidate of eligible) {
const positions = await request('GET', `${PLATFORM}/inventory/balances/${encodeURIComponent(candidate.inventoryBalanceId)}/positions${scope}`)
check('库存位置回查必须返回完整数组', Array.isArray(positions))
position = positions.find(p => id(p.customerId) === proof.customerId && id(p.overseasWarehouseId) === proof.warehouseId
&& p.inboundCode === proof.inboundCode && id(p.inboundOrderId) === id(candidate.inboundOrderId)
&& id(p.balanceId) === id(candidate.inventoryBalanceId)
&& validId(p.id) && validId(p.palletId) && Number(p.availablePackages) >= 1
&& candidate.positions?.some(cp => id(cp.positionId) === id(p.id) && cp.palletNo === p.palletNo && Number(cp.availablePackages) >= 1))
if (position) { source = candidate; break }
}
check('只使用本轮入仓单同SKU实际可用托盘仓位', Boolean(source && position))
Object.assign(proof, { sourceBalanceId: id(source.inventoryBalanceId), sourcePositionId: id(position.id), sourcePalletId: id(position.palletId),
sourcePalletNo: position.palletNo, sourceInboundOrderId: id(source.inboundOrderId) })
const body = evidence.request = { idempotencyKey: `${context.marker}-TRANSFER-ALLOCATE`, allocations: [{ inventoryPositionId: proof.sourcePositionId, palletId: proof.sourcePalletId, packageCount: 1 }] }
const allocated = await request('POST', `${base}/allocations${scope}`, body)
check('原库存分配进入待换标并产生唯一关联申请', allocated?.transferNo === proof.transferNo && allocated.status === 'WAIT_RELABEL'
&& typeof allocated.relabelNo === 'string' && allocated.relabelNo.length > 0 && validId(allocated.relabelApplicationId)
&& allocated.allocations?.length === 1 && id(allocated.allocations[0].inventoryPositionId) === proof.sourcePositionId
&& id(allocated.allocations[0].inboundOrderId) === proof.sourceInboundOrderId && allocated.allocations[0].inboundCode === proof.inboundCode
&& id(allocated.allocations[0].palletId) === proof.sourcePalletId && allocated.allocations[0].packageCount === 1)
const replay = await request('POST', `${base}/allocations${scope}`, body)
check('转运分配重放不生成第二申请或重复分配', JSON.stringify(replay) === JSON.stringify(allocated))
const merchant = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户回查同单同换标申请及真实来源', merchant?.transferNo === proof.transferNo && merchant.status === 'WAIT_RELABEL'
&& merchant.relabelNo === allocated.relabelNo && id(merchant.relabelApplicationId) === id(allocated.relabelApplicationId)
&& JSON.stringify(merchant.allocations) === JSON.stringify(allocated.allocations))
Object.assign(proof, { status: 'WAIT_RELABEL', relabelNo: allocated.relabelNo, relabelApplicationId: id(allocated.relabelApplicationId) })
evidence.status = 'ALLOCATED_WAIT_RELABEL'; await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
// 替身返回独立的单据与库存证据,验证错误流水不能被成功状态掩盖。
function fixture(fault = '') {
const allocation = { inventoryPositionId: '301', palletId: '401', palletNo: 'P-1', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1', status: 'RESERVED' }
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
status: 'ALLOCATED', outboundAllocations: [allocation], relabelFulfillment: { execution: { status: 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' } } }
const calls = [], snapshots = []
let shipped = false, writes = 0
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (method === 'POST') {
writes++
if (fault === 'network') throw Error('connection lost')
shipped = true
}
if (path.includes('/ledger?')) {
const rows = shipped || fault === 'existing shipment' ? [{ id: '601', palletNo: fault === 'wrong pallet' ? 'P-OTHER' : 'P-1', customerId: fault === 'foreign customer' ? '999' : '101',
overseasWarehouseId: '201', skuCode: fault === 'old sku' ? 'OLD' : 'NEW', inboundCode: 'IN-1', businessType: 'SHIP', businessOrderNo: 'WT-1', deltaPackages: -1 }] : []
if (fault === 'double shipment' && shipped) rows.push({ ...rows[0], id: '602' })
if (fault === 'replay ledger' && writes === 2) rows[0].id = '699'
data = { total: rows.length, records: rows }
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: fault === 'wrong relabel' ? 'COMPLETED' : 'OUTBOUNDED' } }
else data = { transferNo: 'WT-1', status: shipped ? 'OUTBOUNDED' : 'ALLOCATED', relabelNo: 'RL-1', relabelApplicationId: '501', operatingWarehouseId: '201',
packageCount: 1, fbaWarehouseCode: 'FBA-1', outboundAt: shipped ? '2026-10-22T12:00:00' : null,
allocations: [{ ...allocation, status: shipped ? 'SHIPPED' : 'RESERVED' }] }
return { http: 200, body: { code: '200', data } }
} }
const options = { proof, merchantToken: 'secret', checkpoint: async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
return { context, options, proof, calls, snapshots }
}
test('原转运出库验证同单新SKU扣库及重放唯一性', async () => {
const f = fixture()
await outboundWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'OUTBOUNDED')
assert.equal(f.proof.outbound.status, 'OUTBOUNDED_HTTP_VERIFIED')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
assert.equal(JSON.stringify(f.snapshots).includes('secret'), false)
const count = f.calls.length
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, count)
})
for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'disk']) {
test(`原转运出库拒绝 ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.outbound.status, 'FAILED')
assert.equal(f.proof.status, 'ALLOCATED')
const writes = f.calls.filter(c => c.method === 'POST').length
assert.equal(writes, ['disk', 'existing shipment'].includes(fault) ? 0 : ['replay ledger', 'wrong relabel'].includes(fault) ? 2 : 1)
if (fault === 'network') assert.equal(f.proof.outbound.requests.at(-1).status, 'UNKNOWN_RESULT')
})
}
test('原转运未完成换标不得发出请求', async () => {
const f = fixture()
f.proof.relabelFulfillment.execution.status = 'FAILED'
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, 0)
})
@@ -0,0 +1,83 @@
import { mkdir, writeFile } from 'node:fs/promises'
const BASE = '/api/platform/v1/last-leg'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 原转运换标确认后出库,独立核验新SKU出库流水及重放不重复扣库。 */
export async function outboundWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| proof.status !== 'ALLOCATED' || proof.relabelFulfillment?.execution?.status !== 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND'
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.outbound) throw Error('转运出库缺少本轮原单换标完成证据,或已执行')
const allocation = proof.outboundAllocations[0]
if (!validId(allocation.inventoryPositionId) || !validId(allocation.palletId) || allocation.packageCount !== 1
|| allocation.status !== 'RESERVED' || allocation.inboundCode !== proof.inboundCode || !allocation.skuCode || !allocation.palletNo) throw Error('转运实际出库预留无效')
const evidence = proof.outbound = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-outbound-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, ok) => {
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
{ scenario: 'warehouse-transfer-outbound', deploymentBlocker: true })
if (!ok) throw Error(name)
}
const request = async (method, path, body, token = context.token) => {
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
let response
try { response = await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' })
await persist()
check(`转运出库 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
return response.body.data
}
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
&& id(detail.operatingWarehouseId) === proof.warehouseId && detail.packageCount === 1
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
&& id(detail.allocations[0].palletId) === id(allocation.palletId) && detail.allocations[0].skuCode === allocation.skuCode
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].inboundCode === proof.inboundCode
&& detail.allocations[0].status === (status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED')
const ledger = async () => {
const page = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
overseasWarehouseId: proof.warehouseId, businessOrderNo: proof.transferNo, businessType: 'SHIP', page: '1', size: '200' })}`)
check('转运出库流水完整且无重复记录', Array.isArray(page?.records) && Number(page.total) === page.records.length
&& new Set(page.records.map(row => id(row.id))).size === page.records.length)
return page.records
}
try {
const original = await request('GET', base + scope)
check('出库前仍为原转运实际新SKU预留', same(original, 'ALLOCATED') && Boolean(original.fbaWarehouseCode))
check('原转运出库前没有出库流水', (await ledger()).length === 0)
const body = { idempotencyKey: `${context.marker}-TRANSFER-OUTBOUND`, remark: context.marker }
const shipped = await request('POST', `${base}/outbound${scope}`, body)
check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.outboundAt))
const movements = await ledger()
check('原转运唯一SHIP流水按实际新SKU托盘扣除一箱', movements.length === 1 && movements.every(row => validId(row.id)
&& row.businessType === 'SHIP' && row.businessOrderNo === proof.transferNo && row.inboundCode === proof.inboundCode
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === proof.warehouseId
&& row.palletNo === allocation.palletNo
&& row.skuCode === allocation.skuCode && Number(row.deltaPackages) === -1))
const replay = await request('POST', `${base}/outbound${scope}`, body)
check('出库重放保持原单和流水不变', stable(replay) === stable(shipped) && stable(await ledger()) === stable(movements))
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.outboundAt === shipped.outboundAt)
const relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
check('原关联换标同步出库且未提前完成', relabel?.application?.relabelNo === proof.relabelNo
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.fulfillmentStatus === 'OUTBOUNDED'
&& relabel.application.status === 'READY_FOR_OUTBOUND')
Object.assign(proof, { status: 'OUTBOUNDED' }); evidence.shipLedger = movements; evidence.status = 'OUTBOUNDED_HTTP_VERIFIED'
await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
// 收货替身使用独立目标仓资源,覆盖资源不足、串单、重复入库和结算未完成。
function fixture(fault = '') {
const allocation = { inventoryPositionId: '301', palletId: '401', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1' }
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
status: 'OUTBOUNDED', outboundAllocations: [allocation], outbound: { status: 'OUTBOUNDED_HTTP_VERIFIED' } }
const pallet = { id: '601', warehouseId: '202', customerId: '0', locationId: '701', palletCode: 'TARGET', status: 'IDLE', fbaWarehouseCode: 'FBA-1' }
const calls = [], saved = []
let received = false, writes = 0
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (method === 'POST') {
writes++
if (fault === 'network') throw Error('connection lost')
assert.equal(body.destinationOverseasWarehouseId, '202')
assert.equal(body.destinationPalletId, '601')
received = true
}
if (path.includes('/pallets?')) data = { total: fault === 'no resource' ? 0 : 1, records: fault === 'no resource' ? [] : [{ ...pallet }] }
else if (path.endsWith('/pallets/601')) data = { ...pallet, status: fault === 'occupied' ? 'OCCUPIED' : 'IDLE' }
else if (path.endsWith('/locations/701')) data = { id: '701', warehouseId: '202', customerId: '0', locationCode: 'A0', locationType: 'VIRTUAL', status: fault === 'location blocked' ? 'OCCUPIED' : 'ACTIVE' }
else if (path.includes('/warehouse-admin/')) data = { warehouse: { id: '202', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'WH2', warehouseName: 'Target' } }
else if (path.includes('/ledger?')) {
const rows = received ? [{ id: '801', customerId: fault === 'foreign customer' ? '999' : '101', overseasWarehouseId: '202', businessOrderNo: 'WT-1',
businessType: 'STOCK_IN', inboundCode: 'IN-1', skuCode: 'NEW', palletNo: 'TARGET', warehouseLocationCode: 'A0', deltaPackages: 1 }] : []
if (fault === 'double receipt' && received) rows.push({ ...rows[0], id: '802' })
if (fault === 'replay drift' && writes === 2) rows[0].id = '899'
data = { total: rows.length, records: rows }
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'COMPLETED', fulfillmentStatus: 'COMPLETED', walletStatus: fault === 'unsettled' ? 'SETTLE_PENDING' : 'SETTLED' } }
else data = { transferNo: 'WT-1', status: received ? 'RECEIVED' : 'OUTBOUNDED', relabelNo: 'RL-1', relabelApplicationId: '501', fbaWarehouseCode: 'FBA-1',
receiptCommands: received ? [{ status: 'COMPLETED', sourceInventoryPositionId: '301', request: { idempotencyKey: 'BATCH-TRANSFER-RECEIVE', batchNo: 'WT-1' },
target: { warehouseId: '202', palletId: fault === 'target drift' ? '999' : '601', palletNo: 'TARGET', locationId: '701', locationCode: 'A0', fbaCode: 'FBA-1' } }] : [],
allocations: [{ ...allocation, status: received ? 'RECEIVED' : 'SHIPPED', destinationInventoryPositionId: received ? '901' : null }] }
return { http: 200, body: { code: '200', data } }
} }
const options = { proof, merchantToken: 'secret', delay: async () => {}, checkpoint: async () => { saved.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
return { context, options, proof, calls, saved }
}
test('原转运复用异仓托盘收货并核对唯一入库和结算终态', async () => {
const f = fixture()
await receiveWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'RECEIVED')
assert.equal(f.proof.receipt.status, 'RECEIVED_AND_SETTLED_HTTP_VERIFIED')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
assert.ok(f.calls.filter(c => c.method === 'POST').every(c => c.path.includes('/receipts?')))
assert.equal(JSON.stringify(f.saved).includes('secret'), false)
const count = f.calls.length
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, count)
})
for (const fault of ['no resource', 'occupied', 'location blocked', 'network', 'target drift', 'foreign customer', 'double receipt', 'replay drift', 'unsettled', 'disk']) {
test(`转运收货遇到 ${fault} 明确失败且不补造资源`, async () => {
const f = fixture(fault)
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.receipt.status, 'FAILED')
const writes = f.calls.filter(c => c.method === 'POST').length
assert.equal(writes, ['no resource', 'occupied', 'location blocked', 'disk'].includes(fault) ? 0 : ['replay drift', 'unsettled'].includes(fault) ? 2 : 1)
if (fault === 'network') assert.equal(f.proof.receipt.requests.at(-1).status, 'UNKNOWN_RESULT')
})
}
@@ -0,0 +1,125 @@
import { mkdir, writeFile } from 'node:fs/promises'
const BASE = '/api/platform/v1/last-leg'
const RESOURCES = '/api/platform/v1/overseas-warehouse-resources'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 转运收货复用其他海外仓的空闲匹配托盘,并验证原单入库及关联换标结算。 */
export async function receiveWarehouseTransfer(context, { proof, merchantToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| proof.status !== 'OUTBOUNDED' || proof.outbound?.status !== 'OUTBOUNDED_HTTP_VERIFIED'
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.receipt) throw Error('转运收货缺少本轮已出库原单,或已执行')
const allocation = proof.outboundAllocations[0]
const evidence = proof.receipt = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-receipt-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, ok) => {
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
{ scenario: 'warehouse-transfer-receipt', deploymentBlocker: true })
if (!ok) throw Error(name)
}
const request = async (method, path, body, token = context.token) => {
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
let response
try { response = await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' }); await persist()
check(`转运收货 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
return response.body.data
}
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].skuCode === allocation.skuCode
&& detail.allocations[0].status === (status === 'OUTBOUNDED' ? 'SHIPPED' : 'RECEIVED')
try {
const original = await request('GET', base + scope)
check('原转运出库来源不变', same(original, 'OUTBOUNDED') && Boolean(original.fbaWarehouseCode))
let target
// 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。
for (let page = 1; page <= 100 && !target; page++) {
const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`)
check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0)
for (const candidate of pallets.records) {
if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId)
|| id(candidate.warehouseId) === proof.warehouseId || candidate.fbaWarehouseCode !== original.fbaWarehouseCode
|| !validId(candidate.id) || !validId(candidate.locationId)) continue
const resource = `${RESOURCES}/${candidate.warehouseId}`
const pallet = await request('GET', `${resource}/pallets/${candidate.id}`)
if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0'
|| id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId)
|| pallet.fbaWarehouseCode !== original.fbaWarehouseCode || !pallet.palletCode) continue
const location = await request('GET', `${resource}/locations/${pallet.locationId}`)
if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId)
|| id(location?.id) !== id(pallet.locationId) || !location.locationCode
|| !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue
const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`)
const warehouse = master?.warehouse
if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0'
|| warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue
target = { pallet, location, warehouse }; break
}
if (page * 200 >= Number(pallets.total)) break
}
check('必须存在其他海外仓同目的仓的空闲托盘与可预留库位', Boolean(target))
evidence.target = target
const { pallet, location, warehouse } = target
const ledger = async () => {
const data = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
overseasWarehouseId: id(warehouse.id), businessOrderNo: proof.transferNo, businessType: 'STOCK_IN', page: '1', size: '200' })}`)
check('目标收货流水完整无重复', Array.isArray(data?.records) && Number(data.total) === data.records.length
&& new Set(data.records.map(row => id(row.id))).size === data.records.length)
return data.records
}
check('目标仓未重复接收原转运单', (await ledger()).length === 0)
const body = evidence.command = { sourceInventoryPositionId: id(allocation.inventoryPositionId),
destinationOverseasWarehouseId: id(warehouse.id), destinationOverseasWarehouseCode: warehouse.warehouseCode,
destinationOverseasWarehouseName: warehouse.warehouseName, destinationWarehouseCode: original.fbaWarehouseCode,
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
warehouseLocationId: id(location.id), warehouseLocationCode: location.locationCode,
batchNo: proof.transferNo, idempotencyKey: `${context.marker}-TRANSFER-RECEIVE`, remark: context.marker }
const received = await request('POST', `${base}/receipts${scope}`, body)
check('原转运收货完成且生成目标库存位置', same(received, 'RECEIVED') && validId(received.allocations[0].destinationInventoryPositionId))
const committed = received.receiptCommands?.[0]
check('收货命令完成且权威目标绑定保持一致', received.receiptCommands?.length === 1 && committed?.status === 'COMPLETED'
&& id(committed.sourceInventoryPositionId) === id(allocation.inventoryPositionId)
&& committed.request?.idempotencyKey === body.idempotencyKey && committed.request?.batchNo === body.batchNo
&& id(committed.target?.warehouseId) === id(warehouse.id) && id(committed.target?.palletId) === id(pallet.id)
&& committed.target?.palletNo === pallet.palletCode && id(committed.target?.locationId) === id(location.id)
&& committed.target?.locationCode === location.locationCode && committed.target?.fbaCode === original.fbaWarehouseCode)
const movements = await ledger()
check('目标仓唯一原单新SKU入库一箱', movements.length === 1 && movements.every(row => validId(row.id)
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === id(warehouse.id)
&& row.businessOrderNo === proof.transferNo && row.businessType === 'STOCK_IN' && row.inboundCode === proof.inboundCode
&& row.skuCode === allocation.skuCode && row.palletNo === pallet.palletCode && row.warehouseLocationCode === location.locationCode
&& Number(row.deltaPackages) === 1))
const replay = await request('POST', `${base}/receipts${scope}`, body)
check('收货重放不重复入库', stable(replay) === stable(received) && stable(await ledger()) === stable(movements))
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户原转运收货事实一致', same(owned, 'RECEIVED') && stable(owned.allocations) === stable(received.allocations))
let relabel
for (let attempt = 0; attempt < 31; attempt++) {
relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
check('原换标随转运收货完成且身份保持', relabel?.application?.relabelNo === proof.relabelNo
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.status === 'COMPLETED'
&& relabel.application.fulfillmentStatus === 'COMPLETED')
if (relabel.application.walletStatus === 'SETTLED') break
if (attempt < 30) await delay()
}
check('原转运关联换标费用完成结算', relabel.application.walletStatus === 'SETTLED')
evidence.stockInLedger = movements; evidence.status = 'RECEIVED_AND_SETTLED_HTTP_VERIFIED'; proof.status = 'RECEIVED'
await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}
+11 -2
View File
@@ -46,7 +46,9 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'],
let output = ''; let errors = ''
child.stdout.on('data', chunk => { output += chunk })
child.stderr.on('data', chunk => { errors += chunk })
const ready = await new Promise((resolve, reject) => {
// Python 启动失败时也必须关闭本地消费者,否则失败用例会占住整个测试进程。
let ready
try { ready = await new Promise((resolve, reject) => {
const timer = setTimeout(() => { child.kill(); reject(new Error('Proxy readiness timeout')) }, 8000)
const onData = () => {
const line = output.split('\n').find(item => item.trim())
@@ -57,7 +59,14 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'],
child.stdout.on('data', onData)
child.once('error', error => { clearTimeout(timer); reject(error) })
child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } })
})
}) } catch (error) {
if (child.exitCode === null && !child.killed) child.kill()
await close(consumer)
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
assert.ok(basename(directory).startsWith('event-fault-offline-'))
await rm(directory, { recursive: true })
throw error
}
const dataUrl = `http://127.0.0.1:${ready.dataPort}${endpoint}`
const controlUrl = `http://127.0.0.1:${ready.controlPort}`
return { calls, applied, log, invalidAck: value => { invalidAck = value },
@@ -15,6 +15,9 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
})
export const SYNCHRONOUS_CLIENTS = Object.freeze([
feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java',
'lastleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ',
'尾程列表按客户及订单筛选分页读取Order未开船订单,内部令牌保护的GET只读查询,不写订单或库存'),
feign('firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java',
'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ',
'直送路线守卫经内部令牌读取原预约路线事实,GET无远端业务写入,不接管预约创建'),
@@ -36,6 +36,7 @@ const expectedOnlineEdges = [
'lastleg-service->finance-service',
'lastleg-service->firstleg-service',
'lastleg-service->iam-service',
'lastleg-service->order-service',
'lastleg-service->print-service',
'lastleg-service->warehouse-overseas-service',
'lastleg-service->warehouse-service',
@@ -108,7 +109,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata',
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
.map(({ path, source, target }) => ({ path, source, target }))
.sort((left, right) => left.path.localeCompare(right.path))
assert.equal(reviewed.length, 81)
assert.equal(reviewed.length, 82)
assert.deepEqual(await discoverFeignEdges(), reviewed)
})
@@ -287,10 +288,14 @@ test('known user entries expose every longest simple REQUEST path over the two-h
'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
] },
'firstleg-service': { hops: 6, paths: [
'firstleg-service->lastleg-service->order-service->finance-service->file-service->iam-service->auth-service',
'firstleg-service->lastleg-service->order-service->finance-service->file-service->iam-service->warehouse-service',
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->auth-service',
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service'
] },
'finance-service': { hops: 6, paths: [
'finance-service->firstleg-service->lastleg-service->order-service->file-service->iam-service->auth-service',
'finance-service->firstleg-service->lastleg-service->order-service->file-service->iam-service->warehouse-service',
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->auth-service',
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->warehouse-service'
] },
@@ -302,7 +307,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service',
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->auth-service',
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service'
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service',
'lastleg-service->order-service->finance-service->firstleg-service->file-service->iam-service->auth-service',
'lastleg-service->order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
] },
'warehouse-service': { hops: 0, paths: ['warehouse-service'] },
'warehouse-overseas-service': { hops: 1, paths: [
+1 -1
View File
@@ -11,7 +11,7 @@
"numberSequences": "KEEP"
},
"sourceSha256": {
"deploy/sql/头程数据库统一增量-MySQL8.sql": "38013327896214ccc4fe317f507bfcf0b11377b07a66cd4ffa41b109b73558fd",
"deploy/sql/头程数据库统一增量-MySQL8.sql": "20f8f8129dad86957872d68fdea81967b72e5bdc7f1adef612677cc59e091bf4",
"deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7"
},
"pendingReview": [
+2 -2
View File
@@ -133,7 +133,7 @@ test('invalid unload proof still restores and incomplete restoration never becom
}
})
test('Python reverse SQL routing, scope isolation and full configuration recovery fail closed offline', () => {
const result = spawnSync('python', ['-c', String.raw`
const result = spawnSync(process.env.PYTHON || 'python', ['-c', String.raw`
import ast, copy, importlib.util, io, pathlib, sys
from unittest.mock import patch
sys.dont_write_bytecode = True
@@ -310,7 +310,7 @@ test('F40 hook faults only first signature, captures whole inventory and always
assert.equal(context.context.f40RootFulfillmentProof.deliveryEventRecovery.restored,true)
})
test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{
const result=spawnSync('python',['-c',String.raw`
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
import importlib.util,sys,ast,pathlib,copy
from unittest.mock import patch,MagicMock,mock_open
sys.dont_write_bytecode=True
+2 -2
View File
@@ -134,7 +134,7 @@ test('NON_NULL source hashes agree across runtime and proof and reject explicit
invalid.producer.envelope.payloadHash=invalid.producer.payloadHash
invalid.producer.envelopeJson=JSON.stringify(invalid.producer.envelope)
assert.throws(()=>assertTestFeeVoidFaultProof(invalid,scope),/SOURCE_HASH/)
const result=spawnSync('python',['-c',String.raw`
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
import sys,importlib.util,json,copy,hashlib
from unittest.mock import patch
sys.dont_write_bytecode=True
@@ -160,7 +160,7 @@ with patch.object(m,'inspect'),patch.object(m,'read_sql',side_effect=lambda *arg
})
test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{
const result=spawnSync('python',['-c',String.raw`
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
import sys,importlib.util,pathlib,copy,decimal,ast,io
from unittest.mock import patch,mock_open
sys.dont_write_bytecode=True