fix(dispatch): confirm scanned loading without repeated measurements

This commit is contained in:
2026-10-21 18:44:26 +08:00
parent 6088ff10c2
commit 538a63c635
20 changed files with 105 additions and 73 deletions
+1 -1
View File
@@ -97,7 +97,7 @@
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -66,9 +66,9 @@ public final class DispatchCommands {
public Pick { recordVersion = requireVersion(recordVersion); }
}
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
/** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
List<LoadingMeasurement> measurements, String remark) {
String remark) {
/** 后台与PDA共用必填版本约束。 */
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
@@ -535,10 +535,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true));
if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true));
}
var absent=new ArrayList<String>();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
var absent=new ArrayList<String>();if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList());
pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(),
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),1,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
}
if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源");
String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets));
@@ -561,16 +561,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
var supplied=new HashMap<Long,LoadingMeasurement>();
for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复");
for(var p:preview.pallets()) {
var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex()
|| p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对");
}
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
verifyReviewResources(row);var context=context(actor,row);
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
@@ -37,7 +37,7 @@ public class DispatchLoadingDepartureService {
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark()));
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
@@ -12,14 +12,14 @@ public final class TripCapacityPolicy {
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
public Load {
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
if (palletSpaces < 0 || weightKg != null && weightKg.signum() < 0 || volumeCbm != null && volumeCbm.signum() < 0) {
throw new IllegalArgumentException("托位、重量和体积须为非负数");
}
}
public Load plus(Load other) {
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
weightKg == null || other.weightKg == null ? null : weightKg.add(other.weightKg),
volumeCbm == null || other.volumeCbm == null ? null : volumeCbm.add(other.volumeCbm));
}
}
@@ -28,7 +28,7 @@ public final class TripCapacityPolicy {
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
Objects.requireNonNull(load, "load");
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
if (load.palletSpaces() == 0 || load.weightKg() != null && load.weightKg().signum() == 0 || load.volumeCbm() != null && load.volumeCbm().signum() == 0) {
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
}
}
@@ -37,31 +37,48 @@ public final class TripCapacityPolicy {
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
return calculate(stopCount, capacity, bookings, false);
}
/** 装车使用已有测量;未知维度保留空值,仅校验已知货量,不把未知当作可售余量。 */
public static List<Segment> calculateActual(int stopCount, Load capacity, List<Booking> bookings) {
return calculate(stopCount, capacity, bookings, true);
}
private static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings, boolean actual) {
Objects.requireNonNull(capacity, "capacity");
Objects.requireNonNull(bookings, "bookings");
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
if (capacity.weightKg() == null || capacity.volumeCbm() == null || capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
throw new IllegalArgumentException("车辆容量必须大于零");
}
var numbers = new HashSet<String>();
for (Booking booking : bookings) {
Objects.requireNonNull(booking, "booking");
if (!actual && (booking.load().weightKg() == null || booking.load().volumeCbm() == null))
throw new IllegalArgumentException("计划承运货物须有重量和体积");
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
}
List<Segment> segments = new ArrayList<>();
for (int index = 0; index < stopCount - 1; index++) {
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
BigDecimal knownWeight = BigDecimal.ZERO, knownVolume = BigDecimal.ZERO;
for (Booking booking : bookings) {
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) {
occupied = occupied.plus(booking.load());
if (booking.load().weightKg() != null) knownWeight = knownWeight.add(booking.load().weightKg());
if (booking.load().volumeCbm() != null) knownVolume = knownVolume.add(booking.load().volumeCbm());
}
}
if (occupied.palletSpaces() > capacity.palletSpaces()
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
|| knownWeight.compareTo(capacity.weightKg()) > 0
|| knownVolume.compareTo(capacity.volumeCbm()) > 0) {
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
}
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
occupied.weightKg() == null ? null : capacity.weightKg().subtract(occupied.weightKg()),
occupied.volumeCbm() == null ? null : capacity.volumeCbm().subtract(occupied.volumeCbm()));
segments.add(new Segment(index, index + 1, occupied, remaining));
}
return List.copyOf(segments);
@@ -112,7 +112,7 @@ public final class FleetHandoverPolicy {
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
}
private static boolean positive(Load load) {
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
return load != null && load.palletSpaces() > 0 && load.weightKg() != null && load.volumeCbm() != null && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
}
private static boolean blank(String text) { return text == null || text.isBlank(); }
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
@@ -31,7 +31,11 @@ public class FleetBookingReservations {
/** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
return segments(trip,own.pallets(),excludedId,addition);
return segments(trip,own.pallets(),excludedId,addition,false);
}
/** 已扫码装车无需重复测量,未知重量体积不计作可接运力。 */
public List<TripCapacityPolicy.Segment> actualSegments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> pallets) {
return segments(trip,pallets,null,null,true);
}
/** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */
public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) {
@@ -40,17 +44,20 @@ public class FleetBookingReservations {
throw conflict("请先重新核对自有货容量计划,再确认外接实收");
// 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。
List<FleetExecutionModels.Measurement> pallets=releasedReceipt?List.of():own.capacityPlan().pallets();
segments(trip,pallets,row.getId(),row);
segments(trip,pallets,row.getId(),row,false);
}
/** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */
private List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> own,
Long excludedId,FleetBookingEntity addition) {
Long excludedId,FleetBookingEntity addition,boolean actual) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
if(addition!=null) add(loads,stops,addition);
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
try {
var capacity=new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm());
return actual?TripCapacityPolicy.calculateActual(stops.size(),capacity,loads):TripCapacityPolicy.calculate(stops.size(),capacity,loads);
}
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
}
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
@@ -88,9 +88,8 @@ public class FleetTripExecutionService {
List<FleetExecutionModels.Measurement> measurements,String reason) {
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输");
var ownSegments=capacity(trip,cargo,measurements);
var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now());
var segments=bookingReservations.segments(trip,plan,null,null);
capacity(trip,cargo,measurements,true);
var segments=bookingReservations.actualSegments(trip,measurements);
// 同时核对原有外接预订占用;不因新主流程绕过已确认容量。
var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now());
trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator);
@@ -103,7 +102,7 @@ public class FleetTripExecutionService {
var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对");
if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId());
if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId());
bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null);
bookingReservations.actualSegments(trip,load.pallets());
appointmentAllocations.requireDeparture(no);
}
@@ -157,7 +156,7 @@ public class FleetTripExecutionService {
String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now());
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets, false), operator, Instant.now());
bookingReservations.segments(trip, plan, null, null);
trip.setPlannedLoadJson(write(plan));
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
@@ -234,7 +233,7 @@ public class FleetTripExecutionService {
return null;
}
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> pallets) {
List<FleetExecutionModels.Measurement> pallets, boolean actual) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
@@ -262,7 +261,8 @@ public class FleetTripExecutionService {
if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积");
if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null)
throw conflict("车辆容量未确认");
return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings);
var limits = new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm());
return actual ? TripCapacityPolicy.calculateActual(stops.size(), limits, bookings) : TripCapacityPolicy.calculate(stops.size(), limits, bookings);
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
}
private void requireResource(String kind, long id, long tripId) {
@@ -426,8 +426,8 @@ public class FleetTripService {
if (trip.getLoadJson() != null) {
var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class);
if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces()
|| segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
|| segment.occupied().weightKg() != null && segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm() != null && segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
}
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
@@ -6023,18 +6023,13 @@ components:
manifestHash:
type: string
nullable: true
measurements:
type: array
items:
$ref: '#/components/schemas/DispatchLoadingMeasurement'
nullable: true
remark:
type: string
nullable: true
required:
- recordVersion
- idempotencyKey
description: 必须先由真实PDA拣货核平;这里只确认实载,不代填拣货箱数,不等于实际发车。
description: 必须先由真实PDA拣货核平;按已扫实托确认装车;重量体积由服务端已有测量带出,缺失不阻塞,不接受现场补填测量或箱数。
DispatchDepart:
type: object
x-java-record: DispatchCommands.Depart
@@ -283,10 +283,8 @@ public class DispatchAggregatePersistenceTest {
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual);
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces");
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),"实际核对");
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
@@ -298,6 +296,10 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
var facts=((DispatchResults.Loaded)loaded.result()).confirmedLoad();
assertThat(facts.pallets()).hasSize(2);
assertThat(facts.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(facts.pallets().get(1).weightKg()).isNull();assertThat(facts.pallets().get(1).volumeCbm()).isNull();
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
@@ -316,7 +318,7 @@ public class DispatchAggregatePersistenceTest {
f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2");
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
}
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
@@ -64,7 +64,7 @@ class DispatchDeparturePersistenceTest {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created);
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载")));
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),"实际实载")));
var context=f.tx.execute(s->f.service.preview(f.actor,number));
outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1));
deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1));
@@ -84,7 +84,7 @@ class DispatchLoadingDeparturePersistenceTest {
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
@@ -243,7 +243,7 @@ class DispatchLoadingDeparturePersistenceTest {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
@@ -50,7 +50,7 @@ class DispatchLoadingEligibilityPersistenceTest {
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
@@ -96,7 +96,7 @@ class DispatchMergedRelabelPersistenceTest {
var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5);
var pallet=preview.pallets().get(0);
var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(),
List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null)));
null)));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2);
}
@@ -53,4 +53,18 @@ class TripCapacityPolicyTest {
@Test void emptyTripRetainsCapacityOnEachSegment() {
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
}
@Test void thirtyActualPalletsWithoutMeasurementsDoNotBecomeZeroWeightOrSellableCapacity() {
var pallets=java.util.stream.IntStream.range(0,30).mapToObj(i->new Booking("P"+i,0,1,new Load(1,null,null))).toList();
var segment=calculateActual(2,capacity,pallets).get(0);
assertThat(segment.occupied().palletSpaces()).isEqualTo(30);
assertThat(segment.occupied().weightKg()).isNull();assertThat(segment.remaining().volumeCbm()).isNull();
assertThatThrownBy(()->calculate(2,capacity,pallets)).hasMessageContaining("重量和体积");
var tooMany=new java.util.ArrayList<>(pallets);tooMany.add(new Booking("EXTRA",0,1,new Load(1,null,null)));
assertThatThrownBy(()->calculateActual(2,capacity,tooMany)).hasMessageContaining("超过");
}
@Test void unknownPalletDoesNotHideKnownOverweight() {
assertThatThrownBy(()->calculateActual(2,capacity,List.of(new Booking("UNKNOWN",0,1,new Load(1,null,null)),
new Booking("KNOWN",0,1,load(1,"18001","1"))))).hasMessageContaining("超过");
}
}
@@ -70,11 +70,11 @@ function model(fault = '') {
if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'}
return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平')
}
assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2)
assert.equal(body.recordVersion,nativeVersion); assert.equal('measurements' in body,false)
stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT'
for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} }
if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'}
confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
confirmedLoad={manifestHash:body.manifestHash,pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletSpaces:1,weightKg:100,volumeCbm:1,deliveryIndex:i+1})),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE')
}
if (path.endsWith('/pick')) {
@@ -119,13 +119,13 @@ test('current DTO-shaped two-customer trip rejects unready gate, departs once an
assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind')))
})
test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{
test('scanned pallets confirm without a separate pallet-space measurement receipt',async()=>{
const m=model();delete m.fixture.loadingMeasurements
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/)
assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null))
assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent')
assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false)
await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})
assert.ok(m.state.every(row=>row.picked===1&&row.delivery!==null))
assert.ok(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).every(call=>!('measurements' in call.body)))
})
test('explicit rollback verifier runs after both picks and before successful departure',async()=>{
const m=model();let calls=0
const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory,
@@ -169,7 +169,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
&& canonical(priorProof.members) === canonical(proof.members)
&& priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED')
&& Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion)
&& canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets)
&& canonical(fixture.tripProof.loadingPreview?.pallets?.map(p=>({warehousePalletId:id(p.warehousePalletId),palletSpaces:1,weightKg:p.weightKg??null,volumeCbm:p.volumeCbm??null,deliveryIndex:p.deliveryIndex}))) === canonical(command?.result?.result?.confirmedLoad?.pallets)
&& prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING'
&& command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING'
&& command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey
@@ -200,7 +200,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
&& rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false)
const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true)
await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'),
manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true,
manifestHash: preview.manifestHash, remark: context.marker }, 'Unready second customer blocks explicit loading', true,
message => /拣货.*核平|尚未核平/.test(message))
equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED'
@@ -143,9 +143,9 @@ function server(fault = '') {
}
assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3)
assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST')
assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }])
assert.equal('measurements' in body, false)
loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM')
detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })
detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: PALLET, palletSpaces: 1, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }] } })
}
if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) {
const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind')
@@ -313,7 +313,7 @@ test('PDA original-actor replay restores loading while another platform actor is
assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO')
assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay)
assert.equal(proof.loadingCommand.platformReplay, undefined)
assert.equal(writes[0].body.measurements[0].weightKg, '187.25')
assert.equal('measurements' in writes[0].body, false)
assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path)))
const count = s.calls.length
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/)
@@ -347,10 +347,11 @@ for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`l
if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false)
})
test('missing actual producer measurement blocks before any edit rejection or loading write', async () => {
const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/)
assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET')
test('scanned loading no longer requires the operator to supply a measurement receipt', async () => {
const s = server(), proof = await prepare(s); s.pick()
await confirmFleetLoading(s.context, 'native', proof, 'PDA')
assert.equal(proof.loadingCommand.result.stage, 'DELIVERING')
assert.equal('measurements' in proof.loadingCommand.body, false)
})
test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => {
@@ -228,11 +228,11 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets)
const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex }))
await verifyPreDepartureReassignment(context, module, proof)
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
if (beforeComplete) await beforeComplete(proof.loadingCommand)
const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken)
check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING'
@@ -240,8 +240,8 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
const returned = confirmed.result.confirmedLoad.pallets
check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length
&& measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId
&& r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg)
&& decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex)))
&& r.palletSpaces === m.palletSpaces && (r.weightKg == null ? null : decimal(r.weightKg)) === (m.weightKg == null ? null : decimal(m.weightKg))
&& (r.volumeCbm == null ? null : decimal(r.volumeCbm)) === (m.volumeCbm == null ? null : decimal(m.volumeCbm)) && r.deliveryIndex === m.deliveryIndex)))
proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad
const snapshot = async label => ({
main: await command(context, module, 'GET', path, undefined, `${label}主单`),