fix(dispatch): confirm scanned loading without repeated measurements
This commit is contained in:
+2
-2
@@ -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); }
|
||||
}
|
||||
|
||||
+6
-10
@@ -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());
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+26
-9
@@ -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);
|
||||
|
||||
+1
-1
@@ -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); }
|
||||
|
||||
+11
-4
@@ -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) {
|
||||
|
||||
+7
-7
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user