Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c385b7543b | ||
|
|
080ca600e6 | ||
|
|
86889d7e0e | ||
|
|
56f2853fb7 | ||
|
|
fc25507915 | ||
|
|
760bf8daa1 |
+7
-1
@@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort {
|
||||
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
|
||||
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
|
||||
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
|
||||
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
|
||||
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
|
||||
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
|
||||
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
|
||||
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
|
||||
List<AllocationOption> options,List<String> blockReasons) { }
|
||||
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
|
||||
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
|
||||
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
|
||||
@@ -27,7 +33,7 @@ public interface AppointmentWorkDispatchPort {
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
|
||||
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
|
||||
+12
@@ -53,6 +53,18 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 本车货物逐票读取正式预约选项及当前分配。 */
|
||||
@GetMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 原车版本化关联正式预约,不新建排车或修改库存。 */
|
||||
@PutMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<CommandResult> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) {
|
||||
return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 读取实际起点和卸货顺序。 */
|
||||
@GetMapping("/{dispatchNo}/route")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
+18
@@ -21,6 +21,24 @@ public interface DispatchSourceMapper {
|
||||
record OrderReference(Long orderId,String customerId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
|
||||
@Select("""
|
||||
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
|
||||
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
|
||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
|
||||
@@ -14,6 +14,14 @@ public final class DispatchWorkModels {
|
||||
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
|
||||
public record Stop(int index,String name,String address,String destinationKey) { }
|
||||
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
|
||||
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
|
||||
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
|
||||
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
|
||||
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
|
||||
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
|
||||
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
|
||||
}
|
||||
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
|
||||
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
|
||||
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
|
||||
|
||||
+4
@@ -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); }
|
||||
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
|
||||
|
||||
+2
@@ -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)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+72
-2
@@ -174,9 +174,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
|
||||
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
|
||||
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
|
||||
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var fact=type!=SourceType.INBOUND_PLAN || row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
|
||||
reasons.addAll(group.blockReasons());
|
||||
if(type==SourceType.INBOUND_PLAN && "FBA".equals(destination.type()) && fact==null)reasons.add("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
|
||||
if(fact!=null) {
|
||||
reasons.addAll(fact.blockReasons());
|
||||
// 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。
|
||||
@@ -380,6 +381,53 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
/** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);
|
||||
var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId());
|
||||
var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(),
|
||||
options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())),
|
||||
current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList();
|
||||
boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty();
|
||||
return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable,
|
||||
!permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约"));
|
||||
}
|
||||
|
||||
/** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) {
|
||||
var row=lockPlatform(actor,number);
|
||||
if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限");
|
||||
var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配");
|
||||
var ids=request.dispatchItemIds();
|
||||
if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细");
|
||||
if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配");
|
||||
var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList();
|
||||
if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物");
|
||||
// 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。
|
||||
appointments.lock(repository.batches(row.getId()),request.batchAllocations());
|
||||
sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched);
|
||||
appointments.bind(row.getId(),touched,request.batchAllocations());
|
||||
String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason);
|
||||
return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
|
||||
}
|
||||
/** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */
|
||||
private List<DispatchItem> appointmentItems(DispatchAggregate row) {
|
||||
return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType())
|
||||
&& !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList();
|
||||
}
|
||||
/** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */
|
||||
private boolean appointmentEditable(DispatchAggregate row) {
|
||||
return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage())
|
||||
&& n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0
|
||||
&& repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status()))
|
||||
&& repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status()));
|
||||
}
|
||||
|
||||
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
|
||||
@@ -645,9 +693,30 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
else {
|
||||
var batches=json.valueToTree(repository.batches(row.getId()));
|
||||
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
|
||||
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
|
||||
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));
|
||||
}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */
|
||||
boolean currentReceiptBasis(DispatchAggregate row) {
|
||||
return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row));
|
||||
}
|
||||
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1)
|
||||
throw conflict("排车实收交接历史未保存");
|
||||
}
|
||||
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
|
||||
@@ -811,6 +880,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var source:resolved) {
|
||||
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
|
||||
var c=source.candidate();
|
||||
if(source.selection().sourceType()==SourceType.INBOUND_PLAN && "FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
|
||||
sources.expire(c.customerId(),source.sourceKey(),at);
|
||||
if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用");
|
||||
try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");}
|
||||
|
||||
+8
@@ -22,6 +22,14 @@ public class DispatchAppointmentAllocations {
|
||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||
/** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>> options(long dispatch,List<DispatchItem> items) {
|
||||
var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList();
|
||||
if(identities.isEmpty())return Map.of();
|
||||
var result=provider.allocationOptions(dispatch,identities);
|
||||
if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
|
||||
|
||||
+9
-3
@@ -113,7 +113,9 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
var resolved=lockedIndex.get(member.sourceKey());
|
||||
if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId())
|
||||
|| resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化");
|
||||
next.add(candidate(resolved,group));keys.add(member.sourceKey());
|
||||
var projected=candidate(resolved,group);
|
||||
if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons()));
|
||||
next.add(projected);keys.add(member.sourceKey());
|
||||
}
|
||||
requireCapacity(container.getCapacity(),keys,index(next));
|
||||
var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream()
|
||||
@@ -421,14 +423,18 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
.map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));}
|
||||
/** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */
|
||||
private Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> readCandidateBatches(List<Candidate> candidates) {
|
||||
var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
|
||||
var identities=candidates.stream().filter(c->c.selection().sourceType()==DispatchTypes.SourceType.INBOUND_PLAN && c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
|
||||
var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
|
||||
for(int offset=0;offset<identities.size();offset+=1000)result.putAll(appointmentProvider.candidateBatches(identities.subList(offset,Math.min(offset+1000,identities.size()))));
|
||||
return result;
|
||||
}
|
||||
/** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */
|
||||
private Candidate projectCandidate(Candidate c,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches) {
|
||||
if(c.selection().planPalletId()==null)return c;
|
||||
if(c.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN)return c;
|
||||
if(c.selection().planPalletId()==null) {
|
||||
if(!"FBA".equals(c.destination().type()))return c;
|
||||
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup());
|
||||
}
|
||||
var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId()));
|
||||
var reasons=new LinkedHashSet<String>();
|
||||
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
|
||||
|
||||
+111
@@ -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); }
|
||||
}
|
||||
+1
-13
@@ -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作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+2
-43
@@ -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());}
|
||||
/** 不可变扫码证据序列化失败随根命令回滚。 */
|
||||
|
||||
+5
-27
@@ -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);
|
||||
}
|
||||
|
||||
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
|
||||
|
||||
+12
@@ -394,6 +394,18 @@ public class DispatchService {
|
||||
if(!pallets.isEmpty())palletSerial.lock(pallets);
|
||||
}
|
||||
|
||||
/** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void lockAppointmentSources(String warehouse,List<DispatchItem> items) {
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||
var orders=executionOrders(warehouse,items,pallets);
|
||||
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
|
||||
palletSerial.lock(List.copyOf(pallets));
|
||||
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
|
||||
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
|
||||
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
|
||||
}
|
||||
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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"));
|
||||
|
||||
+12
-1
@@ -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();
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
|
||||
/** 为本车已有货物提供逐票原预约选项,读取不会释放占用或修改预约。 */
|
||||
@Service
|
||||
public class AppointmentDispatchAllocationReader {
|
||||
private final AppointmentDispatchAllocationRepository repository;
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentDispatchAvailability availability;
|
||||
/** 注入本域只读仓储、来源规范化和正式确认资格。 */
|
||||
public AppointmentDispatchAllocationReader(AppointmentDispatchAllocationRepository repository,AppointmentSourceService sources,AppointmentDispatchAvailability availability) {
|
||||
this.repository=repository;this.sources=sources;this.availability=availability;
|
||||
}
|
||||
/** 本车来源身份由上层核对;只返回明确已确认且仍有可分配数量的原批次。 */
|
||||
public Map<CandidatePhysical,List<AllocationSource>> read(long dispatch,List<CandidatePhysical> items) {
|
||||
if(dispatch<=0 || items==null || items.size()>1000 || items.stream().anyMatch(Objects::isNull))throw bad("本车预约实物范围无效");
|
||||
if(items.isEmpty())return Map.of();
|
||||
var physical=repository.physical(dispatch,items);
|
||||
if(physical.size()>20000)throw unavailable("本车逐票实物超过完整读取上限");
|
||||
var orders=physical.stream().map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).toList();
|
||||
var canonical=new HashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
|
||||
var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList();
|
||||
for(int offset=0;offset<distinct.size();offset+=100)canonical.putAll(AppointmentWorkPhysicalSources.canonical(distinct.subList(offset,Math.min(offset+100,distinct.size())),sources));
|
||||
var refs=orders.stream().filter(p->p.orderId()!=null && p.destinationKey()!=null && p.destinationKey().startsWith("FBA:"))
|
||||
.map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
|
||||
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():repository.batches(dispatch,refs,orders);
|
||||
if(batches.size()>20000)throw unavailable("本车原预约选项超过完整读取上限");
|
||||
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
|
||||
var result=new LinkedHashMap<CandidatePhysical,List<AllocationSource>>();
|
||||
for(var identity:items) {
|
||||
var rows=new ArrayList<AllocationSource>();
|
||||
for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) {
|
||||
var reasons=new ArrayList<String>();var options=new ArrayList<AllocationOption>();
|
||||
if(p.orderId()==null || p.destinationKey()==null || p.packages()==null || p.packages()<=0)throw conflict("本车逐票实物来源或箱数尚未核实");
|
||||
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).sourceKey();
|
||||
if(p.destinationKey().startsWith("FBA:")) {
|
||||
for(var b:batches)if(identity.customerId().equals(b.customerId()) && source.equals(b.sourceKey()) && p.destinationKey().equals(b.destinationKey())) {
|
||||
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey());
|
||||
long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated();
|
||||
if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) {
|
||||
var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status())
|
||||
&& a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow();
|
||||
var details=agreement.details();
|
||||
options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(),
|
||||
details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone()));
|
||||
}
|
||||
}
|
||||
if(options.stream().mapToLong(AllocationOption::availablePackages).sum()<p.packages())reasons.add("请先在预约列表确认本票FBA送仓时间及覆盖箱数,再返回刷新");
|
||||
}
|
||||
rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons)));
|
||||
}
|
||||
if(rows.isEmpty())throw conflict("本车托盘缺少真实逐票货物来源");
|
||||
result.put(identity,List.copyOf(rows));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 已有排车关联预约的本域只读持久化边界。 */
|
||||
@Repository
|
||||
public class AppointmentDispatchAllocationRepository {
|
||||
private final AppointmentWorkDispatchMapper mapper;
|
||||
/** 复用权威实物与预约Mapper,不新增分配存储。 */
|
||||
public AppointmentDispatchAllocationRepository(AppointmentWorkDispatchMapper mapper) {this.mapper=mapper;}
|
||||
/** 按服务层已核实的本车实物读取逐票业务标识及数量。 */
|
||||
public List<AppointmentWorkDispatchMapper.AllocationPhysical> physical(long dispatch,List<CandidatePhysical> items) {return mapper.allocationPhysical(dispatch,items);}
|
||||
/** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */
|
||||
public List<AppointmentWorkDispatchMapper.CandidateBatch> batches(long dispatch,List<SourceRef> refs,List<AppointmentWorkDispatchMapper.Physical> orders) {
|
||||
return mapper.candidateBatches(refs,orders,dispatch);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */
|
||||
@Service
|
||||
public class AppointmentDispatchAvailability {
|
||||
private final FbaReceivingStore receiving;
|
||||
/** 只消费原收货确认权威,不使用车辆补充预约号。 */
|
||||
public AppointmentDispatchAvailability(FbaReceivingStore receiving) {this.receiving=receiving;}
|
||||
/** 按既有200批上限分块批读,避免逐托查询预约。 */
|
||||
public Map<String,BatchReceiving> snapshots(List<String> keys) {
|
||||
var unique=keys.stream().distinct().sorted().toList();var result=new HashMap<String,BatchReceiving>();
|
||||
for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot);
|
||||
return result;
|
||||
}
|
||||
/** 未确认、待仓库回复、时间无效或覆盖身份不唯一时均不可作为排车依据。 */
|
||||
public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) {
|
||||
if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0;
|
||||
var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey())
|
||||
&& a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList();
|
||||
if(agreements.size()!=1)return 0;
|
||||
var agreement=agreements.get(0);var details=agreement.details();
|
||||
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0;
|
||||
var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset());
|
||||
if(start==null)return 0;
|
||||
if(details.appointmentEndAt()!=null) {
|
||||
var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset());
|
||||
if(end==null || end.isBefore(start))return 0;
|
||||
}
|
||||
var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList();
|
||||
if(members.size()!=1)return 0;
|
||||
var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList();
|
||||
return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0;
|
||||
}
|
||||
/** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */
|
||||
private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) {
|
||||
try {
|
||||
var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);
|
||||
if(offsets.isEmpty() || offset==null && offsets.size()!=1)return null;
|
||||
var selected=offset==null?offsets.get(0):ZoneOffset.of(offset);
|
||||
return offsets.contains(selected)?at.toInstant(selected):null;
|
||||
} catch(DateTimeException|NullPointerException error){return null;}
|
||||
}
|
||||
}
|
||||
+68
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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); }
|
||||
}
|
||||
+14
-2
@@ -38,6 +38,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
private final AppointmentWorkChanges changes;
|
||||
private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
|
||||
private final AppointmentWorkDispatchCandidates candidates;
|
||||
@jakarta.annotation.Resource private AppointmentDispatchAllocationReader allocationReader;
|
||||
private final Object prelockedBatchesKey=new Object();
|
||||
private final Object pendingInvalidationKey=new Object();
|
||||
/** 注入原预约、真实实物和收货确认权威。 */
|
||||
@@ -48,6 +49,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
/** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */
|
||||
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
|
||||
public Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> input) {return candidates.batchReferences(input);}
|
||||
/** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */
|
||||
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
|
||||
public Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items) {return allocationReader.read(dispatchId,items);}
|
||||
/** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */
|
||||
public void lockBatches(Map<String,Long> expectedBatchVersions) {
|
||||
if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效");
|
||||
@@ -115,9 +119,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
var appointment=appointments.batchContext(batch).appointment();
|
||||
if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选");
|
||||
if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次");
|
||||
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList())
|
||||
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) {
|
||||
requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId()))
|
||||
.map(a->allocation(a,appointment.version().longValue())).toList());
|
||||
// FBA确认前不能把货物加入车辆;提交审核仍复验运输时间。
|
||||
requireReceiving(batch,customer,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())).toList());
|
||||
}
|
||||
}
|
||||
// 所有校验结束后才按明确item删除;没有整车清空操作。
|
||||
for(var item:touched) mapper.removeItem(dispatch,item);
|
||||
@@ -230,7 +237,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复");
|
||||
var agreement=receiving.currentAgreement(batch,customer);
|
||||
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus())
|
||||
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车");
|
||||
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先预约成功并明确送仓时间,再加入排车或发车");
|
||||
receivingInstant(agreement.details().appointmentAt(),agreement.timezone(),agreement.details().startOffset());
|
||||
var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList();
|
||||
if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对");
|
||||
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) {
|
||||
@@ -260,7 +268,11 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
|
||||
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
|
||||
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
|
||||
if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车");
|
||||
}
|
||||
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
|
||||
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
|
||||
|
||||
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
|
||||
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
|
||||
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
|
||||
|
||||
+10
-5
@@ -16,8 +16,9 @@ public class AppointmentWorkDispatchCandidates {
|
||||
private final AppointmentWorkDispatchMapper mapper;
|
||||
private final InboundFbaAppointmentService appointments;
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentDispatchAvailability availability;
|
||||
/** 注入唯一原批次、实物和来源规范化边界。 */
|
||||
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; }
|
||||
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { this.mapper=mapper; this.appointments=appointments; this.sources=sources;this.availability=availability; }
|
||||
/** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Map<CandidatePhysical,CandidateBatches> batchReferences(List<CandidatePhysical> input) {
|
||||
@@ -32,8 +33,9 @@ public class AppointmentWorkDispatchCandidates {
|
||||
var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
|
||||
if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限");
|
||||
var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList();
|
||||
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders);
|
||||
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders,-1);
|
||||
if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限");
|
||||
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
|
||||
var output=new LinkedHashMap<CandidatePhysical,CandidateBatches>();
|
||||
for(var identity:identities) {
|
||||
var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList();
|
||||
@@ -46,15 +48,16 @@ public class AppointmentWorkDispatchCandidates {
|
||||
if(!row.destinationKey().startsWith("FBA:")) continue;
|
||||
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey();
|
||||
var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList();
|
||||
if(matched.isEmpty()) {unassigned=true;continue;}
|
||||
if(matched.isEmpty()) {unassigned=true;reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");continue;}
|
||||
related=true;long available=0;var seen=new HashSet<String>();
|
||||
for(var b:matched) {
|
||||
if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;}
|
||||
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;}
|
||||
int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining);
|
||||
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey());
|
||||
int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining);
|
||||
if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count)));
|
||||
}
|
||||
if(available<count) reasons.add("关联批次逐票剩余量不足以覆盖本托实物");
|
||||
if(available<count) reasons.add("FBA已确认预约及逐票剩余量不足,请先确认送仓时间和本票箱数再排车");
|
||||
}
|
||||
if(related && unassigned) reasons.add("托盘含未归属当前预约批次的货物,请先核对逐票分配");
|
||||
var result=references.entrySet().stream().map(e->new BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList();
|
||||
@@ -77,6 +80,7 @@ public class AppointmentWorkDispatchCandidates {
|
||||
// Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility.
|
||||
if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物"));
|
||||
var candidates=mapper.candidates(appointment.id());
|
||||
var confirmation=availability.snapshots(List.of(batch)).get(batch);
|
||||
if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批");
|
||||
var output=new ArrayList<Candidate>();
|
||||
for(var candidate:candidates) {
|
||||
@@ -93,6 +97,7 @@ public class AppointmentWorkDispatchCandidates {
|
||||
if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; }
|
||||
long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1);
|
||||
if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
|
||||
if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");
|
||||
if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失");
|
||||
allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count));
|
||||
}
|
||||
|
||||
+31
-2
@@ -18,6 +18,26 @@ public interface AppointmentWorkDispatchMapper {
|
||||
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
|
||||
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
|
||||
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
|
||||
record AllocationPhysical(long planPalletId,String customerId,Long orderId,String orderNo,String inboundCode,String destinationKey,Integer packages) { }
|
||||
/** 来源展示使用业务编号;逐票数量按本车原计划或实收阶段读取。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT i.plan_pallet_id,i.customer_id,l.order_id,o.order_no,o.inbound_code,
|
||||
CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
|
||||
CASE WHEN i.source_stage='PLAN' THEN
|
||||
CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END
|
||||
ELSE CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END END packages
|
||||
FROM ll_dispatch_item i
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
|
||||
WHERE i.dispatch_order_id=#{dispatchId} AND
|
||||
(<foreach collection='items' item='p' separator=' OR '>(i.plan_pallet_id=#{p.planPalletId} AND i.customer_id=#{p.customerId})</foreach>)
|
||||
GROUP BY i.plan_pallet_id,i.customer_id,i.source_stage,l.order_id,o.order_no,o.inbound_code,l.destination_type,l.fba_warehouse_code
|
||||
ORDER BY i.plan_pallet_id,l.order_id LIMIT 20001
|
||||
</script>
|
||||
""")
|
||||
List<AllocationPhysical> allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical> items);
|
||||
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
|
||||
@Select("""
|
||||
<script>
|
||||
@@ -43,7 +63,7 @@ public interface AppointmentWorkDispatchMapper {
|
||||
i.customer_id,i.order_id,i.source_key,CONCAT('FBA:',i.fba_warehouse_code) destination_key,i.package_count packages,
|
||||
(SELECT COALESCE(SUM(a.package_count),0) FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
|
||||
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) AND a.customer_id=i.customer_id AND a.source_key=i.source_key
|
||||
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status<>'CANCELLED') allocated
|
||||
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status<>'CANCELLED' AND d.id<>#{excludedDispatchId}) allocated
|
||||
FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment p ON p.id=i.appointment_id
|
||||
WHERE i.active=1 AND (p.replacement_keys_json IS NULL OR p.replacement_keys_json='[]') AND
|
||||
(<foreach collection='refs' item='r' separator=' OR '>(i.customer_id=#{r.customerId} AND i.source_key=#{r.sourceKey} AND CONCAT('FBA:',i.fba_warehouse_code)=#{r.destinationKey})</foreach>
|
||||
@@ -51,7 +71,7 @@ public interface AppointmentWorkDispatchMapper {
|
||||
ORDER BY p.id,i.customer_id,i.source_key,i.fba_warehouse_code LIMIT 20001
|
||||
</script>
|
||||
""")
|
||||
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders);
|
||||
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders,@Param("excludedDispatchId") long excludedDispatchId);
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT a.dispatch_id,a.batch_key,CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.dispatch_no ELSE p.appointment_no END batch_no,
|
||||
@@ -115,5 +135,14 @@ public interface AppointmentWorkDispatchMapper {
|
||||
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
|
||||
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
|
||||
List<Bound> bindings(String number);
|
||||
|
||||
/** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual}
|
||||
WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId}
|
||||
AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey}
|
||||
AND destination_key=#{row.destinationKey} AND package_count=#{row.packages}
|
||||
""")
|
||||
int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual);
|
||||
@Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List<Bound> byDispatch(long id);
|
||||
}
|
||||
|
||||
@@ -1798,6 +1798,53 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/appointment-allocations:
|
||||
parameters:
|
||||
- name: dispatchNo
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: 读取本车逐票正式预约选项及当前分配
|
||||
description: 权限 platform:overseas-warehouse:dispatch:list。只读本车真实货物;已确认时段和覆盖来自正式收货预约。本车原占用不扣减本车可重分配量,其他有效车辆占用仍扣减。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
$ref: '#/components/schemas/DispatchAppointmentAllocations'
|
||||
put:
|
||||
summary: 在原排车关联或调整正式预约分配
|
||||
description: 权限 platform:overseas-warehouse:dispatch:edit。主单及原批次版本必填,0合法;每个触及实物必须按客户/来源/目的仓完整分配。仅装车前零真实扫码且无有效下游单据,成功增加主版本并使两级审核失效,不新建排车、不改变货物或库存。失败原子回滚;同键同载荷恢复原结果,同键异载荷拒绝。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DispatchAppointmentAllocationCommand'
|
||||
responses:
|
||||
'200':
|
||||
description: 统一ApiResponse;业务失败通过code返回。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
$ref: '#/components/schemas/DispatchCommandResult'
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/route:
|
||||
get:
|
||||
summary: 读取实际起点与既有卸货站序
|
||||
@@ -5927,7 +5974,7 @@ components:
|
||||
required:
|
||||
- idempotencyKey
|
||||
- warehouseId
|
||||
description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。
|
||||
description: 同一起运仓,允许空单或多客户、多目的仓。普通入仓FBA货物须先取得覆盖本客户、来源和箱数的正式预约时间才能选入,不能等提交审核才检查;换标和退货独立来源保留原流程。客户和来源归属由服务端解析,临时资源不生成假档案。
|
||||
DispatchAddCargo:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.AddCargo
|
||||
@@ -6951,6 +6998,94 @@ components:
|
||||
destinationKey:
|
||||
type: string
|
||||
nullable: true
|
||||
DispatchAppointmentAllocations:
|
||||
type: object
|
||||
x-java-record: DispatchWorkModels.AppointmentAllocations
|
||||
required: [dispatchNo, recordVersion, items, edit]
|
||||
properties:
|
||||
dispatchNo: {type: string}
|
||||
recordVersion:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchAppointmentItem'
|
||||
edit:
|
||||
$ref: '#/components/schemas/DispatchGate'
|
||||
DispatchAppointmentItem:
|
||||
type: object
|
||||
x-java-record: DispatchWorkModels.AppointmentItem
|
||||
required: [dispatchItemId, planPalletId, customerId, palletNo, sources, allocations]
|
||||
properties:
|
||||
dispatchItemId: {type: string}
|
||||
planPalletId: {type: string}
|
||||
customerId: {type: string}
|
||||
customerName: {type: string, nullable: true}
|
||||
palletNo: {type: string}
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchAppointmentSource'
|
||||
allocations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchBatchAllocation'
|
||||
DispatchAppointmentSource:
|
||||
type: object
|
||||
required: [sourceKey, destinationKey, options, blockReasons]
|
||||
properties:
|
||||
sourceKey: {type: string}
|
||||
orderNo: {type: string, nullable: true}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
destinationKey: {type: string}
|
||||
packageCount: {type: integer, nullable: true, minimum: 1}
|
||||
options:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchAppointmentOption'
|
||||
blockReasons:
|
||||
type: array
|
||||
items: {type: string}
|
||||
DispatchAppointmentOption:
|
||||
type: object
|
||||
required: [batchKey, batchNo, recordVersion, availablePackages, appointmentAt, timezone]
|
||||
properties:
|
||||
batchKey: {type: string}
|
||||
batchNo: {type: string}
|
||||
recordVersion:
|
||||
type: string
|
||||
pattern: ^[0-9]+$
|
||||
availablePackages: {type: integer, minimum: 1}
|
||||
confirmationNo: {type: string, nullable: true}
|
||||
appointmentAt:
|
||||
type: string
|
||||
description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。
|
||||
appointmentEndAt: {type: string, nullable: true}
|
||||
timezone: {type: string}
|
||||
DispatchAppointmentAllocationCommand:
|
||||
type: object
|
||||
x-java-record: DispatchWorkModels.AppointmentAllocationCommand
|
||||
required: [recordVersion, idempotencyKey, dispatchItemIds, batchAllocations]
|
||||
properties:
|
||||
recordVersion:
|
||||
oneOf:
|
||||
- type: string
|
||||
pattern: ^[0-9]+$
|
||||
- type: integer
|
||||
minimum: 0
|
||||
idempotencyKey: {type: string}
|
||||
dispatchItemIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1000
|
||||
uniqueItems: true
|
||||
items: {type: string}
|
||||
batchAllocations:
|
||||
type: array
|
||||
maxItems: 1000
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchBatchAllocation'
|
||||
DispatchRoute:
|
||||
type: object
|
||||
x-java-record: DispatchWorkModels.Route
|
||||
|
||||
+7
-2
@@ -58,8 +58,13 @@ class Phase4BrowserApiBoundaryTest {
|
||||
assertTrue(condition != null);
|
||||
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
|
||||
assertFalse(condition.matchIfMissing());
|
||||
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3);
|
||||
Set<String> readPaths = Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(GetMapping.class))
|
||||
.flatMap(method -> {
|
||||
String[] values = method.getAnnotation(GetMapping.class).value();
|
||||
return values.length == 0 ? java.util.stream.Stream.of("") : Arrays.stream(values);
|
||||
}).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("", "/capacity-demands", "/{dispatchNo}", "/{dispatchNo}/prints/bol"), readPaths);
|
||||
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||
.flatMap(method -> Arrays.stream(method.getAnnotations()))
|
||||
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ public final class DispatchPublicJsonContract {
|
||||
DispatchCommands.Depart.class, ArrangeDelivery.class,
|
||||
DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class,
|
||||
DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class,
|
||||
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,
|
||||
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,DispatchWorkModels.AppointmentAllocationCommand.class,
|
||||
BatchAllocation.class,BatchReference.class
|
||||
}) {
|
||||
rejectsMissing(requests, request, "{}");
|
||||
@@ -77,7 +77,7 @@ public final class DispatchPublicJsonContract {
|
||||
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
|
||||
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
|
||||
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
|
||||
System.out.println("DispatchPublicJsonContract: PASS (17 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
|
||||
}
|
||||
|
||||
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 原车关联命令使用真实主单、版本CAS、历史与命令账本事务。 */
|
||||
class DispatchAppointmentLinkPersistenceTest {
|
||||
/** 每项测试分别授权列表与维护,避免缺权限造成伪业务拦截。 */
|
||||
@BeforeEach void authorize() {SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));}
|
||||
/** 不把测试身份传给后续其他用例。 */
|
||||
@AfterEach void clear() {SecurityContextHolder.clearContext();}
|
||||
|
||||
/** 补分配保留原车、同车其他客户与来源占用,作废旧审批且支持原键恢复。 */
|
||||
@Test void linksExistingCargoAndInvalidatesReviewWithoutRecreatingTruckOrClaims() throws Exception {
|
||||
var s=new Scenario();s.f.db.update("UPDATE ll_dispatch_order SET review_state='DISPATCH_PENDING',review_basis_hash='old-basis'");
|
||||
long claims=s.count("ll_dispatch_pallet_lock"),history=s.count("ll_dispatch_history");
|
||||
var cargo=s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
|
||||
var saved=s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original")));
|
||||
assertThat(saved.dispatchNo()).isEqualTo(s.no);assertThat(saved.recordVersion()).isEqualTo(s.version+1);
|
||||
assertThat(s.f.repository.find(s.no).getReviewState()).isEqualTo("DRAFT");assertThat(s.f.repository.find(s.no).getReviewBasisHash()).isNull();
|
||||
assertThat(s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(cargo);
|
||||
assertThat(s.count("ll_dispatch_pallet_lock")).isEqualTo(claims);assertThat(s.count("ll_dispatch_order")).isOne();assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
|
||||
verify(s.provider).bindItems(eq(s.root),eq(List.of(new AppointmentWorkDispatchPort.PhysicalItem(s.item,"101",1L,"RECEIVED",10))),eq(List.of(new AppointmentWorkDispatchPort.Allocation(1L,"FBA_APPOINTMENT:9",0L,"101","ORDER:9001","FBA:ACY2",10))));
|
||||
assertThat(s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original"))).replayed()).isTrue();
|
||||
verify(s.provider,times(1)).bindItems(anyLong(),anyList(),anyList());assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
|
||||
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"stale-new-key")))).hasMessageContaining("版本");
|
||||
}
|
||||
/** 真实扫码优先于头部零计数,外车明细与只读权限都不能进入写入。 */
|
||||
@Test void rejectsActualPicksForeignItemsAndReadOnlyActorBeforeProviderMutation() throws Exception {
|
||||
var s=new Scenario();
|
||||
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,new DispatchWorkModels.AppointmentAllocationCommand(s.version,"foreign",List.of(s.item+999),s.allocations())))).hasMessageContaining("本车");
|
||||
s.f.picked(1,"101",501,1);
|
||||
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"picked")))).hasMessageContaining("实际扫码");
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
|
||||
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"read-only")))).hasMessageContaining("权限");
|
||||
verifyNoInteractions(s.provider);assertThat(s.f.repository.find(s.no).getRecordVersion()).isEqualTo(s.version);
|
||||
}
|
||||
/** 原批次拒绝时,主单版本、审核和账本都保留修改前事实。 */
|
||||
@Test void providerFailureRollsBackVersionReviewAndCommandLedger() throws Exception {
|
||||
var s=new Scenario();var before=s.f.json.writeValueAsString(s.f.repository.find(s.no));long history=s.count("ll_dispatch_history"),commands=s.count("ll_dispatch_command");
|
||||
doThrow(new IllegalStateException("预约版本已变化")).when(s.provider).bindItems(anyLong(),anyList(),anyList());
|
||||
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"reject-batch")))).hasMessageContaining("预约版本");
|
||||
assertThat(s.f.json.writeValueAsString(s.f.repository.find(s.no))).isEqualTo(before);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);
|
||||
}
|
||||
/** 候选必须属于本车真实实物,原版本零与本车分配对称返回。 */
|
||||
@Test void readsOnlyOwnedPhysicalOptionsAndDisablesWriteAfterActualPick() throws Exception {
|
||||
var s=new Scenario();
|
||||
when(s.provider.allocationOptions(eq(s.root),anyList())).thenAnswer(call->{
|
||||
List<AppointmentWorkDispatchPort.CandidatePhysical> identities=call.getArgument(1);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>>();
|
||||
for(var identity:identities)result.put(identity,List.of(new AppointmentWorkDispatchPort.AllocationSource("ORDER:9001","WC-1","IN-1","FBA:ACY2",10,List.of(),List.of())));return result;
|
||||
});
|
||||
var result=s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no));
|
||||
assertThat(result.dispatchNo()).isEqualTo(s.no);assertThat(result.items()).hasSize(2);assertThat(result.edit().allowed()).isTrue();
|
||||
s.f.picked(1,"101",501,1);
|
||||
assertThat(s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no)).edit().allowed()).isFalse();
|
||||
}
|
||||
private static class Scenario {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class);
|
||||
final String no;final long root,item,version;
|
||||
/** 先建已有车和两客户货物,模拟后续新增正式预约,不能靠重建车完成补关联。 */
|
||||
Scenario() throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("original-before-appointment",List.of(f.source(1,"101",501,"ACY2",10),f.source(2,"202",502,"ONT8",5)))));
|
||||
no=created.dispatchNo();version=created.recordVersion();root=f.repository.find(no).getId();
|
||||
item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class);
|
||||
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
}
|
||||
/** 本次只触及第一客户原有托盘,不重发选货请求。 */
|
||||
DispatchWorkModels.AppointmentAllocationCommand request(long at,String key) {return new DispatchWorkModels.AppointmentAllocationCommand(at,key,List.of(item),allocations());}
|
||||
/** 正式预约显式提交当前权威版本零。 */
|
||||
List<DispatchContracts.BatchAllocation> allocations() {return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:9","101","ORDER:9001","FBA:ACY2",10,0L));}
|
||||
/** 统计真实持久化记录,验证失败和重放均不产生额外副作用。 */
|
||||
long count(String table) {return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
|
||||
}
|
||||
}
|
||||
+21
@@ -120,6 +120,27 @@ class DispatchDraftPersistenceTest {
|
||||
return result(no,request.idempotencyKey(),request.cargo().size());});
|
||||
}
|
||||
|
||||
/** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */
|
||||
@Test void unconfirmedFbaCannotBeSelectedIntoDraftOrLeaveClaims() {
|
||||
var state=empty(1);
|
||||
when(appointmentProvider.candidateBatches(anyList())).thenReturn(Map.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",1L),
|
||||
new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("FBA货物须先预约成功并明确送仓时间"),false)));
|
||||
assertThatThrownBy(()->select(state,0,1)).hasMessageContaining("先预约成功");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero();
|
||||
assertThat(service.drafts(owner).get(0).sources()).isEmpty();
|
||||
}
|
||||
/** 本轮门禁限普通入仓,独立换标和退货FBA保留原排车流程。 */
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(value=SourceType.class,names={"RELABEL","RETURN_RECEIPT"})
|
||||
void independentFbaKeepsExistingDraftFlow(SourceType sourceType) {
|
||||
var state=empty(1);var choice=sourceType==SourceType.RELABEL
|
||||
?new DispatchCommands.CargoSelection(sourceType,null,null,"RL-1",1001L,"version-1")
|
||||
:new DispatchCommands.CargoSelection(sourceType,null,1L,null,1001L,"version-1");choices.put(1L,choice);
|
||||
doAnswer(call->{var resolved=whole(choice);resolved.candidate().destinationType("FBA");return List.of(resolved);}).when(sources).lockDraftSources(anyString(),anyList());
|
||||
assertThat(select(state,0,1).sources()).hasSize(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isOne();
|
||||
verify(appointmentProvider,never()).candidateBatches(anyList());
|
||||
}
|
||||
/** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */
|
||||
@Test void restartRecoversOriginalResultsAndRejectsChangedActorVersionOrPayload(){
|
||||
var request=new CreateDraft("create","92");var draft=service.createDraft(owner,request);
|
||||
|
||||
+211
@@ -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); }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);}
|
||||
|
||||
+7
-36
@@ -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) {
|
||||
|
||||
+19
-52
@@ -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));
|
||||
|
||||
+10
@@ -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) {
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+2
@@ -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
-10
@@ -20,7 +20,7 @@ import static org.mockito.Mockito.*;
|
||||
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
|
||||
|
||||
class AppointmentWorkPreArrangementPersistenceTest {
|
||||
@Test void siblingPlanChangeCannotGrantOrRemovePhysicalPreArrangementAndNeverAuthorizesDeparture() {
|
||||
@Test void siblingPlanChangeNeverAuthorizesUnconfirmedFbaCargoToEnterDispatch() {
|
||||
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
|
||||
var sources=context.getBean(AppointmentSourceService.class);
|
||||
var cargo=List.of(source("B",1,60),source("D",2,40));
|
||||
@@ -40,17 +40,17 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
|
||||
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
|
||||
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
|
||||
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources,new AppointmentDispatchAvailability(store));
|
||||
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates);
|
||||
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
|
||||
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
|
||||
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
|
||||
assertBindable(tx,binding,mapper,before);
|
||||
assertFalse(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
|
||||
assertNotBindable(tx,binding,mapper,before);
|
||||
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
|
||||
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
|
||||
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
|
||||
assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons());
|
||||
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertBindable(tx,binding,mapper,after);
|
||||
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertNotBindable(tx,binding,mapper,after);
|
||||
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
|
||||
doReturn(List.of(bound)).when(mapper).bindings("DP-B");
|
||||
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
|
||||
@@ -65,13 +65,11 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
assertTrue(mapper.byDispatch(20).isEmpty());
|
||||
}
|
||||
}
|
||||
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
|
||||
tx.executeWithoutResult(status->{
|
||||
private static void assertNotBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(status->{
|
||||
binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion()));
|
||||
binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),candidate.allocations());
|
||||
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
|
||||
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
|
||||
});
|
||||
}));
|
||||
assertTrue(mapper.byDispatch(20).isEmpty());
|
||||
}
|
||||
private static FbaAppointmentDetails plan(String status) {return new FbaAppointmentDetails(LocalDate.of(2026,10,25),null,null,null,null,null,status,"仓库暂无确认",null,null);}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */
|
||||
class AppointmentDispatchAvailabilityTest {
|
||||
private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class));
|
||||
/** 预约头成功不能替代当前客户的有效确认,取消及无时间均不放行。 */
|
||||
@Test void onlyCurrentConfirmedCustomerSourceAndDestinationCanEnterDispatch() {
|
||||
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isEqualTo(20);
|
||||
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"B"),"A")).isZero();
|
||||
assertThat(count(snapshot("CANCELLED","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
|
||||
assertThat(count(snapshot("ACTIVE","PENDING","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
|
||||
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",null,"A"),"A")).isZero();
|
||||
var confirmed=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A");
|
||||
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).isZero();
|
||||
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:1","FBA:ONT8")).isZero();
|
||||
}
|
||||
/** 夏令时歧义及未维护时区不能猜成一个可排时间。 */
|
||||
@Test void invalidOrAmbiguousLocalTimeCannotAuthorizeDispatch() {
|
||||
assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
|
||||
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A"),"A")).isZero();
|
||||
var before=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A");var old=before.agreements().get(0);
|
||||
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.of(2026,11,1,1,30),LocalDateTime.of(2026,11,1,1,15),"SUCCESS",null,"-04:00","-05:00");
|
||||
var explicit=new Agreement(old.agreementKey(),old.version(),old.status(),old.destinationKey(),details,old.timezone(),old.members(),old.warehouseEvidence());
|
||||
assertThat(count(new BatchReceiving(before.batchKey(),List.of(explicit),List.of(),List.of()),"A")).isEqualTo(20);
|
||||
}
|
||||
/** 批读分块不丢客户确认,不逐托重复查询。 */
|
||||
@Test void batchSnapshotReadHonorsExistingProviderChunkLimit() {
|
||||
var store=mock(FbaReceivingStore.class);var reader=new AppointmentDispatchAvailability(store);
|
||||
when(store.snapshots(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().map(key->new BatchReceiving(key,List.of(),List.of(),List.of())).toList();});
|
||||
var keys=java.util.stream.IntStream.rangeClosed(1,401).mapToObj(i->"FBA_APPOINTMENT:"+i).toList();
|
||||
assertThat(reader.snapshots(keys)).hasSize(401);verify(store,times(3)).snapshots(anyList());
|
||||
}
|
||||
/** 使用与页面相同的稳定身份检查来源覆盖。 */
|
||||
private int count(BatchReceiving snapshot,String customer) {return policy.confirmedPackages(snapshot,customer,"ORDER:1","FBA:ACY2");}
|
||||
/** 构造独立客户的正式收货确认,避免靠预约头状态模拟成功。 */
|
||||
private BatchReceiving snapshot(String status,String result,String timezone,LocalDateTime at,String customer) {
|
||||
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",at,null,result,null,null,null);
|
||||
var member=new Member("FBA_APPOINTMENT:1","YY-1",0,customer,List.of(new Source("ORDER:1","FBA:ACY2",20,"v1",1,"IN-1")),"客户甲");
|
||||
return new BatchReceiving("FBA_APPOINTMENT:1",List.of(new Agreement("RA-1",0,status,"FBA:ACY2",details,timezone,List.of(member),"确认回复")),List.of(),List.of());
|
||||
}
|
||||
}
|
||||
+48
-2
@@ -51,6 +51,7 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
private AppointmentWorkDispatchCandidates candidates;
|
||||
private InboundFbaAppointmentService appointments;
|
||||
private AppointmentSourceService sources;
|
||||
private FbaReceivingStore receiving;
|
||||
private final String batch="FBA_APPOINTMENT:1";
|
||||
|
||||
/** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */
|
||||
@@ -78,8 +79,21 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
when(sources.canonicalOrders(anyList())).thenAnswer(c->{List<AppointmentExternalSourceGateway.OrderRef> orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();});
|
||||
doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged();
|
||||
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources);
|
||||
var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
|
||||
receiving=mock(FbaReceivingStore.class);
|
||||
when(receiving.currentAgreement(eq(batch),anyString())).thenAnswer(call->confirmed(call.getArgument(1)));
|
||||
when(receiving.snapshots(anyList())).thenAnswer(call->List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(batch,List.of(confirmed("101"),confirmed("202")),List.of(),List.of())));
|
||||
var availability=new AppointmentDispatchAvailability(receiving);
|
||||
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources,availability);
|
||||
var target=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,candidates);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target,"allocationReader",new AppointmentDispatchAllocationReader(new AppointmentDispatchAllocationRepository(mapper),sources,availability));
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
|
||||
}
|
||||
/** 成功场景明确提供本客户原来源的正式收货时间,不借其他客户确认通过。 */
|
||||
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement confirmed(String customer) {
|
||||
var details=new FbaAppointmentDetails(null,null,null,"CONF-"+customer,java.time.LocalDateTime.of(2026,10,26,10,0),null,"SUCCESS",null,null,null);
|
||||
var source=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Source("101".equals(customer)?"ORDER:1":"BOOKING:real-2","FBA:ONT8","101".equals(customer)?80:160,"v1",1,"IN-"+customer);
|
||||
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member(batch,"AP-1",0,customer,List.of(source),null);
|
||||
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-"+customer,0,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库明确确认");
|
||||
}
|
||||
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
|
||||
private void seed(long item,String customer,long order,int count,String destination) {
|
||||
@@ -157,4 +171,36 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 同一票两托在原车补齐预约分配,数量仍按各托真实来源核验。 */
|
||||
@Test void lateConfirmedAppointmentCanBindBothExistingPalletsWithoutReplacingCargo() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET package_count=40 WHERE id=11");
|
||||
jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=40,counted_package_count=40 WHERE pallet_id=111");
|
||||
seed(12,"101",1,40,"FBA");
|
||||
var original=jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
|
||||
tx.executeWithoutResult(s->bind(11,12));
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"));
|
||||
}
|
||||
/** 正式确认未完成时,候选和写入都阻止提前排车,不留下部分分配。 */
|
||||
@Test void unconfirmedAppointmentBlocksBothCandidateAndBindingBeforeReview() {
|
||||
when(receiving.currentAgreement(batch,"101")).thenReturn(null);
|
||||
when(receiving.snapshots(anyList())).thenReturn(List.of());
|
||||
assertFalse(candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L)).blockReasons().isEmpty());
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
}
|
||||
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
|
||||
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),order_no VARCHAR(40),inbound_code VARCHAR(40))");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(1,'101','WC-1','IN-1'),(2,'202','WC-2','IN-2'),(3,'303','WC-3','IN-3')");
|
||||
tx.executeWithoutResult(s->bind(11));
|
||||
var identity=new CandidatePhysical("101",111L);
|
||||
var option=binding.allocationOptions(1,List.of(identity)).get(identity).get(0);
|
||||
assertEquals("WC-1",option.orderNo());assertEquals("IN-1",option.inboundCode());assertEquals(80,option.options().get(0).availablePackages());
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(2,99,999,'FBA_APPOINTMENT:1','101','ORDER:1','FBA:ONT8',5)");
|
||||
assertEquals(75,binding.allocationOptions(1,List.of(identity)).get(identity).get(0).options().get(0).availablePackages());
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,39 @@ import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
|
||||
|
||||
/** 首页八个业务入口的脚本审计;登记范围不是运行证据。 */
|
||||
export function describeHomepageBusinessCoverage() {
|
||||
const scope = describeAcceptanceScope()
|
||||
return [
|
||||
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment'],
|
||||
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
|
||||
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
|
||||
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
|
||||
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
|
||||
covered: '退货预报、审核、签收、合箱、换标、客户确认及换标后出库',
|
||||
remaining: '换标、客户确认及换标后出库已接入默认后续模块;固定客户跨客户边界仍缺资源,真实HTTP待验' },
|
||||
{ title: '美国本土卡', route: '/cross-border/domestic-trucking/create', modules: ['phase4-overseas-business'],
|
||||
covered: '创建、编辑、审核及原单实收、两托部分至全部分配、派车、运输完成,逐步幂等重放', remaining: '独立本土卡车正常及部分分配链已接入;超托审核与真实HTTP待验,不能替代车队签收证据' },
|
||||
{ title: '美本土散板', route: '/cross-border/pallet-shipping/create', modules: ['phase4-overseas-business', 'phase5-overseas-fulfillment'],
|
||||
covered: '原单创建、需求池接车、PDA实收、原车加货及共享装车签收,核对原单终态与数量', remaining: '正常链已接入默认编排;异常履约、完整费用边界及真实HTTP待验' },
|
||||
{ title: '海外采购', route: '/cross-border/procurement/create', modules: ['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
|
||||
covered: '创建、备注编辑、审核驳回;固定客户正常样品采购至寄客户及资金核对已接入默认链',
|
||||
remaining: '固定客户已接入正常样品采购、收货、核费及寄客户;异常、退款及其他采购类型未全覆盖,真实HTTP待验' },
|
||||
{ title: '贴标申请', route: '/overseas-warehouse/labeling/create', modules: ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
|
||||
covered: '客户原申请审核、PDA换标、客户确认、同单新箱唛出库签收、车次释放及原费用结算回查', remaining: '异常边界与真实HTTP仍待验' },
|
||||
{ title: '仓储转运', route: '/overseas-warehouse/storage-transfer/create', modules: ['phase4-overseas-client-services'],
|
||||
covered: '客户申请、提交、审核与拒绝、取消边界;已有库存原单分配、关联换标、出库、异仓收货及唯一库存流水与结算终态核验', remaining: '在途转运入库计量、多来源及部分收货异常矩阵仍待补;真实HTTP待验' },
|
||||
{ title: '货物废弃', route: '/overseas-warehouse/cargo-disposal/create', modules: ['phase4-overseas-client-services'],
|
||||
covered: '申请、拒绝释放、批准锁箱、PDA照片与实际扣库、客户确认及终态重放', remaining: '已接入同一申请公共库存总账核对;独立箱/SKU流水证据、多来源边界及真实HTTP仍待验' },
|
||||
].map(business => ({ ...business,
|
||||
defaultModules: business.modules.filter(name => scope.defaultModules.includes(name)),
|
||||
additionalModules: (business.additionalModules || []).map(name => ({ name,
|
||||
defaultSelected: scope.defaultModules.includes(name),
|
||||
skippedWithFixedResources: scope.fixedResourceSkippedModules.includes(name) })),
|
||||
endToEndCoverage: 'INCOMPLETE', currentVersionVerified: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 只输出白名单统计和模块名,不转抄报告中的账号、地址、凭据或业务载荷。 */
|
||||
export function summarizeReport(report) {
|
||||
if (!report || !Array.isArray(report.steps) || report.steps.length === 0
|
||||
@@ -53,6 +86,7 @@ async function main(argv) {
|
||||
const result = {
|
||||
purpose: '离线验收范围总览,不执行HTTP,不证明当前部署通过',
|
||||
admin: { entry: 'scripts/admin-test-env-acceptance.mjs', ...scope },
|
||||
homepageBusinesses: describeHomepageBusinessCoverage(),
|
||||
otherEntries: [
|
||||
{ entry: 'scripts/client-test-env-acceptance.mjs', scope: 'Client全量HTTP,独立执行' },
|
||||
{ entry: 'scripts/client-phase2-test-env-acceptance.mjs', scope: '测量计费专项,不替代Client全量' },
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { summarizeReport } from './acceptance-overview.mjs'
|
||||
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
|
||||
import { summarizeReport, describeHomepageBusinessCoverage } from './acceptance-overview.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describeAcceptanceScope, normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
|
||||
|
||||
test('业务覆盖清单逐项对应客户端首页八个入口,未闭环不可标记通过', () => {
|
||||
const homepage = readFileSync(new URL('../../apps/client-web/src/modules/dashboard/views/DashboardView.vue', import.meta.url), 'utf8')
|
||||
const actions = [...homepage.matchAll(/\{ title: '([^']+)'[^\n]+route: '([^']+)'/g)]
|
||||
.map(match => ({ title: match[1], route: match[2] }))
|
||||
const businesses = describeHomepageBusinessCoverage()
|
||||
assert.equal(actions.length, 8)
|
||||
assert.deepEqual(businesses.map(({ title, route }) => ({ title, route })), actions)
|
||||
const scope = describeAcceptanceScope()
|
||||
const registered = new Set([...scope.defaultModules, ...scope.optionalModules])
|
||||
for (const business of businesses) {
|
||||
assert.ok(business.modules.every(name => registered.has(name)))
|
||||
assert.deepEqual(business.defaultModules, business.modules, `${business.title}正常链必须默认执行`)
|
||||
assert.ok(business.modules.every(name => !scope.fixedResourceSkippedModules.includes(name)), `${business.title}不能因固定客户跳过`)
|
||||
assert.ok(business.additionalModules.every(({ name }) => registered.has(name)))
|
||||
assert.ok(business.remaining)
|
||||
assert.equal(business.currentVersionVerified, false)
|
||||
assert.equal(business.endToEndCoverage, 'INCOMPLETE')
|
||||
}
|
||||
assert.ok(businesses.find(item => item.title === '海外采购').modules.includes('phase4-overseas-wallet-and-pallet-cost'))
|
||||
assert.ok(businesses.find(item => item.title === '退货换标').modules.includes('phase4-overseas-return-followups'))
|
||||
})
|
||||
|
||||
test('八大业务定向执行保留正常履约的直接前置顺序', () => {
|
||||
const dependencies = [
|
||||
['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
|
||||
['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
|
||||
['phase4-overseas-client-services', 'phase4-overseas-dispatch'],
|
||||
['phase4-overseas-business', 'phase5-overseas-fulfillment'],
|
||||
['phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
|
||||
]
|
||||
for (const [producer, consumer] of dependencies) {
|
||||
const planned = normalizeModuleOrder([consumer])
|
||||
assert.ok(planned.includes(producer), `${consumer}缺少${producer}`)
|
||||
assert.ok(planned.indexOf(producer) < planned.indexOf(consumer))
|
||||
assert.equal(planned.filter(name => name === producer).length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('注册表返回独立副本,默认与可选不重复', () => {
|
||||
const scope = describeAcceptanceScope()
|
||||
assert.ok(scope.defaultModules.includes('appointment-work'))
|
||||
assert.ok(scope.optionalModules.includes('phase4-overseas-return-followups'))
|
||||
assert.ok(scope.defaultModules.includes('phase4-overseas-return-followups'))
|
||||
assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name)))
|
||||
assert.ok(scope.fixedResourceSkippedModules.includes('customers'))
|
||||
assert.ok(!scope.fixedResourceSkippedModules.includes('suppliers'))
|
||||
scope.fixedResourceSkippedModules.length = 0
|
||||
assert.ok(describeAcceptanceScope().fixedResourceSkippedModules.length > 0)
|
||||
scope.defaultModules.length = 0
|
||||
assert.ok(describeAcceptanceScope().defaultModules.length > 0)
|
||||
})
|
||||
|
||||
@@ -5,14 +5,16 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-return-followups', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['customers', 'supplemental-boundaries', 'system', 'master-data',
|
||||
'notifications', 'pricing', 'phase4-overseas-master-data'])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
|
||||
@@ -106,8 +108,8 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
|
||||
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
|
||||
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations', 'phase4-overseas-client-services']],
|
||||
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch', 'phase4-overseas-business']],
|
||||
['phase5-terminal-drayage', ['phase4-overseas-pda']],
|
||||
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
|
||||
['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']],
|
||||
@@ -291,6 +293,7 @@ export function describeAcceptanceScope() {
|
||||
return {
|
||||
defaultModules: [...DEFAULT_MODULES],
|
||||
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
|
||||
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_MODULES],
|
||||
}
|
||||
}
|
||||
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
|
||||
@@ -821,8 +824,7 @@ export class AcceptanceRun {
|
||||
}
|
||||
|
||||
async executeModule(name, handler) {
|
||||
if (this.options.fixedCustomerId && new Set(['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications',
|
||||
'pricing', 'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']).has(name)) {
|
||||
if (this.options.fixedCustomerId && FIXED_RESOURCE_SKIPPED_MODULES.has(name)) {
|
||||
this.addCoverageGap(name, 'fixed-resource-lifecycle', '固定资源模式排除新增主档及账户生命周期',
|
||||
'业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化', 'not-implemented', { blocking: false })
|
||||
this.modules.push({ name, success: false, skipped: true, passed: 0, failed: 0,
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
样品采购履约每次聚合回查均核对原客户、原采购单号和有效版本;创建重放及客户最终详情同样校验身份,串单立即停止后续写入。`procurement-sample-fulfillment-contract.test.mjs` 离线走完整个分次收货、双箱打包、两段交寄及 1 元货款、2 元运费冻结、1.5 元实际运费结算链,另以串单和重放重复扣款验证失败路径;这不代表真实钱包或部署已验收。
|
||||
|
||||
货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。
|
||||
|
||||
本土卡车履约每次请求前后保存检查点:总编排提供 `checkpoint` 时沿用其证据持久化,否则独占创建 `reports/us-truck-checkpoints/<marker>.json`。同批次文件已存在即拒绝重跑,不覆盖旧证据;落盘失败不发送请求,发送后未知结果停止后续命令。记录原请求体、拒绝预期、诊断和最终状态,不记录会话凭据。对应磁盘失败、未知响应及新执行覆盖拒绝均有离线合同。
|
||||
|
||||
本土卡车默认履约在正式实收和分配前,各用原单增加一条无效命令:逐托箱数超过请求总箱数、重复选同一托盘。必须明确返回参数错误并带诊断标识,随后回查原单数量、状态、历史和版本完全不变,才继续正常链。两托原单仅分配首托时,还尝试进入待派车,必须返回 409/A0409 状态冲突并保留诊断标识,整个原单回查不变后才继续分配第二托;未知结果、无关拒绝或意外接受均停止。此边界不增加客户或业务单据,不替代超托审核。
|
||||
|
||||
客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运链存在源码待回归风险:PDA 完工调用 `settlePdaCompletion`,而 `completeWarehouseTransferFulfillment` 当前只接受 `FROZEN/NOT_REQUIRED`;脚本保留正式收货及结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。
|
||||
|
||||
`warehouse-transfer-receipt.mjs` 在默认原转运出库后复用异仓既有公共空闲托盘,要求目的 FBA 一致、主档启用及库位可预留;不足时失败,不创建或修改公共资源。按原出库仓位正式收货,核对完成命令的权威目标、目标库存位置、唯一新 SKU 入库一箱流水、原键重放及客户详情,再有限轮询关联换标 `COMPLETED/SETTLED`。逐请求保存 `reports/warehouse-transfer-receipt-checkpoints/<marker>.json`;未知结果停写,不覆盖同批次,不直接修改资金状态。离线合同为 `warehouse-transfer-receipt-contract.test.mjs`;只补正常已有库存转运链,在途、多来源、部分收货与异常矩阵仍未全覆盖,真实 HTTP 待运行。
|
||||
|
||||
`warehouse-transfer-outbound.mjs` 由默认客户业务链在原转运关联换标确认后调用,仅接受登记的新 SKU 实托预留。出库前核对原单和零 SHIP 流水;出库后独立核对同客户、同仓、原入仓码、新 SKU、实际托盘号的唯一扣一箱流水,原键重放后单据及流水均不变,客户回查一致且关联换标同步 `OUTBOUNDED`。不另建转运单或客户;未知结果停写,证据保存于 `reports/warehouse-transfer-outbound-checkpoints/<marker>.json`,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-outbound-contract.test.mjs`;收货及关联换标结算由 warehouse-transfer-receipt.mjs 继续核验,尚未运行真实 HTTP。
|
||||
|
||||
已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。检查点独立保存 `reports/transfer-relabel-checkpoints/<marker>.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。
|
||||
|
||||
`warehouse-transfer-allocation.mjs` 由默认 `phase4-overseas-client-services.mjs` 在本轮已有库存转运审核通过后调用。仅分配原入仓单、同 SKU、同客户同仓的真实可用仓位,核对关联换标申请唯一、同键重放及客户详情一致,达到 `WAIT_RELABEL`。不另建客户或替代转运单;请求前后保存 `reports/warehouse-transfer-checkpoints/<marker>.json`,未知结果停写,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-allocation-contract.test.mjs`;关联换标完工与转运出库已衔接,收货已由后续助手衔接,尚未运行真实 HTTP。
|
||||
|
||||
客户首页贴标原申请已接入默认 `phase4-overseas-dispatch.mjs` 的 `createClientRelabelDispatch`:总入口显式展开 `phase4-overseas-client-services`,从其 `relabelFulfillment` 读取本轮已确认申请;按 RELABEL 来源、申请号、客户、仓库及全部目标托盘精确选货,创建独立原生排车并核验幂等与逐行明细。不能复用 `phase4OverseasRelabel` 另一份样本的通过结果。检查点同时保存客户贴标来源和排车请求;创建后交由默认 phase5-overseas-fulfillment 串行执行装车、同单新箱唛出库核验、正式签收及车次资源释放;先释放主车,再分别履约旧样本和客户原申请,避免固定资源池重叠占用。deliveryExecution 保存独立终态且随 phase5 检查点落盘;签收后由 verifyClientRelabelSettlement 独立只读轮询原申请资金结算;必须同来源、同仓、原测试费用0.01且 SETTLED,真实 HTTP 未运行。合同复用 `phase4-overseas-dispatch-contract.test.mjs`。
|
||||
|
||||
`looseboard-fulfillment.mjs` 已由默认 `phase5-overseas-fulfillment` 调用;总入口将 `phase4-overseas-business` 展开为前置,保留同一张待接车散板单。串接余位发布、原单绑定、PDA 实收、独立 STOCK_IN 数量及唯一性核验、原车加货和重新两级审核,再由原有主链核验全部客户库存、SHIP、装车发车和签收,最后回查原散板的 booking、来货、车次、派送和签收箱数达到 COMPLETED。使用同一固定客户、车队资源池及 PDA;测试约定单价25/托,逐托测量明确为合成 TEST 样本。检查点沿用 `reports/phase5-checkpoints/<marker>.json` 并包含散板证据,未知结果停写。离线合同为 `looseboard-fulfillment-contract.test.mjs`;当前只补脚本,真实 HTTP、异常履约及页面仍未验收。
|
||||
|
||||
`looseboard-pool-dispatch.mjs` 接受散板原单已运营确认的实收证据,原车零扫码时显式退回审核,经需求池 `arrange-dispatch` 正式排入本车唯一主单,重放核对唯一性并逐行核对原货不丢失、新增实托与箱数一致。保留原容量计划,仅追加本次实收托盘及测量;使用共享排车写入边界,未知结果全批停写。终态为 `BOUND_AWAITING_REVIEW`,由默认 looseboard-fulfillment.mjs 继续重新审核、装车、签收与流水核验。离线合同为 `looseboard-pool-dispatch-contract.test.mjs`。
|
||||
|
||||
`looseboard-pool-receipt.mjs` 衔接散板原单的已确认 booking,登记唯一来货,按调用方明确提供的逐托测量经 PDA 草稿、生成、绑定、合成测试照片和完成打托,最后运营确认实收并核验重放不变。检查同客户、同仓、同目的地、原托身份及逐托箱数;请求前后写总入口 checkpoint,未知结果停止,不创建客户或主档,不取消现场。此助手仅达到 `RECEIVED_READY_FOR_DISPATCH`,默认 looseboard-fulfillment.mjs 已衔接独立入库流水核验、原车加货及后续共享履约。离线合同为 `looseboard-pool-receipt-contract.test.mjs`,不代表真实 HTTP 通过。
|
||||
|
||||
`looseboard-pool-preparation.mjs` 是 `looseboard-pool-binding.mjs` 的车次前置编排:仅接受本轮已核验、同仓同车同司机、原排车尚未扫码发运的车次;已有有效容量计划直接复用,缺计划才依据本轮真实实托生成明确标记为非实测的受控预计计划。随后创建/发布余位,分别同键重放核验,调用原散板单绑定助手。过程经总入口 checkpoint 保存原请求和诊断,未知结果停止,不创建车辆、司机或客户。离线合同为 `looseboard-pool-preparation-contract.test.mjs`。此编排由默认 looseboard-fulfillment.mjs 调用;脚本接入不代表真实 HTTP 已通过。
|
||||
|
||||
`looseboard-pool-binding.mjs` 由默认 looseboard-fulfillment.mjs 经余位准备调用,负责原散板单接车。输入必须是本轮登记的待接单原单、本轮车次及其 `looseboardOffer` 发布证据;核对客户、版本、同仓目的地和容量后,携带 `sourceOrderNo/sourceOrderVersion` 创建询价及确认承运,重放核对唯一性,再回查原单进入待入仓且关联同一 booking/trip。约定单价由调用方明确提供,不使用历史订单或自动创建客户。逐请求通过总入口 checkpoint 保存诊断,未知结果停止,不自动取消。离线用例位于 `looseboard-pool-binding-contract.test.mjs`;完整编排和运行限制见 looseboard-fulfillment.mjs 条目;此助手单独不代表签收。
|
||||
|
||||
`us-truck-fulfillment.mjs` 由默认 `phase4-overseas-business.mjs` 衔接本轮已审核的独立本土卡车原单,调用正式实收、分配、待派车、派车、运输和完成接口。原单至少两箱时分为两托(1箱与余量),依次验证部分分配及全部分配;单箱仅实收一托并明确未覆盖部分分配。逐托箱数取自原订单;每步核对客户、版本、托盘身份、数量及历史,并执行同键重放和详情回查。不再在 finally 取消这张卡车单,失败保留现场,未知写入不重发;请求诊断与结果放在总入口报告的 `phase4OverseasBusiness.truck.fulfillment`。不创建客户、车辆或司机;订单实收托盘属于业务数据。当前只补脚本,尚未真实 HTTP 运行;该独立订单完成不代表车队 POD 签收,超托审核仍待补;部分分配已在两托原单默认链覆盖。离线合同位于 `phase4-overseas-business-contract.test.mjs`。
|
||||
|
||||
`client-relabel-fulfillment.mjs` 由默认 `phase4-overseas-client-services.mjs` 衔接客户刚创建的同一张单箱贴标申请:审核与资金冻结、PDA箱标/SKU核验、原箱换标组托、合成完工图片、正常完工和客户确认,达到 `READY_FOR_OUTBOUND/WAIT_PRE_DISPATCH`。来源严格匹配本轮入仓码、仓库、余额和实际托盘;固定客户先核验既有余额,不补款。新目标托盘属于本轮换标业务产物。逐请求写入 `reports/client-relabel-checkpoints/<marker>.json`,未知结果停写,同批次不覆盖重跑,完成后保存 `relabelFulfillment.targetPalletIds` 等供同单后续排车使用。此助手只到待排车;默认排车及 phase5 后续已衔接同一申请到签收和资源释放,资金结算仍单独核验。离线合同不代表真实HTTP通过。
|
||||
|
||||
`cargo-discard-fulfillment.mjs` 为默认 `phase4-overseas-client-services.mjs` 的内部后续:仅衔接本批已批准的单箱一件代发废弃申请,复用同仓 PDA,扫码解析、上传合成测试图片、确认实际废弃、核对公共余额减一箱及唯一 DISCARD 总账,再由客户确认完成,重放两端完成命令核对库存、历史和时间不变。每次请求前保存 `reports/cargo-discard-checkpoints/<marker>.json`,未知结果停写,不自动重跑或清理;不创建客户或公共资源。多来源/多箱边界及独立箱、SKU、箱级流水底层取证仍保留缺口,不能用公共总账替代底层证据。真实 HTTP 以后执行;离线合同只验证编排与断言。
|
||||
|
||||
固定客户模式下 `phase4-overseas-wallet-and-pallet-cost.mjs` 不再整模块跳过:默认链调用既有 `procurement-sample-fulfillment.mjs` 完成样品采购、分次海外收货、装箱与面单、预估冻结、国际交寄、国内收货、实际核费及寄客户。使用本轮已核验固定客户,再次核对会话归属,收货仓从正式采购候选复用;既有可用现金须至少 3 元,正常样本实扣 2.50 元,不充值、不修改授信、不创建客户或仓库。仍按原依赖展开;定向入口为 `--modules phase4-overseas-wallet-and-pallet-cost`。凭据来自显式环境文件,失败保留报告及 `reports/procurement-wallet-checkpoints/<marker>.json`,未知结果停写,同批次不自动重跑。合成图片仅作测试凭证,客户会话在 finally 退出。授信充值、贴标资金、托盘汇总及拆柜成本综合场景继续列为覆盖缺口;该分支不代表采购异常、退款与全部类型已验收。离线检查不代表已实际执行 TEST HTTP。
|
||||
|
||||
固定客户模式下 `suppliers` 执行既有供应商四类启用筛选、分页、统计、详情身份与平台作用域拒绝的只读检查,不再整模块跳过;缺少某类样本时保留详情覆盖缺口。新增、编辑、停用生命周期仍明确未验收,不创建或修改供应商。`acceptance-overview.mjs` 从执行入口直接输出 `fixedResourceSkippedModules`,区分默认注册与固定资源模式实际跳过的模块。
|
||||
|
||||
客户端首页八大业务的脚本覆盖审计统一通过 `node scripts/acceptance-overview.mjs` 的 `homepageBusinesses` 查看,逐项对应首页实际标题及路由,并由离线测试检查八个入口无遗漏。输出区分默认模块、补充模块、固定资源跳过项、已写场景与剩余闭环;这是源码覆盖审计,不是当前 HTTP 或页面通过证明。审核通过、其他样本的 PDA 作业和独立专项通过均不能替代同一业务单的后续履约闭环。
|
||||
|
||||
固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。
|
||||
|
||||
国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。
|
||||
@@ -14,7 +56,7 @@
|
||||
|
||||
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
|
||||
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
|
||||
|
||||
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
|
||||
|
||||
@@ -146,7 +188,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。
|
||||
|
||||
`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。
|
||||
`phase4-overseas-return-followups.mjs` 已接入默认全量,在退货签收合箱后执行换标、客户确认及普通/换标后出库;显式只选签收模块仍不运行后续。前置必须是同批次、同客户、主链断言通过的退货资源,仓库严格沿用实际退货收货仓并核对当前启用状态,不回退其他仓。固定客户模式保留跨客户、跨仓和并发边界缺口,不创建第二客户;后续复用既有 PDA 池,两个模块均退出本轮客户会话。写请求网络结果未知不自动重试。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;当前只补脚本,真实 HTTP 及页面仍待执行。
|
||||
|
||||
退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。
|
||||
|
||||
@@ -329,7 +371,7 @@ export async function run(context) {
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
|
||||
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外业务 HTTP:采购创建/备注/审核边界,独立卡车原单履约完成,保留散板原单供默认主链接车 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
|
||||
|
||||
function harness(fault = '') {
|
||||
const calls = [], persisted = []
|
||||
let discarded = false, confirmed = false, repeats = 0, invalidPhotoRejected = false
|
||||
const proof = { approvedNo: 'CD202610220001', customerId: '101', warehouseId: '201', inboundCode: 'RK-TEST', skuCode: 'SKU-TEST',
|
||||
status: 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING' }
|
||||
const task = { discardNo: proof.approvedNo, customerId: '101', overseasWarehouseId: '201', palletNo: 'PALLET-TEST',
|
||||
status: 'PENDING', skus: [{ itemId: '301', skuCode: proof.skuCode, quantityUnit: '箱', plannedPackages: 1 }], cartonMarks: ['BOX-TEST'] }
|
||||
const context = { marker: 'DISCARD-TEST', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasClientServices: { marker: 'DISCARD-TEST' } },
|
||||
assert(_module, name, _response, passed) { assert.ok(passed, name) },
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body, token })
|
||||
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
|
||||
let data
|
||||
if (method === 'GET') data = [{ ...task, customerId: fault === 'wrong customer' ? '999' : '101' }]
|
||||
else if (path.endsWith('/resolve')) data = task
|
||||
else if (path.endsWith('/confirm-completion')) { confirmed = true; data = {} }
|
||||
else if (path.endsWith('/confirm')) {
|
||||
if (body.photoFileIds.length === 2) {
|
||||
invalidPhotoRejected = true
|
||||
return { http: 200, requestId: 'request-test', traceId: fault === 'photo missing trace' ? null : 'trace-test',
|
||||
body: { code: fault === 'photo accepted' ? '200' : fault === 'photo wrong code' ? 'A0400' : 'A0409', data: null } }
|
||||
}
|
||||
if (fault === 'unknown confirm') throw new Error('network unknown')
|
||||
if (discarded) repeats++
|
||||
discarded = true
|
||||
data = { ...task, status: 'COMPLETED', skus: [{ ...task.skus[0], actualPackages: 1 }] }
|
||||
} else throw new Error(`unexpected ${path}`)
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestMultipart(method, path, form, token) {
|
||||
calls.push({ method, path, token })
|
||||
assert.ok(form.get('file'))
|
||||
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
|
||||
return { http: 200, body: { code: '200', data: { fileId: '401', palletNo: task.palletNo } } }
|
||||
},
|
||||
}
|
||||
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret',
|
||||
async checkpoint() {
|
||||
if (fault === 'disk') throw new Error('disk full')
|
||||
persisted.push(structuredClone(proof.fulfillment))
|
||||
},
|
||||
async snapshot() {
|
||||
const quantity = discarded ? fault === 'no deduction' ? 4 : fault === 'replay deduction' && repeats ? 2 : 3 : 4
|
||||
const ledger = discarded ? [{ businessOrderNo: proof.approvedNo, businessType: 'DISCARD', deltaPackages: -1,
|
||||
id: '501', customerId: proof.customerId, overseasWarehouseId: proof.warehouseId, palletNo: task.palletNo,
|
||||
beforePackages: 4, afterPackages: 3,
|
||||
skuCode: proof.skuCode, inboundCode: proof.inboundCode }] : []
|
||||
if (discarded && fault === 'duplicate ledger') ledger.push({ ...ledger[0] })
|
||||
if (discarded && fault === 'ledger customer') ledger[0].customerId = '999'
|
||||
if (discarded && fault === 'ledger warehouse') ledger[0].overseasWarehouseId = '999'
|
||||
if (discarded && fault === 'ledger pallet') ledger[0].palletNo = 'OTHER-PALLET'
|
||||
if (discarded && fault === 'ledger identity') delete ledger[0].id
|
||||
if (discarded && fault === 'ledger arithmetic') ledger[0].afterPackages = 4
|
||||
return { balances: [{ inboundCode: proof.inboundCode, skuCode: proof.skuCode, totalPackages: quantity, availablePackages: quantity }],
|
||||
ledger, detail: { summary: { discardNo: proof.approvedNo, customerId: proof.customerId, overseasWarehouseId: proof.warehouseId,
|
||||
status: confirmed ? 'COMPLETED' : discarded ? 'PDA_COMPLETED' : 'APPROVED', totalPackages: 1,
|
||||
actualPackages: discarded || invalidPhotoRejected && fault === 'photo drift' ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
|
||||
} }
|
||||
return { context, inputs, calls, persisted }
|
||||
}
|
||||
|
||||
test('discard executes same application through PDA and customer completion with zero-drift replays', async () => {
|
||||
const h = harness()
|
||||
const result = await verifyCargoDiscardFulfillment(h.context, h.inputs)
|
||||
assert.equal(result.status, 'HTTP_VERIFIED')
|
||||
assert.equal(h.inputs.proof.status, 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING')
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 4)
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/photos/files')).length, 1)
|
||||
assert.ok(!JSON.stringify(h.persisted).includes('client-secret'))
|
||||
assert.ok(!JSON.stringify(h.persisted).includes('pda-secret'))
|
||||
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /已批准申请|不自动重跑/)
|
||||
})
|
||||
|
||||
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk',
|
||||
'ledger customer', 'ledger warehouse', 'ledger pallet', 'ledger identity', 'ledger arithmetic',
|
||||
'photo accepted', 'photo wrong code', 'photo missing trace', 'photo drift']) {
|
||||
test(`discard stops on ${fault}`, async () => {
|
||||
const h = harness(fault)
|
||||
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs))
|
||||
assert.equal(h.inputs.proof.fulfillment.status, 'FAILED')
|
||||
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
|
||||
if (fault === 'wrong customer') assert.equal(h.calls.length, 1)
|
||||
if (fault === 'disk') assert.equal(h.calls.length, 0)
|
||||
if (fault === 'unknown confirm') {
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 2)
|
||||
assert.equal(h.inputs.proof.fulfillment.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('foreign batch or customer fails before HTTP or checkpoint', async () => {
|
||||
const h = harness()
|
||||
h.inputs.proof.customerId = '999'
|
||||
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /本轮已批准申请/)
|
||||
assert.equal(h.calls.length, 0)
|
||||
assert.equal(h.persisted.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const PDA = '/api/pda/v1/last-leg/cargo-discards'
|
||||
const CLIENT = '/api/merchant/v1/last-leg/cargo-discards'
|
||||
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
||||
const integer = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
||||
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
|
||||
|
||||
/** 仅衔接本轮单箱一件代发废弃申请;所有写入前保存请求,未知结果不重试。 */
|
||||
export async function verifyCargoDiscardFulfillment(context, { proof, merchantToken, pdaToken, snapshot, checkpoint }) {
|
||||
if (!proof?.approvedNo || proof.status !== 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
|
||||
|| !merchantToken || !pdaToken || typeof snapshot !== 'function'
|
||||
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| context.createdResources.phase4OverseasClientServices?.marker !== context.marker) {
|
||||
throw new Error('废弃履约缺少本轮已批准申请、客户或PDA会话')
|
||||
}
|
||||
if (proof.fulfillment) throw new Error('已有废弃履约证据,不自动重跑')
|
||||
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('废弃批次标识不合法')
|
||||
const evidence = proof.fulfillment = { marker: context.marker, status: 'RUNNING', requests: [], snapshots: {} }
|
||||
let initialized = false
|
||||
const directory = new URL('../../reports/cargo-discard-checkpoints/', import.meta.url)
|
||||
const persist = checkpoint || (async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
|
||||
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
})
|
||||
const require = async (name, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'cargo-discard-fulfillment', deploymentBlocker: true })
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function command(method, path, body, token, upload = false, expectedConflict = false) {
|
||||
const entry = { method, path, body: upload ? { syntheticImage: true } : body, expectedConflict, status: 'SENDING' }
|
||||
evidence.requests.push(entry); await persist()
|
||||
let response
|
||||
try { response = upload ? await context.requestMultipart(method, path, body, token)
|
||||
: await context.request(method, path, body, token) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
|
||||
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
|
||||
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await persist()
|
||||
if (expectedConflict) {
|
||||
await require('废弃重复照片明确冲突且保留诊断', [200, 409].includes(response?.http)
|
||||
&& ['409', 'A0409'].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
|
||||
return response.body.data
|
||||
}
|
||||
await require(`废弃履约 ${method} ${path}`, ok(response))
|
||||
return response.body.data
|
||||
}
|
||||
const capture = async name => {
|
||||
const state = await snapshot()
|
||||
evidence.snapshots[name] = structuredClone(state); await persist(); return state
|
||||
}
|
||||
const matchTask = task => task?.discardNo === proof.approvedNo && task?.customerId === proof.customerId
|
||||
&& task?.overseasWarehouseId === proof.warehouseId && typeof task?.palletNo === 'string' && task.palletNo.length > 0
|
||||
try {
|
||||
await persist()
|
||||
const before = await capture('before')
|
||||
await require('废弃执行前仍为同客户同仓已批准单箱申请', before.detail?.summary?.discardNo === proof.approvedNo
|
||||
&& before.detail.summary.customerId === proof.customerId && before.detail.summary.overseasWarehouseId === proof.warehouseId
|
||||
&& before.detail.summary.status === 'APPROVED' && Number(before.detail.summary.totalPackages) === 1)
|
||||
const tasks = await command('GET', PDA, undefined, pdaToken)
|
||||
const selected = Array.isArray(tasks) ? tasks.filter(task => task.discardNo === proof.approvedNo) : []
|
||||
await require('本轮废弃任务唯一且客户仓库一致', selected.length === 1 && matchTask(selected[0]))
|
||||
const task = selected[0]
|
||||
await require('单箱废弃任务明细和真实箱唛完整', task.status === 'PENDING' && task.skus?.length === 1
|
||||
&& task.skus[0].skuCode === proof.skuCode && task.skus[0].quantityUnit === '箱'
|
||||
&& Number(task.skus[0].plannedPackages) === 1 && /^[1-9]\d*$/.test(String(task.skus[0].itemId))
|
||||
&& task.cartonMarks?.length === 1 && typeof task.cartonMarks[0] === 'string' && task.cartonMarks[0].length > 0)
|
||||
const resolved = await command('POST', `${PDA}/resolve`, { discardNo: proof.approvedNo, palletNo: task.palletNo }, pdaToken)
|
||||
await require('PDA扫码解析保持原任务及明细', stable(resolved) === stable(task))
|
||||
const root = `${PDA}/${encodeURIComponent(proof.approvedNo)}/pallets/${encodeURIComponent(task.palletNo)}`
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')],
|
||||
{ type: 'image/png' }), `${context.marker}-synthetic-discard.png`)
|
||||
const photo = await command('POST', `${root}/photos/files`, form, pdaToken, true)
|
||||
await require('废弃图片绑定当前托盘', /^[1-9]\d*$/.test(String(photo?.fileId)) && photo.palletNo === task.palletNo)
|
||||
const body = { lines: [{ itemId: String(task.skus[0].itemId), actualPackages: 1 }], photoFileIds: [String(photo.fileId)],
|
||||
scannedCartonMarks: [...task.cartonMarks], confirmAllCartons: false }
|
||||
const beforeInvalid = await capture('beforeDuplicatePhoto')
|
||||
await command('POST', `${root}/confirm`, { ...body, photoFileIds: [String(photo.fileId), String(photo.fileId)] }, pdaToken, false, true)
|
||||
await require('重复照片拒绝后申请与库存零漂移', stable(await capture('duplicatePhotoRejected')) === stable(beforeInvalid))
|
||||
const completedTask = await command('POST', `${root}/confirm`, body, pdaToken)
|
||||
await require('PDA完成保留原任务身份和实际数量', matchTask(completedTask) && completedTask.palletNo === task.palletNo
|
||||
&& completedTask.status === 'COMPLETED' && completedTask.skus?.length === 1
|
||||
&& String(completedTask.skus[0].itemId) === String(task.skus[0].itemId)
|
||||
&& Number(completedTask.skus[0].actualPackages) === 1)
|
||||
const completed = await capture('pdaCompleted')
|
||||
await require('客户详情等待客户确认且实际废弃数量为一箱', completed.detail?.summary?.status === 'PDA_COMPLETED'
|
||||
&& completed.detail.summary.discardNo === proof.approvedNo && Number(completed.detail.summary.actualPackages) === 1)
|
||||
const scopedBalances = state => state.balances.filter(row => row.inboundCode === proof.inboundCode && row.skuCode === proof.skuCode)
|
||||
const prior = scopedBalances(before), after = scopedBalances(completed)
|
||||
await require('废弃库存快照包含同一来源余额', prior.length > 0 && prior.length === after.length
|
||||
&& [...prior, ...after].every(row => integer(row.totalPackages) && integer(row.availablePackages)))
|
||||
const sum = (rows, field) => rows.reduce((value, row) => value + Number(row[field]), 0)
|
||||
await require('PDA废弃只扣本来源一箱总量和可用量', sum(prior, 'totalPackages') - sum(after, 'totalPackages') === 1
|
||||
&& sum(prior, 'availablePackages') - sum(after, 'availablePackages') === 1)
|
||||
const movements = completed.ledger.filter(row => row.businessOrderNo === proof.approvedNo)
|
||||
await require('本申请只有一笔正确废弃总账', !before.ledger.some(row => row.businessOrderNo === proof.approvedNo)
|
||||
&& movements.length === 1 && movements[0].businessType === 'DISCARD' && Number(movements[0].deltaPackages) === -1
|
||||
&& /^[1-9]\d*$/.test(String(movements[0].id))
|
||||
&& movements[0].customerId === proof.customerId && movements[0].overseasWarehouseId === proof.warehouseId
|
||||
&& movements[0].palletNo === task.palletNo
|
||||
&& movements[0].skuCode === proof.skuCode && movements[0].inboundCode === proof.inboundCode
|
||||
&& integer(movements[0].beforePackages) && integer(movements[0].afterPackages)
|
||||
&& Number(movements[0].beforePackages) - Number(movements[0].afterPackages) === 1)
|
||||
await command('POST', `${root}/confirm`, body, pdaToken)
|
||||
await require('PDA完成重放库存与单据零漂移', stable(await capture('pdaReplay')) === stable(completed))
|
||||
const clientPath = `${CLIENT}/${encodeURIComponent(proof.approvedNo)}/confirm-completion`
|
||||
await command('POST', clientPath, undefined, merchantToken)
|
||||
const confirmed = await capture('customerCompleted')
|
||||
await require('客户确认完成且库存不再扣减', confirmed.detail?.summary?.status === 'COMPLETED'
|
||||
&& Boolean(confirmed.detail.summary.customerConfirmedAt)
|
||||
&& stable(confirmed.balances) === stable(completed.balances) && stable(confirmed.ledger) === stable(completed.ledger))
|
||||
await command('POST', clientPath, undefined, merchantToken)
|
||||
await command('POST', `${root}/confirm`, body, pdaToken)
|
||||
await require('客户及PDA终态重放保持原完成事实', stable(await capture('terminalReplay')) === stable(confirmed))
|
||||
evidence.status = 'HTTP_VERIFIED'; proof.status = 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING'
|
||||
proof.remainingEvidence = '公共库存总账已核对;独立箱、SKU及箱级流水底层证据仍待验证。'
|
||||
await persist()
|
||||
return evidence
|
||||
} catch (error) {
|
||||
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
|
||||
|
||||
function harness(fault = '', transfer = false) {
|
||||
const calls = [], saved = [], commands = new Set()
|
||||
const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201',
|
||||
sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' }
|
||||
let status = 'PENDING_REVIEW', moved = false, scanned = false, version = 0, taskStatus = 'AVAILABLE'
|
||||
const item = { itemId: '401', expectedPackages: 1, cartonMark: 'BOX-TEST', fbaCartonMark: 'FBA-TEST',
|
||||
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'SKU', newSkuCode: 'NEW', quantity: 1 }] }
|
||||
const detail = () => ({ application: { relabelNo: proof.relabelNo, customerOrderNo: proof.sourceOrderNo,
|
||||
lastlegOrderId: '1001', reviewChannel: transfer ? 'RELABEL_ORDER' : 'TAIL_ORDER',
|
||||
overseasWarehouseId: fault === 'wrong warehouse' ? '999' : '201', status,
|
||||
walletStatus: fault === 'no funds' ? 'PENDING' : 'FROZEN', fulfillmentStatus: status === 'READY_FOR_OUTBOUND' ? transfer && fault !== 'wrong fulfillment' ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH' : null },
|
||||
items: [{ sourceInventoryPositionId: '301', cartonMark: 'BOX-TEST' }] })
|
||||
const task = () => ({ relabelNo: proof.relabelNo, taskStatus, version, applicationStatus: status,
|
||||
relabelledPackages: scanned ? 1 : 0, skippedPackages: 0, exceptionPackages: 0, items: [item] })
|
||||
const context = { marker: proof.marker, token: 'admin-token', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
assert(_module, name, _response, passed) { assert.ok(passed, name) },
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body, token })
|
||||
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
|
||||
const repeated = body?.idempotencyKey && commands.has(body.idempotencyKey)
|
||||
if (body?.idempotencyKey) commands.add(body.idempotencyKey)
|
||||
let data
|
||||
if (path.endsWith('/tenant/profile')) data = { id: fault === 'wrong customer' ? '999' : '101', status: 'ACTIVE' }
|
||||
else if (path.endsWith('/status') || path.includes('/relabel-review?')) {
|
||||
assert.equal(path.endsWith('/status'), transfer)
|
||||
status = 'PENDING_PROCESSING'; data = detail()
|
||||
}
|
||||
else if (path.endsWith('/claim')) { taskStatus = 'PROCESSING'; if (!repeated) version++; data = task() }
|
||||
else if (path.endsWith('/cartons/scan')) { if (!repeated) version++; data = task() }
|
||||
else if (path.endsWith('/units')) {
|
||||
if (fault === 'unknown scan') throw new Error('unknown scan response')
|
||||
scanned = true; if (!repeated) version++; data = task()
|
||||
if (fault === 'scan replay drift' && repeated) data.relabelledPackages = 2
|
||||
} else if (path.endsWith('/relabel-pallet-label')) data = { palletId: '501', palletCode: 'TARGET' }
|
||||
else if (path.endsWith('/pallets/resolve')) {
|
||||
const source = body.code === 'SOURCE'
|
||||
data = { palletCode: body.code, palletVersion: moved ? 1 : 0,
|
||||
totalPackages: source ? moved ? fault === 'no source deduction' ? 3 : 2 : 3 : moved ? 1 : 0 }
|
||||
} else if (path.endsWith('/relabel-disassemble')) { moved = true; data = { status: 'COMPLETED', operationId: '601', replayed: Boolean(repeated) } }
|
||||
else if (path.endsWith('/confirm-completion')) { status = 'READY_FOR_OUTBOUND'; data = detail() }
|
||||
else if (path.endsWith('/complete')) { status = 'CUSTOMER_CONFIRM_PENDING'; taskStatus = 'WAIT_REVIEW'; data = task() }
|
||||
else if (path.includes('/relabel-tasks/')) data = task()
|
||||
else if (path.includes('/warehouse-transfers/')) data = { transferNo: proof.sourceOrderNo, status: 'ALLOCATED', relabelNo: proof.relabelNo,
|
||||
relabelApplicationId: '801', allocations: [{ status: 'RESERVED', packageCount: 1, palletId: fault === 'wrong target' ? '999' : '501',
|
||||
inboundCode: 'IN-1', inventoryPositionId: '901', skuCode: 'NEW' }] }
|
||||
else data = detail()
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestMultipart(method, path, body, token) {
|
||||
calls.push({ method, path, token })
|
||||
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
|
||||
return { http: 200, body: { code: '200', data: { fileId: '701' } } }
|
||||
} }
|
||||
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret', delay: async () => {},
|
||||
checkpoint: async () => { if (fault === 'disk') throw new Error('disk full'); saved.push(structuredClone(proof.execution)) } }
|
||||
if (transfer) {
|
||||
inputs.transferProof = { ...proof, transferNo: proof.sourceOrderNo, status: 'WAIT_RELABEL', inboundCode: 'IN-1',
|
||||
relabelApplicationId: '801', allocation: { status: 'ALLOCATED_WAIT_RELABEL' }, relabelFulfillment: proof }
|
||||
context.createdResources = { phase4OverseasClientServices: { transferFulfillment: inputs.transferProof } }
|
||||
}
|
||||
return { context, inputs, calls, saved }
|
||||
}
|
||||
|
||||
test('original customer relabel reaches ready for dispatch through real command sequence and exact replays', async () => {
|
||||
const h = harness()
|
||||
const result = await verifyClientRelabelFulfillment(h.context, h.inputs)
|
||||
assert.equal(result.status, 'READY_FOR_OUTBOUND')
|
||||
assert.deepEqual(result.targetPalletIds, ['501'])
|
||||
assert.equal(result.execution.status, 'HTTP_VERIFIED_READY_FOR_DISPATCH')
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 2)
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/relabel-disassemble')).length, 2)
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
|
||||
assert.ok(!JSON.stringify(h.saved).includes('client-secret'))
|
||||
assert.ok(!JSON.stringify(h.saved).includes('pda-secret'))
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /不自动重跑/)
|
||||
})
|
||||
|
||||
for (const fault of ['wrong customer', 'wrong warehouse', 'no funds', 'unknown scan', 'scan replay drift', 'no source deduction', 'disk']) {
|
||||
test(`relabel stops before completion on ${fault}`, async () => {
|
||||
const h = harness(fault)
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
|
||||
assert.equal(h.inputs.proof.execution.status, 'FAILED')
|
||||
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
|
||||
if (fault === 'no funds') assert.equal(h.calls.some(call => call.path.endsWith('/claim')), false)
|
||||
if (fault === 'unknown scan') {
|
||||
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 1)
|
||||
assert.equal(h.inputs.proof.execution.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
}
|
||||
if (fault === 'disk') assert.equal(h.calls.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('foreign-batch relabel is rejected before any HTTP', async () => {
|
||||
const h = harness()
|
||||
h.inputs.proof.marker = 'OLD'
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /本轮客户申请/)
|
||||
assert.equal(h.calls.length, 0)
|
||||
})
|
||||
|
||||
test('已有库存转运原关联换标完成后回到原转运新SKU预留', async () => {
|
||||
const h = harness('', true)
|
||||
await verifyClientRelabelFulfillment(h.context, h.inputs)
|
||||
assert.equal(h.inputs.transferProof.status, 'ALLOCATED')
|
||||
assert.equal(h.inputs.proof.execution.status, 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND')
|
||||
assert.equal(h.inputs.transferProof.outboundAllocations[0].palletId, '501')
|
||||
})
|
||||
|
||||
for (const fault of ['wrong fulfillment', 'wrong target']) {
|
||||
test(`转运关联换标拒绝 ${fault}`, async () => {
|
||||
const h = harness(fault, true)
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
|
||||
assert.equal(h.inputs.transferProof.status, 'WAIT_RELABEL')
|
||||
assert.equal(h.inputs.proof.execution.status, 'FAILED')
|
||||
})
|
||||
}
|
||||
|
||||
test('未登记转运分配证据不得操作关联换标', async () => {
|
||||
const h = harness('', true)
|
||||
h.inputs.transferProof = { ...h.inputs.transferProof }
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /原分配证据/)
|
||||
assert.equal(h.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const PALLETS = '/api/pda/v1/last-leg/pallet-operations'
|
||||
const validId = value => /^[1-9]\d*$/.test(String(value))
|
||||
const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
||||
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
||||
|
||||
/** 原申请单箱贴标作业链;独立贴标准备排车,关联转运恢复原单预留。 */
|
||||
export async function verifyClientRelabelFulfillment(context, { proof, transferProof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
|
||||
// 已有库存转运的换标必须回到原转运预留,不进入普通贴标排车链。
|
||||
if (transferProof && (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== transferProof
|
||||
|| transferProof.relabelFulfillment !== proof || transferProof.marker !== context.marker
|
||||
|| transferProof.status !== 'WAIT_RELABEL' || transferProof.allocation?.status !== 'ALLOCATED_WAIT_RELABEL'
|
||||
|| transferProof.relabelNo !== proof?.relabelNo || transferProof.transferNo !== proof?.sourceOrderNo
|
||||
|| transferProof.customerId !== proof?.customerId || transferProof.warehouseId !== proof?.warehouseId
|
||||
|| transferProof.sourcePositionId !== proof?.sourcePositionId || transferProof.sourcePalletNo !== proof?.sourcePalletNo)) {
|
||||
throw new Error('转运关联换标缺少原分配证据')
|
||||
}
|
||||
if (proof?.marker !== context.marker || !proof?.relabelNo || !merchantToken || !pdaToken
|
||||
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| !validId(proof.warehouseId) || !validId(proof.sourcePositionId) || !proof.sourcePalletNo) {
|
||||
throw new Error('贴标后续缺少本轮客户申请、来源仓位及会话')
|
||||
}
|
||||
if (proof.execution) throw new Error('已有贴标执行证据,不自动重跑')
|
||||
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('贴标批次标识不合法')
|
||||
const evidence = proof.execution = { status: 'RUNNING', requests: [] }
|
||||
let initialized = false
|
||||
const directory = new URL(transferProof ? '../../reports/transfer-relabel-checkpoints/' : '../../reports/client-relabel-checkpoints/', import.meta.url)
|
||||
const persist = checkpoint || (async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(transferProof || proof, null, 2),
|
||||
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
})
|
||||
const require = (name, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true })
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const key = action => `${context.marker}-CLIENT-RELABEL-${action}`
|
||||
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
|
||||
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
|
||||
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}`
|
||||
async function command(method, path, body, token, upload = false) {
|
||||
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
|
||||
evidence.requests.push(entry); await persist()
|
||||
let response
|
||||
try { response = upload ? await context.requestMultipart(method, path, body, token)
|
||||
: await context.request(method, path, body, token) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
|
||||
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
|
||||
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await persist()
|
||||
require(`贴标后续 ${method} ${path}`, response?.http === 200 && String(response?.body?.code) === '200')
|
||||
return response.body.data
|
||||
}
|
||||
const read = () => command('GET', platform, undefined, context.token)
|
||||
const sameApplication = detail => detail?.application?.relabelNo === proof.relabelNo
|
||||
&& detail.application.customerOrderNo === proof.sourceOrderNo
|
||||
&& String(detail.application.overseasWarehouseId) === proof.warehouseId
|
||||
const resolve = palletCode => command('POST', `${PALLETS}/pallets/resolve`, { code: palletCode }, pdaToken)
|
||||
try {
|
||||
await persist()
|
||||
const profile = await command('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken)
|
||||
require('贴标当前客户会话身份有效', String(profile?.id) === proof.customerId && profile?.status === 'ACTIVE')
|
||||
const owned = await command('GET', merchant, undefined, merchantToken)
|
||||
require('客户可读取同一贴标申请', sameApplication(owned))
|
||||
const original = await read()
|
||||
require('贴标原申请客户、仓库、来源仓位与待审核状态一致', sameApplication(original)
|
||||
&& original.application.status === 'PENDING_REVIEW' && original.items?.length === 1
|
||||
&& String(original.items[0].sourceInventoryPositionId) === proof.sourcePositionId)
|
||||
require('贴标审核渠道与业务来源一致', original.application.reviewChannel === (transferProof ? 'RELABEL_ORDER' : 'TAIL_ORDER')
|
||||
&& validId(original.application.lastlegOrderId))
|
||||
const reviewPath = transferProof ? `${platform}/status`
|
||||
: `/api/platform/v1/last-leg/orders/${original.application.lastlegOrderId}/relabel-review?customerId=${encodeURIComponent(proof.customerId)}`
|
||||
const reviewed = await command('PUT', reviewPath, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
|
||||
require('贴标审核推进同一申请', sameApplication(reviewed) && reviewed.application.status === 'PENDING_PROCESSING')
|
||||
let funded
|
||||
for (let attempt = 0; attempt < 31; attempt++) {
|
||||
funded = await read()
|
||||
require('贴标等待资金期间申请身份不变', sameApplication(funded))
|
||||
if (funded.application.walletStatus === 'FROZEN') break
|
||||
if (attempt < 30) await delay()
|
||||
}
|
||||
require('贴标费用已冻结后才开始PDA作业', funded.application.walletStatus === 'FROZEN')
|
||||
const claim = { idempotencyKey: key('claim') }
|
||||
let task = await command('POST', `${work}/claim`, claim, pdaToken)
|
||||
const replay = await command('POST', `${work}/claim`, claim, pdaToken)
|
||||
require('认领原键重放不改变任务', stable(task) === stable(replay))
|
||||
require('PDA原申请单箱任务及版本有效', task.relabelNo === proof.relabelNo && task.taskStatus === 'PROCESSING'
|
||||
&& count(task.version) && task.items?.length === 1 && Number(task.items[0].expectedPackages) === 1)
|
||||
const item = task.items[0]
|
||||
require('PDA箱唛及SKU校验来自原申请', validId(item.itemId) && Boolean(item.cartonMark)
|
||||
&& ['CARTON_AND_SKU', 'SKU_LABEL'].includes(item.operationType) && item.skuItems?.length > 0
|
||||
&& item.skuItems.every(sku => sku.skuCode && sku.newSkuCode && count(sku.quantity) && Number(sku.quantity) > 0))
|
||||
if (item.operationType === 'CARTON_AND_SKU') task = await command('POST', `${work}/cartons/scan`,
|
||||
{ cartonMark: item.cartonMark, expectedVersion: Number(task.version), idempotencyKey: key('carton') }, pdaToken)
|
||||
const scan = { itemId: String(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark || item.newSkuCode,
|
||||
skuVerifications: item.skuItems.map(sku => ({ skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity) })),
|
||||
expectedVersion: Number(task.version), idempotencyKey: key('unit') }
|
||||
task = await command('POST', `${work}/units`, scan, pdaToken)
|
||||
const scannedReplay = await command('POST', `${work}/units`, scan, pdaToken)
|
||||
require('单箱贴标完成且扫描重放不重复计数', task.relabelNo === proof.relabelNo && Number(task.relabelledPackages) === 1
|
||||
&& Number(task.skippedPackages) === 0 && Number(task.exceptionPackages) === 0 && stable(task) === stable(scannedReplay))
|
||||
const detail = await read()
|
||||
require('组托前来源位置和箱唛仍属于原申请', sameApplication(detail) && detail.items?.length === 1
|
||||
&& String(detail.items[0].sourceInventoryPositionId) === proof.sourcePositionId && detail.items[0].cartonMark === item.cartonMark)
|
||||
const target = await command('POST', `${PALLETS}/relabel-pallet-label`, { relabelNo: proof.relabelNo, idempotencyKey: key('label') }, pdaToken)
|
||||
require('换标生成本次业务目标托盘', validId(target?.palletId) && Boolean(target.palletCode) && target.palletCode !== proof.sourcePalletNo)
|
||||
const from = await resolve(proof.sourcePalletNo), to = await resolve(target.palletCode)
|
||||
require('源目标托盘数量版本有效', from?.palletCode === proof.sourcePalletNo && to?.palletCode === target.palletCode
|
||||
&& [from, to].every(value => count(value?.totalPackages) && count(value?.palletVersion)))
|
||||
const binding = { relabelNo: proof.relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
|
||||
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, cartonMarks: [item.cartonMark],
|
||||
reason: context.marker, idempotencyKey: key('bind') }
|
||||
const bound = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
|
||||
const boundReplay = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
|
||||
const fromAfter = await resolve(proof.sourcePalletNo), toAfter = await resolve(target.palletCode)
|
||||
require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true
|
||||
&& String(bound.operationId) === String(boundReplay.operationId)
|
||||
&& Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1)
|
||||
task = await command('GET', work, undefined, pdaToken)
|
||||
const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true)
|
||||
require('换标完工图片返回正式文件引用', validId(photo?.fileId))
|
||||
const complete = { expectedVersion: Number(task.version), photoFileIds: [String(photo.fileId)], manualCompletion: false,
|
||||
idempotencyKey: key('complete'), remark: context.marker }
|
||||
const completed = await command('POST', `${work}/complete`, complete, pdaToken)
|
||||
const completedReplay = await command('POST', `${work}/complete`, complete, pdaToken)
|
||||
require('PDA完工及重放等待客户确认', completed.relabelNo === proof.relabelNo && completed.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
|
||||
&& stable(completed) === stable(completedReplay))
|
||||
const confirm = { idempotencyKey: key('confirm'), remark: context.marker }
|
||||
const confirmed = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
|
||||
const finalReplay = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
|
||||
require('客户确认原申请进入对应出库流程且原键重放零漂移', sameApplication(confirmed)
|
||||
&& confirmed.application.status === 'READY_FOR_OUTBOUND'
|
||||
&& confirmed.application.fulfillmentStatus === (transferProof ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH')
|
||||
&& stable(confirmed) === stable(finalReplay))
|
||||
if (transferProof) {
|
||||
const transfer = await command('GET', `/api/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferProof.transferNo)}?customerId=${encodeURIComponent(proof.customerId)}`, undefined, context.token)
|
||||
const active = transfer?.allocations?.filter(row => row.status === 'RESERVED') || []
|
||||
require('关联换标完成后原转运保留新SKU实托唯一预留', transfer?.transferNo === transferProof.transferNo
|
||||
&& transfer.status === 'ALLOCATED' && transfer.relabelNo === proof.relabelNo
|
||||
&& String(transfer.relabelApplicationId) === transferProof.relabelApplicationId
|
||||
&& active.length === 1 && active[0].packageCount === 1 && String(active[0].palletId) === String(target.palletId)
|
||||
&& active[0].inboundCode === transferProof.inboundCode && validId(active[0].inventoryPositionId)
|
||||
&& active[0].skuCode === item.skuItems[0].newSkuCode)
|
||||
Object.assign(transferProof, { status: 'ALLOCATED', outboundAllocations: active })
|
||||
}
|
||||
Object.assign(proof, { status: 'READY_FOR_OUTBOUND', targetPalletIds: [String(target.palletId)],
|
||||
sourceCartonMarks: [item.cartonMark], relabelCartonMarks: [item.fbaCartonMark].filter(Boolean) })
|
||||
evidence.status = transferProof ? 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' : 'HTTP_VERIFIED_READY_FOR_DISPATCH'
|
||||
await persist(); return proof
|
||||
} catch (error) {
|
||||
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ export async function dispatchEvidenceContext(context) {
|
||||
scoped.checkpoint = async () => {
|
||||
const resources = context.createdResources
|
||||
const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary,
|
||||
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch })
|
||||
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch,
|
||||
clientRelabel: resources.phase4OverseasClientServices?.relabelFulfillment })
|
||||
await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
|
||||
await rename(`${path}.tmp`, path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', finalStatus: 'PENDING_ACCEPTANCE' }
|
||||
const tripProof = { marker: 'FT-independent', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12', stops: [{}, { destinationKey: 'FBA:TEST' }] }
|
||||
const calls = [], stages = []; let reviewed = false, complete = false
|
||||
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
|
||||
assert(_m, label, _r, ok) { assert.ok(ok, label) }, async checkpoint() {},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (path.includes('/local-looseboard-orders/')) data = { summary: { orderNo: 'LB-1', customerId: '101',
|
||||
status: complete ? 'COMPLETED' : 'PENDING_ACCEPTANCE', estimatedPalletCount: 2, packageCount: 3,
|
||||
fleetArrangement: complete ? { fulfillmentStage: 'COMPLETED', bookingNo: 'BK-1', tripNo: 'TR-1', dispatchNo: 'DP-1',
|
||||
incomingNo: 'IN-1', deliveryNo: 'DE-1', signedPackages: fault === 'signed quantity' ? 2 : 3 } : null } }
|
||||
else if (path.includes('/inventory/ledger?')) data = { total: 1, records: [{ id: '91', businessOrderNo: 'IN-1', businessType: 'STOCK_IN',
|
||||
customerId: '101', overseasWarehouseId: '42', deltaPackages: fault === 'ledger' ? 4 : 3 }] }
|
||||
else if (method === 'POST') {
|
||||
stages.push(path.split('/').at(-1))
|
||||
reviewed = stages.length === 3
|
||||
data = { recordVersion: stages.length + 1 }
|
||||
} else data = { dispatchNo: 'DP-1', stage: reviewed ? 'LOADING' : 'BEFORE_LOADING', recordVersion: 1, counts: { pickedPackageCount: 0 },
|
||||
reviewState: 'APPROVED', reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } },
|
||||
vehicleInfo: { vehicle: { vehicleId: fault === 'vehicle' ? '99' : '11' }, driver: { driverId: '12' } } }
|
||||
return { http: 200, body: { code: '200', data }, requestId: 'req', traceId: 'trace' }
|
||||
} }
|
||||
const helpers = {
|
||||
async prepare(ctx, args) {
|
||||
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
|
||||
demand.poolBinding = { bookingNo: 'BK-1', createRequest: { cargo: { packageCount: 3 } } }
|
||||
},
|
||||
async receive(ctx, args) {
|
||||
assert.equal(args.pdaToken, 'pda'); assert.deepEqual(args.measurements.map(m => m.cartonCount), [2, 1])
|
||||
demand.receipt = { incomingNo: 'IN-1' }
|
||||
},
|
||||
async arrange() { return { status: 'BOUND_AWAITING_REVIEW', dispatchNo: 'DP-1', addedItems: [{ sourceKey: 'NEW-1' }, { sourceKey: 'NEW-2' }] } },
|
||||
}
|
||||
return { context, tripProof, demand, helpers, calls, stages, markCompleted: () => { complete = true } }
|
||||
}
|
||||
|
||||
test('default orchestration uses original demand and existing trip, then verifies original signed order', async () => {
|
||||
const f = fixture()
|
||||
const result = await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
|
||||
assert.equal(result.proof.status, 'READY_FOR_SHARED_LOADING')
|
||||
assert.deepEqual(f.stages, ['submit-review', 'dispatch-review', 'loading-review'])
|
||||
assert.deepEqual(result.proof.sourceKeys, ['NEW-1', 'NEW-2'])
|
||||
f.markCompleted()
|
||||
await verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }])
|
||||
assert.equal(f.demand.finalStatus, 'COMPLETED')
|
||||
assert.equal(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
|
||||
})
|
||||
|
||||
for (const fault of ['ledger', 'vehicle']) test(`orchestration stops on ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers))
|
||||
assert.equal(f.demand.fulfillment.status, 'FAILED')
|
||||
if (fault === 'ledger') assert.equal(f.stages.length, 0)
|
||||
})
|
||||
|
||||
test('whole vehicle signed does not substitute for original looseboard signed quantity', async () => {
|
||||
const f = fixture('signed quantity')
|
||||
await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
|
||||
f.markCompleted()
|
||||
await assert.rejects(verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }]))
|
||||
assert.notEqual(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
|
||||
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
|
||||
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
|
||||
import { dispatchIo, version } from './grouped-dispatch-acceptance.mjs'
|
||||
|
||||
/** 默认主链复用原散板原车;测量仅为明确TEST样本,不代表生产实测。 */
|
||||
export async function attachLooseboardToTrip(context, tripProof, pdaToken,
|
||||
helpers = { prepare: prepareLooseboardPool, receive: receiveLooseboardPool, arrange: arrangeLooseboardPool }) {
|
||||
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard
|
||||
if (!demand || demand.marker !== context.marker || demand.finalStatus !== 'PENDING_ACCEPTANCE'
|
||||
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !pdaToken || demand.fulfillment) throw Error('默认散板履约缺少本轮待接车原单或已执行')
|
||||
const proof = demand.fulfillment = { marker: context.marker, status: 'ATTACHING', requests: [] }
|
||||
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-MAIN')
|
||||
const check = (name, ok) => io.check(name, null, Boolean(ok))
|
||||
try {
|
||||
const original = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '读取原散板货量')
|
||||
const row = original?.summary
|
||||
check('原散板身份和货量可供受控实收', row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId)
|
||||
&& row.status === 'PENDING_ACCEPTANCE' && Number.isSafeInteger(row.estimatedPalletCount) && row.estimatedPalletCount > 0
|
||||
&& Number.isSafeInteger(row.packageCount) && row.packageCount >= row.estimatedPalletCount)
|
||||
const destinationKey = tripProof.stops?.[1]?.destinationKey
|
||||
check('原车存在明确卸货目的地', typeof destinationKey === 'string' && destinationKey.length > 0)
|
||||
// 当前受控样本以每实托100×100×90cm、40kg执行,原单箱数均分且总数守恒。
|
||||
const measurements = Array.from({ length: row.estimatedPalletCount }, (_, index) => ({
|
||||
cartonCount: Math.floor(row.packageCount / row.estimatedPalletCount) + (index < row.packageCount % row.estimatedPalletCount ? 1 : 0),
|
||||
lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
|
||||
}))
|
||||
proof.measurements = { kind: 'CONTROLLED_TEST_MEASUREMENT', measurements }
|
||||
await helpers.prepare(context, { demand, tripProof, destinationKey, unitPrice: 25 })
|
||||
await helpers.receive(context, { demand, tripProof, pdaToken, measurements })
|
||||
const ledger = await io.command('GET', `/api/platform/v1/last-leg/inventory/ledger?${new URLSearchParams({
|
||||
customerId: String(demand.customerId), overseasWarehouseId: String(tripProof.warehouseId),
|
||||
businessOrderNo: demand.receipt.incomingNo, businessType: 'STOCK_IN', page: '1', size: '200',
|
||||
})}`, undefined, '独立核验原散板来货入库流水')
|
||||
check('散板实收仅产生足量且唯一的本票入库流水', Array.isArray(ledger?.records) && ledger.records.length > 0
|
||||
&& Number(ledger.total) === ledger.records.length && new Set(ledger.records.map(r => String(r.id))).size === ledger.records.length
|
||||
&& ledger.records.every(r => /^[1-9]\d*$/.test(String(r.id)) && r.businessOrderNo === demand.receipt.incomingNo
|
||||
&& r.businessType === 'STOCK_IN' && String(r.customerId) === String(demand.customerId)
|
||||
&& String(r.overseasWarehouseId) === String(tripProof.warehouseId)
|
||||
&& Number.isSafeInteger(Number(r.deltaPackages)) && Number(r.deltaPackages) > 0)
|
||||
&& ledger.records.reduce((sum, r) => sum + Number(r.deltaPackages), 0) === row.packageCount)
|
||||
proof.stockInLedger = ledger.records
|
||||
const arranged = await helpers.arrange(context, { demand, tripProof })
|
||||
check('原散板全部实收货物已经加入本车', arranged?.status === 'BOUND_AWAITING_REVIEW' && arranged.dispatchNo === tripProof.dispatchNo
|
||||
&& arranged.addedItems?.length === measurements.length && new Set(arranged.addedItems.map(r => r.sourceKey)).size === measurements.length)
|
||||
const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
|
||||
let current = await io.command('GET', path, undefined, '散板加入后重新审核原车')
|
||||
check('加货后仅在原车装车前重新审核', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING'
|
||||
&& Number(current.counts?.pickedPackageCount) === 0)
|
||||
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
|
||||
current = await io.command('POST', `${path}/${action}`, { recordVersion: version(current.recordVersion), idempotencyKey: io.key(action),
|
||||
...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }, `散板加货后${action}`)
|
||||
}
|
||||
const reviewed = await io.command('GET', path, undefined, '核对加货后两级审核及原车资源')
|
||||
check('加货审核后原车资源不变且进入装车', reviewed?.stage === 'LOADING' && reviewed.reviewState === 'APPROVED'
|
||||
&& reviewed.reviews?.dispatchReview?.status === 'APPROVED' && reviewed.reviews?.loadingReview?.status === 'APPROVED'
|
||||
&& String(reviewed.vehicleInfo?.vehicle?.vehicleId) === String(tripProof.vehicleId)
|
||||
&& String(reviewed.vehicleInfo?.driver?.driverId) === String(tripProof.driverId))
|
||||
proof.status = 'READY_FOR_SHARED_LOADING'
|
||||
proof.sourceKeys = arranged.addedItems.map(row => row.sourceKey)
|
||||
proof.incomingNo = demand.receipt.incomingNo
|
||||
await context.checkpoint()
|
||||
return { proof, detail: reviewed }
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 同车正常签收后回查原散板商业单,不能用整车状态代替原单履约。 */
|
||||
export async function verifyLooseboardDelivered(context, tripProof, deliveries) {
|
||||
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard, proof = demand?.fulfillment
|
||||
if (demand?.marker !== context.marker || proof?.status !== 'READY_FOR_SHARED_LOADING') throw Error('缺少本轮原散板装车前证据')
|
||||
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-COMPLETE')
|
||||
const detail = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '签收后回查原散板终态')
|
||||
const row = detail?.summary, arrangement = row?.fleetArrangement
|
||||
const passed = row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId) && row.status === 'COMPLETED'
|
||||
&& arrangement?.fulfillmentStage === 'COMPLETED' && arrangement.bookingNo === demand.poolBinding.bookingNo
|
||||
&& arrangement.tripNo === tripProof.tripNo && arrangement.dispatchNo === tripProof.dispatchNo
|
||||
&& arrangement.incomingNo === demand.receipt.incomingNo && Number(arrangement.signedPackages) === demand.poolBinding.createRequest.cargo.packageCount
|
||||
&& deliveries.some(d => d.deliveryNo === arrangement.deliveryNo && String(d.customerId) === String(demand.customerId))
|
||||
io.check('原散板通过真实同车签收完成且数量守恒', null, Boolean(passed))
|
||||
Object.assign(proof, { status: 'COMPLETED_HTTP_VERIFIED', deliveryNo: arrangement.deliveryNo })
|
||||
demand.finalStatus = 'COMPLETED'
|
||||
await context.checkpoint()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
|
||||
|
||||
// 有状态模拟保留原单与独立询价,重放返回原响应,故障必须在后续写入前停止。
|
||||
function fixture(fault = '') {
|
||||
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', version: 2, finalStatus: 'PENDING_ACCEPTANCE' }
|
||||
const tripProof = { tripNo: 'TR-1', warehouseId: '42', looseboardOffer: { marker: 'fixture', offerNo: 'OF-1' } }
|
||||
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', warehouseId: '42', status: 'OPEN', tripRecordVersion: 1, recordVersion: 0,
|
||||
canCreateInquiry: true, canConfirm: true, capacityKnown: true,
|
||||
destinations: [{ destinationKey: 'FBA:TEST', remaining: { palletSpaces: fault === 'capacity' ? 0 : 4, weightKg: 400, volumeCbm: 4 } }] }
|
||||
const original = { remark: 'fixture-original', summary: { ...demand, status: 'PENDING_ACCEPTANCE', estimatedPalletCount: 2,
|
||||
packageCount: 3, totalWeightKg: 80, totalVolumeCbm: 1.8, productName: 'TEST', currency: 'USD', originContactName: 'TEST', originContactPhone: '19900000000' } }
|
||||
let created, confirmed, writes = 0
|
||||
const calls = [], snapshots = []
|
||||
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
|
||||
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
|
||||
async checkpoint() {
|
||||
if (fault === 'disk') throw Error('disk unavailable')
|
||||
snapshots.push(structuredClone(demand.poolBinding))
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (method === 'POST') writes += 1
|
||||
let data
|
||||
if (path.includes('/local-looseboard-orders/')) {
|
||||
data = structuredClone(original)
|
||||
if (fault === 'customer') data.summary.customerId = '102'
|
||||
if (confirmed) {
|
||||
data.summary.status = 'WAITING_INBOUND'; data.summary.version += 1
|
||||
data.summary.fleetArrangement = { businessOrderNo: 'LB-1', bookingNo: 'BK-1', tripNo: 'TR-1' }
|
||||
if (fault === 'binding') data.summary.fleetArrangement.tripNo = 'OTHER'
|
||||
}
|
||||
} else if (method === 'GET' && path.endsWith('/OF-1')) data = { offer }
|
||||
else if (method === 'GET' && path.endsWith('/BK-1')) data = created.inquiry
|
||||
else if (method === 'POST' && path.endsWith('/OF-1/inquiries')) {
|
||||
assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(body.sourceOrderVersion, 2)
|
||||
assert.equal(body.customerId, '101'); assert.equal(body.expectedPalletCount, 2)
|
||||
assert.equal(body.cargo.packageCount, 3)
|
||||
if (fault === 'network') return { http: 0 }
|
||||
if (!created) {
|
||||
created = { inquiry: { ...body, offerNo: 'OF-1', tripNo: 'TR-1', bookingNo: 'BK-1', status: 'DRAFT', recordVersion: 0 }, offer }
|
||||
data = created
|
||||
} else {
|
||||
data = { ...structuredClone(created), replayed: true }
|
||||
if (fault === 'replay') data.inquiry.bookingNo = 'BK-2'
|
||||
}
|
||||
} else if (method === 'POST' && path.endsWith('/BK-1/confirm')) {
|
||||
if (!confirmed) {
|
||||
confirmed = { inquiry: { ...created.inquiry, status: 'CONFIRMED', recordVersion: 1, agreedAmount: fault === 'price' ? 25 : 50,
|
||||
businessOrderNo: fault === 'new order' ? 'LB-2' : 'LB-1' }, offer }
|
||||
data = confirmed
|
||||
} else data = { ...confirmed, replayed: true }
|
||||
} else throw Error(`Unexpected ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
|
||||
} }
|
||||
return { context, args: { demand, tripProof, offerNo: 'OF-1', destinationKey: 'FBA:TEST', unitPrice: 25 }, calls, snapshots, writes: () => writes }
|
||||
}
|
||||
|
||||
test('pool binding preserves original demand and confirms only one commercial order', async () => {
|
||||
const f = fixture()
|
||||
const proof = await bindLooseboardDemand(f.context, f.args)
|
||||
assert.equal(proof.status, 'CONFIRMED_ORIGINAL_ORDER')
|
||||
assert.equal(f.args.demand.finalStatus, 'WAITING_INBOUND')
|
||||
assert.equal(f.writes(), 4)
|
||||
assert.equal(f.snapshots.at(-1).status, 'CONFIRMED_ORIGINAL_ORDER')
|
||||
await assert.rejects(bindLooseboardDemand(f.context, f.args), /本轮原单/)
|
||||
assert.equal(f.writes(), 4)
|
||||
})
|
||||
|
||||
for (const [fault, maxWrites] of [['customer', 0], ['capacity', 0], ['network', 1], ['replay', 2], ['new order', 3], ['price', 3], ['binding', 4], ['disk', 0]]) {
|
||||
test(`pool binding stops on ${fault} without another order or automatic retry`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(bindLooseboardDemand(f.context, f.args))
|
||||
assert.equal(f.args.demand.poolBinding.status, 'FAILED')
|
||||
assert.equal(f.writes(), maxWrites)
|
||||
assert.equal(f.calls.some(c => /cancel|\/iam\/|\/auth\/register/.test(c.path)), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('foreign batch or unregistered trip is rejected before HTTP', async () => {
|
||||
for (const alter of [f => { f.args.demand.marker = 'other' }, f => { f.context.createdResources.fleetTrips = [] }]) {
|
||||
const f = fixture(); alter(f)
|
||||
await assert.rejects(bindLooseboardDemand(f.context, f.args))
|
||||
assert.equal(f.calls.length, 0)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
const BASE = '/api/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-binding', deploymentBlocker: true }
|
||||
const version = value => Number.isSafeInteger(value) && value >= 0
|
||||
|
||||
/** 将本轮散板原单绑定本轮已发布车次余位,禁止无来源另建商业订单。 */
|
||||
export async function bindLooseboardDemand(context, { demand, tripProof, offerNo, destinationKey, unitPrice }) {
|
||||
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|
||||
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding
|
||||
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo
|
||||
|| tripProof.looseboardOffer?.marker !== context.marker || tripProof.looseboardOffer?.offerNo !== offerNo
|
||||
|| !destinationKey || !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
|
||||
throw Error('散板匹配缺少本轮原单、车次、发布证据、目的地、约定价格或检查点')
|
||||
}
|
||||
const proof = demand.poolBinding = { marker: context.marker, offerNo, tripNo: tripProof.tripNo, status: 'CHECKING', requests: [] }
|
||||
const check = (name, ok) => {
|
||||
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${demand.orderNo}`, OPTIONS)
|
||||
if (!ok) throw Error(name)
|
||||
}
|
||||
const request = async (method, path, body) => {
|
||||
const log = { method, path, status: 'SENDING' }; proof.requests.push(log)
|
||||
await context.checkpoint()
|
||||
const response = await context.request(method, path, body)
|
||||
Object.assign(log, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
const success = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
log.status = success ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED'
|
||||
await context.checkpoint()
|
||||
context.apiSuccess(MODULE, `散板原单接车 ${method}`, response, JSON.stringify(log), OPTIONS)
|
||||
if (!success) throw Error('散板接车请求失败,保留原单与请求证据,不自动重试')
|
||||
return response.body.data
|
||||
}
|
||||
const originalPath = `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`
|
||||
const validateInquiry = (row, status) => check('询价归属本轮原车与原客户', row?.tripNo === tripProof.tripNo && row.offerNo === offerNo
|
||||
&& String(row.customerId) === String(demand.customerId) && row.businessType === 'LOCAL_LOOSEBOARD'
|
||||
&& row.status === status && row.bookingNo && (!proof.bookingNo || row.bookingNo === proof.bookingNo)
|
||||
&& version(row.recordVersion) && row.cargo?.destinationKey === destinationKey)
|
||||
try {
|
||||
const original = await request('GET', originalPath)
|
||||
const summary = original?.summary
|
||||
check('散板原单仍可接车且无已有承运', summary?.orderNo === demand.orderNo && String(summary.customerId) === String(demand.customerId)
|
||||
&& summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && summary.version === demand.version
|
||||
&& !summary.fleetArrangement && original.remark?.startsWith(context.marker))
|
||||
const offer = (await request('GET', `${BASE}/${encodeURIComponent(offerNo)}`))?.offer
|
||||
const destination = offer?.destinations?.find(row => row.destinationKey === destinationKey)
|
||||
check('余位为本轮同仓开放车次且容量明确', offer?.offerNo === offerNo && offer.tripNo === tripProof.tripNo
|
||||
&& String(offer.warehouseId) === String(tripProof.warehouseId) && offer.status === 'OPEN'
|
||||
&& offer.canCreateInquiry && offer.canConfirm && offer.capacityKnown && destination
|
||||
&& version(offer.tripRecordVersion) && version(offer.recordVersion))
|
||||
const pallets = Number(summary.estimatedPalletCount), packages = Number(summary.packageCount)
|
||||
const weight = Number(summary.totalWeightKg), volume = Number(summary.totalVolumeCbm)
|
||||
check('接车货量沿用原散板单且不超过余位', Number.isSafeInteger(pallets) && pallets > 0 && Number.isSafeInteger(packages) && packages > 0
|
||||
&& Number.isFinite(weight) && weight > 0 && Number.isFinite(volume) && volume > 0
|
||||
&& destination.remaining?.palletSpaces >= pallets && destination.remaining?.weightKg >= weight && destination.remaining?.volumeCbm >= volume)
|
||||
const body = proof.createRequest = { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
|
||||
customerId: demand.customerId, businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
|
||||
sourceOrderNo: demand.orderNo, sourceOrderVersion: summary.version, expectedPalletCount: pallets,
|
||||
estimatedArrivalAt: new Date(Date.now() + 900000).toISOString(), idempotencyKey: `${context.marker}-looseboard-bind`,
|
||||
cargo: { destinationKey, cargoName: summary.productName, packageCount: packages, palletSpaces: pallets,
|
||||
weightKg: weight, volumeCbm: volume, priceBasis: 'PALLET', unitPrice, currency: summary.currency,
|
||||
contactName: summary.originContactName, contactPhone: summary.originContactPhone, remark: context.marker } }
|
||||
const created = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
|
||||
validateInquiry(created?.inquiry, 'DRAFT')
|
||||
proof.bookingNo = created.inquiry.bookingNo
|
||||
const replay = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
|
||||
check('原单询价重放保持唯一承运与版本', replay.replayed === true
|
||||
&& JSON.stringify(replay.inquiry) === JSON.stringify(created.inquiry) && JSON.stringify(replay.offer) === JSON.stringify(created.offer))
|
||||
const path = `${BASE}/inquiries/${encodeURIComponent(proof.bookingNo)}`
|
||||
const latest = await request('GET', path)
|
||||
validateInquiry(latest, 'DRAFT')
|
||||
check('确认承运使用完整即时版本', version(latest.tripRecordVersion) && version(latest.offerRecordVersion))
|
||||
const confirmBody = proof.confirmRequest = { tripRecordVersion: latest.tripRecordVersion, offerRecordVersion: latest.offerRecordVersion,
|
||||
recordVersion: latest.recordVersion, idempotencyKey: `${context.marker}-looseboard-confirm`, reason: context.marker }
|
||||
const confirmed = await request('POST', `${path}/confirm`, confirmBody)
|
||||
validateInquiry(confirmed?.inquiry, 'CONFIRMED')
|
||||
check('原单约定货量和运费没有漂移', confirmed.inquiry.cargo?.packageCount === packages
|
||||
&& confirmed.inquiry.cargo?.palletSpaces === pallets && confirmed.inquiry.cargo?.currency === summary.currency
|
||||
&& Number(confirmed.inquiry.cargo?.unitPrice) === unitPrice
|
||||
&& Math.round(Number(confirmed.inquiry.agreedAmount) * 100) === Math.round(unitPrice * pallets * 100))
|
||||
check('承运复用原散板单且尚未伪造来货或排车', confirmed.inquiry.businessOrderNo === demand.orderNo
|
||||
&& !confirmed.inquiry.incomingNo && !confirmed.inquiry.dispatchNo)
|
||||
const confirmReplay = await request('POST', `${path}/confirm`, confirmBody)
|
||||
check('承运确认重放未生成第二单或重复占位', confirmReplay.replayed === true
|
||||
&& JSON.stringify(confirmReplay.inquiry) === JSON.stringify(confirmed.inquiry) && JSON.stringify(confirmReplay.offer) === JSON.stringify(confirmed.offer))
|
||||
const result = await request('GET', originalPath)
|
||||
const arrangement = result?.summary?.fleetArrangement
|
||||
check('原散板单回写同一承运并进入待入仓', result?.summary?.orderNo === demand.orderNo
|
||||
&& String(result.summary.customerId) === String(demand.customerId) && result.summary.status === 'WAITING_INBOUND'
|
||||
&& arrangement?.businessOrderNo === demand.orderNo && arrangement.bookingNo === proof.bookingNo && arrangement.tripNo === tripProof.tripNo
|
||||
&& !arrangement.incomingNo && !arrangement.dispatchNo)
|
||||
Object.assign(demand, { finalStatus: 'WAITING_INBOUND', version: result.summary.version, retained: true })
|
||||
proof.status = 'CONFIRMED_ORIGINAL_ORDER'
|
||||
await context.checkpoint()
|
||||
return proof
|
||||
} catch (error) {
|
||||
for (const log of proof.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
|
||||
proof.status = 'FAILED'; proof.failure = error.message
|
||||
await context.checkpoint()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const tripProof = { tripNo: 'TR-1', dispatchNo: 'DP-1' }
|
||||
const pallet = { warehousePalletId: '72', palletNo: 'P-2', packages: 3 }
|
||||
const receipt = { status: 'RECEIVED_READY_FOR_DISPATCH', incomingNo: 'IN-1', pallets: [pallet],
|
||||
acceptedReceipt: { source: { receiptHash: 'hash', pallets: [{ ...pallet, weightKg: 80, volumeCbm: 1.8 }] } } }
|
||||
const demand = { marker: 'fixture', customerId: '101', receipt, poolBinding: {
|
||||
bookingNo: 'BK-1', tripNo: 'TR-1', createRequest: { cargo: { destinationKey: 'FBA:TEST' } } } }
|
||||
const oldRow = { dispatchItemId: '1', sourceKey: 'OLD', customerId: '101', warehousePalletId: '71', inboundCode: 'OLD', scheduledPackages: 2, pickedPackages: 0 }
|
||||
const newRow = { dispatchItemId: '2', sourceKey: 'NEW', customerId: '101', ...pallet, inboundCode: 'IN-1', scheduledPackages: 3, pickedPackages: 0 }
|
||||
const planPallet = { warehousePalletId: '71', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }
|
||||
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', incomingNo: 'IN-1', status: 'RECEIVED', recordVersion: 2, receipt: receipt.acceptedReceipt }
|
||||
let stage = 'LOADING', arranged = false, arrangeCalls = 0, returnCalls = 0
|
||||
const calls = []
|
||||
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
|
||||
assert(_m, name, _r, ok) { assert.ok(ok, name) }, async checkpoint() {},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage, recordVersion: 1,
|
||||
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
|
||||
else if (path.endsWith('/items')) {
|
||||
data = { dispatchNo: 'DP-1', items: arranged ? [oldRow, newRow] : [oldRow] }
|
||||
data = structuredClone(data)
|
||||
if (arranged && fault === 'lost original') data.items.shift()
|
||||
if (arranged && fault === 'wrong boxes') data.items[1].scheduledPackages = 4
|
||||
} else if (path.endsWith('/return-to-review')) {
|
||||
returnCalls += 1; stage = 'BEFORE_LOADING'
|
||||
data = { dispatchNo: 'DP-1', action: 'RETURN_TO_REVIEW', replayed: returnCalls > 1 }
|
||||
} else if (path.endsWith('/capacity-plan')) {
|
||||
data = { planValid: !arranged, recordVersion: 1, manifestHash: 'hash', capacityPlan: { pallets: [planPallet] }, cargo: [{ dispatchNo: 'DP-1' }] }
|
||||
if (method === 'POST') {
|
||||
assert.deepEqual(body.pallets[0], planPallet)
|
||||
assert.deepEqual(body.pallets[1], { warehousePalletId: '72', palletSpaces: 1, weightKg: 80, volumeCbm: 1.8, deliveryIndex: 1 })
|
||||
data.planValid = true
|
||||
}
|
||||
} else if (path.endsWith('/bookings')) data = { tripRecordVersion: 2, records: [booking] }
|
||||
else if (path.endsWith('/arrange-dispatch')) {
|
||||
arrangeCalls += 1
|
||||
assert.equal('dispatchNo' in body, false)
|
||||
if (fault === 'network') return { http: 0 }
|
||||
arranged = true
|
||||
data = { tripRecordVersion: 3, booking: { ...booking, status: 'BOUND', dispatchNo: fault === 'wrong main' ? 'OTHER' : 'DP-1' }, replayed: arrangeCalls > 1 }
|
||||
if (fault === 'replay' && arrangeCalls > 1) data.tripRecordVersion += 1
|
||||
} else if (path.endsWith('/trips/TR-1')) data = { trip: { tripNo: 'TR-1', stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] } }
|
||||
else throw Error(`Unexpected ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
|
||||
} }
|
||||
return { context, args: { demand, tripProof }, calls, counts: () => arrangeCalls }
|
||||
}
|
||||
|
||||
test('original looseboard receipt joins same main preserving existing rows and planning', async () => {
|
||||
const f = fixture()
|
||||
const result = await arrangeLooseboardPool(f.context, f.args)
|
||||
assert.equal(result.status, 'BOUND_AWAITING_REVIEW')
|
||||
assert.equal(f.counts(), 2)
|
||||
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
|
||||
assert.equal(f.counts(), 2)
|
||||
})
|
||||
|
||||
for (const fault of ['picked', 'network', 'wrong main', 'replay', 'lost original', 'wrong boxes']) {
|
||||
test(`join original main rejects ${fault} and preserves evidence`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
|
||||
assert.equal(f.args.demand.receipt.dispatch.status, 'FAILED')
|
||||
assert.equal(f.calls.some(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')), false)
|
||||
if (fault === 'network') {
|
||||
assert.equal(f.counts(), 1)
|
||||
assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { dispatchIo, replay, version } from './grouped-dispatch-acceptance.mjs'
|
||||
|
||||
const API = '/api/platform/v1/last-leg'
|
||||
const id = value => String(value ?? '')
|
||||
const rowFact = row => JSON.stringify([id(row.dispatchItemId), row.sourceKey, id(row.customerId), id(row.warehousePalletId),
|
||||
row.inboundCode, Number(row.scheduledPackages), Number(row.pickedPackages)])
|
||||
|
||||
/** 把原散板实收货加入本车唯一主单,并核对既有货物和新增实托守恒。 */
|
||||
export async function arrangeLooseboardPool(context, { demand, tripProof }) {
|
||||
const receipt = demand?.receipt, binding = demand?.poolBinding
|
||||
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|
||||
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|
||||
|| receipt?.status !== 'RECEIVED_READY_FOR_DISPATCH' || !receipt.incomingNo || receipt.dispatch
|
||||
|| !receipt.pallets?.length || typeof context.checkpoint !== 'function') throw Error('散板排入原车缺少本轮确认实收证据')
|
||||
const proof = receipt.dispatch = { marker: context.marker, status: 'ARRANGING', requests: [], dispatchNo: tripProof.dispatchNo }
|
||||
const io = dispatchIo(context, 'phase4-overseas-business', proof, `LOOSEBOARD-${binding.bookingNo}`)
|
||||
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const dispatchPath = `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
|
||||
const check = (name, ok) => io.check(name, null, Boolean(ok))
|
||||
try {
|
||||
let current = await io.command('GET', dispatchPath, undefined, '散板加货前原车详情')
|
||||
const before = await io.command('GET', `${dispatchPath}/items`, undefined, '冻结原车既有货物')
|
||||
check('原车仍未扫码且原货完整', current?.dispatchNo === tripProof.dispatchNo && current.status === 'ACTIVE'
|
||||
&& ['BEFORE_LOADING', 'LOADING'].includes(current.stage) && Number(current.counts?.pickedPackageCount) === 0
|
||||
&& Number(current.counts?.shippedPackageCount) === 0 && before?.dispatchNo === tripProof.dispatchNo
|
||||
&& Array.isArray(before.items) && before.items.length > 0
|
||||
&& before.items.every(row => row.inboundCode !== receipt.incomingNo && Number(row.pickedPackages) === 0))
|
||||
const originalRows = before.items.map(rowFact)
|
||||
const originalPlan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '冻结原车容量计划')
|
||||
check('本车加货前容量计划有效', originalPlan?.planValid === true && !originalPlan.blockReason && originalPlan.capacityPlan?.pallets?.length > 0)
|
||||
if (current.stage === 'LOADING') {
|
||||
const body = { recordVersion: version(current.recordVersion), idempotencyKey: io.key('return-review'), reason: '本轮零扫码明确退回,加入散板原单实际实收货物' }
|
||||
const result = await io.command('POST', `${dispatchPath}/return-to-review`, body, '原车零扫码退回审核')
|
||||
await replay(io, `${dispatchPath}/return-to-review`, body, result, '退回审核同键重放')
|
||||
current = await io.command('GET', dispatchPath, undefined, '回查原车可加货阶段')
|
||||
}
|
||||
check('原车已处装车前', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING')
|
||||
const list = await io.command('GET', `${tripPath}/bookings`, undefined, '读取原散板承运最新版本')
|
||||
const rows = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || [], booking = rows[0]
|
||||
check('仅把原承运原来货排入本车', rows.length === 1 && booking.tripNo === tripProof.tripNo
|
||||
&& id(booking.customerId) === id(demand.customerId) && booking.incomingNo === receipt.incomingNo
|
||||
&& booking.status === 'RECEIVED' && !booking.dispatchNo
|
||||
&& booking.receipt?.source?.receiptHash === receipt.acceptedReceipt?.source?.receiptHash)
|
||||
const body = proof.arrangeRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(booking.recordVersion), idempotencyKey: io.key('arrange') }
|
||||
const path = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}/arrange-dispatch`
|
||||
const arranged = await io.command('POST', path, body, '正式接口排入本车唯一主单')
|
||||
check('排入结果绑定同一主单和来源', arranged?.booking?.status === 'BOUND' && arranged.booking.bookingNo === binding.bookingNo
|
||||
&& arranged.booking.tripNo === tripProof.tripNo && arranged.booking.dispatchNo === tripProof.dispatchNo
|
||||
&& arranged.booking.incomingNo === receipt.incomingNo && id(arranged.booking.customerId) === id(demand.customerId))
|
||||
const again = await replay(io, path, body, arranged, '排入本车同键重放')
|
||||
check('排入重放明确返回原结果', again?.replayed === true)
|
||||
const after = await io.command('GET', `${dispatchPath}/items`, undefined, '加货后回查全部真实货物')
|
||||
const added = after?.items?.filter(row => row.inboundCode === receipt.incomingNo) || []
|
||||
check('加货不删除或重复既有货物', after?.dispatchNo === tripProof.dispatchNo && after.items?.length === before.items.length + receipt.pallets.length
|
||||
&& originalRows.every(fact => after.items.filter(row => rowFact(row) === fact).length === 1))
|
||||
check('新增行精确对应原散板实收各托箱数', added.length === receipt.pallets.length
|
||||
&& receipt.pallets.every(p => added.filter(row => id(row.warehousePalletId) === id(p.warehousePalletId)
|
||||
&& id(row.customerId) === id(demand.customerId) && row.palletNo === p.palletNo
|
||||
&& Number(row.scheduledPackages) === p.packages && Number(row.pickedPackages) === 0).length === 1))
|
||||
const plan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '新增实收后读取容量依据')
|
||||
const detail = await io.command('GET', tripPath, undefined, '读取原车真实卸货站')
|
||||
check('容量货物仅属于本车唯一主单', plan?.planValid === false && plan.cargo?.every(row => row.dispatchNo === tripProof.dispatchNo))
|
||||
const destinationKey = binding.createRequest.cargo.destinationKey
|
||||
const stopIndexes = detail?.trip?.stops?.map((stop, index) => stop.destinationKey === destinationKey ? index : -1).filter(index => index > 0) || []
|
||||
check('实收目的地对应唯一卸货站', detail?.trip?.tripNo === tripProof.tripNo && stopIndexes.length === 1)
|
||||
const pallets = structuredClone(originalPlan.capacityPlan.pallets)
|
||||
for (const p of receipt.pallets) {
|
||||
const measured = receipt.acceptedReceipt?.source?.pallets?.find(row => id(row.warehousePalletId) === id(p.warehousePalletId))
|
||||
check('新增容量仅用实际实托及实测', measured && Number(measured.weightKg) > 0 && Number(measured.volumeCbm) > 0
|
||||
&& !pallets.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)))
|
||||
pallets.push({ warehousePalletId: p.warehousePalletId, palletSpaces: 1,
|
||||
weightKg: Number(measured.weightKg), volumeCbm: Number(measured.volumeCbm), deliveryIndex: stopIndexes[0] })
|
||||
}
|
||||
const refreshed = await io.command('POST', `${tripPath}/capacity-plan`, { recordVersion: version(plan.recordVersion), manifestHash: plan.manifestHash,
|
||||
pallets, reason: '保留原货预计占用,追加散板实际实收容量,仍须独立完成装车确认' }, '重核原车加货后的容量计划')
|
||||
check('原车加货后容量有效', refreshed?.planValid === true && !refreshed.blockReason)
|
||||
proof.status = 'BOUND_AWAITING_REVIEW'
|
||||
proof.pallets = receipt.pallets
|
||||
proof.addedItems = added
|
||||
await context.checkpoint()
|
||||
return proof
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; proof.failure = error.message
|
||||
await context.checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
|
||||
|
||||
// 模拟原车资源及容量版本;记录所有写入以验证不会创建主档或自动补发。
|
||||
function fixture(fault = '', validPlan = false) {
|
||||
const tripProof = { marker: 'fixture', preparation: 'VERIFIED', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12' }
|
||||
const demand = { marker: 'fixture', finalStatus: 'PENDING_ACCEPTANCE' }
|
||||
const trip = { ...tripProof, status: 'PLANNED', recordVersion: 1,
|
||||
stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] }
|
||||
const plan = { planValid: validPlan, recordVersion: 1, manifestHash: 'a'.repeat(64), availableSegments: [{}],
|
||||
cargo: [{ dispatchNo: 'DP-1', warehousePalletId: '71', palletNo: 'P-1', destinationKey: 'FBA:TEST' }] }
|
||||
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 0, tripRecordVersion: 1 }
|
||||
const calls = [], checkpoints = []; let creates = 0, publishes = 0, bindCalls = 0
|
||||
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
|
||||
assert(_m, name, _r, passed) { assert.ok(passed, name) }, apiSuccess() {},
|
||||
async checkpoint() { if (fault === 'disk') throw Error('disk'); checkpoints.push(structuredClone(tripProof.looseboardOffer)) },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage: 'LOADING',
|
||||
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
|
||||
else if (path.endsWith('/trips/TR-1')) data = { trip: { ...trip, driverId: fault === 'driver' ? '13' : '12' }, dispatches: [{ dispatchNo: 'DP-1' }] }
|
||||
else if (path.endsWith('/capacity-plan')) {
|
||||
if (method === 'POST') {
|
||||
assert.equal(body.pallets[0].warehousePalletId, '71')
|
||||
assert.equal(body.pallets[0].deliveryIndex, 1)
|
||||
plan.planValid = true
|
||||
}
|
||||
data = structuredClone(plan)
|
||||
if (fault === 'foreign cargo') data.cargo[0].dispatchNo = 'OTHER'
|
||||
} else if (path.endsWith('/loading')) data = { confirmedLoad: fault === 'loaded' ? {} : null }
|
||||
else if (path.endsWith('/capacity-offers') && method === 'POST') {
|
||||
creates += 1
|
||||
if (fault === 'network') return { http: 0 }
|
||||
data = { offer: { ...offer }, replayed: creates > 1 }
|
||||
if (fault === 'draft replay' && creates > 1) data.offer.offerNo = 'OF-2'
|
||||
} else if (path.endsWith('/OF-1/publish')) {
|
||||
publishes += 1
|
||||
data = { offer: { ...offer, status: 'OPEN', capacityKnown: true, canCreateInquiry: true, canConfirm: true }, replayed: publishes > 1 }
|
||||
if (fault === 'publish replay' && publishes > 1) data.offer.recordVersion = 9
|
||||
} else throw Error(`Unexpected ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
|
||||
} }
|
||||
const bind = async (ctx, args) => {
|
||||
bindCalls += 1
|
||||
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
|
||||
assert.equal(args.offerNo, 'OF-1'); assert.equal(args.destinationKey, 'FBA:TEST')
|
||||
assert.equal(tripProof.looseboardOffer.status, 'OPEN')
|
||||
return { status: 'CONFIRMED_ORIGINAL_ORDER', tripNo: 'TR-1', offerNo: 'OF-1' }
|
||||
}
|
||||
return { context, args: { demand, tripProof, destinationKey: 'FBA:TEST', unitPrice: 25 }, bind, calls, checkpoints, counts: () => ({ creates, publishes, bindCalls }) }
|
||||
}
|
||||
|
||||
for (const valid of [false, true]) test(`prepare owned pool and pass original demand to binding; existing plan=${valid}`, async () => {
|
||||
const f = fixture('', valid)
|
||||
await prepareLooseboardPool(f.context, f.args, f.bind)
|
||||
assert.deepEqual(f.counts(), { creates: 2, publishes: 2, bindCalls: 1 })
|
||||
assert.equal(f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')).length, valid ? 0 : 1)
|
||||
assert.equal(f.args.tripProof.looseboardOffer.status, 'BOUND_ORIGINAL_DEMAND')
|
||||
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
|
||||
assert.equal(f.calls.some(c => /\/vehicles|\/drivers|\/iam/.test(c.path)), false)
|
||||
})
|
||||
|
||||
for (const fault of ['picked', 'driver', 'foreign cargo', 'loaded', 'network', 'draft replay', 'publish replay', 'disk']) {
|
||||
test(`pool preparation fails closed on ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
|
||||
assert.equal(f.args.tripProof.looseboardOffer.status, 'FAILED')
|
||||
assert.equal(f.counts().bindCalls, 0)
|
||||
if (fault === 'network') assert.equal(f.counts().creates, 1)
|
||||
if (fault === 'disk') assert.equal(f.calls.length, 0)
|
||||
assert.equal(f.calls.some(c => /cancel|close|delete/.test(c.path)), false)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs'
|
||||
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
|
||||
|
||||
const API = '/api/platform/v1/last-leg'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-preparation', deploymentBlocker: true }
|
||||
const validVersion = value => Number.isSafeInteger(value) && value >= 0
|
||||
|
||||
/** 复用本轮已审核且未扫码车次,发布可核验余位并衔接散板原单。 */
|
||||
export async function prepareLooseboardPool(context, { demand, tripProof, destinationKey, unitPrice }, bind = bindLooseboardDemand) {
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof)
|
||||
|| tripProof.preparation !== 'VERIFIED' || !tripProof.tripNo || !tripProof.dispatchNo || tripProof.looseboardOffer
|
||||
|| context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|
||||
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding || !destinationKey
|
||||
|| !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
|
||||
throw Error('散板余位准备缺少本轮原单、已审核未占用车次、约定目的地/价格或检查点')
|
||||
}
|
||||
const proof = tripProof.looseboardOffer = { marker: context.marker, status: 'PREPARING', requests: [] }
|
||||
const check = (name, condition) => {
|
||||
context.assert(MODULE, name, null, Boolean(condition), `tripNo=${tripProof.tripNo}`, OPTIONS)
|
||||
if (!condition) throw Error(name)
|
||||
}
|
||||
const request = async (method, path, body) => {
|
||||
const log = { method, path, ...(body ? { body: structuredClone(body) } : {}), status: 'PREPARED' }
|
||||
proof.requests.push(log)
|
||||
await context.checkpoint()
|
||||
let response
|
||||
try { log.status = 'SENDING'; response = await context.request(method, path, body) }
|
||||
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
|
||||
Object.assign(log, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
const ok = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED'
|
||||
await context.checkpoint()
|
||||
context.apiSuccess(MODULE, `散板余位准备 ${method}`, response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
|
||||
if (!ok) throw Error('散板余位准备失败;不得换键重造或自动重试')
|
||||
return response.body.data
|
||||
}
|
||||
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const pool = `${API}/fleet/capacity-offers`
|
||||
try {
|
||||
const dispatch = await request('GET', `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`)
|
||||
check('仅在原排车未扫码未发运时接入散板', dispatch?.dispatchNo === tripProof.dispatchNo && dispatch.status === 'ACTIVE'
|
||||
&& ['BEFORE_LOADING', 'LOADING'].includes(dispatch.stage)
|
||||
&& Number(dispatch.counts?.pickedPackageCount) === 0 && Number(dispatch.counts?.shippedPackageCount) === 0)
|
||||
let detail = await request('GET', tripPath)
|
||||
check('散板接车复用本轮同仓同车同司机', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'PLANNED'
|
||||
&& String(detail.trip.warehouseId) === String(tripProof.warehouseId)
|
||||
&& String(detail.trip.vehicleId) === String(tripProof.vehicleId) && String(detail.trip.driverId) === String(tripProof.driverId)
|
||||
&& detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
|
||||
&& detail.trip.stops?.filter(stop => stop.destinationKey === destinationKey).length === 1)
|
||||
let plan = await request('GET', `${tripPath}/capacity-plan`)
|
||||
check('容量依据来自本轮原车真实货物', Array.isArray(plan?.cargo) && plan.cargo.length > 0
|
||||
&& plan.cargo.every(row => row.dispatchNo === tripProof.dispatchNo) && validVersion(plan.recordVersion)
|
||||
&& typeof plan.manifestHash === 'string' && /^[0-9a-f]{64}$/.test(plan.manifestHash))
|
||||
if (plan.planValid !== true) {
|
||||
const pallets = testPlannedCapacity(plan.cargo, detail.trip.stops)
|
||||
proof.planningBasis = { actual: false, kind: 'CONTROLLED_TEST_PLAN', pallets }
|
||||
plan = await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
|
||||
pallets, reason: '仅本轮真实实托的受控预计占用;不得作为装车实测或回执' })
|
||||
}
|
||||
check('车次容量计划有效且无阻断', plan?.planValid === true && !plan.blockReason && plan.availableSegments?.length > 0)
|
||||
const loading = await request('GET', `${tripPath}/loading`)
|
||||
check('预计容量未冒充实载确认', loading?.confirmedLoad == null)
|
||||
detail = await request('GET', tripPath)
|
||||
check('发布前车次仍未发车且版本有效', detail?.trip?.status === 'PLANNED' && validVersion(detail.trip.recordVersion))
|
||||
const draftBody = { tripNo: tripProof.tripNo, tripRecordVersion: detail.trip.recordVersion,
|
||||
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000',
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-looseboard-offer` }
|
||||
const draft = await request('POST', pool, draftBody)
|
||||
check('余位草稿属于原车且版本完整', draft?.offer?.tripNo === tripProof.tripNo && draft.offer.status === 'DRAFT'
|
||||
&& typeof draft.offer.offerNo === 'string' && draft.offer.offerNo.length > 0
|
||||
&& validVersion(draft.offer.tripRecordVersion) && validVersion(draft.offer.recordVersion))
|
||||
proof.offerNo = draft.offer.offerNo
|
||||
const replay = await request('POST', pool, draftBody)
|
||||
check('余位发布前草稿重放不生成第二份', replay?.replayed === true && JSON.stringify(replay.offer) === JSON.stringify(draft.offer))
|
||||
const publishBody = { tripRecordVersion: draft.offer.tripRecordVersion, offerRecordVersion: draft.offer.recordVersion,
|
||||
idempotencyKey: `${context.marker}-looseboard-publish` }
|
||||
const publishPath = `${pool}/${encodeURIComponent(proof.offerNo)}/publish`
|
||||
const published = await request('POST', publishPath, publishBody)
|
||||
check('原车余位已开放且可登记承运', published?.offer?.offerNo === proof.offerNo && published.offer.tripNo === tripProof.tripNo
|
||||
&& published.offer.status === 'OPEN' && published.offer.capacityKnown && published.offer.canCreateInquiry && published.offer.canConfirm)
|
||||
const publishedAgain = await request('POST', publishPath, publishBody)
|
||||
check('余位发布重放不重复推进版本', publishedAgain?.replayed === true && JSON.stringify(publishedAgain.offer) === JSON.stringify(published.offer))
|
||||
proof.status = 'OPEN'
|
||||
await context.checkpoint()
|
||||
const binding = await bind(context, { demand, tripProof, offerNo: proof.offerNo, destinationKey, unitPrice })
|
||||
check('散板原单绑定已确认且指向原车余位', binding?.status === 'CONFIRMED_ORIGINAL_ORDER'
|
||||
&& binding.tripNo === tripProof.tripNo && binding.offerNo === proof.offerNo)
|
||||
proof.status = 'BOUND_ORIGINAL_DEMAND'
|
||||
await context.checkpoint()
|
||||
return binding
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; proof.failure = error.message
|
||||
await context.checkpoint()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const cargo = { destinationKey: 'FBA:TEST', packageCount: 3, unitPrice: 25 }
|
||||
const tripProof = { tripNo: 'TR-1', warehouseId: '42' }
|
||||
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', poolBinding: {
|
||||
tripNo: 'TR-1', bookingNo: 'BK-1', status: 'CONFIRMED_ORIGINAL_ORDER', createRequest: { cargo, expectedPalletCount: 2 } } }
|
||||
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', status: 'CONFIRMED', recordVersion: 1, cargo }
|
||||
const pallets = [], calls = [], snapshots = []; let incomingWrites = 0, receiptWrites = 0, palletized = false
|
||||
const source = () => ({ incomingNo: 'IN-1', warehouseId: fault === 'warehouse' ? '99' : '42', destinationKey: cargo.destinationKey,
|
||||
status: palletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING', receiptHash: 'hash',
|
||||
receivedPackages: fault === 'quantity' ? 4 : 3,
|
||||
pallets: palletized ? pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletNo: p.palletNo, packages: p.cartonCount })) : [] })
|
||||
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
|
||||
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
|
||||
async checkpoint() { if (fault === 'disk') throw Error('disk'); snapshots.push(structuredClone(demand.receipt)) },
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
let data
|
||||
if (path.endsWith('/bookings')) data = { tripRecordVersion: 1, records: [booking] }
|
||||
else if (path.endsWith('/incoming') && method === 'POST') {
|
||||
incomingWrites += 1
|
||||
if (fault === 'network') return { http: 0 }
|
||||
data = { tripRecordVersion: 2, booking: { ...booking, incomingNo: 'IN-1' }, replayed: incomingWrites > 1 }
|
||||
if (fault === 'replay' && incomingWrites > 1) data.booking.incomingNo = 'IN-2'
|
||||
} else if (path.endsWith('/fulfillment')) data = { preview: { tripRecordVersion: 2, recordVersion: 2, incoming: source() } }
|
||||
else if (path.endsWith('/pallets/drafts')) {
|
||||
assert.equal(token, 'pda'); assert.equal(body.cartonCount, pallets.length ? 1 : 2)
|
||||
pallets.push({ id: String(pallets.length + 1), cartonCount: body.cartonCount, status: 'DRAFT' })
|
||||
data = { pallets }
|
||||
} else if (path.endsWith('/generate')) {
|
||||
const pallet = pallets.find(p => path.includes(`/pallets/${p.id}/`))
|
||||
Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)) })
|
||||
data = { pallets }
|
||||
} else if (path.endsWith('/bind')) data = { pallets }
|
||||
else if (path.endsWith('/palletize')) {
|
||||
assert.deepEqual(body.photoFileIds, ['9'])
|
||||
palletized = true; pallets.forEach(p => { p.status = 'COMPLETED' })
|
||||
data = { status: 'WAIT_CUSTOMER_CONFIRMATION', pallets }
|
||||
} else if (path.endsWith('/receipt-confirmation')) {
|
||||
receiptWrites += 1
|
||||
assert.deepEqual(body.pallets, [{ warehousePalletId: '71', palletSpaces: 1 }, { warehousePalletId: '72', palletSpaces: 1 }])
|
||||
assert.equal(body.unitPrice, 25)
|
||||
data = { tripRecordVersion: 3, booking: { ...booking, status: 'RECEIVED', incomingNo: 'IN-1',
|
||||
receipt: { source: { ...source(), status: 'READY_FOR_DISPATCH' } } }, replayed: receiptWrites > 1 }
|
||||
} else throw Error(`Unexpected ${method} ${path}`)
|
||||
if (path.includes('/warehouse-transfers/')) data.transferNo = fault === 'wrong incoming' ? 'OTHER' : 'IN-1'
|
||||
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
|
||||
},
|
||||
async requestMultipart(method, path, file, token) {
|
||||
assert.equal(token, 'pda'); assert.equal(file.type, 'image/png'); assert.ok(file.content.length > 0)
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: { fileId: fault === 'file' ? null : '9' } } }
|
||||
} }
|
||||
return { context, calls, snapshots, counts: () => ({ incomingWrites, receiptWrites }),
|
||||
args: { demand, tripProof, pdaToken: 'pda', measurements: [2, 1].map(cartonCount => ({ cartonCount, lengthCm: 100, widthCm: 100,
|
||||
heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40 })) } }
|
||||
}
|
||||
|
||||
test('original looseboard booking receives two pallets three boxes and confirms once with replay', async () => {
|
||||
const f = fixture()
|
||||
const result = await receiveLooseboardPool(f.context, f.args)
|
||||
assert.equal(result.status, 'RECEIVED_READY_FOR_DISPATCH')
|
||||
assert.deepEqual(result.pallets.map(p => p.packages), [2, 1])
|
||||
assert.deepEqual(f.counts(), { incomingWrites: 2, receiptWrites: 2 })
|
||||
assert.equal(JSON.stringify(f.snapshots).includes('pdaToken'), false)
|
||||
await assert.rejects(receiveLooseboardPool(f.context, f.args))
|
||||
})
|
||||
|
||||
for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming']) {
|
||||
test(`receipt stops on ${fault} before operational confirmation`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(receiveLooseboardPool(f.context, f.args))
|
||||
assert.equal(f.args.demand.receipt.status, 'FAILED')
|
||||
assert.equal(f.counts().receiptWrites, 0)
|
||||
if (fault === 'network') assert.equal(f.counts().incomingWrites, 1)
|
||||
if (fault === 'disk') assert.equal(f.calls.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('wrong batch and inconsistent measured totals are blocked before HTTP', async () => {
|
||||
for (const change of [f => { f.args.demand.marker = 'other' }, f => { f.args.measurements[0].cartonCount = 3 }]) {
|
||||
const f = fixture(); change(f)
|
||||
await assert.rejects(receiveLooseboardPool(f.context, f.args))
|
||||
assert.equal(f.calls.length, 0)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
const API = '/api/platform/v1/last-leg'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true }
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
const validVersion = value => Number.isSafeInteger(value) && value >= 0
|
||||
const id = value => String(value ?? '')
|
||||
|
||||
/** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */
|
||||
export async function receiveLooseboardPool(context, { demand, tripProof, pdaToken, measurements }) {
|
||||
const binding = demand?.poolBinding, cargo = binding?.createRequest?.cargo
|
||||
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|
||||
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|
||||
|| binding?.status !== 'CONFIRMED_ORIGINAL_ORDER' || demand.receipt || !pdaToken
|
||||
|| typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length
|
||||
|| measurements.length !== binding.createRequest.expectedPalletCount
|
||||
|| measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0
|
||||
|| ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0))
|
||||
|| measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) {
|
||||
throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据')
|
||||
}
|
||||
const proof = demand.receipt = { marker: context.marker, status: 'RECEIVING', bookingNo: binding.bookingNo, requests: [], pallets: [] }
|
||||
const check = (name, condition) => {
|
||||
context.assert('phase4-overseas-business', name, null, Boolean(condition), `orderNo=${demand.orderNo}`, OPTIONS)
|
||||
if (!condition) throw Error(name)
|
||||
}
|
||||
const io = async (method, path, body, token, multipart = false) => {
|
||||
const log = { method, path, status: 'PREPARED', ...(body && !multipart ? { body: structuredClone(body) } : {}) }
|
||||
proof.requests.push(log); await context.checkpoint()
|
||||
let response
|
||||
try {
|
||||
log.status = 'SENDING'
|
||||
response = multipart ? await context.requestMultipart(method, path, body, token) : await context.request(method, path, body, token)
|
||||
} catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
|
||||
const ok = response?.http === 200 && id(response?.body?.code) === '200'
|
||||
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
|
||||
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await context.checkpoint()
|
||||
context.apiSuccess('phase4-overseas-business', '散板原承运实收', response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
|
||||
if (!ok) throw Error('散板实收请求失败;未知结果不自动重发')
|
||||
return response.body.data
|
||||
}
|
||||
const key = action => `${context.marker}-looseboard-${action}`
|
||||
const pool = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}`
|
||||
const trip = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const bookingMatches = row => row?.bookingNo === binding.bookingNo && row.tripNo === tripProof.tripNo && id(row.customerId) === id(demand.customerId)
|
||||
try {
|
||||
const list = await io('GET', `${trip}/bookings`)
|
||||
const matches = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || []
|
||||
const booking = matches[0]
|
||||
check('来货前保持原车原客户唯一承运且未登记来货', matches.length === 1 && bookingMatches(booking)
|
||||
&& booking.status === 'CONFIRMED' && !booking.incomingNo && !booking.dispatchNo
|
||||
&& validVersion(list.tripRecordVersion) && validVersion(booking.recordVersion)
|
||||
&& JSON.stringify(booking.cargo) === JSON.stringify(cargo))
|
||||
const body = { tripRecordVersion: list.tripRecordVersion, recordVersion: booking.recordVersion,
|
||||
estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(), deliveryInstructions: `${context.marker} TEST自送`, idempotencyKey: key('incoming') }
|
||||
const created = await io('POST', `${pool}/incoming`, body)
|
||||
check('登记来货保留原承运身份', bookingMatches(created?.booking) && created.booking.status === 'CONFIRMED'
|
||||
&& typeof created.booking.incomingNo === 'string' && created.booking.incomingNo.length > 0 && !created.booking.receipt)
|
||||
proof.incomingNo = created.booking.incomingNo
|
||||
const replay = await io('POST', `${pool}/incoming`, body)
|
||||
check('来货重放没有产生第二单', replay?.replayed === true && JSON.stringify(replay.booking) === JSON.stringify(created.booking)
|
||||
&& replay.tripRecordVersion === created.tripRecordVersion)
|
||||
const initial = (await io('GET', `${pool}/fulfillment`))?.preview?.incoming
|
||||
check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId)
|
||||
&& initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0)
|
||||
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
|
||||
for (const [index, measurement] of measurements.entries()) {
|
||||
const draft = await io('POST', `${pda}/pallets/drafts`, { ...measurement, idempotencyKey: key(`measure-${index}`) }, pdaToken)
|
||||
const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || []
|
||||
check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1
|
||||
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === measurement.cartonCount
|
||||
&& /^[1-9]\d*$/.test(id(newPallets[0].id)))
|
||||
const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}`
|
||||
const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken)
|
||||
const pallet = generated?.pallets?.find(row => id(row.id) === id(palletId))
|
||||
check('本托生成真实唯一托盘资源', generated?.transferNo === proof.incomingNo && pallet?.palletNo && /^[1-9]\d*$/.test(id(pallet.warehousePalletId))
|
||||
&& !proof.pallets.some(row => row.palletNo === pallet.palletNo || id(row.warehousePalletId) === id(pallet.warehousePalletId)))
|
||||
await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken)
|
||||
proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount })
|
||||
}
|
||||
const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true)
|
||||
check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId)))
|
||||
const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken)
|
||||
check('本次PDA完成全部原实托', received?.transferNo === proof.incomingNo && received?.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets?.length === proof.pallets.length
|
||||
&& proof.pallets.every(p => received.pallets.some(row => id(row.id) === id(p.id) && row.palletNo === p.palletNo
|
||||
&& id(row.warehousePalletId) === p.warehousePalletId && row.cartonCount === p.packages && row.status === 'COMPLETED')))
|
||||
const preview = (await io('GET', `${pool}/fulfillment`))?.preview, source = preview?.incoming
|
||||
check('运营确认依据为同仓同目的地同箱数的实际实收', source?.incomingNo === proof.incomingNo && id(source.warehouseId) === id(tripProof.warehouseId)
|
||||
&& source.destinationKey === cargo.destinationKey && source.status === 'WAIT_CUSTOMER_CONFIRMATION'
|
||||
&& source.receiptHash && source.receivedPackages === cargo.packageCount && source.pallets?.length === proof.pallets.length
|
||||
&& proof.pallets.every(p => source.pallets.some(row => id(row.warehousePalletId) === p.warehousePalletId && row.packages === p.packages))
|
||||
&& validVersion(preview.tripRecordVersion) && validVersion(preview.recordVersion))
|
||||
const receiptBody = { tripRecordVersion: preview.tripRecordVersion, recordVersion: preview.recordVersion, receiptHash: source.receiptHash,
|
||||
pallets: proof.pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletSpaces: 1 })), unitPrice: cargo.unitPrice, idempotencyKey: key('receipt') }
|
||||
const accepted = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
|
||||
check('运营确认回写原承运并达到可排车', bookingMatches(accepted?.booking) && accepted.booking.status === 'RECEIVED'
|
||||
&& accepted.booking.incomingNo === proof.incomingNo && accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH')
|
||||
const again = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
|
||||
check('实收确认重放保持原结果和版本', again?.replayed === true && JSON.stringify(again.booking) === JSON.stringify(accepted.booking)
|
||||
&& again.tripRecordVersion === accepted.tripRecordVersion)
|
||||
proof.status = 'RECEIVED_READY_FOR_DISPATCH'
|
||||
proof.acceptedReceipt = accepted.booking.receipt
|
||||
await context.checkpoint()
|
||||
return proof
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; proof.failure = error.message
|
||||
await context.checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,9 @@ test('focused matrix execution injects real container lifecycle prerequisites an
|
||||
'phase4-tail-order-controls',
|
||||
'phase4-overseas-pda',
|
||||
'phase4-overseas-pda-operations',
|
||||
'phase4-overseas-client-services',
|
||||
'phase4-overseas-dispatch',
|
||||
'phase4-overseas-business',
|
||||
'phase5-overseas-fulfillment',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
@@ -85,7 +87,9 @@ test('focused splitting and manual groups resolve producers before consumers wit
|
||||
'phase4-tail-order-controls',
|
||||
'phase4-overseas-pda',
|
||||
'phase4-overseas-pda-operations',
|
||||
'phase4-overseas-client-services',
|
||||
'phase4-overseas-dispatch',
|
||||
'phase4-overseas-business',
|
||||
'phase5-overseas-fulfillment',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
|
||||
@@ -74,12 +74,12 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
|
||||
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
|
||||
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
|
||||
let calls = 0
|
||||
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing',
|
||||
'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) {
|
||||
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
|
||||
'phase4-overseas-master-data']) {
|
||||
await runner.executeModule(name, async () => { calls++ })
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(runner.modules.length, 9)
|
||||
assert.equal(runner.modules.length, 7)
|
||||
assert.ok(runner.modules.every(module => module.skipped && !module.success))
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ test('review helper refuses stale fixture before any request', async () => {
|
||||
test('transfer review integration covers both source types and known blank-reason failure', async () => {
|
||||
const source = await read('warehouse-transfer-review.mjs')
|
||||
const caller = await read('phase4-overseas-client-services.mjs')
|
||||
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
|
||||
assert.match(caller, /if \(!await verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)\) return false/)
|
||||
assert.ok(caller.indexOf('await allocateWarehouseTransfer(') > caller.indexOf('if (!await verifyWarehouseTransferReview('))
|
||||
assert.ok(caller.indexOf('await receiveWarehouseTransfer(') > caller.indexOf('await outboundWarehouseTransfer('))
|
||||
for (const value of ['WAIT_PDA_RECEIVING', 'INBOUND_MEASURING', 'CANCELLED',
|
||||
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
|
||||
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
|
||||
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
|
||||
|
||||
test('fixed partner warehouse is reused and never retired', async () => {
|
||||
const calls = []
|
||||
@@ -21,6 +23,149 @@ test('fixed partner warehouse is reused and never retired', async () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
|
||||
|
||||
test('卡车请求先持久化,正常与失败响应均留有证据', async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (context.createdResources.phase4OverseasBusiness?.truck?.fulfillment) {
|
||||
const last = context.savedTruckEvidence.at(-1)?.requests.at(-1)
|
||||
if (path.includes('/us-truck-orders/TK1')) {
|
||||
assert.equal(last?.status, 'PREPARED')
|
||||
assert.equal(last?.method, method)
|
||||
}
|
||||
}
|
||||
return original(method, path, ...args)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(context.savedTruckEvidence.at(-1).status, 'HTTP_VERIFIED')
|
||||
assert.ok(context.savedTruckEvidence.some(e => e.requests.some(r => r.expectedRejection && r.status === 'REJECTED')))
|
||||
})
|
||||
|
||||
test('卡车检查点写入失败不发送请求,不能误记未知网络结果', async () => {
|
||||
const proof = { marker: 'BATCH', orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
|
||||
let calls = 0
|
||||
const context = { marker: 'BATCH', createdResources: { phase4OverseasBusiness: { truck: proof } },
|
||||
request: async () => { calls++; throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } }
|
||||
await assert.rejects(verifyUsTruckFulfillment(context, proof), /disk full/)
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(proof.fulfillment.requests[0].status, 'PREPARED')
|
||||
assert.equal(proof.fulfillment.status, 'FAILED')
|
||||
})
|
||||
|
||||
test('卡车默认落盘禁止新进程用同批次覆盖失败证据', async () => {
|
||||
const marker = `TRUCK-OFFLINE-${randomUUID()}`
|
||||
const file = new URL(`../../reports/us-truck-checkpoints/${marker}.json`, import.meta.url)
|
||||
let calls = 0
|
||||
const make = () => {
|
||||
const proof = { marker, orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
|
||||
return { proof, context: { marker, createdResources: { phase4OverseasBusiness: { truck: proof } },
|
||||
request: async () => { calls++; throw Error('connection lost') } } }
|
||||
}
|
||||
try {
|
||||
const first = make()
|
||||
await assert.rejects(verifyUsTruckFulfillment(first.context, first.proof), /connection lost/)
|
||||
const saved = fs.readFileSync(file, 'utf8')
|
||||
assert.equal(JSON.parse(saved).fulfillment.requests[0].status, 'UNKNOWN_RESULT')
|
||||
const second = make()
|
||||
await assert.rejects(verifyUsTruckFulfillment(second.context, second.proof), /EEXIST/)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(fs.readFileSync(file, 'utf8'), saved)
|
||||
} finally {
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file)
|
||||
}
|
||||
})
|
||||
|
||||
for (const action of ['receive-pallets', 'allocate-pallets']) {
|
||||
for (const fault of ['accepted', 'unknown', 'wrong rejection', 'drift']) {
|
||||
test(`卡车 ${action} 无效命令拒绝校验: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let rejected = false
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
if (method === 'POST' && path.includes(`/${action}?`) && body?.idempotencyKey?.endsWith('-invalid')) {
|
||||
rejected = true
|
||||
if (fault === 'accepted') return response({})
|
||||
if (fault === 'unknown') return { http: 0 }
|
||||
if (fault === 'wrong rejection') return response(null, 'A0403')
|
||||
}
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (fault === 'drift' && rejected && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
|
||||
result.body.data.summary.version++
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.method === 'POST' && c.path.includes(`/${action}?`) && !c.body.idempotencyKey.endsWith('-invalid')), false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay drift']) {
|
||||
test(`truck fulfillment stops and retains original order on ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let receipts = 0
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (method === 'POST' && path.includes('/receive-pallets?') && !args[0]?.idempotencyKey?.endsWith('-invalid')) {
|
||||
receipts += 1
|
||||
if (fault === 'network') return { http: 0 }
|
||||
const result = structuredClone(await original(method, path, ...args))
|
||||
if (fault === 'wrong customer') result.body.data.summary.customerId = 'other'
|
||||
if (fault === 'wrong pallet count') result.body.data.pallets.push({ ...result.body.data.pallets[0] })
|
||||
if (fault === 'replay drift' && receipts === 2) result.body.data.summary.version += 1
|
||||
return result
|
||||
}
|
||||
return original(method, path, ...args)
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
const proof = context.createdResources.phase4OverseasBusiness.truck
|
||||
assert.equal(proof.fulfillment.status, 'FAILED')
|
||||
assert.equal(proof.retained, true)
|
||||
assert.equal(receipts, fault === 'replay drift' ? 2 : 1)
|
||||
assert.equal(context.calls.some(c => c.path.includes('/allocate-pallets?') || c.path.includes('/TK1/status?')), false)
|
||||
await assert.rejects(verifyUsTruckFulfillment(context, proof), /本轮未执行/)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['accepted', 'wrong code', 'missing trace', 'detail drift']) {
|
||||
test(`卡车部分分配待派车拒绝检查失败即停止: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let rejected = false
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (body?.idempotencyKey?.endsWith('prepare-dispatch-partial')) {
|
||||
rejected = true
|
||||
if (fault === 'accepted') result.body.code = 200
|
||||
if (fault === 'wrong code') result.body.code = 'A0400'
|
||||
if (fault === 'missing trace') delete result.traceId
|
||||
} else if (rejected && fault === 'detail drift' && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
|
||||
result.body.data.summary.version++
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['premature complete', 'unselected allocated', 'selected unchanged']) {
|
||||
test(`卡车部分分配拒绝错误结果: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (method === 'POST' && path.includes('/allocate-pallets?') && body?.idempotencyKey?.endsWith('-0')) {
|
||||
if (fault === 'premature complete') result.body.data.summary.status = 'ALLOCATED'
|
||||
if (fault === 'unselected allocated') result.body.data.pallets[1].status = 'ALLOCATED'
|
||||
if (fault === 'selected unchanged') result.body.data.pallets[0].status = 'RECEIVED'
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.path.includes('/prepare-dispatch?')), false)
|
||||
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
|
||||
const context = fixture(), request = context.request.bind(context), pages = []
|
||||
context.options = { fixedCustomerId: '10001' }
|
||||
@@ -63,11 +208,16 @@ function fixture(options = {}) {
|
||||
}
|
||||
function purchaseCustomer() { return { order: state.purchase, fees: [], progress: [], boxes: [], shipments: [], exceptionSummaries: [] } }
|
||||
function purchasePlatform() { return { customerView: purchaseCustomer(), internalRemark: state.purchase?.remark ?? null, reviewHistory: state.purchaseHistories } }
|
||||
function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } }
|
||||
function truckDetail() { return structuredClone({ summary: state.truck, pallets: state.truckPallets || [], remark: state.truck?.remark ?? null, histories: state.truckHistories }) }
|
||||
function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } }
|
||||
|
||||
const context = {
|
||||
marker, calls, steps, gaps, blockers,
|
||||
savedTruckEvidence: [],
|
||||
async checkpoint() {
|
||||
if (this.createdResources.phase4OverseasBusiness?.truck?.fulfillment)
|
||||
this.savedTruckEvidence.push(structuredClone(this.createdResources.phase4OverseasBusiness.truck.fulfillment))
|
||||
},
|
||||
context: { merchantQualificationFixture: credentials },
|
||||
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
|
||||
async request(...args) {
|
||||
@@ -122,7 +272,7 @@ function fixture(options = {}) {
|
||||
const hash = JSON.stringify(body)
|
||||
if (state.truck) return hash === state.truckHash ? response(truckDetail()) : response(null, 'A0409')
|
||||
state.truckHash = hash
|
||||
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }
|
||||
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', receivedPalletCount: 0, allocatedPalletCount: 0, status: 'PENDING_REVIEW', version: 0, ...body }
|
||||
state.truckHistories.push({ fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
|
||||
return response(truckDetail())
|
||||
}
|
||||
@@ -144,6 +294,43 @@ function fixture(options = {}) {
|
||||
state.truckReviews.set(body.idempotencyKey, { hash, result })
|
||||
return response(result)
|
||||
}
|
||||
const stages = { 'receive-pallets': ['WAITING_INBOUND', 'WAITING_CONSOLIDATION'],
|
||||
'allocate-pallets': ['WAITING_CONSOLIDATION', 'ALLOCATED'], 'prepare-dispatch': ['ALLOCATED', 'PENDING_DISPATCH'],
|
||||
dispatch: ['PENDING_DISPATCH', 'DISPATCHED'], 'start-transit': ['DISPATCHED', 'IN_TRANSIT'], complete: ['IN_TRANSIT', 'COMPLETED'] }
|
||||
const action = requestPath.match(/\/us-truck-orders\/TK1\/([^?]+)\?/)?.[1]
|
||||
if (method === 'POST' && stages[action]) {
|
||||
if (action === 'prepare-dispatch' && state.truck.status === 'PARTIALLY_ALLOCATED') {
|
||||
assert.equal(body.version, state.truck.version)
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
const existing = state.truckStatuses.get(body.idempotencyKey)
|
||||
if (existing) return response(truckDetail())
|
||||
let [from, to] = stages[action]
|
||||
if (action === 'allocate-pallets') {
|
||||
from = state.truck.allocatedPalletCount === 0 ? 'WAITING_CONSOLIDATION' : 'PARTIALLY_ALLOCATED'
|
||||
to = state.truck.allocatedPalletCount === 0 ? 'PARTIALLY_ALLOCATED' : 'ALLOCATED'
|
||||
}
|
||||
assert.equal(state.truck.status, from)
|
||||
assert.equal(body.version, state.truck.version)
|
||||
if (action === 'receive-pallets') {
|
||||
if (body.palletPackageCounts.reduce((sum, count) => sum + count, 0) !== body.packageCount) return response(null, 'A0400')
|
||||
assert.deepEqual(body.palletPackageCounts, [1, 1])
|
||||
assert.equal(body.actualPalletCount, 2)
|
||||
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 1, status: 'RECEIVED' },
|
||||
{ id: '702', palletNo: 'TK1-P002', packageCount: 1, status: 'RECEIVED' }]
|
||||
state.truck.receivedPalletCount = 2
|
||||
}
|
||||
if (action === 'allocate-pallets') {
|
||||
if (new Set(body.palletIds).size !== body.palletIds.length) return response(null, 'A0400')
|
||||
assert.deepEqual(body.palletIds, [state.truck.allocatedPalletCount === 0 ? '701' : '702'])
|
||||
state.truckPallets.find(row => row.id === body.palletIds[0]).status = 'ALLOCATED'
|
||||
state.truck.allocatedPalletCount++
|
||||
}
|
||||
state.truck.status = to; state.truck.version += 1
|
||||
state.truckHistories.push({ fromStatus: from, toStatus: to, idempotencyKey: body.idempotencyKey })
|
||||
state.truckStatuses.set(body.idempotencyKey, true)
|
||||
return response(truckDetail())
|
||||
}
|
||||
if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') {
|
||||
assert.equal(body.status, 'CANCELLED')
|
||||
assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true)
|
||||
@@ -272,7 +459,9 @@ test('procurement creation review boundaries and transport cancellation paths pa
|
||||
assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey)
|
||||
|
||||
const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders'))
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 7)
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 24)
|
||||
assert.equal(truckCalls.filter(call => call.body?.idempotencyKey?.endsWith('prepare-dispatch-partial')).length, 1)
|
||||
assert.equal(truckCalls.filter(call => call.method === 'POST' && call.body?.idempotencyKey?.endsWith('-invalid')).length, 2)
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
|
||||
const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?'))
|
||||
assert.equal(truckReviews.length, 4)
|
||||
@@ -281,7 +470,7 @@ test('procurement creation review boundaries and transport cancellation paths pa
|
||||
assert.equal(new Set(truckReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
|
||||
assert.notEqual(truckReviews[3].body.idempotencyKey, truckReviews[0].body.idempotencyKey)
|
||||
const truckStatuses = truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
|
||||
assert.equal(truckStatuses.length, 1)
|
||||
assert.equal(truckStatuses.length, 0)
|
||||
assert.ok(truckStatuses.every((call) => call.body.status === 'CANCELLED'
|
||||
&& typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0))
|
||||
|
||||
@@ -290,20 +479,23 @@ test('procurement creation review boundaries and transport cancellation paths pa
|
||||
assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
|
||||
assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2)
|
||||
const looseboardStatuses = looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
|
||||
assert.equal(looseboardStatuses.length, 5)
|
||||
assert.equal(looseboardStatuses.length, 4)
|
||||
assert.ok(looseboardStatuses.every((call) => typeof call.body.idempotencyKey === 'string'
|
||||
&& call.body.idempotencyKey.length > 0))
|
||||
assert.equal(new Set(looseboardStatuses.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
|
||||
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 2)
|
||||
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 1)
|
||||
assert.ok(looseboardStatuses.slice(3).every((call) => call.body.idempotencyKey !== looseboardStatuses[0].body.idempotencyKey))
|
||||
|
||||
const registry = context.createdResources.phase4OverseasBusiness
|
||||
assert.equal(registry.purchase.finalStatus, 'REJECTED')
|
||||
assert.equal(registry.purchase.retained, false)
|
||||
assert.equal(registry.truck.finalStatus, 'CANCELLED')
|
||||
assert.equal(registry.truck.retained, false)
|
||||
assert.equal(registry.looseboard.finalStatus, 'CANCELLED')
|
||||
assert.equal(registry.looseboard.retained, false)
|
||||
assert.equal(registry.truck.finalStatus, 'COMPLETED')
|
||||
assert.equal(registry.truck.retained, true)
|
||||
assert.equal(registry.truck.fulfillment.status, 'HTTP_VERIFIED')
|
||||
assert.equal(registry.truck.fulfillment.partialAllocationVerified, true)
|
||||
assert.equal(registry.truck.fulfillment.pallets.length, 2)
|
||||
assert.equal(registry.looseboard.finalStatus, 'PENDING_ACCEPTANCE')
|
||||
assert.equal(registry.looseboard.retained, true)
|
||||
assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
assert.equal(registry.partnerWarehouse.retained, false)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const SCENARIO = 'phase4-overseas-business-write-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -426,8 +428,10 @@ async function truckFlow(context, customerId) {
|
||||
&& histories.some((item) => String(item?.remark || '').includes(context.marker)),
|
||||
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}; ${trace(history.response)}`, OPTIONS)
|
||||
Object.assign(registry.truck, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
|
||||
await verifyUsTruckFulfillment(context, registry.truck)
|
||||
} finally {
|
||||
await cancelMarkerOrder(context, registry.truck, base, ['PENDING_REVIEW', 'WAITING_INBOUND'], '卡车')
|
||||
// 原单保留到履约完成;失败也保留现场,避免取消掩盖未知写入结果。
|
||||
registry.truck.retained = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,11 +501,12 @@ async function looseboardFlow(context, customerId) {
|
||||
records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId
|
||||
&& Number(row.version) === Number(historySummary?.version)),
|
||||
trace(pending.response), OPTIONS)
|
||||
context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
|
||||
'本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture')
|
||||
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
|
||||
'本模块保留原单待接车;后续由 phase5-overseas-fulfillment 复用本轮原车完成实收与签收,单独执行本模块不代表全链完成。', 'fixture')
|
||||
Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
|
||||
} finally {
|
||||
await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板')
|
||||
// 后续用原单接入本轮车次,失败保留现场,不另建替代散板单。
|
||||
registry.looseboard.retained = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,38 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { verifyDiscardApproval } from './phase4-overseas-client-services.mjs'
|
||||
import { verifyDiscardApproval, submitClientTransfer } from './phase4-overseas-client-services.mjs'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
for (const fault of ['', 'network', 'server', 'rejected', 'wrong order', 'wrong status', 'throws']) {
|
||||
test(`transfer submit sends once and stops on uncertain result: ${fault || 'success'}`, async () => {
|
||||
const calls = []
|
||||
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: {} },
|
||||
apiSuccess(_m, _n, response) { return response?.body?.data },
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body, token })
|
||||
if (fault === 'throws') throw new Error('connection closed')
|
||||
return { http: fault === 'network' ? 0 : fault === 'server' ? 503 : 200,
|
||||
requestId: 'request', traceId: 'trace', body: { code: fault === 'rejected' ? 'A0409' : '200',
|
||||
data: { transferNo: fault === 'wrong order' ? 'OTHER' : 'WT-1',
|
||||
status: fault === 'wrong status' ? 'DRAFT' : 'PENDING_REVIEW' } } }
|
||||
} }
|
||||
if (fault) await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'))
|
||||
else assert.equal((await submitClientTransfer(context, 'merchant', 'WT-1')).status, 'PENDING_REVIEW')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'POST')
|
||||
assert.equal(calls[0].path, '/api/merchant/v1/last-leg/warehouse-transfers/WT-1/submit')
|
||||
const proof = context.createdResources.phase4OverseasClientServices.transferSubmission
|
||||
assert.equal(proof.status, !fault ? 'PENDING_REVIEW' : ['network', 'server', 'throws'].includes(fault)
|
||||
? 'UNKNOWN_RESULT' : fault === 'rejected' ? 'REJECTED' : 'UNEXPECTED_RESULT')
|
||||
assert.equal(JSON.stringify(proof).includes('merchant'), false)
|
||||
await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'), /禁止重复提交/)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => {
|
||||
assert.match(source, /Approval retains the existing reservation without deducting stock/)
|
||||
assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/)
|
||||
@@ -70,7 +97,10 @@ test('module records marker-owned resources without forbidden fulfillment cleanu
|
||||
assert.match(source, /relabelNo = relabelNo/)
|
||||
assert.match(source, /transferNo = transferNo/)
|
||||
assert.match(source, /discardNo = discardNo/)
|
||||
assert.match(source, /no outbound, dispatch or delivery action is invoked/)
|
||||
assert.match(source, /不自动删除业务数据/)
|
||||
assert.match(source, /transfer: transferDone \? context\.createdResources\.phase4OverseasClientServices\.transferFulfillment\?\.status \?\? null : null/)
|
||||
assert.match(source, /await verifyCargoDiscardFulfillment\(context/)
|
||||
assert.match(source, /snapshot: \(\) => discardSnapshot\(context, token, proof, proof.approvedNo\)/)
|
||||
})
|
||||
|
||||
test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
|
||||
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
|
||||
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
|
||||
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
|
||||
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
|
||||
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
|
||||
@@ -51,9 +58,9 @@ async function loginMerchant(context) {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
|
||||
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(auth?.accessToken) ? auth.accessToken : null
|
||||
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
|
||||
}
|
||||
|
||||
async function currentOrders(context, token) {
|
||||
@@ -98,13 +105,17 @@ async function verifyRelabel(context, token, order) {
|
||||
}))
|
||||
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
|
||||
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
|
||||
&& item?.skuCode === cargo.skuCode
|
||||
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
|
||||
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
|
||||
&& item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode
|
||||
&& id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId)
|
||||
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0
|
||||
&& nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND'))
|
||||
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0
|
||||
&& nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND')
|
||||
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
|
||||
Boolean(source && position),
|
||||
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
|
||||
if (!source || !position) return false
|
||||
if (!await fundRelabelWallet(context, id(context.context.merchantQualificationFixture.tenantId))) return false
|
||||
const body = {
|
||||
customerOrderNo: order.row.orderNo,
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
@@ -120,9 +131,11 @@ async function verifyRelabel(context, token, order) {
|
||||
productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
|
||||
newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker,
|
||||
operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL',
|
||||
skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }],
|
||||
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
}],
|
||||
}
|
||||
@@ -150,9 +163,52 @@ async function verifyRelabel(context, token, order) {
|
||||
&& Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_REVIEW'),
|
||||
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo
|
||||
const proof = context.createdResources.phase4OverseasClientServices.relabelFulfillment = {
|
||||
marker: context.marker, relabelNo, customerId: id(context.context.merchantQualificationFixture.tenantId),
|
||||
sourceOrderNo: order.row.orderNo, warehouseId: id(order.row.overseasWarehouseId),
|
||||
sourcePositionId: id(position.positionId), sourcePalletNo: position.palletNo, status: 'PENDING_REVIEW',
|
||||
}
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!access) throw new Error('贴标后续缺少有效PDA角色')
|
||||
const picker = await createPicker(context, 94, proof.warehouseId, access)
|
||||
try {
|
||||
if (!picker?.token) throw new Error('贴标后续未取得本仓PDA会话')
|
||||
await verifyClientRelabelFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token })
|
||||
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
|
||||
'当前定向范围仅到客户确认;完整默认链继续使用本申请targetPalletIds排车并签收,不能借其他换标单结果',
|
||||
'fixture', { blocking: false })
|
||||
} finally {
|
||||
if (picker?.membershipId) await disablePicker(context, picker, 94)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交只发送一次,未知结果保留单号并停止,不能用详情回查掩盖失败。
|
||||
export async function submitClientTransfer(context, token, transferNo) {
|
||||
if (!nonBlank(transferNo)) throw new Error('仓储转运提交缺少单号')
|
||||
if (context.createdResources.phase4OverseasClientServices.transferSubmission) throw new Error('本轮仓储转运已有提交证据,禁止重复提交')
|
||||
const proof = { transferNo, marker: context.marker, status: 'SUBMITTING' }
|
||||
context.createdResources.phase4OverseasClientServices.transferSubmission = proof
|
||||
try {
|
||||
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
|
||||
Object.assign(proof, { http: response?.http, code: codeOf(response), requestId: response?.requestId, traceId: response?.traceId })
|
||||
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
|
||||
if (!success(response)) {
|
||||
proof.status = !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
|
||||
throw new Error(`仓储转运提交未成功,停止后续写入: ${transferNo}; ${trace(response)}`)
|
||||
}
|
||||
if (id(data?.transferNo) !== transferNo || data?.status !== 'PENDING_REVIEW') {
|
||||
proof.status = 'UNEXPECTED_RESULT'
|
||||
throw new Error(`仓储转运提交返回单号或状态不符: ${transferNo}`)
|
||||
}
|
||||
proof.status = 'PENDING_REVIEW'
|
||||
return data
|
||||
} catch (error) {
|
||||
if (proof.status === 'SUBMITTING') proof.status = 'UNKNOWN_RESULT'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTransfer(context, token, orders) {
|
||||
const order = orders.find((value) => nonBlank(value.row?.inboundCode)
|
||||
&& nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value))
|
||||
@@ -184,26 +240,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body)
|
||||
const transferNo = id(created.data?.transferNo)
|
||||
if (transferNo && created.data?.status === 'DRAFT') {
|
||||
// The overseas gateway occasionally drops a connection while the submit
|
||||
// request is still being processed. Retry transport failures only, then
|
||||
// reconcile from the authoritative detail before asserting the state.
|
||||
let submitted = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
|
||||
if (response?.http === 0 && attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
|
||||
continue
|
||||
}
|
||||
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
|
||||
submitted = { response, data }
|
||||
break
|
||||
}
|
||||
if (submitted?.data) created.data = submitted.data
|
||||
if (created.data?.status !== 'PENDING_REVIEW') {
|
||||
const reconciled = await merchant(context, token, 'GET', '提交后回查仓储转运申请',
|
||||
`${TRANSFER}/${encodeURIComponent(transferNo)}`)
|
||||
if (reconciled.data) created.data = reconciled.data
|
||||
}
|
||||
created.data = await submitClientTransfer(context, token, transferNo)
|
||||
}
|
||||
context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response,
|
||||
Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW'
|
||||
@@ -226,7 +263,30 @@ async function verifyTransfer(context, token, orders) {
|
||||
&& detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0,
|
||||
`status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.transferNo = transferNo
|
||||
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
|
||||
if (!await verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)) return false
|
||||
const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = {
|
||||
marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId),
|
||||
transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED',
|
||||
}
|
||||
await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
||||
const relabelProof = transferProof.relabelFulfillment = {
|
||||
marker: context.marker, relabelNo: transferProof.relabelNo, customerId: transferProof.customerId,
|
||||
warehouseId: transferProof.warehouseId, sourceOrderNo: transferNo,
|
||||
sourcePositionId: transferProof.sourcePositionId, sourcePalletNo: transferProof.sourcePalletNo,
|
||||
status: 'PENDING_REVIEW',
|
||||
}
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!access) throw new Error('转运换标后续缺少有效PDA角色')
|
||||
const picker = await createPicker(context, 96, transferProof.warehouseId, access)
|
||||
try {
|
||||
if (!picker?.token) throw new Error('转运换标未取得本仓PDA会话')
|
||||
await verifyClientRelabelFulfillment(context, { proof: relabelProof, transferProof, merchantToken: token, pdaToken: picker.token })
|
||||
} finally {
|
||||
if (picker?.membershipId) await disablePicker(context, picker, 96)
|
||||
}
|
||||
await outboundWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
||||
await receiveWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
||||
return true
|
||||
}
|
||||
|
||||
export async function verifyDiscard(context, token, markerOrders) {
|
||||
@@ -275,7 +335,21 @@ export async function verifyDiscard(context, token, markerOrders) {
|
||||
&& Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'),
|
||||
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.discardNo = discardNo
|
||||
try { await verifyDiscardApproval(context, token, body, discardNo, proof) }
|
||||
try {
|
||||
await verifyDiscardApproval(context, token, body, discardNo, proof)
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!access) throw new Error('废弃执行缺少有效PDA角色')
|
||||
const picker = await createPicker(context, 93, proof.warehouseId, access)
|
||||
try {
|
||||
if (!picker?.token) throw new Error('废弃执行未取得本仓PDA会话')
|
||||
await verifyCargoDiscardFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token,
|
||||
snapshot: () => discardSnapshot(context, token, proof, proof.approvedNo) })
|
||||
context.addCoverageGap(MODULE, 'discard-carton-storage-evidence', '废弃底层箱、SKU和箱级流水独立取证',
|
||||
'已接入PDA执行、客户完成、公共库存总账和终态重放;未执行底层独立取证', 'prerequisite', { blocking: false })
|
||||
} finally {
|
||||
if (picker?.membershipId) await disablePicker(context, picker, 93)
|
||||
}
|
||||
}
|
||||
catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error }
|
||||
return true
|
||||
}
|
||||
@@ -363,13 +437,22 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const auth = await loginMerchant(context)
|
||||
if (!auth) return
|
||||
try {
|
||||
return await runWithSession(context, auth.accessToken)
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
||||
context.apiSuccess(MODULE, '退出本轮客户服务会话', logout, trace(logout), OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithSession(context, token) {
|
||||
context.createdResources.phase4OverseasClientServices = {
|
||||
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
|
||||
finalStatuses: { relabel: null, transfer: null, discard: null },
|
||||
cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch or delivery action is invoked',
|
||||
cleanup: '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行',
|
||||
}
|
||||
const token = await loginMerchant(context)
|
||||
if (!token) return
|
||||
|
||||
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
|
||||
const denied = await context.request('GET', query(path, { page: 1, size: 1 }))
|
||||
@@ -390,8 +473,8 @@ export async function run(context) {
|
||||
const transferDone = await verifyTransfer(context, token, orders)
|
||||
const discardDone = await verifyDiscard(context, token, orders)
|
||||
context.createdResources.phase4OverseasClientServices.finalStatuses = {
|
||||
relabel: relabelDone ? 'PENDING_REVIEW' : null,
|
||||
transfer: transferDone ? 'APPROVED' : null,
|
||||
discard: discardDone ? 'APPROVED' : null,
|
||||
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
|
||||
transfer: transferDone ? context.createdResources.phase4OverseasClientServices.transferFulfillment?.status ?? null : null,
|
||||
discard: discardDone ? 'COMPLETED' : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { readFileSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, basename } from 'node:path'
|
||||
import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs'
|
||||
import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
import { DISPATCH_BASE as BASE, dispatchEvidenceContext, dispatchIo } from './grouped-dispatch-acceptance.mjs'
|
||||
import { run, createClientRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
|
||||
const clone = value => structuredClone(value)
|
||||
const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
|
||||
@@ -46,7 +46,8 @@ function server(fault = '', initial = false) {
|
||||
if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) }
|
||||
if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE'))
|
||||
if (path === `${BASE}/candidates`) {
|
||||
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
|
||||
assert.equal(params.get('warehouseId'), '44')
|
||||
assert.equal(params.get('sourceStage'), params.get('orderNo') === 'RL-CLIENT' ? 'RELABEL' : 'RECEIVED')
|
||||
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
|
||||
for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
|
||||
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
|
||||
@@ -97,6 +98,37 @@ function server(fault = '', initial = false) {
|
||||
return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } }
|
||||
}
|
||||
|
||||
function addClientRelabel(h) {
|
||||
const candidate = clone(rows()[0])
|
||||
Object.assign(candidate, { sourceKey: 'RELABEL:RL-CLIENT:91', availablePackages: 1 })
|
||||
candidate.selection = { sourceType: 'RELABEL', relabelNo: 'RL-CLIENT', warehousePalletId: '91', sourceVersion: candidate.sourceVersion }
|
||||
h.candidates.push(candidate)
|
||||
h.context.createdResources.phase4OverseasClientServices = { relabelFulfillment: { marker: 'CURRENT', relabelNo: 'RL-CLIENT',
|
||||
status: 'READY_FOR_OUTBOUND', execution: { status: 'HTTP_VERIFIED_READY_FOR_DISPATCH' }, customerId: '55', warehouseId: '44', targetPalletIds: ['91'] } }
|
||||
}
|
||||
|
||||
test('client original relabel gets its own exact dispatch and refuses repeat execution', async () => {
|
||||
const h = server(); addClientRelabel(h)
|
||||
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
|
||||
await createClientRelabelDispatch(h.context, io, proof)
|
||||
assert.equal(proof.clientRelabel.status, 'CREATED_BEFORE_LOADING')
|
||||
assert.equal(h.context.createdResources.phase4OverseasClientServices.relabelFulfillment.dispatchNo, 'DS-1')
|
||||
assert.deepEqual(proof.clientRelabel.sourceKeys, ['RELABEL:RL-CLIENT:91'])
|
||||
const writes = h.calls.filter(c => c.method === 'POST').length
|
||||
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
|
||||
assert.equal(h.calls.filter(c => c.method === 'POST').length, writes)
|
||||
})
|
||||
|
||||
for (const fault of ['customer', 'pallet', 'missing']) test(`client relabel excludes ${fault} candidates before writes`, async () => {
|
||||
const h = server(); addClientRelabel(h)
|
||||
if (fault === 'customer') h.candidates.at(-1).customerId = '99'
|
||||
if (fault === 'pallet') h.candidates.at(-1).selection.warehousePalletId = '99'
|
||||
if (fault === 'missing') h.candidates.pop()
|
||||
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
|
||||
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
|
||||
assert.equal(h.calls.some(c => c.method === 'POST'), false)
|
||||
})
|
||||
|
||||
test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => {
|
||||
const c = rows(), selected = selectDispatchRouteFixture(c)
|
||||
assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23')
|
||||
@@ -139,7 +171,7 @@ test('phase4 missing current marker or real multi-destination fixture is explici
|
||||
test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-'))
|
||||
try {
|
||||
const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
|
||||
const h = server(); addClientRelabel(h); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
|
||||
h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' }
|
||||
const originalRequest = h.context.request
|
||||
h.context.request = async (method, path, body) => {
|
||||
|
||||
@@ -151,6 +151,39 @@ async function createRelabel(context, io, proof) {
|
||||
Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data })
|
||||
proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' }
|
||||
}
|
||||
|
||||
/** 客户首页贴标原申请独立排车;不可借用旧PDA换标样本的通过结果。 */
|
||||
export async function createClientRelabelDispatch(context, io, proof) {
|
||||
const fixture = context.createdResources?.phase4OverseasClientServices?.relabelFulfillment
|
||||
io.check('客户贴标排车前置为本轮已确认原申请', null, fixture?.marker === context.marker
|
||||
&& fixture.status === 'READY_FOR_OUTBOUND' && fixture.execution?.status === 'HTTP_VERIFIED_READY_FOR_DISPATCH'
|
||||
&& fixture.relabelNo && fixture.customerId && fixture.warehouseId && fixture.targetPalletIds?.length > 0
|
||||
&& new Set(fixture.targetPalletIds.map(id)).size === fixture.targetPalletIds.length && !fixture.dispatchNo && !proof.clientRelabel)
|
||||
const evidence = proof.clientRelabel = { marker: context.marker, relabelNo: fixture.relabelNo, status: 'CREATING' }
|
||||
try {
|
||||
const candidates = (await candidatePage(io, fixture.warehouseId,
|
||||
{ orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] }))
|
||||
.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo
|
||||
&& id(c.customerId) === id(fixture.customerId) && id(c.warehouseId) === id(fixture.warehouseId))
|
||||
io.check('客户原申请目标托盘候选完整且唯一', null, candidates.length === fixture.targetPalletIds.length
|
||||
&& new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length
|
||||
&& candidates.every(c => fixture.targetPalletIds.map(id).includes(id(c.selection.warehousePalletId))))
|
||||
const body = createBody(context.marker, 'CLIENT-RELABEL', candidates)
|
||||
evidence.createRequest = body
|
||||
const created = await io.command('POST', BASE, body, '客户贴标原申请创建独立排车')
|
||||
io.check('客户原申请排车返回业务单号', null, typeof created?.dispatchNo === 'string' && created.dispatchNo.length > 0)
|
||||
evidence.dispatchNo = created.dispatchNo
|
||||
await replay(io, BASE, body, created, '客户原贴标申请排车创建重放')
|
||||
const current = await readDispatch(io, created.dispatchNo)
|
||||
io.check('客户贴标排车逐行匹配原申请实托与箱数', null, dispatchMatches(current, candidates))
|
||||
Object.assign(fixture, { dispatchNo: created.dispatchNo, dispatchStage: current.data.stage })
|
||||
Object.assign(evidence, { status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey) })
|
||||
await context.checkpoint()
|
||||
} catch (error) {
|
||||
evidence.status = 'FAILED'; evidence.failure = error.message
|
||||
await context.checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
export async function run(context) {
|
||||
context.createdResources ??= {}
|
||||
if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建')
|
||||
@@ -193,6 +226,7 @@ export async function run(context) {
|
||||
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
|
||||
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
|
||||
await createRelabel(context, io, proof)
|
||||
await createClientRelabelDispatch(context, io, proof)
|
||||
await context.checkpoint()
|
||||
} catch (error) {
|
||||
if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message)
|
||||
|
||||
@@ -47,18 +47,16 @@ async function overseas(context, token, method, name, path, body = undefined) {
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
|
||||
async function resolveWarehouse(context) {
|
||||
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
|
||||
const response = await context.request('GET',
|
||||
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const rows = records(response?.body?.data)
|
||||
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
|
||||
const warehouse = rows.find((item) => id(item?.id) === existing)
|
||||
|| rows.find((item) => fixedIds.has(id(item?.id)))
|
||||
|| rows[0]
|
||||
export async function resolveReturnFollowupWarehouse(context) {
|
||||
const existing = id(context.createdResources?.phase4OverseasReturnReceipt?.warehouseId)
|
||||
if (!/^[1-9]\d*$/.test(existing)) throw new Error('缺少本轮退货实际收货仓,禁止回退其他仓库')
|
||||
const response = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(existing)}?tenantId=0`)
|
||||
const warehouse = response?.body?.data?.warehouse
|
||||
const valid = success(response) && id(warehouse?.id) === existing
|
||||
&& warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE'
|
||||
add(context, '读取可执行退货签收的海外仓', response,
|
||||
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
|
||||
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
|
||||
valid, `warehouseId=${id(warehouse?.id) || 'missing'}`)
|
||||
return valid ? { id: existing, name: warehouse.warehouseName || warehouse.name } : null
|
||||
}
|
||||
|
||||
async function loginMerchant(context) {
|
||||
@@ -70,9 +68,10 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
const auth = success(response) ? response.body?.data : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
|
||||
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
|
||||
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
|
||||
}
|
||||
|
||||
function itemMap(items) {
|
||||
@@ -81,12 +80,23 @@ function itemMap(items) {
|
||||
|
||||
export async function run(context) {
|
||||
const source = context.createdResources.phase4OverseasReturnReceipt
|
||||
if (source?.marker !== context.marker || !source?.returnNo) {
|
||||
if (source?.marker !== context.marker || !source?.returnNo || source?.mainChainStatus !== 'READY_FOR_FOLLOWUPS'
|
||||
|| id(source?.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)) {
|
||||
add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置')
|
||||
return
|
||||
}
|
||||
const merchantToken = await loginMerchant(context)
|
||||
const warehouse = await resolveWarehouse(context)
|
||||
const auth = await loginMerchant(context)
|
||||
if (!auth) return
|
||||
try {
|
||||
return await runWithSession(context, source, auth.accessToken)
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
||||
add(context, '退出本轮退货后续客户会话', logout, success(logout))
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithSession(context, source, merchantToken) {
|
||||
const warehouse = await resolveReturnFollowupWarehouse(context)
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!merchantToken || !warehouse || !access) return
|
||||
const picker = await createPicker(context, 92, warehouse.id, access)
|
||||
@@ -381,8 +391,11 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0
|
||||
&& task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL'))
|
||||
for (const item of task.items) {
|
||||
require('退货换标逐项具有真实箱唛和SKU及有效数量', nonBlank(item.cartonMark) && nonBlank(item.skuCode)
|
||||
&& Number.isSafeInteger(Number(item.expectedPackages)) && Number(item.expectedPackages) > 0)
|
||||
task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, {
|
||||
itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version),
|
||||
cartonMark: item.cartonMark, skuCode: item.skuCode,
|
||||
idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } from './phase4-overseas-return-followups.mjs'
|
||||
import { normalizeModuleOrder, describeAcceptanceScope } from '../admin-test-env-acceptance.mjs'
|
||||
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound, resolveReturnFollowupWarehouse, run as runFollowups } from './phase4-overseas-return-followups.mjs'
|
||||
|
||||
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
|
||||
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
|
||||
@@ -177,13 +177,17 @@ test('relabelled outbound shares accounting and strict PDA scan checks', async (
|
||||
|
||||
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
|
||||
const calls = []
|
||||
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
|
||||
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
|
||||
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async request(method,path,body,token,_headers) {
|
||||
calls.push({ method, path, body, token })
|
||||
let data = task
|
||||
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
|
||||
if (path.endsWith('/return-skus/complete')) {
|
||||
assert.equal(body.cartonMark, 'BOX')
|
||||
assert.equal(body.skuCode, 'SKU-1')
|
||||
}
|
||||
if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }]
|
||||
if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' }
|
||||
if (path.endsWith('/resolve')) {
|
||||
@@ -215,6 +219,25 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
|
||||
assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant')
|
||||
})
|
||||
|
||||
for (const missing of ['cartonMark', 'skuCode', 'expectedPackages']) {
|
||||
test(`return relabel blocks incomplete task before quantity write: ${missing}`, async () => {
|
||||
const calls = []
|
||||
const item = { itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', expectedPackages: 1, operationType: 'RETURN_BULK_SKU_LABEL' }
|
||||
delete item[missing]
|
||||
const context = { marker: 'batch', token: 'admin', addStep() {}, assert() {},
|
||||
async request(method, path) {
|
||||
calls.push(path)
|
||||
const data = path.endsWith('/status')
|
||||
? { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
|
||||
: { relabelNo: 'RL-1', version: 1, items: [item] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
await assert.rejects(completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1'), /真实箱唛和SKU/)
|
||||
assert.equal(calls.length, 2)
|
||||
assert.ok(calls.every(path => !path.endsWith('/return-skus/complete')))
|
||||
})
|
||||
}
|
||||
|
||||
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
|
||||
const source = await readFile(acceptanceUrl, 'utf8')
|
||||
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
|
||||
@@ -260,12 +283,55 @@ test('HTTP acceptance uses injected clients and never bypasses APIs with databas
|
||||
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
|
||||
})
|
||||
|
||||
test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => {
|
||||
test('receipt-only scope remains focused while default full plan includes ordered return followups', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/)
|
||||
const ordered = normalizeModuleOrder(['phase4-overseas-return-followups'])
|
||||
assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups'))
|
||||
assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups'))
|
||||
const defaults = describeAcceptanceScope().defaultModules
|
||||
assert.ok(defaults.includes('phase4-overseas-return-followups'))
|
||||
assert.ok(defaults.indexOf('phase4-overseas-return-receipt') < defaults.indexOf('phase4-overseas-return-followups'))
|
||||
})
|
||||
|
||||
test('return followups read only the actual receipt warehouse and reject drift', async () => {
|
||||
for (const patch of [{}, { id: '202' }, { status: 'INACTIVE' }, { warehouseType: 'DOMESTIC' }]) {
|
||||
const calls = [], checks = []
|
||||
const context = { createdResources: { phase4OverseasReturnReceipt: { warehouseId: '201' } },
|
||||
addStep(module, name, response, passed) { checks.push(passed) },
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: { warehouse: { id: '201', status: 'ACTIVE', warehouseType: 'OVERSEAS', ...patch } } } }
|
||||
} }
|
||||
const warehouse = await resolveReturnFollowupWarehouse(context)
|
||||
assert.equal(Boolean(warehouse), Object.keys(patch).length === 0)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].path, '/api/platform/v1/warehouse-admin/201?tenantId=0')
|
||||
}
|
||||
await assert.rejects(resolveReturnFollowupWarehouse({ createdResources: {} }), /实际收货仓/)
|
||||
})
|
||||
|
||||
test('return followups stop before login when main chain failed or customer changed', async () => {
|
||||
for (const patch of [{ mainChainStatus: 'FAILED' }, { customerId: '102' }, { marker: 'OLD' }]) {
|
||||
const checks = []
|
||||
await runFollowups({ marker: 'CURRENT', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasReturnReceipt: { marker: 'CURRENT', returnNo: 'TH202610220001',
|
||||
mainChainStatus: 'READY_FOR_FOLLOWUPS', customerId: '101', ...patch } },
|
||||
addStep(module, name, response, passed) { checks.push(passed) },
|
||||
request() { throw new Error('must not send HTTP') } })
|
||||
assert.deepEqual(checks, [false])
|
||||
}
|
||||
})
|
||||
|
||||
test('fixed return main chain excludes customer provisioning and write transport retries', async () => {
|
||||
const source = await readFile(moduleUrl, 'utf8')
|
||||
assert.match(source, /context.options.returnMainChainOnly \|\| context.options.fixedCustomerId/)
|
||||
assert.match(source, /addCoverageGap\(MODULE, 'return-isolation-boundaries'/)
|
||||
assert.match(source, /method === 'GET' && attempt < 3/)
|
||||
assert.match(source, /mainChainStatus = context.steps.slice\(start\).every/)
|
||||
for (const url of [moduleUrl, new URL('./phase4-overseas-return-followups.mjs', import.meta.url)]) {
|
||||
assert.match(await readFile(url, 'utf8'), /finally \{\s*const logout = await context.request\('POST', '\/api\/auth\/logout'/)
|
||||
}
|
||||
})
|
||||
|
||||
test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ function add(context, name, response, passed, evidence = '') {
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
let response = await context.request(method, path, body, token, {})
|
||||
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
for (let attempt = 1; method === 'GET' && attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
response = await context.request(method, path, body, token, {})
|
||||
}
|
||||
@@ -87,9 +87,10 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
const auth = success(response) ? response.body?.data : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
|
||||
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
|
||||
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
|
||||
}
|
||||
|
||||
function itemMap(items) {
|
||||
@@ -123,14 +124,38 @@ export async function approveReturnFixture(context, merchantToken, created) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const start = context.steps.length
|
||||
const auth = await loginMerchant(context)
|
||||
if (!auth) return
|
||||
try {
|
||||
return await runWithSession(context, auth.accessToken)
|
||||
} catch (error) {
|
||||
const source = context.createdResources.phase4OverseasReturnReceipt
|
||||
if (source?.marker === context.marker) source.mainChainStatus = 'FAILED'
|
||||
throw error
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
||||
add(context, '退出本轮退货客户会话', logout, success(logout))
|
||||
const source = context.createdResources.phase4OverseasReturnReceipt
|
||||
if (source?.marker === context.marker && context.steps.slice(start).some(step => !step.passed)) {
|
||||
source.mainChainStatus = 'FAILED'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithSession(context, merchantToken) {
|
||||
const start = context.steps.length
|
||||
context.createdResources.phase4OverseasReturnReceipt = {
|
||||
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
|
||||
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
|
||||
}
|
||||
const merchantToken = await loginMerchant(context)
|
||||
const warehouse = await resolveWarehouse(context)
|
||||
const access = await resolvePickerAccess(context)
|
||||
if (!merchantToken || !warehouse || !access) return
|
||||
Object.assign(context.createdResources.phase4OverseasReturnReceipt, {
|
||||
warehouseId: warehouse.id, customerId: id(context.context?.merchantQualificationFixture?.tenantId),
|
||||
mainChainStatus: 'RUNNING',
|
||||
})
|
||||
const picker = await createPicker(context, 91, warehouse.id, access)
|
||||
if (!picker?.token) {
|
||||
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
|
||||
@@ -477,11 +502,16 @@ export async function run(context) {
|
||||
trace(merchantSummary.response), OPTIONS)
|
||||
}
|
||||
|
||||
if (context.options.returnMainChainOnly) {
|
||||
if (context.options.returnMainChainOnly || context.options.fixedCustomerId) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios';
|
||||
context.addCoverageGap(MODULE, 'return-isolation-boundaries', '跨客户、跨仓及并发退货扩展',
|
||||
'主链复用固定客户;跨客户扩展仍需独立固定资源,不创建第二客户,也不计边界通过',
|
||||
'prerequisite', { blocking: false })
|
||||
} else {
|
||||
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
|
||||
}
|
||||
context.createdResources.phase4OverseasReturnReceipt.mainChainStatus = context.steps.slice(start).every(step => step.passed)
|
||||
? 'READY_FOR_FOLLOWUPS' : 'FAILED'
|
||||
} finally {
|
||||
await disablePicker(context, picker, 91)
|
||||
}
|
||||
|
||||
+76
-1
@@ -1,12 +1,87 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { beijingDate, matchesProcurementNo } from './phase4-overseas-wallet-and-pallet-cost.mjs'
|
||||
import { beijingDate, matchesProcurementNo, runFixedCustomerProcurement, run } from './phase4-overseas-wallet-and-pallet-cost.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase4-overseas-wallet-and-pallet-cost.mjs', import.meta.url), 'utf8')
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), 'utf8')
|
||||
|
||||
function fixedHarness({ warehouse = true, profileId = '101', logoutCode = '200' } = {}) {
|
||||
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: 'TEN101', quiet: true })
|
||||
context.marker = 'PROC-FIXED-TEST'
|
||||
context.context.merchantQualificationFixture = { marker: context.marker, tenantId: '101',
|
||||
tenantName: 'QA', reused: true, username: 'fixture-user', password: 'fixture-password' }
|
||||
const calls = []
|
||||
context.request = async (method, path) => {
|
||||
calls.push({ method, path })
|
||||
let data, code = '200'
|
||||
if (method === 'GET' && path.endsWith('/warehouse-options')) data = warehouse ? [{ warehouseId: '201' }] : []
|
||||
else if (method === 'POST' && path === '/api/auth/login') data = { accessToken: 'test-token', refreshToken: 'test-refresh' }
|
||||
else if (method === 'GET' && path === '/api/merchant/v1/iam/tenant/profile') data = { id: profileId, status: 'ACTIVE' }
|
||||
else if (method === 'POST' && path === '/api/auth/logout') code = logoutCode
|
||||
else throw new Error(`unexpected request ${method} ${path}`)
|
||||
return { http: 200, body: { code, data } }
|
||||
}
|
||||
return { context, calls }
|
||||
}
|
||||
|
||||
test('固定客户采购进入默认模块,仅复用候选仓与会话,不初始化钱包', async () => {
|
||||
const { context, calls } = fixedHarness()
|
||||
let invoked = 0
|
||||
await context.executeModule('phase4-overseas-wallet-and-pallet-cost', () => runFixedCustomerProcurement(context,
|
||||
async (actualContext, fixture, token, warehouseId) => {
|
||||
invoked++
|
||||
assert.equal(actualContext, context)
|
||||
assert.equal(fixture.tenantId, '101')
|
||||
assert.equal(token, 'test-token')
|
||||
assert.equal(warehouseId, '201')
|
||||
}))
|
||||
assert.equal(invoked, 1)
|
||||
assert.equal(context.modules[0].skipped, undefined)
|
||||
assert.equal(context.modules[0].success, true)
|
||||
assert.equal(context.coverageGaps.length, 1)
|
||||
assert.equal(calls.at(-1).path, '/api/auth/logout')
|
||||
assert.match(source, /if \(context.options\?\.fixedCustomerId\) return runFixedCustomerProcurement\(context\)/)
|
||||
})
|
||||
|
||||
test('错误客户及旧批次资格在任何网络请求前阻断默认入口', async () => {
|
||||
for (const patch of [{ tenantId: '102' }, { marker: 'OLD' }, { reused: false }]) {
|
||||
const { context, calls } = fixedHarness()
|
||||
Object.assign(context.context.merchantQualificationFixture, patch)
|
||||
await assert.rejects(run(context), /固定客户资格/)
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('缺少候选仓不建仓;登录身份漂移不执行采购且退出会话', async () => {
|
||||
for (const options of [{ warehouse: false }, { profileId: '102' }]) {
|
||||
const { context, calls } = fixedHarness(options)
|
||||
let invoked = false
|
||||
await assert.rejects(runFixedCustomerProcurement(context, async () => { invoked = true }), /合作仓|身份不匹配/)
|
||||
assert.equal(invoked, false)
|
||||
if (options.profileId) assert.equal(calls.at(-1).path, '/api/auth/logout')
|
||||
else assert.equal(calls.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('采购执行失败仍退出会话,退出失败保留失败断言', async () => {
|
||||
const { context, calls } = fixedHarness()
|
||||
await assert.rejects(runFixedCustomerProcurement(context, async () => { throw new Error('unknown result') }), /unknown result/)
|
||||
assert.equal(calls.at(-1).path, '/api/auth/logout')
|
||||
const failedLogout = fixedHarness({ logoutCode: 'A0409' })
|
||||
await runFixedCustomerProcurement(failedLogout.context, async () => {})
|
||||
assert.ok(failedLogout.context.steps.some(step => !step.passed))
|
||||
})
|
||||
|
||||
test('已存在采购证据不重复创建样本', async () => {
|
||||
const { context, calls } = fixedHarness()
|
||||
context.createdResources.procurementSample = { status: 'FAILED' }
|
||||
await assert.rejects(runFixedCustomerProcurement(context), /不自动重跑/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('采购单号按北京时间日期执行精确合同', () => {
|
||||
const instant = new Date('2026-09-05T16:30:00.000Z')
|
||||
assert.equal(beijingDate(instant), '20260906')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
|
||||
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
|
||||
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
|
||||
@@ -314,7 +315,41 @@ async function verifyDestuffCosts(context, completedFacts) {
|
||||
return evidence
|
||||
}
|
||||
|
||||
/** 固定客户只执行采购正常链,不初始化余额、授信或修改公共资源。 */
|
||||
export async function runFixedCustomerProcurement(context, fulfill = procurementFlow) {
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
const customerId = normalizeFixedCustomerId(context.options?.fixedCustomerId)
|
||||
if (fixture?.marker !== context.marker || id(fixture?.tenantId) !== customerId
|
||||
|| fixture?.reused !== true || !fixture?.username || !fixture?.password) {
|
||||
throw new Error('采购正向链缺少本轮固定客户资格,禁止创建或借用其他客户')
|
||||
}
|
||||
if (context.createdResources.procurementSample) throw new Error('已有采购验收证据,不自动重跑或覆盖')
|
||||
context.addCoverageGap(MODULE, 'fixed-wallet-lifecycle', '授信充值、贴标资金及拆柜成本综合场景',
|
||||
'本分支仅执行既有余额上的采购正向履约;未执行钱包初始化及原综合模块其他场景',
|
||||
'not-implemented', { blocking: false })
|
||||
const warehousesResponse = await context.request('GET', `${PROCUREMENT}/warehouse-options`)
|
||||
const candidates = success(warehousesResponse) ? warehousesResponse.body.data : null
|
||||
const warehouse = Array.isArray(candidates) ? candidates.find(row => /^[1-9]\d*$/.test(id(row?.warehouseId))) : null
|
||||
assert(context, '采购正向链复用正式候选收货仓', warehousesResponse, Boolean(warehouse))
|
||||
if (!warehouse) throw new Error('缺少现有采购合作仓,不自动创建')
|
||||
const auth = await merchantLogin(context, fixture)
|
||||
if (!auth?.accessToken || !auth?.refreshToken) throw new Error('采购客户会话或退出凭据缺失')
|
||||
try {
|
||||
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
|
||||
const profile = success(profileResponse) ? profileResponse.body.data : null
|
||||
const matched = id(profile?.id) === customerId && profile?.status === 'ACTIVE'
|
||||
assert(context, '采购当前会话属于指定固定客户', profileResponse, matched)
|
||||
if (!matched) throw new Error('采购客户会话身份不匹配,停止业务写入')
|
||||
// 共享助手在创建订单前核验现金可用余额至少3元,并逐请求持久化;不足时直接失败。
|
||||
return await fulfill(context, fixture, auth.accessToken, id(warehouse.warehouseId))
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
||||
assert(context, '退出本轮固定采购客户会话', logout, success(logout))
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
if (context.options?.fixedCustomerId) return runFixedCustomerProcurement(context)
|
||||
const start = context.steps.length
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
|
||||
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -10,6 +10,80 @@ import { fileURLToPath } from 'node:url'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resource held', 'settlement failed']) {
|
||||
test(`client original relabel delivery includes receipt and resource release: ${fault || 'normal'}`, async () => {
|
||||
const relabel = { marker: 'batch', relabelNo: 'RL-CLIENT', dispatchNo: 'DP-CLIENT', warehouseId: '42', customerId: '101',
|
||||
sourceCartonMarks: ['OLD'], relabelCartonMarks: ['NEW'] }
|
||||
const calls = []
|
||||
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } },
|
||||
checkpoint: async () => {}, assert(_m, name, _r, ok) { assert.ok(ok, name) },
|
||||
apiSuccess(_m, _name, response) { return response.body.data },
|
||||
async request(method, path) {
|
||||
calls.push('outbound')
|
||||
assert.equal(method, 'GET'); assert.ok(path.endsWith('/outbound-orders/OUT-1'))
|
||||
return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101',
|
||||
status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } }
|
||||
} }
|
||||
const helpers = {
|
||||
async prepare(_c, _m, dispatch, warehouse) { calls.push('prepare'); assert.equal(dispatch, relabel.dispatchNo); assert.equal(warehouse, '42'); return { tripNo: 'TR-1' } },
|
||||
async pick() { calls.push('pick'); return true }, async load() { calls.push('load') },
|
||||
async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1' }],
|
||||
deliveries: [{ customerId: fault === 'foreign customer' ? '102' : '101', outboundNo: 'OUT-1', deliveryNo: 'DE-1', packageCount: 1 }] } },
|
||||
async sign() { calls.push('sign'); return { order: { status: fault === 'sign failure' ? 'DELIVERING' : 'DELIVERED',
|
||||
deliveryNo: 'DE-1', customerId: '101', outboundNo: 'OUT-1' } } },
|
||||
async finish() { calls.push('finish'); return { status: fault === 'resource held' ? 'CHECKING' : 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
|
||||
async settle() { calls.push('settle'); return { status: fault === 'settlement failed' ? 'FAILED' : 'SETTLED_HTTP_VERIFIED' } },
|
||||
}
|
||||
if (fault) {
|
||||
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
|
||||
assert.equal(relabel.deliveryExecution.status, 'FAILED')
|
||||
if (['foreign customer', 'old marks'].includes(fault)) assert.equal(calls.includes('sign'), false)
|
||||
if (fault === 'sign failure') assert.equal(calls.includes('finish'), false)
|
||||
} else {
|
||||
await verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers)
|
||||
assert.equal(relabel.deliveryExecution.status, 'DELIVERED_HTTP_VERIFIED')
|
||||
assert.deepEqual(calls, ['prepare', 'pick', 'load', 'read', 'outbound', 'sign', 'finish', 'settle'])
|
||||
}
|
||||
const count = calls.length
|
||||
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
|
||||
assert.equal(calls.length, count)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['', 'foreign order', 'wrong amount', 'pending', 'rejected']) {
|
||||
test(`原贴标结算只读回查: ${fault || 'settled'}`, async () => {
|
||||
const relabel = { marker: 'batch', relabelNo: 'RL-1', sourceOrderNo: 'WC-1', customerId: '101', warehouseId: '42', outboundNo: 'OUT-1',
|
||||
deliveryExecution: { closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } } }
|
||||
let calls = 0
|
||||
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } }, checkpoint: async () => {}, assert() {},
|
||||
apiSuccess(_m, _n, response) { return response.body.data },
|
||||
async request(method, path) {
|
||||
assert.equal(method, 'GET'); assert.ok(path.endsWith('/relabel-applications/RL-1')); calls++
|
||||
return { http: 200, body: { code: '200', data: { application: { relabelNo: 'RL-1',
|
||||
customerOrderNo: fault === 'foreign order' ? 'OTHER' : 'WC-1', overseasWarehouseId: '42',
|
||||
chargeAmount: fault === 'wrong amount' ? 1 : 0.01, chargeCurrency: 'USD', customerConfirmedAt: '2026-10-22T01:00:00',
|
||||
walletStatus: fault === 'pending' ? 'SETTLE_PENDING' : fault === 'rejected' ? 'SETTLE_FAILED' : calls === 1 ? 'SETTLE_PENDING' : 'SETTLED' } } } }
|
||||
} }
|
||||
if (fault) {
|
||||
await assert.rejects(verifyClientRelabelSettlement(context, relabel, async () => {}))
|
||||
assert.equal(relabel.settlement.status, 'FAILED')
|
||||
assert.equal(calls, fault === 'pending' ? 31 : 1)
|
||||
} else {
|
||||
await verifyClientRelabelSettlement(context, relabel, async () => {})
|
||||
assert.equal(relabel.settlement.status, 'SETTLED_HTTP_VERIFIED'); assert.equal(calls, 2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('relabel fleets run after main resource release and retain both original proofs in checkpoints', () => {
|
||||
const start = source.indexOf('export async function run(context)')
|
||||
const run = source.slice(start)
|
||||
assert.ok(run.indexOf('await finishRelabelDeliveries(') > run.indexOf('await finishFleetTrip('))
|
||||
assert.equal(run.includes('await verifyRelabelDispatchOutbound('), false)
|
||||
assert.match(source, /clientRelabel: context.createdResources.phase4OverseasClientServices\?\.relabelFulfillment/)
|
||||
})
|
||||
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
|
||||
@@ -53,15 +127,24 @@ test('native route conserves source keys and destinations while same destination
|
||||
|
||||
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
|
||||
assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/)
|
||||
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/)
|
||||
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch', 'phase4-overseas-business'\]/)
|
||||
const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules
|
||||
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize',
|
||||
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) {
|
||||
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch', 'phase4-overseas-business']) {
|
||||
assert.ok(modules.indexOf(prerequisite) >= 0
|
||||
&& modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite)
|
||||
}
|
||||
})
|
||||
|
||||
test('default looseboard attaches before stock snapshots and verifies its original order after signature', () => {
|
||||
const start = source.indexOf('export async function run(context)')
|
||||
const run = source.slice(start)
|
||||
assert.ok(run.indexOf('await attachLooseboardToTrip(') < run.indexOf('const sourceTask ='))
|
||||
assert.ok(run.indexOf('await verifyLooseboardDelivered(') > run.indexOf('const completedDeliveries ='))
|
||||
assert.match(source, /looseboard: context.createdResources.phase4OverseasBusiness\?\.looseboard/)
|
||||
assert.match(run, /sourceKeys: \[\.\.\.source.dispatch.sourceKeys, \.\.\.looseboard.proof.sourceKeys\]/)
|
||||
})
|
||||
|
||||
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
|
||||
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
|
||||
import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
|
||||
import { mkdir, writeFile, rename, readFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
@@ -45,6 +46,9 @@ export async function fulfillmentEvidenceContext(context) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence,
|
||||
fulfillment: context.createdResources.phase5OverseasFulfillment,
|
||||
looseboard: context.createdResources.phase4OverseasBusiness?.looseboard,
|
||||
clientRelabel: context.createdResources.phase4OverseasClientServices?.relabelFulfillment,
|
||||
pdaRelabel: context.createdResources.phase4OverseasRelabel,
|
||||
fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8')
|
||||
await rename(`${path}.tmp`, path)
|
||||
}
|
||||
@@ -842,26 +846,107 @@ export function relabelOutboundMarksMatch(detail, relabel) {
|
||||
&& Number(detail?.outbound?.packageCount) === expected.length
|
||||
}
|
||||
|
||||
async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) {
|
||||
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
|
||||
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
|
||||
return
|
||||
// 客户原贴标费用只读等待结算回执,不以派送成功推定扣费成功。
|
||||
export async function verifyClientRelabelSettlement(context, relabel, delay = () => new Promise(resolve => setTimeout(resolve, 1000))) {
|
||||
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment !== relabel
|
||||
|| relabel?.marker !== context.marker || !nonBlank(relabel.sourceOrderNo)
|
||||
|| id(relabel.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| relabel.deliveryExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|
||||
|| !nonBlank(relabel.outboundNo)) throw Error('贴标结算缺少本轮原申请已交付证据')
|
||||
const evidence = relabel.settlement = { status: 'POLLING', observations: [] }
|
||||
try {
|
||||
for (let attempt = 0; attempt < 31; attempt++) {
|
||||
await context.checkpoint()
|
||||
const result = await platform(context, 'GET', '客户原贴标申请结算回查',
|
||||
`${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`)
|
||||
const application = result.data?.application
|
||||
const same = success(result.response) && application?.relabelNo === relabel.relabelNo
|
||||
&& application.customerOrderNo === relabel.sourceOrderNo && id(application.overseasWarehouseId) === id(relabel.warehouseId)
|
||||
&& Number(application.chargeAmount) === 0.01 && nonBlank(application.chargeCurrency)
|
||||
&& Boolean(application.customerConfirmedAt)
|
||||
context.assert(MODULE, '结算回执属于原贴标申请及原费用', result.response, same, `relabelNo=${relabel.relabelNo}`, OPTIONS)
|
||||
if (!same) throw Error('原贴标申请结算身份或金额不一致')
|
||||
evidence.observations.push({ walletStatus: application.walletStatus, chargeAmount: application.chargeAmount,
|
||||
chargeCurrency: application.chargeCurrency, requestId: result.response.requestId, traceId: result.response.traceId })
|
||||
await context.checkpoint()
|
||||
if (application.walletStatus === 'SETTLED') { evidence.status = 'SETTLED_HTTP_VERIFIED'; await context.checkpoint(); return evidence }
|
||||
if (!['FROZEN', 'SETTLE_PENDING'].includes(application.walletStatus)) throw Error(`贴标结算异常状态: ${application.walletStatus}`)
|
||||
if (attempt < 30) await delay()
|
||||
}
|
||||
throw Error('原贴标费用结算回执等待超时')
|
||||
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await context.checkpoint(); throw error }
|
||||
}
|
||||
|
||||
// 原换标申请逐车完成,必须等前车释放复用资源,不能只出库不签收。
|
||||
export async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId, helpers = {
|
||||
prepare: prepareFleetTrip, pick: pickAll, load: confirmFleetLoading, read: readAutomaticDispatchDelivery,
|
||||
sign: saveReceiptAndComplete, finish: finishFleetTrip, settle: verifyClientRelabelSettlement,
|
||||
}) {
|
||||
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo) || !token || relabel.deliveryExecution
|
||||
|| ![context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment].includes(relabel)
|
||||
|| id(relabel.warehouseId ?? relabel.overseasWarehouseId) !== id(warehouseId)) throw Error('缺少本轮原换标排车、同仓PDA或履约已执行')
|
||||
const evidence = relabel.deliveryExecution = { status: 'RUNNING', marker: context.marker }
|
||||
const require = (name, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabel.relabelNo}`, OPTIONS)
|
||||
if (!passed) throw Error(name)
|
||||
}
|
||||
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
|
||||
if (!await pickAll(context, token, relabel.dispatchNo)) return
|
||||
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
|
||||
const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
|
||||
if (!transferred) return
|
||||
try {
|
||||
const proof = await helpers.prepare(context, MODULE, relabel.dispatchNo, warehouseId)
|
||||
evidence.tripNo = proof.tripNo
|
||||
require('原换标排车实际扫码完成', await helpers.pick(context, token, relabel.dispatchNo))
|
||||
await helpers.load(context, MODULE, proof, token, relabel.loadingMeasurements)
|
||||
const transferred = await helpers.read(context, relabel.dispatchNo)
|
||||
const customerId = id(relabel.customerId)
|
||||
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
|
||||
if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return }
|
||||
require('原换标排车生成唯一客户出库且派送无外来客户', transferred?.outbounds?.length === 1
|
||||
&& id(transferred.outbounds[0].customerId) === customerId && nonBlank(transferred.outbounds[0].outboundNo)
|
||||
&& transferred.deliveries?.length > 0 && new Set(transferred.deliveries.map(row => row.deliveryNo)).size === transferred.deliveries.length
|
||||
&& transferred.deliveries.every(row => id(row.customerId) === customerId && row.outboundNo === transferred.outbounds[0].outboundNo)
|
||||
&& transferred.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === relabel.relabelCartonMarks.length)
|
||||
const outboundNo = transferred.outbounds[0].outboundNo
|
||||
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
|
||||
`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`)
|
||||
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
|
||||
require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱',
|
||||
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
||||
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
||||
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
|
||||
&& id(detail.data?.outbound?.customerId) === customerId && detail.data?.outbound?.status === 'OUTBOUNDED'
|
||||
&& relabelOutboundMarksMatch(detail.data, relabel))
|
||||
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
|
||||
for (const [index, delivery] of transferred.deliveries.entries()) {
|
||||
const signed = await helpers.sign(context, delivery, customerId, index)
|
||||
require('原换标货物经正式签收完成派送', signed?.order?.status === 'DELIVERED'
|
||||
&& signed.order.deliveryNo === delivery.deliveryNo && id(signed.order.customerId) === customerId
|
||||
&& signed.order.outboundNo === outboundNo)
|
||||
}
|
||||
evidence.closure = await helpers.finish(context, MODULE, proof)
|
||||
require('原换标车签收后车辆司机均已释放', evidence.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment === relabel) {
|
||||
const settled = await helpers.settle(context, relabel)
|
||||
require('客户原贴标申请费用已结算', settled?.status === 'SETTLED_HTTP_VERIFIED')
|
||||
}
|
||||
evidence.status = 'DELIVERED_HTTP_VERIFIED'
|
||||
await context.checkpoint()
|
||||
return evidence
|
||||
} catch (error) {
|
||||
evidence.status = 'FAILED'; evidence.failure = error.message
|
||||
await context.checkpoint(); throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。
|
||||
async function finishRelabelDeliveries(context, mainTrip, warehouseId) {
|
||||
if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约')
|
||||
const access = await resolvePickerAccess(context)
|
||||
const picker = access ? await createPicker(context, 95, warehouseId, access) : null
|
||||
try {
|
||||
if (!picker?.token) throw Error('换标完整履约缺少可复用同仓PDA')
|
||||
for (const original of [context.createdResources.phase4OverseasRelabel, context.createdResources.phase4OverseasClientServices?.relabelFulfillment]) {
|
||||
await verifyRelabelDispatchOutbound(context, original, picker.token, warehouseId)
|
||||
}
|
||||
} finally {
|
||||
await disablePicker(context, picker, 95)
|
||||
if (picker) picker.token = null
|
||||
await context.checkpoint()
|
||||
}
|
||||
}
|
||||
|
||||
async function loginMerchant(context, customerId) {
|
||||
@@ -1058,7 +1143,8 @@ export async function run(context) {
|
||||
if (!merchantToken) return
|
||||
|
||||
context = await fulfillmentEvidenceContext(context)
|
||||
const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
let beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
let fulfillmentDispatch = source.dispatch
|
||||
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
|
||||
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
|
||||
sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes,
|
||||
@@ -1079,6 +1165,12 @@ export async function run(context) {
|
||||
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
|
||||
return
|
||||
}
|
||||
const looseboard = await attachLooseboardToTrip(context, tripProof, picker.token)
|
||||
fulfillmentDispatch = { ...source.dispatch, sourceKeys: [...source.dispatch.sourceKeys, ...looseboard.proof.sourceKeys] }
|
||||
beforeEdit = { ...beforeEdit, data: looseboard.detail }
|
||||
if (!dispatchDestinationConservation(beforeEdit.data, fulfillmentDispatch)) throw new Error('散板加货后原车来源或目的地不守恒')
|
||||
Object.assign(routeProof, { sourceKeys: fulfillmentDispatch.sourceKeys, beforeEdit: beforeEdit.data,
|
||||
looseboardIncomingNo: looseboard.proof.incomingNo })
|
||||
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return
|
||||
@@ -1118,8 +1210,8 @@ export async function run(context) {
|
||||
deliveries = transferred.deliveries; outbounds = transferred.outbounds
|
||||
routeProof.deliveries = deliveries
|
||||
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
|
||||
dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries), trace(transferred.response), OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries)) return
|
||||
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
|
||||
if (!ready) return
|
||||
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
|
||||
@@ -1135,8 +1227,6 @@ export async function run(context) {
|
||||
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
|
||||
if (!unchanged) return
|
||||
}
|
||||
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
|
||||
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId)
|
||||
} finally {
|
||||
try { await disablePicker(context, picker, 5) }
|
||||
finally { if (picker) picker.token = null; await context.checkpoint() }
|
||||
@@ -1192,6 +1282,7 @@ export async function run(context) {
|
||||
success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS)
|
||||
if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return
|
||||
context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data
|
||||
await verifyLooseboardDelivered(context, tripProof, deliveries)
|
||||
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
|
||||
removedComplete?.http === 404 || removedComplete?.http === 405
|
||||
@@ -1256,6 +1347,7 @@ export async function run(context) {
|
||||
// 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。
|
||||
if (context.context?.phase5TailVerification?.runReturnScenario !== true) {
|
||||
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
await finishRelabelDeliveries(context, tripProof, warehouseId)
|
||||
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
|
||||
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'
|
||||
@@ -1305,6 +1397,7 @@ export async function run(context) {
|
||||
exceptionNo: returnExceptionNo, outboundNo })
|
||||
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
|
||||
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
await finishRelabelDeliveries(context, tripProof, warehouseId)
|
||||
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
|
||||
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
|
||||
|
||||
@@ -48,3 +48,95 @@ test('failure to persist the request prevents network dispatch', async () => {
|
||||
await assert.rejects(verifyProcurementSample(context, inputs), /disk write failed/)
|
||||
assert.equal(events.length, 0)
|
||||
})
|
||||
|
||||
function completeFixture(fault = '') {
|
||||
const { context, inputs } = fixture()
|
||||
const calls = [], seen = new Map()
|
||||
let spent = 0, frozen = 0, creations = 0
|
||||
const order = { orderNo: 'CG-TEST-1', customerId: '101', status: 'PENDING_REVIEW', version: 0, updatedAt: '2026-10-22T12:00:00' }
|
||||
const fees = [], physical = { receipts: [], cartons: [], packingVersion: 0 }
|
||||
const transport = { overseasLabels: { version: 0 }, domesticLabels: { version: 0 }, shipments: [] }
|
||||
const detail = () => structuredClone({ customerView: { order, fees }, physical, transport, fees })
|
||||
inputs.upload = async () => ({ fileId: '501' })
|
||||
inputs.readWallet = async () => ({ actualBalance: ((1000 - spent) / 100).toFixed(2), availableBalance: ((1000 - spent - frozen) / 100).toFixed(2),
|
||||
frozenBalance: (frozen / 100).toFixed(2), creditUsed: '0.00' })
|
||||
context.request = async (method, path, body) => {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
const reply = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req-test', traceId: 'trace-test' })
|
||||
if (method === 'GET') {
|
||||
if (path.startsWith('/api/merchant/')) return reply({ order: { ...order, ...(fault === 'customer final order' ? { orderNo: 'OTHER' } : {}) } })
|
||||
const result = detail()
|
||||
if (fault === 'aggregate customer') result.customerView.order.customerId = '999'
|
||||
if (fault === 'aggregate order') result.customerView.order.orderNo = 'OTHER'
|
||||
if (fault === 'aggregate version') result.customerView.order.version = null
|
||||
return reply(result)
|
||||
}
|
||||
if (path === '/api/merchant/v1/last-leg/overseas-procurements') {
|
||||
creations++
|
||||
return reply({ order: { ...order, ...(creations === 2 && fault === 'create replay customer' ? { customerId: '999' } : {}) } })
|
||||
}
|
||||
assert.equal(method, 'POST')
|
||||
const action = path.split('/CG-TEST-1/')[1]?.split('?')[0]
|
||||
assert.ok(action, path)
|
||||
if (seen.has(body.idempotencyKey)) {
|
||||
assert.deepEqual(body, seen.get(body.idempotencyKey))
|
||||
if (fault === 'replay deducts' && action === 'purchase') spent += 100
|
||||
return reply(detail())
|
||||
}
|
||||
assert.equal(body.version, order.version)
|
||||
seen.set(body.idempotencyKey, structuredClone(body))
|
||||
switch (action) {
|
||||
case 'review': order.status = 'APPROVED'; break
|
||||
case 'purchase':
|
||||
spent += 100
|
||||
fees.push({ active: true, purpose: 'SAMPLE_PAYMENT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-1' })
|
||||
break
|
||||
case 'overseas-receipts': case 'domestic-receipts':
|
||||
physical.receipts.push({ id: String(601 + physical.receipts.length), leg: body.leg, active: true, goodQuantity: body.goodQuantity }); break
|
||||
case 'overseas-receipts/complete': case 'domestic-receipts/complete': break
|
||||
case 'packing':
|
||||
physical.cartons = body.cartons.map((row, index) => ({ ...row, id: String(701 + index) })); physical.packingVersion++; break
|
||||
case 'packing/confirm': assert.equal(body.packingVersion, physical.packingVersion); break
|
||||
case 'labels': transport[body.leg === 'OVERSEAS' ? 'overseasLabels' : 'domesticLabels'].version++; break
|
||||
case 'international-estimate':
|
||||
frozen = 200; fees.push({ active: true, purpose: 'INTERNATIONAL_ESTIMATE', walletStatus: 'FROZEN' }); break
|
||||
case 'labels/apply': break
|
||||
case 'overseas-shipment': case 'customer-shipment':
|
||||
transport.shipments.push({ leg: body.leg })
|
||||
if (action === 'customer-shipment') order.status = 'SHIPPED_TO_CUSTOMER'
|
||||
break
|
||||
case 'charges/confirm':
|
||||
spent += 150; frozen = 0
|
||||
fees.find(row => row.purpose === 'INTERNATIONAL_ESTIMATE').walletStatus = 'RELEASED'
|
||||
fees.push({ active: true, purpose: 'INTERNATIONAL_FREIGHT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-2' })
|
||||
break
|
||||
default: assert.fail(`Unexpected operation ${action}`)
|
||||
}
|
||||
order.version++
|
||||
return reply(detail())
|
||||
}
|
||||
return { context, inputs, calls, seen, physical, transport }
|
||||
}
|
||||
|
||||
test('sample procurement traverses receipt, packing, both shipments and exact wallet settlement with same-key replays', async () => {
|
||||
const h = completeFixture()
|
||||
const proof = await verifyProcurementSample(h.context, h.inputs)
|
||||
assert.equal(proof.status, 'PASSED')
|
||||
assert.equal(proof.settledAmountCny, '2.50')
|
||||
assert.deepEqual(proof.walletBusinessNos, ['PAY-1', 'PAY-2'])
|
||||
assert.equal(h.physical.receipts.length, 4)
|
||||
assert.equal(h.physical.cartons.length, 2)
|
||||
assert.deepEqual(h.transport.shipments.map(row => row.leg), ['OVERSEAS', 'DOMESTIC'])
|
||||
assert.equal(proof.walletChecks.length, 4)
|
||||
assert.ok(proof.checks.every(check => check.passed))
|
||||
})
|
||||
|
||||
for (const fault of ['aggregate customer', 'aggregate order', 'aggregate version', 'create replay customer', 'customer final order', 'replay deducts']) {
|
||||
test(`procurement stops rather than passing ${fault}`, async () => {
|
||||
const h = completeFixture(fault)
|
||||
await assert.rejects(verifyProcurementSample(h.context, h.inputs))
|
||||
assert.equal(h.context.createdResources.procurementSample.status, 'FAILED')
|
||||
if (fault.startsWith('aggregate') || fault === 'create replay customer') assert.equal(h.seen.size, 0)
|
||||
if (fault === 'replay deducts') assert.equal(h.physical.receipts.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const cents = value => {
|
||||
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
|
||||
}
|
||||
|
||||
/** Internal scenario; the TEST coordinator owns isolated customer creation, funding, sessions and file upload. */
|
||||
/** Internal scenario; the coordinator supplies the configured customer, existing balance, session and upload. */
|
||||
export async function verifyProcurementSample(context, inputs) {
|
||||
if (!context.marker || typeof context.checkpoint !== 'function' || typeof context.request !== 'function'
|
||||
|| inputs?.marker !== context.marker || !/^[1-9]\d*$/.test(String(inputs.customerId))
|
||||
@@ -44,7 +44,13 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
await check(label, response?.http === 200 && String(response?.body?.code) === expectedCode)
|
||||
return response.body.data
|
||||
}
|
||||
async function read() { detail = await request('GET', path(), undefined, '回查采购聚合详情'); return detail }
|
||||
async function read() {
|
||||
detail = await request('GET', path(), undefined, '回查采购聚合详情')
|
||||
await check('采购聚合仍属于原客户原单且版本有效', detail?.customerView?.order?.orderNo === proof.orderNo
|
||||
&& detail.customerView.order.customerId === customerId
|
||||
&& Number.isSafeInteger(detail.customerView.order.version) && detail.customerView.order.version >= 0)
|
||||
return detail
|
||||
}
|
||||
async function command(operation, values = {}, replay = false) {
|
||||
const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
|
||||
await request('POST', path(operation), body, operation)
|
||||
@@ -76,7 +82,7 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
proof.orderNo = created?.order?.orderNo; await context.checkpoint()
|
||||
await check('客户创建响应包含本轮订单', typeof proof.orderNo === 'string' && created.order.customerId === customerId)
|
||||
const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken)
|
||||
await check('重放没有新建采购单', repeated.order.orderNo === proof.orderNo)
|
||||
await check('重放没有新建采购单', repeated?.order?.orderNo === proof.orderNo && repeated.order.customerId === customerId)
|
||||
await read()
|
||||
await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
|
||||
await read()
|
||||
@@ -115,7 +121,9 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
await command('customer-shipment', { leg: 'DOMESTIC', labelVersion: domesticLabels, cartonIds, shippedAt: at() }, true)
|
||||
await check('寄客户正常终态及单次交寄', detail.customerView.order.status === 'SHIPPED_TO_CUSTOMER' && detail.transport.shipments.filter(row => row.leg === 'DOMESTIC').length === 1)
|
||||
const customer = await request('GET', `${MERCHANT}/${encodeURIComponent(proof.orderNo)}`, undefined, '客户最终详情', inputs.customerToken)
|
||||
await check('客户可见终态且没有平台内部数据', customer.order.status === 'SHIPPED_TO_CUSTOMER' && !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
|
||||
await check('客户可见终态且没有平台内部数据', customer?.order?.orderNo === proof.orderNo
|
||||
&& customer.order.customerId === customerId && customer.order.status === 'SHIPPED_TO_CUSTOMER'
|
||||
&& !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
|
||||
await wallet('重复交寄不再次扣款', 250n, 0n)
|
||||
proof.walletBusinessNos = [...new Set(detail.fees.filter(row => row.walletStatus === 'SETTLED').map(row => row.walletBusinessNo))]
|
||||
await check('每笔成功消费保留权威钱包业务引用', proof.walletBusinessNos.length === 2 && proof.walletBusinessNos.every(value => typeof value === 'string' && value.length > 0))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { run } from './suppliers.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
|
||||
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
|
||||
const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8')
|
||||
@@ -18,3 +20,57 @@ test('supplier OpenAPI exposes the internal active category validation contract
|
||||
assert.doesNotMatch(openapi,/\bscope\b/)
|
||||
assert.doesNotMatch(openapi,/platform:supplier:view/)
|
||||
})
|
||||
|
||||
function existingHarness({ empty = false, wrongDetail = false, failStats = false } = {}) {
|
||||
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
|
||||
const calls = []
|
||||
const categories = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
|
||||
runner.request = async (method, path) => {
|
||||
calls.push({ method, path })
|
||||
assert.equal(method, 'GET', 'fixed suppliers must never mutate master data')
|
||||
const url = new URL(path, 'http://offline.invalid')
|
||||
let data
|
||||
let code = '200'
|
||||
if (url.searchParams.get('tenantId') === '1') code = 'A0400'
|
||||
else if (url.pathname.endsWith('/stats')) {
|
||||
data = { booking: 1, customs: 1, trucking: 1, certificate: 1 }
|
||||
if (failStats) code = 'S0500'
|
||||
} else if (url.searchParams.has('category')) {
|
||||
const category = url.searchParams.get('category')
|
||||
data = { records: empty ? [] : [{ id: String(categories.indexOf(category) + 1), category, status: 'ACTIVE' }],
|
||||
page: 1, size: 20, total: empty ? 0 : 1, pages: empty ? 0 : 1 }
|
||||
} else {
|
||||
const id = url.pathname.split('/').at(-1)
|
||||
data = { id, tenantId: wrongDetail ? '88' : '0', category: categories[Number(id) - 1], status: 'ACTIVE' }
|
||||
}
|
||||
return { http: 200, body: { code, data }, requestId: 'request', traceId: 'trace' }
|
||||
}
|
||||
return { runner, calls }
|
||||
}
|
||||
|
||||
test('fixed supplier module runs through orchestrator using only existing read-only records', async () => {
|
||||
const { runner, calls } = existingHarness()
|
||||
await runner.executeModule('suppliers', () => run(runner))
|
||||
assert.equal(calls.length, 10)
|
||||
assert.equal(runner.modules[0].skipped, undefined)
|
||||
assert.equal(runner.modules[0].success, true)
|
||||
assert.equal(runner.coverageGaps.length, 1)
|
||||
assert.equal(runner.coverageGaps[0].scenario, 'fixed-resource-lifecycle')
|
||||
})
|
||||
|
||||
test('empty supplier lists retain detail gaps and never create replacements', async () => {
|
||||
const { runner, calls } = existingHarness({ empty: true })
|
||||
await run(runner)
|
||||
assert.equal(calls.length, 6)
|
||||
assert.equal(runner.coverageGaps.length, 5)
|
||||
assert.ok(runner.steps.every(step => step.passed))
|
||||
})
|
||||
|
||||
test('supplier detail scope drift and failed statistics remain failures', async () => {
|
||||
for (const options of [{ wrongDetail: true }, { failStats: true }]) {
|
||||
const { runner } = existingHarness(options)
|
||||
await runner.executeModule('suppliers', () => run(runner))
|
||||
assert.equal(runner.modules[0].success, false)
|
||||
assert.ok(runner.steps.some(step => !step.passed))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ function query(values = {}) { const p = new URLSearchParams(); for (const [k,v]
|
||||
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
|
||||
|
||||
export async function run(context) {
|
||||
if (context.options?.fixedCustomerId) return runExistingSuppliers(context)
|
||||
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
|
||||
const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20}))
|
||||
const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS)
|
||||
@@ -67,3 +68,40 @@ export async function run(context) {
|
||||
context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true}
|
||||
}
|
||||
}
|
||||
|
||||
/** 固定资源模式只读现有主档,生命周期缺口仍单独保留。 */
|
||||
async function runExistingSuppliers(context) {
|
||||
context.addCoverageGap(MODULE, 'fixed-resource-lifecycle', '供应商新增、编辑、停用生命周期',
|
||||
'固定资源模式仅验收现有供应商列表、筛选、统计、详情和作用域拒绝,不修改公共主档',
|
||||
'not-implemented', { blocking: false })
|
||||
const statsResponse = await context.request('GET', `${BASE}/stats?tenantId=0`)
|
||||
const stats = context.apiSuccess(MODULE, '查询四类供应商统计', statsResponse, '', OPTIONS)
|
||||
const nonNegativeInteger = value => value !== null && value !== undefined && value !== ''
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0
|
||||
context.assert(MODULE, '统计字段完整', statsResponse,
|
||||
['booking', 'customs', 'trucking', 'certificate'].every(key => nonNegativeInteger(stats?.[key])), '', OPTIONS)
|
||||
for (const category of ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']) {
|
||||
const response = await context.request('GET', query({ tenantId: 0, category, status: 'ACTIVE', page: 1, size: 20 }))
|
||||
const page = context.apiSuccess(MODULE, `查询已有${category}供应商`, response, '', OPTIONS)
|
||||
const rows = records(page)
|
||||
context.assert(MODULE, `${category}分页和启用类别筛选正确`, response,
|
||||
Array.isArray(page?.records) && Number(page.page) === 1 && Number(page.size) === 20
|
||||
&& nonNegativeInteger(page.total) && Number(page.pages) === Math.ceil(Number(page.total) / 20)
|
||||
&& rows.length === Math.min(20, Number(page.total))
|
||||
&& rows.every(row => id(row.id) && row.category === category && row.status === 'ACTIVE'), '', OPTIONS)
|
||||
if (!rows.length) {
|
||||
context.addCoverageGap(MODULE, `existing-${category}`, `${category}供应商详情`,
|
||||
'没有启用的既有样本;未创建供应商,详情未验收', 'prerequisite', { blocking: false })
|
||||
continue
|
||||
}
|
||||
const supplierId = id(rows[0].id)
|
||||
if (!supplierId) continue
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
const detail = context.apiSuccess(MODULE, `回查已有${category}供应商详情`, detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${category}详情身份、类别及平台归属一致`, detailResponse,
|
||||
id(detail?.id) === supplierId && id(detail?.tenantId) === '0' && detail?.category === category
|
||||
&& detail?.status === 'ACTIVE' && !Object.hasOwn(detail || {}, 'scope'), '', OPTIONS)
|
||||
}
|
||||
const denied = await context.request('GET', query({ tenantId: 1, page: 1, size: 20 }))
|
||||
context.assert(MODULE, 'tenantId非0被拒绝', denied, code(denied, 'A0400') && traced(denied), '', OPTIONS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
|
||||
|
||||
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
|
||||
export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) {
|
||||
if (!proof || proof.marker !== context.marker || !proof.orderNo || !proof.customerId
|
||||
|| context.createdResources?.phase4OverseasBusiness?.truck !== proof || proof.fulfillment
|
||||
|| proof.finalStatus !== 'WAITING_INBOUND') throw Error('卡车履约必须使用本轮未执行的待入仓原单')
|
||||
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw Error('卡车批次标识不合法')
|
||||
const evidence = proof.fulfillment = { status: 'RUNNING', requests: [] }
|
||||
let initialized = false
|
||||
const directory = new URL('../../reports/us-truck-checkpoints/', import.meta.url)
|
||||
// 总编排提供检查点时沿用其持久化边界;否则独占创建批次文件,拒绝覆盖旧执行。
|
||||
const persist = checkpoint ? () => checkpoint.call(context) : async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
|
||||
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
}
|
||||
const base = `/api/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(proof.orderNo)}`
|
||||
const suffix = `?customerId=${encodeURIComponent(proof.customerId)}`
|
||||
const check = (name, ok) => {
|
||||
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${proof.orderNo}`, OPTIONS)
|
||||
if (!ok) throw Error(name)
|
||||
}
|
||||
const request = async (method, action, body, expectedRejection = false) => {
|
||||
const log = { method, action, body, expectedRejection, status: 'PREPARED' }
|
||||
evidence.requests.push(log)
|
||||
await persist()
|
||||
log.status = 'SENDING'
|
||||
const response = await context.request(method, `${base}${action}${suffix}`, body)
|
||||
Object.assign(log, { http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
const ok = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
|
||||
await persist()
|
||||
if (expectedRejection) {
|
||||
check('卡车无效命令按预期错误码拒绝且保留诊断', [200, expectedRejection].includes(response?.http)
|
||||
&& [String(expectedRejection), `A0${expectedRejection}`].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
|
||||
return response.body.data
|
||||
}
|
||||
context.apiSuccess(MODULE, `本土卡车${method} ${action || '详情'}`, response,
|
||||
`HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, OPTIONS)
|
||||
if (!ok) throw Error(`卡车履约请求失败,不自动重发: ${action}`)
|
||||
return response.body.data
|
||||
}
|
||||
const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo
|
||||
&& String(detail.summary.customerId) === String(proof.customerId)
|
||||
&& Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0)
|
||||
try {
|
||||
let current = await request('GET', '')
|
||||
identity(current)
|
||||
check('卡车实收前仍为本轮待入仓单', current.summary.status === 'WAITING_INBOUND'
|
||||
&& current.remark?.startsWith(context.marker) && Number(current.summary.receivedPalletCount) === 0)
|
||||
const packages = Number(current.summary.packageCount)
|
||||
check('卡车实收箱数来自原订单且为有效正整数', Number.isSafeInteger(packages) && packages > 0)
|
||||
const packageCounts = packages >= 2 ? [1, packages - 1] : [packages]
|
||||
const palletCount = packageCounts.length
|
||||
const stages = [
|
||||
['receive-pallets', 'WAITING_CONSOLIDATION'],
|
||||
...(palletCount === 2 ? [['allocate-pallets', 'PARTIALLY_ALLOCATED', 0], ['allocate-pallets', 'ALLOCATED', 1]] : [['allocate-pallets', 'ALLOCATED', 0]]),
|
||||
['prepare-dispatch', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'],
|
||||
['start-transit', 'IN_TRANSIT'], ['complete', 'COMPLETED'],
|
||||
]
|
||||
let pallets
|
||||
const allocatedIds = new Set()
|
||||
for (const [action, state, allocationIndex] of stages) {
|
||||
if (action === 'allocate-pallets' && allocationIndex === 1) {
|
||||
await request('POST', '/prepare-dispatch', {
|
||||
version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
|
||||
}, 409)
|
||||
const unchanged = await request('GET', '')
|
||||
check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current))
|
||||
}
|
||||
const beforeVersion = current.summary.version
|
||||
const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker }
|
||||
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts })
|
||||
if (action === 'allocate-pallets') {
|
||||
body.palletIds = [pallets[allocationIndex].id]
|
||||
body.idempotencyKey += `-${allocationIndex}`
|
||||
}
|
||||
// 拒绝分支使用同一原单与独立键,回查整个聚合证明未偷偷收托或分配。
|
||||
if (action === 'receive-pallets' || action === 'allocate-pallets' && allocationIndex === 0) {
|
||||
const invalid = { ...body, idempotencyKey: `${body.idempotencyKey}-invalid`,
|
||||
...(action === 'receive-pallets' ? { palletPackageCounts: packageCounts.map((n, i) => n + (i === 0 ? 1 : 0)) } : { palletIds: [body.palletIds[0], body.palletIds[0]] }) }
|
||||
await request('POST', `/${action}`, invalid, 400)
|
||||
const unchanged = await request('GET', '')
|
||||
check(`卡车${action}拒绝后数量状态历史版本均不变`, JSON.stringify(unchanged) === JSON.stringify(current))
|
||||
}
|
||||
const next = await request('POST', `/${action}`, body)
|
||||
identity(next)
|
||||
check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1)
|
||||
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount
|
||||
&& new Set(next.pallets.map(row => String(row.id))).size === palletCount
|
||||
&& next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index]
|
||||
&& /^[1-9]\d*$/.test(String(row.id)) && typeof row.palletNo === 'string' && row.palletNo.startsWith(`${proof.orderNo}-P`)))
|
||||
pallets ??= next.pallets.map(row => ({ id: String(row.id), palletNo: row.palletNo, packageCount: Number(row.packageCount) }))
|
||||
if (action === 'allocate-pallets') allocatedIds.add(String(body.palletIds[0]))
|
||||
check('卡车履约复用原实收托盘且只分配本次选择', next.pallets.every((row, index) => String(row.id) === pallets[index].id
|
||||
&& row.palletNo === pallets[index].palletNo && row.status === (allocatedIds.has(String(row.id)) ? 'ALLOCATED' : 'RECEIVED'))
|
||||
&& Number(next.summary.allocatedPalletCount) === allocatedIds.size)
|
||||
check('卡车履约写入对应操作历史', next.histories?.some(row => row.idempotencyKey === body.idempotencyKey
|
||||
&& row.toStatus === state && row.fromStatus === current.summary.status))
|
||||
const replay = await request('POST', `/${action}`, body)
|
||||
check('卡车同键重放不重复收托或推进版本', JSON.stringify(replay) === JSON.stringify(next))
|
||||
current = await request('GET', '')
|
||||
check('卡车履约状态已持久化且回查一致', JSON.stringify(current) === JSON.stringify(next))
|
||||
}
|
||||
Object.assign(proof, { finalStatus: 'COMPLETED', version: current.summary.version, retained: true })
|
||||
Object.assign(evidence, { status: 'HTTP_VERIFIED', pallets, packages, partialAllocationVerified: palletCount === 2 })
|
||||
await persist()
|
||||
} catch (error) {
|
||||
for (const log of evidence.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
|
||||
evidence.status = 'FAILED'
|
||||
evidence.failure = error.message
|
||||
try { await persist() } catch (checkpointError) { evidence.checkpointFailure = checkpointError.message }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
|
||||
|
||||
// 用独立来源快照验证隔离、幂等与未知结果停写,不访问测试环境。
|
||||
function fixture(fault = '') {
|
||||
const proof = { marker: 'batch', customerId: '101', warehouseId: '201', transferNo: 'WT-1', inboundCode: 'IN-1', skuCode: 'SKU-1', status: 'APPROVED' }
|
||||
const calls = [], snapshots = []
|
||||
const allocation = { inventoryPositionId: '301', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', packageCount: 1 }
|
||||
const allocated = { transferNo: 'WT-1', status: 'WAIT_RELABEL', relabelNo: 'RL-1', relabelApplicationId: '601', allocations: [allocation] }
|
||||
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } },
|
||||
assert() {}, apiSuccess() {},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: structuredClone(body), token })
|
||||
let data
|
||||
if (method === 'POST') {
|
||||
if (fault === 'network') throw Error('connection closed')
|
||||
if (fault === 'server') return { http: 503, body: { code: '503' }, requestId: 'request-1' }
|
||||
data = structuredClone(allocated)
|
||||
if (fault === 'wrong allocation') data.allocations[0].inboundOrderId = '999'
|
||||
if (fault === 'replay' && calls.filter(c => c.method === 'POST').length === 2) data.relabelNo = 'RL-2'
|
||||
} else if (path.includes('inventory-candidates')) {
|
||||
data = [{ inventoryBalanceId: '701', inboundOrderId: '401', inboundCode: 'IN-1', overseasWarehouseId: '201', skuCode: 'SKU-1', positions: [{ positionId: '301', palletNo: 'P-1', availablePackages: 1 }] }]
|
||||
if (fault === 'old stock') data[0].inboundCode = 'IN-OLD'
|
||||
} else if (path.includes('/positions')) {
|
||||
data = [{ id: '301', balanceId: '701', customerId: '101', overseasWarehouseId: '201', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', availablePackages: 1 }]
|
||||
if (fault === 'foreign customer') data[0].customerId = '999'
|
||||
if (fault === 'foreign inbound') data[0].inboundOrderId = '999'
|
||||
if (fault === 'no stock') data[0].availablePackages = 0
|
||||
} else if (path.startsWith('/api/merchant')) {
|
||||
data = structuredClone(allocated)
|
||||
if (fault === 'merchant drift') data.relabelApplicationId = '999'
|
||||
} else data = { transferNo: 'WT-1', status: 'APPROVED', operatingWarehouseId: '201', needRelabel: true, packageCount: 1, allocations: [], sourceOrders: [{ inboundOrderId: '401', inboundCode: 'IN-1' }] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
const checkpoint = async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk unavailable') }
|
||||
return { proof, context, calls, snapshots, options: { proof, merchantToken: 'secret-token', checkpoint } }
|
||||
}
|
||||
|
||||
test('原转运分配同批库存、重放并由客户回查唯一换标申请', async () => {
|
||||
const f = fixture()
|
||||
await allocateWarehouseTransfer(f.context, f.options)
|
||||
assert.equal(f.proof.status, 'WAIT_RELABEL')
|
||||
assert.equal(f.proof.allocation.status, 'ALLOCATED_WAIT_RELABEL')
|
||||
assert.equal(f.proof.sourcePositionId, '301')
|
||||
const writes = f.calls.filter(c => c.method === 'POST')
|
||||
assert.equal(writes.length, 2)
|
||||
assert.deepEqual(writes[0].body, writes[1].body)
|
||||
assert.equal(writes[0].body.allocations[0].packageCount, 1)
|
||||
assert.equal(f.snapshots[0].allocation.requests[0].status, 'PREPARED')
|
||||
assert.equal(JSON.stringify(f.snapshots).includes('secret-token'), false)
|
||||
await assert.rejects(allocateWarehouseTransfer(f.context, f.options), /或已执行/)
|
||||
assert.equal(f.calls.length, 6)
|
||||
})
|
||||
|
||||
for (const fault of ['old stock', 'foreign customer', 'foreign inbound', 'no stock', 'network', 'server', 'wrong allocation', 'replay', 'merchant drift', 'disk']) {
|
||||
test(`转运分配遇到 ${fault} 停止且保留证据`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.proof.allocation.status, 'FAILED')
|
||||
const writes = f.calls.filter(c => c.method === 'POST')
|
||||
assert.equal(writes.length, ['replay', 'merchant drift'].includes(fault) ? 2 : ['network', 'server', 'wrong allocation'].includes(fault) ? 1 : 0)
|
||||
if (['network', 'server'].includes(fault)) assert.equal(f.proof.allocation.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
if (fault === 'disk') assert.equal(f.calls.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('未登记的同形证明不得触发请求', async () => {
|
||||
const f = fixture()
|
||||
f.options.proof = structuredClone(f.proof)
|
||||
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
const PLATFORM = '/api/platform/v1/last-leg'
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const id = value => String(value ?? '')
|
||||
const validId = value => /^[1-9]\d*$/.test(id(value))
|
||||
|
||||
/** 已有库存转运按本轮原入仓单分配真实仓位,保留关联换标申请供后续使用。 */
|
||||
export async function allocateWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
|
||||
if (proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|
||||
|| context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|
||||
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| !proof.transferNo || !proof.inboundCode || !proof.skuCode || !validId(proof.warehouseId)
|
||||
|| proof.status !== 'APPROVED' || !merchantToken || proof.allocation) throw Error('转运库存分配缺少本轮原单和客户来源,或已执行')
|
||||
const evidence = proof.allocation = { status: 'RUNNING', requests: [] }
|
||||
let initialized = false
|
||||
const directory = new URL('../../reports/warehouse-transfer-checkpoints/', import.meta.url)
|
||||
const persist = checkpoint || (async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
})
|
||||
const check = (name, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), `transferNo=${proof.transferNo}`, { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
|
||||
if (!passed) throw Error(name)
|
||||
}
|
||||
const request = async (method, path, body, token) => {
|
||||
const log = { method, path, body, status: 'PREPARED' }; evidence.requests.push(log); await persist()
|
||||
let response
|
||||
try { log.status = 'SENDING'; response = await context.request(method, path, body, token) }
|
||||
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
|
||||
const ok = response?.http === 200 && id(response?.body?.code) === '200'
|
||||
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
|
||||
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await persist()
|
||||
context.apiSuccess(MODULE, '转运原库存分配', response, JSON.stringify(log), { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
|
||||
if (!ok) throw Error('转运库存请求失败,保留原单且不自动重发')
|
||||
return response.body.data
|
||||
}
|
||||
const base = `${PLATFORM}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
|
||||
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
|
||||
try {
|
||||
const transfer = await request('GET', base + scope)
|
||||
check('转运原单已通过且待分配换标库存', transfer?.transferNo === proof.transferNo && transfer.status === 'APPROVED'
|
||||
&& id(transfer.operatingWarehouseId) === proof.warehouseId && transfer.needRelabel === true
|
||||
&& transfer.packageCount === 1 && transfer.allocations?.length === 0
|
||||
&& transfer.sourceOrders?.some(row => row.inboundCode === proof.inboundCode))
|
||||
const candidates = await request('GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?${new URLSearchParams({ operationWarehouseId: proof.warehouseId, inboundCode: proof.inboundCode })}`, undefined, merchantToken)
|
||||
const eligible = (Array.isArray(candidates) ? candidates : []).filter(c => c.inboundCode === proof.inboundCode && c.skuCode === proof.skuCode
|
||||
&& id(c.overseasWarehouseId) === proof.warehouseId && validId(c.inventoryBalanceId)
|
||||
&& transfer.sourceOrders.some(s => id(s.inboundOrderId) === id(c.inboundOrderId)))
|
||||
let source, position
|
||||
for (const candidate of eligible) {
|
||||
const positions = await request('GET', `${PLATFORM}/inventory/balances/${encodeURIComponent(candidate.inventoryBalanceId)}/positions${scope}`)
|
||||
check('库存位置回查必须返回完整数组', Array.isArray(positions))
|
||||
position = positions.find(p => id(p.customerId) === proof.customerId && id(p.overseasWarehouseId) === proof.warehouseId
|
||||
&& p.inboundCode === proof.inboundCode && id(p.inboundOrderId) === id(candidate.inboundOrderId)
|
||||
&& id(p.balanceId) === id(candidate.inventoryBalanceId)
|
||||
&& validId(p.id) && validId(p.palletId) && Number(p.availablePackages) >= 1
|
||||
&& candidate.positions?.some(cp => id(cp.positionId) === id(p.id) && cp.palletNo === p.palletNo && Number(cp.availablePackages) >= 1))
|
||||
if (position) { source = candidate; break }
|
||||
}
|
||||
check('只使用本轮入仓单同SKU实际可用托盘仓位', Boolean(source && position))
|
||||
Object.assign(proof, { sourceBalanceId: id(source.inventoryBalanceId), sourcePositionId: id(position.id), sourcePalletId: id(position.palletId),
|
||||
sourcePalletNo: position.palletNo, sourceInboundOrderId: id(source.inboundOrderId) })
|
||||
const body = evidence.request = { idempotencyKey: `${context.marker}-TRANSFER-ALLOCATE`, allocations: [{ inventoryPositionId: proof.sourcePositionId, palletId: proof.sourcePalletId, packageCount: 1 }] }
|
||||
const allocated = await request('POST', `${base}/allocations${scope}`, body)
|
||||
check('原库存分配进入待换标并产生唯一关联申请', allocated?.transferNo === proof.transferNo && allocated.status === 'WAIT_RELABEL'
|
||||
&& typeof allocated.relabelNo === 'string' && allocated.relabelNo.length > 0 && validId(allocated.relabelApplicationId)
|
||||
&& allocated.allocations?.length === 1 && id(allocated.allocations[0].inventoryPositionId) === proof.sourcePositionId
|
||||
&& id(allocated.allocations[0].inboundOrderId) === proof.sourceInboundOrderId && allocated.allocations[0].inboundCode === proof.inboundCode
|
||||
&& id(allocated.allocations[0].palletId) === proof.sourcePalletId && allocated.allocations[0].packageCount === 1)
|
||||
const replay = await request('POST', `${base}/allocations${scope}`, body)
|
||||
check('转运分配重放不生成第二申请或重复分配', JSON.stringify(replay) === JSON.stringify(allocated))
|
||||
const merchant = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
|
||||
check('客户回查同单同换标申请及真实来源', merchant?.transferNo === proof.transferNo && merchant.status === 'WAIT_RELABEL'
|
||||
&& merchant.relabelNo === allocated.relabelNo && id(merchant.relabelApplicationId) === id(allocated.relabelApplicationId)
|
||||
&& JSON.stringify(merchant.allocations) === JSON.stringify(allocated.allocations))
|
||||
Object.assign(proof, { status: 'WAIT_RELABEL', relabelNo: allocated.relabelNo, relabelApplicationId: id(allocated.relabelApplicationId) })
|
||||
evidence.status = 'ALLOCATED_WAIT_RELABEL'; await persist(); return proof
|
||||
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
|
||||
|
||||
// 替身返回独立的单据与库存证据,验证错误流水不能被成功状态掩盖。
|
||||
function fixture(fault = '') {
|
||||
const allocation = { inventoryPositionId: '301', palletId: '401', palletNo: 'P-1', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1', status: 'RESERVED' }
|
||||
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
|
||||
status: 'ALLOCATED', outboundAllocations: [allocation], relabelFulfillment: { execution: { status: 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' } } }
|
||||
const calls = [], snapshots = []
|
||||
let shipped = false, writes = 0
|
||||
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (method === 'POST') {
|
||||
writes++
|
||||
if (fault === 'network') throw Error('connection lost')
|
||||
shipped = true
|
||||
}
|
||||
if (path.includes('/ledger?')) {
|
||||
const rows = shipped || fault === 'existing shipment' ? [{ id: '601', palletNo: fault === 'wrong pallet' ? 'P-OTHER' : 'P-1', customerId: fault === 'foreign customer' ? '999' : '101',
|
||||
overseasWarehouseId: '201', skuCode: fault === 'old sku' ? 'OLD' : 'NEW', inboundCode: 'IN-1', businessType: 'SHIP', businessOrderNo: 'WT-1', deltaPackages: -1 }] : []
|
||||
if (fault === 'double shipment' && shipped) rows.push({ ...rows[0], id: '602' })
|
||||
if (fault === 'replay ledger' && writes === 2) rows[0].id = '699'
|
||||
data = { total: rows.length, records: rows }
|
||||
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: fault === 'wrong relabel' ? 'COMPLETED' : 'OUTBOUNDED' } }
|
||||
else data = { transferNo: 'WT-1', status: shipped ? 'OUTBOUNDED' : 'ALLOCATED', relabelNo: 'RL-1', relabelApplicationId: '501', operatingWarehouseId: '201',
|
||||
packageCount: 1, fbaWarehouseCode: 'FBA-1', outboundAt: shipped ? '2026-10-22T12:00:00' : null,
|
||||
allocations: [{ ...allocation, status: shipped ? 'SHIPPED' : 'RESERVED' }] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
const options = { proof, merchantToken: 'secret', checkpoint: async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
|
||||
return { context, options, proof, calls, snapshots }
|
||||
}
|
||||
|
||||
test('原转运出库验证同单新SKU扣库及重放唯一性', async () => {
|
||||
const f = fixture()
|
||||
await outboundWarehouseTransfer(f.context, f.options)
|
||||
assert.equal(f.proof.status, 'OUTBOUNDED')
|
||||
assert.equal(f.proof.outbound.status, 'OUTBOUNDED_HTTP_VERIFIED')
|
||||
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
|
||||
assert.equal(JSON.stringify(f.snapshots).includes('secret'), false)
|
||||
const count = f.calls.length
|
||||
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.calls.length, count)
|
||||
})
|
||||
|
||||
for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'disk']) {
|
||||
test(`原转运出库拒绝 ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.proof.outbound.status, 'FAILED')
|
||||
assert.equal(f.proof.status, 'ALLOCATED')
|
||||
const writes = f.calls.filter(c => c.method === 'POST').length
|
||||
assert.equal(writes, ['disk', 'existing shipment'].includes(fault) ? 0 : ['replay ledger', 'wrong relabel'].includes(fault) ? 2 : 1)
|
||||
if (fault === 'network') assert.equal(f.proof.outbound.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
})
|
||||
}
|
||||
|
||||
test('原转运未完成换标不得发出请求', async () => {
|
||||
const f = fixture()
|
||||
f.proof.relabelFulfillment.execution.status = 'FAILED'
|
||||
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg'
|
||||
const id = value => String(value ?? '')
|
||||
const validId = value => /^[1-9]\d*$/.test(id(value))
|
||||
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
||||
|
||||
/** 原转运换标确认后出库,独立核验新SKU出库流水及重放不重复扣库。 */
|
||||
export async function outboundWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
|
||||
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|
||||
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|
||||
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| proof.status !== 'ALLOCATED' || proof.relabelFulfillment?.execution?.status !== 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND'
|
||||
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.outbound) throw Error('转运出库缺少本轮原单换标完成证据,或已执行')
|
||||
const allocation = proof.outboundAllocations[0]
|
||||
if (!validId(allocation.inventoryPositionId) || !validId(allocation.palletId) || allocation.packageCount !== 1
|
||||
|| allocation.status !== 'RESERVED' || allocation.inboundCode !== proof.inboundCode || !allocation.skuCode || !allocation.palletNo) throw Error('转运实际出库预留无效')
|
||||
const evidence = proof.outbound = { status: 'RUNNING', requests: [] }
|
||||
let initialized = false
|
||||
const directory = new URL('../../reports/warehouse-transfer-outbound-checkpoints/', import.meta.url)
|
||||
const persist = checkpoint || (async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
})
|
||||
const check = (name, ok) => {
|
||||
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
|
||||
{ scenario: 'warehouse-transfer-outbound', deploymentBlocker: true })
|
||||
if (!ok) throw Error(name)
|
||||
}
|
||||
const request = async (method, path, body, token = context.token) => {
|
||||
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
|
||||
let response
|
||||
try { response = await context.request(method, path, body, token) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
|
||||
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
|
||||
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' })
|
||||
await persist()
|
||||
check(`转运出库 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
|
||||
return response.body.data
|
||||
}
|
||||
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
|
||||
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
|
||||
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
|
||||
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
|
||||
&& id(detail.operatingWarehouseId) === proof.warehouseId && detail.packageCount === 1
|
||||
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
|
||||
&& id(detail.allocations[0].palletId) === id(allocation.palletId) && detail.allocations[0].skuCode === allocation.skuCode
|
||||
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].inboundCode === proof.inboundCode
|
||||
&& detail.allocations[0].status === (status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED')
|
||||
const ledger = async () => {
|
||||
const page = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
|
||||
overseasWarehouseId: proof.warehouseId, businessOrderNo: proof.transferNo, businessType: 'SHIP', page: '1', size: '200' })}`)
|
||||
check('转运出库流水完整且无重复记录', Array.isArray(page?.records) && Number(page.total) === page.records.length
|
||||
&& new Set(page.records.map(row => id(row.id))).size === page.records.length)
|
||||
return page.records
|
||||
}
|
||||
try {
|
||||
const original = await request('GET', base + scope)
|
||||
check('出库前仍为原转运实际新SKU预留', same(original, 'ALLOCATED') && Boolean(original.fbaWarehouseCode))
|
||||
check('原转运出库前没有出库流水', (await ledger()).length === 0)
|
||||
const body = { idempotencyKey: `${context.marker}-TRANSFER-OUTBOUND`, remark: context.marker }
|
||||
const shipped = await request('POST', `${base}/outbound${scope}`, body)
|
||||
check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.outboundAt))
|
||||
const movements = await ledger()
|
||||
check('原转运唯一SHIP流水按实际新SKU托盘扣除一箱', movements.length === 1 && movements.every(row => validId(row.id)
|
||||
&& row.businessType === 'SHIP' && row.businessOrderNo === proof.transferNo && row.inboundCode === proof.inboundCode
|
||||
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === proof.warehouseId
|
||||
&& row.palletNo === allocation.palletNo
|
||||
&& row.skuCode === allocation.skuCode && Number(row.deltaPackages) === -1))
|
||||
const replay = await request('POST', `${base}/outbound${scope}`, body)
|
||||
check('出库重放保持原单和流水不变', stable(replay) === stable(shipped) && stable(await ledger()) === stable(movements))
|
||||
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
|
||||
check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.outboundAt === shipped.outboundAt)
|
||||
const relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
|
||||
check('原关联换标同步出库且未提前完成', relabel?.application?.relabelNo === proof.relabelNo
|
||||
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.fulfillmentStatus === 'OUTBOUNDED'
|
||||
&& relabel.application.status === 'READY_FOR_OUTBOUND')
|
||||
Object.assign(proof, { status: 'OUTBOUNDED' }); evidence.shipLedger = movements; evidence.status = 'OUTBOUNDED_HTTP_VERIFIED'
|
||||
await persist(); return proof
|
||||
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
|
||||
|
||||
// 收货替身使用独立目标仓资源,覆盖资源不足、串单、重复入库和结算未完成。
|
||||
function fixture(fault = '') {
|
||||
const allocation = { inventoryPositionId: '301', palletId: '401', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1' }
|
||||
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
|
||||
status: 'OUTBOUNDED', outboundAllocations: [allocation], outbound: { status: 'OUTBOUNDED_HTTP_VERIFIED' } }
|
||||
const pallet = { id: '601', warehouseId: '202', customerId: '0', locationId: '701', palletCode: 'TARGET', status: 'IDLE', fbaWarehouseCode: 'FBA-1' }
|
||||
const calls = [], saved = []
|
||||
let received = false, writes = 0
|
||||
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
|
||||
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (method === 'POST') {
|
||||
writes++
|
||||
if (fault === 'network') throw Error('connection lost')
|
||||
assert.equal(body.destinationOverseasWarehouseId, '202')
|
||||
assert.equal(body.destinationPalletId, '601')
|
||||
received = true
|
||||
}
|
||||
if (path.includes('/pallets?')) data = { total: fault === 'no resource' ? 0 : 1, records: fault === 'no resource' ? [] : [{ ...pallet }] }
|
||||
else if (path.endsWith('/pallets/601')) data = { ...pallet, status: fault === 'occupied' ? 'OCCUPIED' : 'IDLE' }
|
||||
else if (path.endsWith('/locations/701')) data = { id: '701', warehouseId: '202', customerId: '0', locationCode: 'A0', locationType: 'VIRTUAL', status: fault === 'location blocked' ? 'OCCUPIED' : 'ACTIVE' }
|
||||
else if (path.includes('/warehouse-admin/')) data = { warehouse: { id: '202', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'WH2', warehouseName: 'Target' } }
|
||||
else if (path.includes('/ledger?')) {
|
||||
const rows = received ? [{ id: '801', customerId: fault === 'foreign customer' ? '999' : '101', overseasWarehouseId: '202', businessOrderNo: 'WT-1',
|
||||
businessType: 'STOCK_IN', inboundCode: 'IN-1', skuCode: 'NEW', palletNo: 'TARGET', warehouseLocationCode: 'A0', deltaPackages: 1 }] : []
|
||||
if (fault === 'double receipt' && received) rows.push({ ...rows[0], id: '802' })
|
||||
if (fault === 'replay drift' && writes === 2) rows[0].id = '899'
|
||||
data = { total: rows.length, records: rows }
|
||||
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'COMPLETED', fulfillmentStatus: 'COMPLETED', walletStatus: fault === 'unsettled' ? 'SETTLE_PENDING' : 'SETTLED' } }
|
||||
else data = { transferNo: 'WT-1', status: received ? 'RECEIVED' : 'OUTBOUNDED', relabelNo: 'RL-1', relabelApplicationId: '501', fbaWarehouseCode: 'FBA-1',
|
||||
receiptCommands: received ? [{ status: 'COMPLETED', sourceInventoryPositionId: '301', request: { idempotencyKey: 'BATCH-TRANSFER-RECEIVE', batchNo: 'WT-1' },
|
||||
target: { warehouseId: '202', palletId: fault === 'target drift' ? '999' : '601', palletNo: 'TARGET', locationId: '701', locationCode: 'A0', fbaCode: 'FBA-1' } }] : [],
|
||||
allocations: [{ ...allocation, status: received ? 'RECEIVED' : 'SHIPPED', destinationInventoryPositionId: received ? '901' : null }] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
const options = { proof, merchantToken: 'secret', delay: async () => {}, checkpoint: async () => { saved.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
|
||||
return { context, options, proof, calls, saved }
|
||||
}
|
||||
|
||||
test('原转运复用异仓托盘收货并核对唯一入库和结算终态', async () => {
|
||||
const f = fixture()
|
||||
await receiveWarehouseTransfer(f.context, f.options)
|
||||
assert.equal(f.proof.status, 'RECEIVED')
|
||||
assert.equal(f.proof.receipt.status, 'RECEIVED_AND_SETTLED_HTTP_VERIFIED')
|
||||
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
|
||||
assert.ok(f.calls.filter(c => c.method === 'POST').every(c => c.path.includes('/receipts?')))
|
||||
assert.equal(JSON.stringify(f.saved).includes('secret'), false)
|
||||
const count = f.calls.length
|
||||
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.calls.length, count)
|
||||
})
|
||||
|
||||
for (const fault of ['no resource', 'occupied', 'location blocked', 'network', 'target drift', 'foreign customer', 'double receipt', 'replay drift', 'unsettled', 'disk']) {
|
||||
test(`转运收货遇到 ${fault} 明确失败且不补造资源`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
|
||||
assert.equal(f.proof.receipt.status, 'FAILED')
|
||||
const writes = f.calls.filter(c => c.method === 'POST').length
|
||||
assert.equal(writes, ['no resource', 'occupied', 'location blocked', 'disk'].includes(fault) ? 0 : ['replay drift', 'unsettled'].includes(fault) ? 2 : 1)
|
||||
if (fault === 'network') assert.equal(f.proof.receipt.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
const BASE = '/api/platform/v1/last-leg'
|
||||
const RESOURCES = '/api/platform/v1/overseas-warehouse-resources'
|
||||
const id = value => String(value ?? '')
|
||||
const validId = value => /^[1-9]\d*$/.test(id(value))
|
||||
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
||||
|
||||
/** 转运收货复用其他海外仓的空闲匹配托盘,并验证原单入库及关联换标结算。 */
|
||||
export async function receiveWarehouseTransfer(context, { proof, merchantToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
|
||||
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|
||||
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|
||||
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
|| proof.status !== 'OUTBOUNDED' || proof.outbound?.status !== 'OUTBOUNDED_HTTP_VERIFIED'
|
||||
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.receipt) throw Error('转运收货缺少本轮已出库原单,或已执行')
|
||||
const allocation = proof.outboundAllocations[0]
|
||||
const evidence = proof.receipt = { status: 'RUNNING', requests: [] }
|
||||
let initialized = false
|
||||
const directory = new URL('../../reports/warehouse-transfer-receipt-checkpoints/', import.meta.url)
|
||||
const persist = checkpoint || (async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
})
|
||||
const check = (name, ok) => {
|
||||
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
|
||||
{ scenario: 'warehouse-transfer-receipt', deploymentBlocker: true })
|
||||
if (!ok) throw Error(name)
|
||||
}
|
||||
const request = async (method, path, body, token = context.token) => {
|
||||
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
|
||||
let response
|
||||
try { response = await context.request(method, path, body, token) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
|
||||
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
|
||||
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' }); await persist()
|
||||
check(`转运收货 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
|
||||
return response.body.data
|
||||
}
|
||||
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
|
||||
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
|
||||
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
|
||||
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
|
||||
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
|
||||
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].skuCode === allocation.skuCode
|
||||
&& detail.allocations[0].status === (status === 'OUTBOUNDED' ? 'SHIPPED' : 'RECEIVED')
|
||||
try {
|
||||
const original = await request('GET', base + scope)
|
||||
check('原转运出库来源不变', same(original, 'OUTBOUNDED') && Boolean(original.fbaWarehouseCode))
|
||||
let target
|
||||
// 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。
|
||||
for (let page = 1; page <= 100 && !target; page++) {
|
||||
const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`)
|
||||
check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0)
|
||||
for (const candidate of pallets.records) {
|
||||
if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId)
|
||||
|| id(candidate.warehouseId) === proof.warehouseId || candidate.fbaWarehouseCode !== original.fbaWarehouseCode
|
||||
|| !validId(candidate.id) || !validId(candidate.locationId)) continue
|
||||
const resource = `${RESOURCES}/${candidate.warehouseId}`
|
||||
const pallet = await request('GET', `${resource}/pallets/${candidate.id}`)
|
||||
if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0'
|
||||
|| id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId)
|
||||
|| pallet.fbaWarehouseCode !== original.fbaWarehouseCode || !pallet.palletCode) continue
|
||||
const location = await request('GET', `${resource}/locations/${pallet.locationId}`)
|
||||
if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId)
|
||||
|| id(location?.id) !== id(pallet.locationId) || !location.locationCode
|
||||
|| !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue
|
||||
const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`)
|
||||
const warehouse = master?.warehouse
|
||||
if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0'
|
||||
|| warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue
|
||||
target = { pallet, location, warehouse }; break
|
||||
}
|
||||
if (page * 200 >= Number(pallets.total)) break
|
||||
}
|
||||
check('必须存在其他海外仓同目的仓的空闲托盘与可预留库位', Boolean(target))
|
||||
evidence.target = target
|
||||
const { pallet, location, warehouse } = target
|
||||
const ledger = async () => {
|
||||
const data = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
|
||||
overseasWarehouseId: id(warehouse.id), businessOrderNo: proof.transferNo, businessType: 'STOCK_IN', page: '1', size: '200' })}`)
|
||||
check('目标收货流水完整无重复', Array.isArray(data?.records) && Number(data.total) === data.records.length
|
||||
&& new Set(data.records.map(row => id(row.id))).size === data.records.length)
|
||||
return data.records
|
||||
}
|
||||
check('目标仓未重复接收原转运单', (await ledger()).length === 0)
|
||||
const body = evidence.command = { sourceInventoryPositionId: id(allocation.inventoryPositionId),
|
||||
destinationOverseasWarehouseId: id(warehouse.id), destinationOverseasWarehouseCode: warehouse.warehouseCode,
|
||||
destinationOverseasWarehouseName: warehouse.warehouseName, destinationWarehouseCode: original.fbaWarehouseCode,
|
||||
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||||
warehouseLocationId: id(location.id), warehouseLocationCode: location.locationCode,
|
||||
batchNo: proof.transferNo, idempotencyKey: `${context.marker}-TRANSFER-RECEIVE`, remark: context.marker }
|
||||
const received = await request('POST', `${base}/receipts${scope}`, body)
|
||||
check('原转运收货完成且生成目标库存位置', same(received, 'RECEIVED') && validId(received.allocations[0].destinationInventoryPositionId))
|
||||
const committed = received.receiptCommands?.[0]
|
||||
check('收货命令完成且权威目标绑定保持一致', received.receiptCommands?.length === 1 && committed?.status === 'COMPLETED'
|
||||
&& id(committed.sourceInventoryPositionId) === id(allocation.inventoryPositionId)
|
||||
&& committed.request?.idempotencyKey === body.idempotencyKey && committed.request?.batchNo === body.batchNo
|
||||
&& id(committed.target?.warehouseId) === id(warehouse.id) && id(committed.target?.palletId) === id(pallet.id)
|
||||
&& committed.target?.palletNo === pallet.palletCode && id(committed.target?.locationId) === id(location.id)
|
||||
&& committed.target?.locationCode === location.locationCode && committed.target?.fbaCode === original.fbaWarehouseCode)
|
||||
const movements = await ledger()
|
||||
check('目标仓唯一原单新SKU入库一箱', movements.length === 1 && movements.every(row => validId(row.id)
|
||||
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === id(warehouse.id)
|
||||
&& row.businessOrderNo === proof.transferNo && row.businessType === 'STOCK_IN' && row.inboundCode === proof.inboundCode
|
||||
&& row.skuCode === allocation.skuCode && row.palletNo === pallet.palletCode && row.warehouseLocationCode === location.locationCode
|
||||
&& Number(row.deltaPackages) === 1))
|
||||
const replay = await request('POST', `${base}/receipts${scope}`, body)
|
||||
check('收货重放不重复入库', stable(replay) === stable(received) && stable(await ledger()) === stable(movements))
|
||||
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
|
||||
check('客户原转运收货事实一致', same(owned, 'RECEIVED') && stable(owned.allocations) === stable(received.allocations))
|
||||
let relabel
|
||||
for (let attempt = 0; attempt < 31; attempt++) {
|
||||
relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
|
||||
check('原换标随转运收货完成且身份保持', relabel?.application?.relabelNo === proof.relabelNo
|
||||
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.status === 'COMPLETED'
|
||||
&& relabel.application.fulfillmentStatus === 'COMPLETED')
|
||||
if (relabel.application.walletStatus === 'SETTLED') break
|
||||
if (attempt < 30) await delay()
|
||||
}
|
||||
check('原转运关联换标费用完成结算', relabel.application.walletStatus === 'SETTLED')
|
||||
evidence.stockInLedger = movements; evidence.status = 'RECEIVED_AND_SETTLED_HTTP_VERIFIED'; proof.status = 'RECEIVED'
|
||||
await persist(); return proof
|
||||
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"numberSequences": "KEEP"
|
||||
},
|
||||
"sourceSha256": {
|
||||
"deploy/sql/头程数据库统一增量-MySQL8.sql": "38013327896214ccc4fe317f507bfcf0b11377b07a66cd4ffa41b109b73558fd",
|
||||
"deploy/sql/头程数据库统一增量-MySQL8.sql": "20f8f8129dad86957872d68fdea81967b72e5bdc7f1adef612677cc59e091bf4",
|
||||
"deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7"
|
||||
},
|
||||
"pendingReview": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user