feat(appointments): support scoped receiving and stable trip continuation
This commit is contained in:
@@ -21,10 +21,25 @@ public interface FleetDispatchGateway {
|
||||
Snapshot lockForPlanning(String dispatchNo);
|
||||
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
|
||||
Snapshot lockForAssignment(String dispatchNo);
|
||||
/** Original dispatch command; caller must first detach the dispatch from its vehicle. */
|
||||
void removeItem(String dispatchNo,long itemId);
|
||||
/** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */
|
||||
void assign(Snapshot dispatch, Assignment assignment);
|
||||
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
|
||||
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
|
||||
record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { }
|
||||
record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages,
|
||||
Instant terminalAt,String status,long recordVersion,java.util.List<ReturnedReceipt> receipts) { }
|
||||
record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages,
|
||||
java.util.List<TerminalDelivery> deliveries) { }
|
||||
/** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */
|
||||
default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) {
|
||||
var evidence=transitEvidence(dispatchNo,lock);
|
||||
return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(),
|
||||
evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(),
|
||||
d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(),
|
||||
d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList());
|
||||
}
|
||||
/** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */
|
||||
com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock);
|
||||
/** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */
|
||||
|
||||
+4
@@ -150,6 +150,10 @@ public class FleetTripExecutionService {
|
||||
String warning = null;
|
||||
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
|
||||
if (reason == null) reason = loadingReviewBlockReason(trip);
|
||||
if (reason == null) for (var member : bindings.listByTrip(trip.getId())) {
|
||||
reason = appointmentAllocations.departureBlockReason(member.getDispatchNo());
|
||||
if (reason != null) break;
|
||||
}
|
||||
if (reason == null) {
|
||||
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
|
||||
|
||||
+6
-1
@@ -2,6 +2,11 @@ package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> { }
|
||||
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> {
|
||||
@Select("SELECT * FROM ll_fleet_trip_history WHERE trip_id=#{tripId} AND action='BIND_DISPATCH' ORDER BY record_version DESC LIMIT 1001")
|
||||
java.util.List<FleetTripHistory> bindingHistory(@Param("tripId") long tripId);
|
||||
}
|
||||
|
||||
@@ -31,14 +31,19 @@ public final class FleetTripModels {
|
||||
public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) {
|
||||
public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); }
|
||||
}
|
||||
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { }
|
||||
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
|
||||
/** Original bound references only; these quantities never authorize current allocation. */
|
||||
public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
|
||||
public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName,
|
||||
String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone,
|
||||
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
|
||||
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
|
||||
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
|
||||
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
|
||||
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview) { }
|
||||
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview,
|
||||
List<PreviousDispatch> previousDispatches,String appointmentHistoryUnavailableReason) { }
|
||||
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
|
||||
FleetCompletionModels.Completed completion, ReviewState loadingReview,
|
||||
FleetExecutionModels.PlannedLoad capacityPlan, List<FleetBookingModels.Response> externalBookings) { }
|
||||
|
||||
@@ -135,7 +135,9 @@ public class FleetTripService {
|
||||
dispatches.assign(dispatch, assignment(trip));
|
||||
}
|
||||
persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator);
|
||||
return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
|
||||
var currentBindings=bindingResponses(trip);
|
||||
var history=previousDispatches(trip,currentBindings);
|
||||
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -302,12 +304,43 @@ public class FleetTripService {
|
||||
return detail(trip);
|
||||
}
|
||||
private FleetTripModels.Detail detail(FleetTripEntity trip) {
|
||||
var currentBindings=bindingResponses(trip);
|
||||
var history=previousDispatches(trip,currentBindings);
|
||||
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
|
||||
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
|
||||
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
|
||||
}
|
||||
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
|
||||
return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
|
||||
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList();
|
||||
var current=bindings.listByTrip(trip.getId());
|
||||
var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList());
|
||||
return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
|
||||
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
|
||||
}
|
||||
private record AppointmentHistory(List<FleetTripModels.PreviousDispatch> dispatches,String unavailableReason) { }
|
||||
private AppointmentHistory previousDispatches(FleetTripEntity trip,List<FleetTripModels.DispatchBinding> current) {
|
||||
var history=histories.bindingHistory(trip.getId());
|
||||
if(history.size()>1000) return new AppointmentHistory(List.of(),"原车绑定历史超过完整读取上限,请分页核对车次历史");
|
||||
String unavailableReason=null;
|
||||
var seen=new HashSet<List<String>>();
|
||||
current.forEach(binding->binding.appointmentBatches().forEach(batch->batch.sources().forEach(source->seen.add(referenceKey(batch,source)))));
|
||||
var previous=new ArrayList<FleetTripModels.PreviousDispatch>();
|
||||
for(var event:history) {
|
||||
var snapshot=read(event.getSnapshotJson(),FleetTripModels.AuditSnapshot.class);
|
||||
if(snapshot.dispatches()==null) { unavailableReason="原车历史缺少批次来源依据,已移出客户的历史追踪不完整";continue; }
|
||||
for(var binding:snapshot.dispatches()) {
|
||||
if(binding.appointmentBatches()==null) { unavailableReason="原车历史缺少批次来源依据,已移出客户的历史追踪不完整";continue; }
|
||||
var references=new ArrayList<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>();
|
||||
for(var batch:binding.appointmentBatches()) {
|
||||
var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList();
|
||||
if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing));
|
||||
}
|
||||
if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references)));
|
||||
}
|
||||
}
|
||||
return new AppointmentHistory(List.copyOf(previous),unavailableReason);
|
||||
}
|
||||
private static List<String> referenceKey(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batch,
|
||||
com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource source) {
|
||||
return List.of(batch.batchKey(),batch.customerId(),source.sourceKey(),source.destinationKey());
|
||||
}
|
||||
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
|
||||
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ public class AppointmentWorkTransportProvider implements AppointmentWorkFactGate
|
||||
var action=new Action(ActionCode.ARRANGE_TRANSPORT,"按批次选择实物并安排车次",request.workItemKey(),new TripTarget(null,request.batchKeys()));
|
||||
return new Fact(request.workItemKey(),"UNASSIGNED","UNASSIGNED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,null,null,
|
||||
List.of(ORDER_READ),List.of("platform:fleet:trip:create","platform:fleet:trip:edit"),action,
|
||||
request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null);
|
||||
request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null,"UNASSIGNED");
|
||||
}
|
||||
String timezone=timezone(trip.getWarehouseId());
|
||||
var missing=timezone==null?List.of("SOURCE_TIMEZONE"):List.<String>of();
|
||||
@@ -49,7 +49,7 @@ public class AppointmentWorkTransportProvider implements AppointmentWorkFactGate
|
||||
target==null?PlanStatus.UNPLANNED:trip.getVehicleId()!=null && trip.getDriverId()!=null?PlanStatus.CONFIRMED:PlanStatus.TARGET_SET,
|
||||
execution,target,trip.getVehicleId()!=null && trip.getDriverId()!=null?target:null,actual,timezone,trip.getTripNo(),trip.getId().toString(),
|
||||
List.of(ORDER_READ,"platform:fleet:trip:list"),action==null?List.of():List.of("platform:fleet:trip:edit"),action,
|
||||
"CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null);
|
||||
"CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null,commitments.impactToken("TRIP_PLAN:"+trip.getId()));
|
||||
}
|
||||
private String timezone(Long warehouse) {
|
||||
if(warehouse==null) return null;
|
||||
|
||||
+4
-1
@@ -14,10 +14,13 @@ public class AppointmentWorkTripChanges {
|
||||
public void changed(FleetTripEntity trip) {
|
||||
changes.requirementsChanged();
|
||||
String assignment=trip.getVehicleId()==null && trip.getDriverId()==null?null:trip.getVehicleId()+":"+trip.getDriverId();
|
||||
String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId())+":"+Objects.toString(trip.getStopsJson(),""));
|
||||
String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId()).stream().distinct().sorted().toList()+":"+Objects.toString(trip.getStopsJson(),""));
|
||||
// Fleet plan storage is explicitly UTC. This is an audit baseline, not a location-time projection.
|
||||
var start=trip.getPlannedDepartureAt(); var end=trip.getExpectedDriverEndAt();
|
||||
var slot=start==null?null:new AppointmentWorkModels.Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),"UTC");
|
||||
commitments.record("TRIP:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,cargo,"CANCELLED".equals(trip.getStatus())));
|
||||
// Cargo/stop membership changes require this vehicle to be rechecked, but do not
|
||||
// invalidate unchanged customers' receiving appointments. Common plan changes do.
|
||||
commitments.record("TRIP_PLAN:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,null,"CANCELLED".equals(trip.getStatus())));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user