Compare commits

...
2 Commits
57 changed files with 1866 additions and 275 deletions
@@ -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")
@@ -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)); }
/** 同托真实来源订单组成。 */
@@ -693,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);
@@ -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);
}
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
@@ -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,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); }
}
@@ -270,6 +270,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车");
}
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
@@ -135,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);
}
@@ -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));
+8 -8
View File
@@ -12,20 +12,20 @@ export function describeHomepageBusinessCoverage() {
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt'],
additionalModules: ['phase4-overseas-return-followups'], covered: '退货预报、审核、签收、合箱及边界',
{ 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待验,不能替代车队签收证据' },
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'],
additionalModules: ['phase4-overseas-wallet-and-pallet-cost'], covered: '创建、备注编辑、审核驳回;固定客户正常样品采购至寄客户及资金核对已接入默认链',
{ 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'],
covered: '客户原申请创建、审核、PDA箱标/SKU执行、换标组托、完工及客户确认', 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: '该转运审核通过后的入库计量、排车、出库及交付未接入默认闭环' },
covered: '客户申请、提交、审核与拒绝、取消边界;已有库存原单分配、关联换标、出库、异仓收货及唯一库存流水与结算终态核验', remaining: '在途转运入库计量、多来源及部分收货异常矩阵仍待补;真实HTTP待验' },
{ title: '货物废弃', route: '/overseas-warehouse/cargo-disposal/create', modules: ['phase4-overseas-client-services'],
covered: '申请、拒绝释放、批准锁箱、PDA照片与实际扣库、客户确认及终态重放', remaining: '已接入同一申请公共库存总账核对;独立箱/SKU流水证据、多来源边界及真实HTTP仍待验' },
].map(business => ({ ...business,
+21 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { summarizeReport, describeHomepageBusinessCoverage } from './acceptance-overview.mjs'
import { readFileSync } from 'node:fs'
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
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')
@@ -15,12 +15,31 @@ test('业务覆盖清单逐项对应客户端首页八个入口,未闭环不
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.equal(businesses.find(item => item.title === '海外采购').additionalModules[0].skippedWithFixedResources, false)
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('注册表返回独立副本,默认与可选不重复', () => {
+1 -1
View File
@@ -108,7 +108,7 @@ 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']],
['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']],
+22 -2
View File
@@ -1,5 +1,25 @@
# 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`。
@@ -10,9 +30,9 @@
`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` 衔接本轮已审核的独立本土卡车原单,调用正式实收、分配、待派车、派车、运输和完成接口。正常样本实收一托,逐托箱数取自原订单;每步核对客户、版本、托盘身份、数量及历史,并执行同键重放和详情回查。不再在 finally 取消这张卡车单,失败保留现场,未知写入不重发;请求诊断与结果放在总入口报告的 `phase4OverseasBusiness.truck.fulfillment`。不创建客户、车辆或司机;订单实收托盘属于业务数据。当前只补脚本,尚未真实 HTTP 运行;该独立订单完成不代表车队 POD 签收,超托与部分分配仍待补。离线合同位于 `phase4-overseas-business-contract.test.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` 等供同单后续排车使用。此链不等于已签收;后续排车、出库、交付仍待接入。离线合同不代表真实HTTP通过。
`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 以后执行;离线合同只验证编排与断言。
@@ -4,7 +4,7 @@ import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
function harness(fault = '') {
const calls = [], persisted = []
let discarded = false, confirmed = false, repeats = 0
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',
@@ -20,6 +20,11 @@ function harness(fault = '') {
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
@@ -42,12 +47,19 @@ function harness(fault = '') {
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 ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
actualPackages: discarded || invalidPhotoRejected && fault === 'photo drift' ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
} }
return { context, inputs, calls, persisted }
}
@@ -57,7 +69,7 @@ test('discard executes same application through PDA and customer completion with
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, 3)
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'))
@@ -65,7 +77,9 @@ test('discard executes same application through PDA and customer completion with
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /已批准申请|不自动重跑/)
})
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk']) {
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))
@@ -74,7 +88,7 @@ for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'repl
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, 1)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 2)
assert.equal(h.inputs.proof.fulfillment.requests.at(-1).status, 'UNKNOWN_RESULT')
}
})
@@ -31,8 +31,8 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
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) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
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)
@@ -41,6 +41,11 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
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
}
@@ -74,6 +79,9 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
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
@@ -92,7 +100,12 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
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
&& movements[0].skuCode === proof.skuCode && movements[0].inboundCode === proof.inboundCode)
&& /^[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`
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
function harness(fault = '') {
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' }
@@ -10,8 +10,9 @@ function harness(fault = '') {
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' ? 'WAIT_PRE_DISPATCH' : null },
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] })
@@ -24,7 +25,10 @@ function harness(fault = '') {
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')) { status = 'PENDING_PROCESSING'; data = detail() }
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')) {
@@ -40,6 +44,9 @@ function harness(fault = '') {
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 } }
},
@@ -50,6 +57,11 @@ function harness(fault = '') {
} }
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 }
}
@@ -88,3 +100,27 @@ test('foreign-batch relabel is rejected before any HTTP', async () => {
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)
})
@@ -7,8 +7,17 @@ const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Numbe
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, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
/** 原申请单箱贴标作业链;独立贴标准备排车,关联转运恢复原单预留。 */
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) {
@@ -18,10 +27,10 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
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('../../reports/client-relabel-checkpoints/', import.meta.url)
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(proof, null, 2),
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(transferProof || proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
@@ -61,7 +70,11 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
require('贴标原申请客户、仓库、来源仓位与待审核状态一致', sameApplication(original)
&& original.application.status === 'PENDING_REVIEW' && original.items?.length === 1
&& String(original.items[0].sourceInventoryPositionId) === proof.sourcePositionId)
const reviewed = await command('PUT', `${platform}/status`, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
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++) {
@@ -120,12 +133,25 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
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 === 'WAIT_PRE_DISPATCH'
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 = 'HTTP_VERIFIED_READY_FOR_DISPATCH'; await persist(); return proof
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)
}
@@ -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',
]
@@ -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,6 +1,7 @@
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'
@@ -22,12 +23,88 @@ 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?')) {
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))
@@ -48,6 +125,47 @@ for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay
})
}
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' }
@@ -95,6 +213,11 @@ function fixture(options = {}) {
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) {
@@ -176,21 +299,32 @@ function fixture(options = {}) {
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())
const [from, to] = stages[action]
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') {
assert.deepEqual(body.palletPackageCounts, [2])
assert.equal(body.actualPalletCount, 1)
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 2, status: 'RECEIVED' }]
state.truck.receivedPalletCount = 1
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') {
assert.deepEqual(body.palletIds, ['701'])
state.truckPallets[0].status = 'ALLOCATED'
state.truck.allocatedPalletCount = 1
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 })
@@ -325,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, 19)
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)
@@ -356,6 +492,8 @@ test('procurement creation review boundaries and transport cancellation paths pa
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')
@@ -97,7 +97,8 @@ 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 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\)/)
})
@@ -1,4 +1,7 @@
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'
@@ -171,9 +174,9 @@ async function verifyRelabel(context, token, order) {
try {
if (!picker?.token) throw new Error('贴标后续未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token })
context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
'原申请已完成PDA贴标和客户确认,后续必须使用本申请targetPalletIds接履约,不能借其他换标单结果',
'not-implemented', { blocking: false })
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)
}
@@ -260,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) {
@@ -425,7 +451,7 @@ 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 ready for dispatch; transfer review fixtures retained; discard PDA execution and customer confirmation retained; no dispatch or delivery action is invoked',
cleanup: '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行',
}
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
@@ -448,7 +474,7 @@ async function runWithSession(context, token) {
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
transfer: transferDone ? 'APPROVED' : 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)
@@ -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,缺失收货或来源证据仍拒绝', () => {
@@ -47,6 +47,8 @@ export async function fulfillmentEvidenceContext(context) {
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)
}
@@ -844,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) {
@@ -1144,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() }
@@ -1266,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'
@@ -1315,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,26 +1,45 @@
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) {
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) => {
const log = { method, action, status: 'SENDING' }
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}`)
@@ -36,28 +55,51 @@ export async function verifyUsTruckFulfillment(context, proof) {
&& 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'], ['allocate-pallets', 'ALLOCATED'],
['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 palletId, palletNo
for (const [action, state] of stages) {
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: 1, packageCount: packages, palletPackageCounts: [packages] })
if (action === 'allocate-pallets') body.palletIds = [palletId]
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 === 1 && Number(next.summary.receivedPalletCount) === 1
&& Number(next.pallets[0].packageCount) === packages && /^[1-9]\d*$/.test(String(next.pallets[0].id))
&& typeof next.pallets[0].palletNo === 'string' && next.pallets[0].palletNo.startsWith(`${proof.orderNo}-P`))
palletId ??= next.pallets[0].id
palletNo ??= next.pallets[0].palletNo
check('卡车履约复用原实收托盘', String(next.pallets[0].id) === String(palletId) && next.pallets[0].palletNo === palletNo
&& next.pallets[0].status === (action === 'receive-pallets' ? 'RECEIVED' : 'ALLOCATED')
&& Number(next.summary.allocatedPalletCount) === (action === 'receive-pallets' ? 0 : 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)
@@ -66,11 +108,13 @@ export async function verifyUsTruckFulfillment(context, proof) {
check('卡车履约状态已持久化且回查一致', JSON.stringify(current) === JSON.stringify(next))
}
Object.assign(proof, { finalStatus: 'COMPLETED', version: current.summary.version, retained: true })
Object.assign(evidence, { status: 'HTTP_VERIFIED', palletId, palletNo, packages })
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