feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -23,6 +23,7 @@ import java.util.*;
@Service
public class FleetTripExecutionService {
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
@@ -228,6 +229,7 @@ public class FleetTripExecutionService {
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
// Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result.
for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo());
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
@@ -7,7 +7,14 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
@Select("SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,expected_driver_end_at,departed_at,completed_at,stops_json FROM ll_fleet_trip WHERE planned_departure_at>=#{from} AND planned_departure_at<#{to} ORDER BY planned_departure_at,trip_no LIMIT 2001")
@Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json
FROM ll_fleet_trip
WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
""")
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@@ -27,6 +27,8 @@ import java.util.*;
@Service
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
@@ -205,6 +207,7 @@ public class FleetTripService {
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
@@ -339,6 +342,7 @@ public class FleetTripService {
record(trip, action, reason, operator);
}
private void record(FleetTripEntity trip, String action, String reason, String operator) {
workChanges.changed(trip);
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.SupplierDirectory;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import com.baiyue.lastleg.fleet.quote.FleetQuoteSupplierClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkSupplierDirectory implements SupplierDirectory {
private final FleetQuoteSupplierClient suppliers;
private final String token;
public AppointmentWorkSupplierDirectory(FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token:}") String token) { this.suppliers=suppliers; this.token=token; }
public Responsibility activeSupplier(String id) {
final long number;
try { number=Long.parseLong(id); if(number<=0 || !Long.toString(number).equals(id)) throw new NumberFormatException(); }
catch(RuntimeException error) { throw bad("供应商关联标识无效"); }
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=suppliers.get(token,number,true,"TRUCKING");
if(response==null || !response.successful() || response.data()==null) throw unavailable("供应商权威目录读取失败");
var supplier=response.data();
if(supplier.supplierId()!=number || !"ACTIVE".equals(supplier.status()) || !"TRUCKING".equals(supplier.category()) || !"OVERSEAS".equals(supplier.truckingScope()) || supplier.name()==null || supplier.name().isBlank()) throw bad("还空责任方须为有效海外车队");
return new Responsibility(ResponsibilityKind.SUPPLIER,id,supplier.name());
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkTransportProvider implements AppointmentWorkFactGateway.BusinessProvider {
private final AppointmentWorkTripMapper mapper;
private final FleetWarehouseClient warehouses;
private final AppointmentWorkCommitmentService commitments;
private final String token;
public AppointmentWorkTransportProvider(AppointmentWorkTripMapper mapper,FleetWarehouseClient warehouses,AppointmentWorkCommitmentService commitments,@Value("${baiyue.security.internal-service-token:}") String token) { this.mapper=mapper; this.warehouses=warehouses; this.commitments=commitments; this.token=token; }
public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.TRANSFER; }
public List<Fact> read(List<FactRequest> requests) { return requests.stream().map(this::read).toList(); }
private Fact read(FactRequest request) {
FleetTripEntity trip=null;
if(request.tripKey()!=null) {
try { trip=mapper.trip(Long.parseLong(request.tripKey())); }
catch(NumberFormatException error) { throw unavailable("车次关联键无效",error); }
if(trip==null) throw unavailable("已关联原车次缺失");
} else if(request.batchKey()!=null) {
var trips=mapper.batchTrips(request.batchKey());
if(trips.size()>1) throw conflict("同一预约批次关联多个车次,请在原业务核对分批与实物关系");
if(!trips.isEmpty()) trip=trips.get(0);
}
if(trip==null) {
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);
}
String timezone=timezone(trip.getWarehouseId());
var missing=timezone==null?List.of("SOURCE_TIMEZONE"):List.<String>of();
var target=localSlot(trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),timezone);
boolean complete="COMPLETED".equals(trip.getStatus()) && trip.getCompletedAt()!=null;
var occurred=complete?trip.getCompletedAt():null;
var actual=occurred==null?null:new Actual(occurred.toInstant(ZoneOffset.UTC),null,timezone);
var execution=complete?ExecutionStatus.COMPLETED:trip.getDepartedAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var action=complete?null:new Action(ActionCode.ARRANGE_TRANSPORT,"维护原车次安排",request.workItemKey(),new TripTarget(trip.getId().toString(),mapper.batches(trip.getId())));
return new Fact(request.workItemKey(),"TRIP:"+trip.getId()+":"+trip.getRecordVersion(),commitments.impactToken("TRIP:"+trip.getId()),true,
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);
}
private String timezone(Long warehouse) {
if(warehouse==null) return null;
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=warehouses.platformMetadata(token,new FleetWarehouseClient.MetadataRequest(List.of(warehouse)));
if(response==null || !response.successful() || response.data()==null || response.data().records()==null || response.data().missingIds()==null) throw unavailable("车次地点目录读取不完整");
var matches=response.data().records().stream().filter(w->warehouse.equals(w.id())).toList();
if(matches.size()>1 || matches.isEmpty() && !response.data().missingIds().contains(warehouse)) throw unavailable("车次地点目录遗漏请求结果");
if(matches.isEmpty() || matches.get(0).timezone()==null) return null;
zone(matches.get(0).timezone()); return matches.get(0).timezone();
}
private Slot localSlot(LocalDateTime start,LocalDateTime end,String timezone) {
// An absolute UTC plan cannot be relabelled as unknown local time.
if(start==null || timezone==null) return null;
var local=start.toInstant(ZoneOffset.UTC).atZone(zone(timezone)); var finish=end==null?null:end.toInstant(ZoneOffset.UTC).atZone(zone(timezone));
return new Slot(local.toLocalDate(),local.toLocalTime(),finish==null?null:finish.toLocalDate(),finish==null?null:finish.toLocalTime(),timezone);
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class AppointmentWorkTripChanges {
private final AppointmentWorkChanges changes;
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkTripMapper mapper;
public AppointmentWorkTripChanges(AppointmentWorkChanges changes,AppointmentWorkCommitmentService commitments,AppointmentWorkTripMapper mapper) { this.changes=changes; this.commitments=commitments; this.mapper=mapper; }
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(),""));
// 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())));
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentWorkTripMapper {
@Select("SELECT DISTINCT t.* FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id JOIN ll_fleet_trip t ON t.id=b.trip_id AND t.status<>'CANCELLED' WHERE a.batch_key=#{batch}")
List<FleetTripEntity> batchTrips(String batch);
@Select("SELECT * FROM ll_fleet_trip WHERE id=#{id}") FleetTripEntity trip(long id);
@Select("SELECT DISTINCT a.batch_key FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE b.trip_id=#{id} ORDER BY a.batch_key")
List<String> batches(long id);
}