feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data @TableName("ll_fleet_external_booking")
|
||||
public class FleetBookingEntity {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private String bookingNo;
|
||||
private Long tripId;
|
||||
private String tripNo;
|
||||
private String customerId;
|
||||
private String customerCode;
|
||||
private String customerName;
|
||||
private String status;
|
||||
private String cargoJson;
|
||||
private String destinationKey;
|
||||
private String destinationLabel;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String confirmKey;
|
||||
private String confirmHash;
|
||||
private String cancelKey;
|
||||
private String cancelHash;
|
||||
private String incomingNo;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo;
|
||||
private String receiptJson;
|
||||
private String incomingKey;
|
||||
private String incomingHash;
|
||||
private String receiptKey;
|
||||
private String receiptHash;
|
||||
private String bindingKey;
|
||||
private String bindingHash;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private String updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Confirmed commercial facts only; delivery owns fee publication in its transaction. */
|
||||
@Component
|
||||
public class FleetBookingFreight {
|
||||
private final FleetBookingMapper bookings;
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetBookingReservations reservations;
|
||||
private final ObjectMapper json;
|
||||
public FleetBookingFreight(FleetBookingMapper bookings, FleetTripDispatchMapper bindings,
|
||||
FleetBookingReservations reservations, ObjectMapper json) {
|
||||
this.bookings = bookings; this.bindings = bindings; this.reservations = reservations; this.json = json;
|
||||
}
|
||||
public record Quote(String bookingNo, long recordVersion, String incomingNo, String inboundCode,
|
||||
String customerName, String destinationKey, String currency, BigDecimal amount,
|
||||
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice) { }
|
||||
public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
|
||||
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { }
|
||||
|
||||
/** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */
|
||||
public Preview preview(long dispatchId, String dispatchNo, String customerId,
|
||||
List<FleetDispatchGateway.SourceCargo> sources) {
|
||||
var quotes = new ArrayList<Quote>();
|
||||
var priorInboundCodes = new TreeSet<String>();
|
||||
var grouped = new TreeMap<String, List<FleetDispatchGateway.SourceCargo>>();
|
||||
boolean requiresFee = false;
|
||||
for (var source : sources) {
|
||||
if (source.inboundCode() == null || source.inboundCode().isBlank()) requiresFee = true;
|
||||
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
|
||||
}
|
||||
for (var entry : grouped.entrySet()) {
|
||||
var booking = bookings.incomingForFreight(entry.getKey());
|
||||
if (booking == null) { requiresFee = true; continue; }
|
||||
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
|
||||
var accepted = reservations.receipt(booking);
|
||||
if (accepted != null && accepted.source().inboundCode() != null && !accepted.source().inboundCode().isBlank())
|
||||
priorInboundCodes.add(accepted.source().inboundCode());
|
||||
if ("CANCELLED".equals(booking.getStatus())) { requiresFee = true; continue; }
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (!"BOUND".equals(booking.getStatus()) || !customerId.equals(booking.getCustomerId())
|
||||
|| !dispatchNo.equals(booking.getDispatchNo()) || binding == null
|
||||
|| !Objects.equals(binding.getDispatchId(), dispatchId)
|
||||
|| !Objects.equals(binding.getTripId(), booking.getTripId()))
|
||||
throw conflict("外接货须先在承诺车次中完成实收确认和排车关联");
|
||||
if (accepted == null) throw conflict("外接货缺少实收与报价确认");
|
||||
if (accepted.source().inboundCode() == null || accepted.source().inboundCode().isBlank())
|
||||
throw conflict("外接实收缺少实际入仓码,请先核对来货来源");
|
||||
var actual = new TreeMap<String, Integer>();
|
||||
for (var source : entry.getValue()) {
|
||||
if (source.warehousePalletId() == null || !Objects.equals(booking.getDestinationKey(), source.destinationKey()))
|
||||
throw conflict("外接发运来源或目的地已变化,请重新核对");
|
||||
actual.merge(source.warehousePalletId(), source.packages(), Math::addExact);
|
||||
}
|
||||
var confirmed = new TreeMap<String, Integer>();
|
||||
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact);
|
||||
if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致");
|
||||
var cargo = reservations.cargo(booking);
|
||||
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
|
||||
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
|
||||
|| quantity.multiply(cargo.unitPrice()).compareTo(booking.getAgreedAmount()) != 0)
|
||||
throw conflict("外接约定运费与实收计价依据不一致");
|
||||
if (!Set.of("CNY", "USD").contains(cargo.currency())) throw conflict("外接运费入账仅支持CNY或USD,请先更正约定币种");
|
||||
quotes.add(new Quote(booking.getBookingNo(), booking.getRecordVersion(), booking.getIncomingNo(),
|
||||
accepted.source().inboundCode(), booking.getCustomerName(), booking.getDestinationKey(), cargo.currency(),
|
||||
booking.getAgreedAmount(), quantity, "PALLET".equals(cargo.priceBasis()) ? "托位" : "单", cargo.unitPrice()));
|
||||
}
|
||||
if (sources.isEmpty()) requiresFee = true;
|
||||
try {
|
||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
|
||||
json.writeValueAsString(List.of(dispatchNo, customerId, sources.stream().sorted(Comparator
|
||||
.comparing(FleetDispatchGateway.SourceCargo::inboundCode, Comparator.nullsFirst(String::compareTo))
|
||||
.thenComparing(FleetDispatchGateway.SourceCargo::warehousePalletId, Comparator.nullsFirst(String::compareTo))
|
||||
.thenComparing(FleetDispatchGateway.SourceCargo::destinationKey, Comparator.nullsFirst(String::compareTo))
|
||||
.thenComparingInt(FleetDispatchGateway.SourceCargo::packages)).toList(), quotes)).getBytes(StandardCharsets.UTF_8)));
|
||||
return new Preview(hash, requiresFee, List.copyOf(quotes), List.copyOf(priorInboundCodes));
|
||||
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) {
|
||||
throw new IllegalStateException(error);
|
||||
}
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** The transfer slice owns coming-goods documents and physical receiving facts. */
|
||||
public interface FleetBookingIncomingGateway {
|
||||
record Incoming(String customerId,String warehouseId,String warehouseName,String bookingNo,FleetBookingModels.Cargo cargo,
|
||||
LocalDateTime estimatedArrivalAt,String deliveryInstructions,String privateAddress,String postalCode,String recipientName,String recipientPhone) { }
|
||||
record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { }
|
||||
record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status,
|
||||
String receiptHash,List<Pallet> pallets,int receivedPackages) { }
|
||||
Evidence create(String operator, Incoming incoming);
|
||||
Evidence inspect(String customerId,String incomingNo,boolean lock);
|
||||
Evidence confirm(String customerId,String incomingNo,String operator,String key);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
|
||||
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
|
||||
FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo);
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
|
||||
List<FleetBookingEntity> list(@Param("tripId") long tripId);
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}")
|
||||
FleetBookingEntity created(@Param("tripId") long tripId,@Param("operator") String operator,@Param("key") String key);
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND booking_no=#{number} FOR UPDATE")
|
||||
FleetBookingEntity lock(@Param("tripId") long tripId,@Param("number") String number);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class FleetBookingModels {
|
||||
private FleetBookingModels() { }
|
||||
public record Cargo(@NotBlank @Size(max=160) String destinationKey, @Size(max=255) String cargoName,
|
||||
@Min(1) int packageCount, @Min(1) Integer palletSpaces, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal weightKg,
|
||||
@DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal volumeCbm,
|
||||
@Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice,
|
||||
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
|
||||
public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId,
|
||||
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { }
|
||||
public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { }
|
||||
public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
|
||||
Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress,
|
||||
@Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { }
|
||||
public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
|
||||
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record BindDispatch(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(max=64) String dispatchNo,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { }
|
||||
public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { }
|
||||
public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName,
|
||||
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { }
|
||||
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
|
||||
public record ListResult(long tripRecordVersion, List<Response> records) { }
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
/** Read under the parent trip lock whenever changing reservations or capacity. */
|
||||
@Component
|
||||
public class FleetBookingReservations {
|
||||
private final FleetBookingMapper bookings;
|
||||
private final ObjectMapper json;
|
||||
public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; }
|
||||
public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); }
|
||||
public List<FleetBookingModels.Response> responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); }
|
||||
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
|
||||
public FleetBookingModels.Response response(FleetBookingEntity row) {
|
||||
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row));
|
||||
}
|
||||
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
|
||||
Long excludedId, FleetBookingEntity addition) {
|
||||
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
|
||||
for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
|
||||
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
|
||||
for(var row:bookings.list(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); }
|
||||
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
|
||||
}
|
||||
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
|
||||
if(!hasConfirmed(trip.getId())) return;
|
||||
if(trip.getPlannedLoadJson()==null) throw conflict("已有外接承运,请先重新核对计划容量再改派");
|
||||
var own=read(trip.getPlannedLoadJson(),FleetExecutionModels.PlannedLoad.class);
|
||||
for(var segment:segments(trip,own,null,null)) if(segment.occupied().palletSpaces()>spaces
|
||||
||segment.occupied().weightKg().compareTo(weight)>0||segment.occupied().volumeCbm().compareTo(volume)>0)
|
||||
throw conflict("替换车辆无法容纳自有货及已确认外接预订");
|
||||
}
|
||||
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
|
||||
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
|
||||
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
|
||||
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
|
||||
for(var entry:bySource.entrySet()){
|
||||
var row=bookings.incoming(entry.getKey());if(row==null)continue;
|
||||
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
|
||||
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
|
||||
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
|
||||
var expected=new TreeMap<String,Integer>();
|
||||
for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
|
||||
var selected=new TreeMap<String,Integer>();
|
||||
for(var item:entry.getValue()){
|
||||
if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
|
||||
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
|
||||
}
|
||||
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
|
||||
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
|
||||
}
|
||||
}
|
||||
public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){
|
||||
for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){
|
||||
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
|
||||
}
|
||||
}
|
||||
private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");}
|
||||
public void requireRoute(FleetTripEntity trip,List<FleetTripModels.Stop> stops) {
|
||||
for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1)
|
||||
throw conflict("路线仍须包含已确认外接预订的卸货站");
|
||||
}
|
||||
private void add(List<TripCapacityPolicy.Booking> loads,List<FleetTripModels.Stop> stops,FleetBookingEntity row){
|
||||
var cargo=cargo(row); int index=index(stops,row.getDestinationKey());
|
||||
if(index<1) throw conflict("外接预订卸货站不在当前路线");
|
||||
if(cargo.palletSpaces()==null||cargo.weightKg()==null||cargo.volumeCbm()==null) throw conflict("确认承运前请补齐预计托位、重量和体积");
|
||||
try {loads.add(new TripCapacityPolicy.Booking(row.getBookingNo(),0,index,new TripCapacityPolicy.Load(cargo.palletSpaces(),cargo.weightKg(),cargo.volumeCbm())));}
|
||||
catch(IllegalArgumentException error){throw conflict(error.getMessage());}
|
||||
}
|
||||
public List<FleetTripModels.Stop> stops(FleetTripEntity trip){
|
||||
try{return json.readValue(trip.getStopsJson(),new TypeReference<>(){});}catch(JsonProcessingException error){throw new IllegalStateException(error);}
|
||||
}
|
||||
public static int index(List<FleetTripModels.Stop> stops,String key){for(int i=1;i<stops.size();i++)if(Objects.equals(stops.get(i).destinationKey(),key))return i;return -1;}
|
||||
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException(error);}}
|
||||
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Currency;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class FleetBookingService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetBookingMapper bookings;
|
||||
private final FleetBookingReservations reservations;
|
||||
private final FleetTripService history;
|
||||
private final FleetTripExecutionService execution;
|
||||
private final FleetCustomerDirectory customers;
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingIncomingGateway incoming;
|
||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){
|
||||
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
|
||||
var trip=trip(tripNo,true);String hash=hash(request);
|
||||
var existing=bookings.created(trip.getId(),operator,request.idempotencyKey());
|
||||
if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
|
||||
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
|
||||
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
|
||||
apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
|
||||
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
|
||||
bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator);
|
||||
return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
|
||||
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
|
||||
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
|
||||
apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运");
|
||||
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
|
||||
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
|
||||
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
|
||||
var plan=execution.capacityPlan(tripNo);
|
||||
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
|
||||
reservations.segments(trip,plan.capacityPlan(),null,row);
|
||||
row.setStatus("CONFIRMED");row.setConfirmKey(request.idempotencyKey());row.setConfirmHash(hash);
|
||||
save(trip,row,operator,"BOOKING_CONFIRM",reason(request.reason(),"确认外接承运并预留容量"));return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!java.util.Set.of("DRAFT","CONFIRMED","RECEIVED").contains(row.getStatus()))throw conflict("请先解除尚未开工的排车关联,再取消预订;已拣货须走现场撤回流程");
|
||||
row.setStatus("CANCELLED");row.setCancelKey(request.idempotencyKey());row.setCancelHash(hash);
|
||||
save(trip,row,operator,"BOOKING_CANCEL",reason(request.reason(),"取消未执行外接预订并释放容量"));return result(trip,row,false);
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingModels.IncomingPreview incoming(String tripNo,String bookingNo){
|
||||
var trip=trip(tripNo,false);var row=bookings.list(trip.getId()).stream().filter(value->value.getBookingNo().equals(bookingNo)).findFirst()
|
||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"外接预订不存在"));
|
||||
return new FleetBookingModels.IncomingPreview(trip.getRecordVersion(),row.getRecordVersion(),row.getIncomingNo()==null?null:incoming.inspect(row.getCustomerId(),row.getIncomingNo(),false));
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result startIncoming(String tripNo,String bookingNo,String operator,FleetBookingModels.StartIncoming request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getIncomingKey(),request.idempotencyKey())){if(!hash.equals(row.getIncomingHash()))throw conflict("来货登记请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作");
|
||||
customers.required(Long.parseLong(row.getCustomerId()));
|
||||
var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt();
|
||||
var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row),
|
||||
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
|
||||
requireEvidence(trip,row,evidence);
|
||||
row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash);
|
||||
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
|
||||
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
|
||||
if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量");
|
||||
if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对");
|
||||
var spaces=new java.util.HashMap<String,Integer>();
|
||||
for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位");
|
||||
var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet());
|
||||
if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致");
|
||||
BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0;
|
||||
for(var pallet:evidence.pallets()){
|
||||
if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积");
|
||||
weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages());
|
||||
}
|
||||
int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row);
|
||||
apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark()));
|
||||
var plan=execution.capacityPlan(tripNo);
|
||||
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收");
|
||||
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
|
||||
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT");
|
||||
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
|
||||
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
|
||||
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
|
||||
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result bindDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.BindDispatch request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费");
|
||||
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
|
||||
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
|
||||
var result=booking(trip,bookingNo);
|
||||
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
|
||||
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
|
||||
}
|
||||
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
|
||||
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
|
||||
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
|
||||
}
|
||||
private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){
|
||||
var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey());
|
||||
if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车");
|
||||
String basis=input.priceBasis()==null?"PALLET":input.priceBasis();String currency=trim(input.currency());
|
||||
if(!basis.equals("PALLET")&&!basis.equals("ORDER"))throw bad("请选择按托或整单报价");
|
||||
if(currency!=null){currency=currency.toUpperCase(java.util.Locale.ROOT);try{Currency.getInstance(currency);}catch(IllegalArgumentException error){throw bad("币种无效");}}
|
||||
var cargo=new FleetBookingModels.Cargo(input.destinationKey(),trim(input.cargoName()),input.packageCount(),input.palletSpaces(),input.weightKg(),input.volumeCbm(),
|
||||
basis,input.unitPrice(),currency,trim(input.contactName()),trim(input.contactPhone()),trim(input.remark()));
|
||||
row.setCargoJson(write(cargo));row.setDestinationKey(cargo.destinationKey());row.setDestinationLabel(stops.get(index).name());
|
||||
BigDecimal amount=null;
|
||||
if(input.unitPrice()!=null && (basis.equals("ORDER")||input.palletSpaces()!=null)) amount=input.unitPrice().multiply(BigDecimal.valueOf(basis.equals("ORDER")?1:input.palletSpaces())).setScale(2);
|
||||
if(amount!=null && amount.precision()>16)throw bad("约定金额超出支持范围");
|
||||
row.setAgreedAmount(amount);
|
||||
}
|
||||
private void save(FleetTripEntity trip,FleetBookingEntity row,String operator,String action,String reason){
|
||||
row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(now());
|
||||
if(bookings.updateById(row)!=1)throw conflict("外接预订已变化,请刷新");
|
||||
history.recordExecution(trip,trip.getRecordVersion(),action,reason,operator);
|
||||
}
|
||||
private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);}
|
||||
private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;}
|
||||
private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;}
|
||||
private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
|
||||
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
|
||||
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
|
||||
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
|
||||
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
|
||||
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
|
||||
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
|
||||
private static ResponseStatusException bad(String value){return new ResponseStatusException(HttpStatus.BAD_REQUEST,value);}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class FleetCompletionModels {
|
||||
private FleetCompletionModels() { }
|
||||
public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination,
|
||||
String status, int packages, Instant terminalAt) { }
|
||||
public record DispatchCompletion(String dispatchNo, String warehouseId, List<Outcome> outcomes, String blockReason) { }
|
||||
public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List<Outcome> outcomes) { }
|
||||
public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
|
||||
@NotBlank @Size(max = 1000) String reason) { }
|
||||
public record Completed(String idempotencyKey, String requestHash, Instant completedAt, Instant recordedAt,
|
||||
String operatorId, List<Outcome> outcomes) { }
|
||||
public record Release(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "VEHICLE|DRIVER") String resourceKind,
|
||||
@NotNull @PastOrPresent Instant actualEndAt, @NotBlank @Size(max = 500) String endLocation,
|
||||
@Pattern(regexp = "IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus,
|
||||
@NotBlank @Size(min = 8, max = 128) String idempotencyKey, @NotBlank @Size(max = 1000) String reason) { }
|
||||
public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String endLocation,
|
||||
String vehicleStatus, String operatorId, Instant recordedAt) { }
|
||||
public record ReleaseInfo(Instant actualEndAt, String endLocation, String vehicleStatus) { }
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class FleetDispatchBindingGuard {
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper json;
|
||||
public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; }
|
||||
|
||||
/** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void requirePickingApproved(long dispatchId) {
|
||||
var binding = bindings.lockByDispatch(dispatchId);
|
||||
if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核");
|
||||
var review = FleetTripReview.read(json, binding.getReviewJson());
|
||||
if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货");
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void requireMatchingAssignment(long dispatchId, FleetDispatchGateway.Assignment actual, FleetDispatchGateway.Assignment requested) {
|
||||
var binding = bindings.lockByDispatch(dispatchId);
|
||||
if (binding != null && (changed(requested.vehiclePlate(), actual.vehiclePlate()) || changed(requested.driverName(), actual.driverName())
|
||||
|| changed(requested.driverPhone(), actual.driverPhone())))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆和司机由车次 " + binding.getTripNo() + " 统一维护");
|
||||
}
|
||||
private static boolean changed(String requested, String actual) {
|
||||
return requested != null && !requested.isBlank() && !requested.trim().equals(actual);
|
||||
}
|
||||
|
||||
/** Must be called after locking the customer dispatch, which serializes binding and mutations. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void requireUnbound(long dispatchId) {
|
||||
var binding = bindings.lockByDispatch(dispatchId);
|
||||
if (binding != null) throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"排车单已关联车次 " + binding.getTripNo() + ",请先在车次中解除关联");
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void requireCoordinatedDeparture(long dispatchId) {
|
||||
var binding = bindings.lockByDispatch(dispatchId);
|
||||
if (binding == null || !"DEPARTING".equals(bindings.tripStatus(dispatchId)))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "请通过车次统一发车");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */
|
||||
public interface FleetDispatchGateway {
|
||||
record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { }
|
||||
record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { }
|
||||
java.util.List<SourceCargo> sourceCargo(String dispatchNo);
|
||||
record Assignment(String vehiclePlate, String driverName, String driverPhone) { }
|
||||
|
||||
static String destinationKey(String type,String code,String fba,String address,String postalCode) {
|
||||
if(!"PRIVATE".equals(type)){String value=code==null||code.isBlank()?fba:code;return value==null||value.isBlank()?null:"FBA:"+value.trim().toUpperCase(java.util.Locale.ROOT);}
|
||||
if(address==null||address.isBlank())return null;
|
||||
String key=address.strip().replaceAll("\\s+"," ").toUpperCase(java.util.Locale.ROOT)+"|"+java.util.Objects.requireNonNullElse(postalCode,"").strip().toUpperCase(java.util.Locale.ROOT);
|
||||
try{return "PRIVATE:"+java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
|
||||
catch(java.security.NoSuchAlgorithmException error){throw new IllegalStateException(error);}
|
||||
}
|
||||
|
||||
/** Locks the dispatch and rejects any picking or departure already performed. */
|
||||
Snapshot lockForPlanning(String dispatchNo);
|
||||
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
|
||||
Snapshot lockForAssignment(String dispatchNo);
|
||||
/** 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);
|
||||
FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
|
||||
String idempotencyKey, String remark, Instant departedAt);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class FleetExecutionModels {
|
||||
private FleetExecutionModels() { }
|
||||
public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId,
|
||||
String palletNo, int packages, String destinationKey, String destinationLabel) { }
|
||||
public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone,
|
||||
List<Cargo> cargo, String blockReason) { }
|
||||
public record Measurement(@NotBlank String warehousePalletId, @Min(1) int palletSpaces,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal weightKg,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal volumeCbm,
|
||||
@Min(1) int deliveryIndex) { }
|
||||
public record Confirm(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "[0-9a-f]{64}") String manifestHash,
|
||||
@NotEmpty @Size(max = 1000) List<@NotNull @Valid Measurement> pallets,
|
||||
@NotBlank @Size(max = 1000) String reason) { }
|
||||
public record ConfirmedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
|
||||
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
|
||||
public record Loading(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
|
||||
ConfirmedLoad confirmedLoad, String blockReason) { }
|
||||
public record PlannedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
|
||||
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
|
||||
public record Planning(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
|
||||
PlannedLoad capacityPlan, boolean planValid, String blockReason, List<TripCapacityPolicy.Segment> availableSegments) { }
|
||||
public record Depart(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) { }
|
||||
public record DepartedDispatch(String dispatchNo, long shippedPackages) { }
|
||||
public record Departed(String tripNo, String status, Instant departedAt, long shippedPackages,
|
||||
List<DepartedDispatch> dispatches, boolean replayed) {
|
||||
public Departed withReplay() { return new Departed(tripNo, status, departedAt, shippedPackages, dispatches, true); }
|
||||
}
|
||||
public record Gate(String tripNo, Long recordVersion, int dispatchCount, boolean canDepart, String blockReason, String driverWarning,
|
||||
boolean canPick, String pickBlockReason) {
|
||||
public Gate withPicking(boolean allowed, String reason) { return new Gate(tripNo, recordVersion, dispatchCount, canDepart, blockReason, driverWarning, allowed, reason); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetResourceMapper {
|
||||
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
|
||||
Assignment find(@Param("kind") String kind, @Param("id") long id);
|
||||
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
|
||||
List<Assignment> byTrip(@Param("tripId") long tripId);
|
||||
@Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})")
|
||||
int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo);
|
||||
@Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}")
|
||||
int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId);
|
||||
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class FleetTripCompletionService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetResourceMapper resources;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final FleetTripService history;
|
||||
private final VehicleRepository vehicles;
|
||||
private final VehicleApplicationService vehicleService;
|
||||
private final ObjectMapper json;
|
||||
|
||||
public FleetTripCompletionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
|
||||
FleetDispatchGateway dispatches, FleetTripService history, VehicleRepository vehicles,
|
||||
VehicleApplicationService vehicleService, ObjectMapper json) {
|
||||
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
|
||||
this.history = history; this.vehicles = vehicles; this.vehicleService = vehicleService; this.json = json;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetCompletionModels.Inspection inspection(String tripNo) { return inspect(required(tripNo, false), false); }
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail complete(String tripNo, String operator, FleetCompletionModels.Complete request) {
|
||||
var trip = required(tripNo, true);
|
||||
String hash = hash(write(request));
|
||||
if (trip.getCompletionJson() != null) {
|
||||
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
|
||||
if (!prior.idempotencyKey().equals(request.idempotencyKey()) || !prior.requestHash().equals(hash))
|
||||
throw conflict("车次已结案,请核对原结案请求");
|
||||
return history.detail(tripNo);
|
||||
}
|
||||
requireVersion(trip, request.recordVersion());
|
||||
var inspection = inspect(trip, true);
|
||||
if (!inspection.canComplete()) throw conflict(inspection.blockReason());
|
||||
Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow();
|
||||
trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
|
||||
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes())));
|
||||
// Completion freezes cargo outcomes. It deliberately does not release a driver still returning.
|
||||
history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator);
|
||||
return history.detail(tripNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) {
|
||||
var trip = required(tripNo, true);
|
||||
if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务");
|
||||
boolean vehicle = "VEHICLE".equals(request.resourceKind());
|
||||
String previous = vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson();
|
||||
String hash = hash(write(request));
|
||||
if (previous != null) {
|
||||
var released = read(previous, FleetCompletionModels.Released.class);
|
||||
if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(hash))
|
||||
throw conflict("该资源任务已结束,请核对原结束请求");
|
||||
return history.detail(tripNo);
|
||||
}
|
||||
requireVersion(trip, request.recordVersion());
|
||||
if (!"COMPLETED".equals(trip.getStatus()) || trip.getCompletionJson() == null || trip.getCompletedAt() == null)
|
||||
throw conflict("请先核对全部货物并完成车次结案");
|
||||
Instant now = Instant.now();
|
||||
if (request.actualEndAt() == null || request.actualEndAt().isAfter(now)
|
||||
|| request.actualEndAt().isBefore(trip.getCompletedAt().toInstant(ZoneOffset.UTC))) throw bad("实际任务结束时间须不早于本车货物履约完成且不晚于当前时间");
|
||||
if (request.endLocation() == null || request.endLocation().isBlank()) throw bad("请填写实际任务结束地点");
|
||||
if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus())))
|
||||
throw bad("请确认车辆结束任务后的实际状态");
|
||||
if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态");
|
||||
if (vehicle) {
|
||||
var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
|
||||
requireRelease("VEHICLE", trip.getVehicleId(), trip.getId());
|
||||
vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion()));
|
||||
} else {
|
||||
// A now-expired or disabled driver may end their task; this never re-enables their qualification.
|
||||
requireRelease("DRIVER", trip.getDriverId(), trip.getId());
|
||||
}
|
||||
var released = new FleetCompletionModels.Released(request.idempotencyKey(), hash, request.actualEndAt(),
|
||||
request.endLocation().trim(), request.vehicleStatus(), operator, now);
|
||||
if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released));
|
||||
history.recordExecution(trip, request.recordVersion(), vehicle ? "RELEASE_VEHICLE" : "RELEASE_DRIVER", request.reason(), operator);
|
||||
return history.detail(tripNo);
|
||||
}
|
||||
|
||||
private FleetCompletionModels.Inspection inspect(FleetTripEntity trip, boolean lock) {
|
||||
if (trip.getCompletionJson() != null) {
|
||||
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
|
||||
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), false, "车次货物已结案", prior.outcomes());
|
||||
}
|
||||
var completions = bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.completion(binding.getDispatchNo(), lock)).toList();
|
||||
var outcomes = completions.stream().flatMap(completion -> completion.outcomes().stream()).toList();
|
||||
String reason = null;
|
||||
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null) reason = "仅已发车的车次可以结案";
|
||||
else if (completions.isEmpty()) reason = "缺少客户执行单据,不能结案";
|
||||
else {
|
||||
for (var completion : completions) {
|
||||
if (!trip.getWarehouseId().toString().equals(completion.warehouseId())) { reason = "客户执行单据起运仓与车次不一致"; break; }
|
||||
if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; }
|
||||
}
|
||||
Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now();
|
||||
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null
|
||||
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
|
||||
}
|
||||
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
|
||||
}
|
||||
private void requireRelease(String kind, Long resourceId, long tripId) {
|
||||
if (resourceId == null || resources.release(kind, resourceId, tripId) != 1) throw conflict("资源占用与车次不一致,结束任务未完成");
|
||||
}
|
||||
private FleetTripEntity required(String tripNo, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
return trip;
|
||||
}
|
||||
private static void requireVersion(FleetTripEntity trip, long version) {
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
|
||||
}
|
||||
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_fleet_trip_dispatch")
|
||||
public class FleetTripDispatch {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long dispatchId;
|
||||
private String dispatchNo;
|
||||
private String customerId;
|
||||
private Long tripId;
|
||||
private String tripNo;
|
||||
private Integer packageCount;
|
||||
private String boundBy;
|
||||
private LocalDateTime boundAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String reviewJson;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetTripDispatchMapper extends BaseMapper<FleetTripDispatch> {
|
||||
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id")
|
||||
List<FleetTripDispatch> listByTrip(@Param("tripId") long tripId);
|
||||
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}")
|
||||
FleetTripDispatch findByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
@Select("<script>SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no IN <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach></script>")
|
||||
List<FleetTripDispatch> findByDispatchNos(@Param("numbers") List<String> numbers);
|
||||
|
||||
// Locking read sees a concurrent binding committed while the caller waited for the dispatch row.
|
||||
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=#{dispatchId} FOR UPDATE")
|
||||
FleetTripDispatch lockByDispatch(@Param("dispatchId") long dispatchId);
|
||||
|
||||
@Select("SELECT t.status FROM ll_fleet_trip_dispatch b JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE b.dispatch_id=#{dispatchId}")
|
||||
String tripStatus(@Param("dispatchId") long dispatchId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_fleet_trip")
|
||||
public class FleetTripEntity {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String tripNo;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private Long warehouseId;
|
||||
private String warehouseCode;
|
||||
private String warehouseName;
|
||||
private Long vehicleId;
|
||||
private String vehiclePlate;
|
||||
private Long driverId;
|
||||
private String driverName;
|
||||
private String driverPhone;
|
||||
private Integer maxPalletSpaces;
|
||||
private BigDecimal maxLoadKg;
|
||||
private BigDecimal maxVolumeCbm;
|
||||
private LocalDateTime plannedDepartureAt;
|
||||
private LocalDateTime expectedDriverEndAt;
|
||||
private LocalDateTime departedAt;
|
||||
private LocalDateTime completedAt;
|
||||
private String stopsJson;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String plannedLoadJson;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String loadJson;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String loadingReviewJson;
|
||||
private String departureKey;
|
||||
private String departureHash;
|
||||
private String departureResultJson;
|
||||
private String completionJson;
|
||||
private String vehicleReleaseJson;
|
||||
private String driverReleaseJson;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String remark;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private String updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverModels;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetTripExecutionService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetResourceMapper resources;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final FleetTripService history;
|
||||
private final DriverService drivers;
|
||||
private final VehicleRepository vehicles;
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingReservations bookingReservations;
|
||||
|
||||
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
|
||||
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
|
||||
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
|
||||
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
|
||||
this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Planning capacityPlan(String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
var cargo = cargo(manifests(trip, false));
|
||||
return planning(trip, cargo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
|
||||
var trip = requireTrip(tripNo, true);
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
var cargo = cargo(manifests(trip, true));
|
||||
String reason = planningBlockReason(trip, cargo);
|
||||
if (reason != null) throw conflict(reason);
|
||||
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());
|
||||
bookingReservations.segments(trip, plan, null, null);
|
||||
trip.setPlannedLoadJson(write(plan));
|
||||
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
|
||||
return planning(trip, cargo);
|
||||
}
|
||||
|
||||
private FleetExecutionModels.Planning planning(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
String fingerprint = hash(write(cargo));
|
||||
var plan = trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class);
|
||||
boolean valid = plan != null && plan.manifestHash().equals(fingerprint);
|
||||
return new FleetExecutionModels.Planning(trip.getTripNo(), trip.getRecordVersion(), cargo, fingerprint, plan, valid, planningBlockReason(trip, cargo), valid ? bookingReservations.segments(trip, plan, null, null) : List.of());
|
||||
}
|
||||
|
||||
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
|
||||
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
|
||||
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
|
||||
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
|
||||
return "请先明确每票货物的实物托盘和卸货目的地";
|
||||
return null;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Loading loading(String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
var manifest = manifests(trip, false);
|
||||
var cargo = cargo(manifest);
|
||||
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) {
|
||||
var trip = requireTrip(tripNo, true);
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
var manifest = manifests(trip, true);
|
||||
String block = blockReason(trip, manifest);
|
||||
if (block != null) throw conflict(block);
|
||||
var cargo = cargo(manifest); 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 segments = capacity(trip, cargo, pallets);
|
||||
var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now());
|
||||
trip.setLoadJson(write(confirmed));
|
||||
history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator);
|
||||
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (binding == null) return missingGate();
|
||||
return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public Map<String, FleetExecutionModels.Gate> gates(String warehouseId, List<String> numbers) {
|
||||
if (numbers.isEmpty()) return Map.of();
|
||||
var result = new HashMap<String, FleetExecutionModels.Gate>();
|
||||
numbers.forEach(number -> result.put(number, missingGate()));
|
||||
var byTrip = new HashMap<String, FleetExecutionModels.Gate>();
|
||||
for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate(
|
||||
byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding));
|
||||
return result;
|
||||
}
|
||||
|
||||
private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
requireWarehouse(trip, warehouseId);
|
||||
var manifest = manifests(trip, false);
|
||||
String reason = blockReason(trip, manifest);
|
||||
String warning = null;
|
||||
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
|
||||
if (reason == null) reason = loadingReviewBlockReason(trip);
|
||||
if (reason == null) {
|
||||
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
|
||||
else reason = driverBlockReason(trip, driver);
|
||||
if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险";
|
||||
}
|
||||
boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null;
|
||||
return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning,
|
||||
canPick, canPick ? null : "当前车次不允许继续拣货");
|
||||
}
|
||||
|
||||
private static FleetExecutionModels.Gate missingGate() {
|
||||
return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核");
|
||||
}
|
||||
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
|
||||
if (!gate.canPick()) return gate;
|
||||
var review = FleetTripReview.read(json, binding.getReviewJson());
|
||||
String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status())
|
||||
? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货";
|
||||
return gate.withPicking(reason == null, reason);
|
||||
}
|
||||
private String loadingReviewBlockReason(FleetTripEntity trip) {
|
||||
var review = FleetTripReview.read(json, trip.getLoadingReviewJson());
|
||||
return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) {
|
||||
var trip = requireTrip(tripNo, true);
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
String reason = FleetTripReview.reason(request);
|
||||
if ("APPROVED".equals(request.decision())) {
|
||||
if (trip.getDriverId() == null) throw conflict("请先分配司机");
|
||||
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
String driverBlock = driverBlockReason(trip, driver);
|
||||
if (driverBlock != null) throw conflict(driverBlock);
|
||||
var manifest = manifests(trip, true);
|
||||
String block = blockReason(trip, manifest);
|
||||
if (block == null) block = loadBlockReason(trip, cargo(manifest));
|
||||
if (block != null) throw conflict(block);
|
||||
}
|
||||
trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion())));
|
||||
history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator);
|
||||
return history.detail(tripNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) {
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (binding == null) throw conflict("请先在平台将排车单关联车次");
|
||||
var trip = requireTrip(binding.getTripNo(), true);
|
||||
requireWarehouse(trip, warehouseId);
|
||||
// The binding may have changed while waiting for the trip lock. Never ship a stale parent.
|
||||
var members = bindings.listByTrip(trip.getId());
|
||||
if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新");
|
||||
String requestHash = hash(write(request));
|
||||
if (trip.getDepartureKey() != null) {
|
||||
if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash))
|
||||
throw conflict("车次已发车,发车请求标识或内容不一致");
|
||||
return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay();
|
||||
}
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
// Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches.
|
||||
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
|
||||
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
|
||||
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())
|
||||
|| !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|
||||
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
|
||||
throw conflict("车辆状态或容量已变化,请重新确认车次");
|
||||
requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId());
|
||||
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
|
||||
String driverBlock = driverBlockReason(trip, driver);
|
||||
if (driverBlock != null) throw conflict(driverBlock);
|
||||
var manifest = manifests(trip, true);
|
||||
String reason = blockReason(trip, manifest);
|
||||
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
|
||||
if (reason == null) reason = loadingReviewBlockReason(trip);
|
||||
if (reason != null) throw conflict(reason);
|
||||
Instant now = Instant.now();
|
||||
trip.setStatus("DEPARTING");
|
||||
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
|
||||
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
|
||||
for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator,
|
||||
trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now));
|
||||
trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC));
|
||||
var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now,
|
||||
results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false);
|
||||
trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result));
|
||||
history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
|
||||
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
|
||||
}
|
||||
private static List<FleetExecutionModels.Cargo> cargo(List<FleetExecutionModels.Manifest> manifests) {
|
||||
return manifests.stream().flatMap(manifest -> manifest.cargo().stream())
|
||||
.sorted(Comparator.comparing(FleetExecutionModels.Cargo::dispatchNo).thenComparing(FleetExecutionModels.Cargo::dispatchItemId)).toList();
|
||||
}
|
||||
private String blockReason(FleetTripEntity trip, List<FleetExecutionModels.Manifest> manifests) {
|
||||
if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划";
|
||||
if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机";
|
||||
if (manifests.isEmpty()) return "车次尚未关联排车单";
|
||||
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
|
||||
for (var binding : bindings.listByTrip(trip.getId())) {
|
||||
if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核";
|
||||
}
|
||||
for (var manifest : manifests) {
|
||||
if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致";
|
||||
if (!Objects.equals(trip.getVehiclePlate(), manifest.vehiclePlate()) || !Objects.equals(trip.getDriverName(), manifest.driverName())
|
||||
|| !Objects.equals(trip.getDriverPhone(), manifest.driverPhone())) return "排车单 " + manifest.dispatchNo() + " 的车辆司机与车次不一致";
|
||||
if (manifest.blockReason() != null) return manifest.dispatchNo() + ":" + manifest.blockReason();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private String loadBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
var load = load(trip);
|
||||
if (load == null) return "请在平台确认本车实际装载";
|
||||
if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认";
|
||||
try { capacity(trip, cargo, load.pallets()); return null; }
|
||||
catch (ResponseStatusException error) { return error.getReason(); }
|
||||
}
|
||||
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
|
||||
List<FleetExecutionModels.Measurement> pallets) {
|
||||
List<FleetTripModels.Stop> stops;
|
||||
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
|
||||
var destinations = new HashMap<String, String>(); var palletNumbers = new HashMap<String, String>();
|
||||
if (cargo.isEmpty()) throw conflict("没有待发运货物");
|
||||
for (var item : cargo) {
|
||||
if (item.warehousePalletId() == null || item.palletNo() == null || item.palletNo().isBlank() || item.destinationKey() == null || item.packages() <= 0)
|
||||
throw conflict("货物实物托盘、数量或目的地尚未确认");
|
||||
String previous = destinations.putIfAbsent(item.warehousePalletId(), item.destinationKey());
|
||||
if (previous != null && !previous.equals(item.destinationKey())) throw conflict("同一实物托盘包含不同卸货目的地,请先拆托");
|
||||
String oldNumber = palletNumbers.putIfAbsent(item.warehousePalletId(), item.palletNo());
|
||||
if (oldNumber != null && !oldNumber.equals(item.palletNo())) throw conflict("实物托盘编号不一致,请核对");
|
||||
}
|
||||
var measured = new HashSet<String>(); var bookings = new ArrayList<TripCapacityPolicy.Booking>();
|
||||
try {
|
||||
for (var pallet : pallets) {
|
||||
if (!measured.add(pallet.warehousePalletId())) throw conflict("同一实物托盘不能重复计量");
|
||||
if (!destinations.containsKey(pallet.warehousePalletId())) throw conflict("装载包含不属于本车的实物托盘");
|
||||
if (pallet.deliveryIndex() < 1 || pallet.deliveryIndex() >= stops.size()
|
||||
|| !destinations.get(pallet.warehousePalletId()).equals(stops.get(pallet.deliveryIndex()).destinationKey()))
|
||||
throw conflict("托盘卸货站与货物目的地不一致");
|
||||
bookings.add(new TripCapacityPolicy.Booking(pallet.warehousePalletId(), 0, pallet.deliveryIndex(),
|
||||
new TripCapacityPolicy.Load(pallet.palletSpaces(), pallet.weightKg(), pallet.volumeCbm())));
|
||||
}
|
||||
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);
|
||||
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
|
||||
}
|
||||
private void requireResource(String kind, long id, long tripId) {
|
||||
var claim = resources.find(kind, id);
|
||||
if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化");
|
||||
}
|
||||
private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) {
|
||||
if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案";
|
||||
if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照";
|
||||
return null;
|
||||
}
|
||||
private FleetTripEntity requireTrip(String tripNo, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
return trip;
|
||||
}
|
||||
private static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
|
||||
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
|
||||
}
|
||||
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
|
||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||
}
|
||||
private static boolean equalNumber(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
|
||||
private FleetExecutionModels.ConfirmedLoad load(FleetTripEntity trip) { return trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); }
|
||||
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_fleet_trip_history")
|
||||
public class FleetTripHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long tripId;
|
||||
private Long recordVersion;
|
||||
private String action;
|
||||
private String reason;
|
||||
private String operatorId;
|
||||
private LocalDateTime occurredAt;
|
||||
private String snapshotJson;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> { }
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
|
||||
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
|
||||
FleetTripEntity findByNo(@Param("tripNo") String tripNo);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key}")
|
||||
FleetTripEntity findCreated(@Param("operator") String operator, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
|
||||
FleetTripEntity lockCreated(@Param("operator") String operator, @Param("key") String key);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class FleetTripModels {
|
||||
private FleetTripModels() { }
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword,
|
||||
String status, @Positive Long warehouseId) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
public record Stop(@NotBlank @Size(max = 128) String name, @Size(max = 500) String address,
|
||||
@Size(max = 160) String destinationKey) { }
|
||||
public record Create(@NotNull @Positive Long warehouseId, @NotNull Instant plannedDepartureAt,
|
||||
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
|
||||
@Size(max = 1000) String remark, @NotBlank @Size(min = 8, max = 128) String idempotencyKey) { }
|
||||
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
||||
@NotNull @Positive Long driverId, @NotBlank @Size(max = 1000) String reason) { }
|
||||
public record Plan(@NotNull @Min(0) Long recordVersion, @NotNull Instant plannedDepartureAt,
|
||||
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
|
||||
@Size(max = 1000) String remark, @NotBlank @Size(max = 1000) String reason) { }
|
||||
public record Cancel(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason) { }
|
||||
public record BindingCommand(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String dispatchNo,
|
||||
@NotBlank @Size(max = 1000) String reason) { }
|
||||
public record Review(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision,
|
||||
@Size(max = 1000) String reason) { }
|
||||
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 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 AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
|
||||
FleetCompletionModels.Completed completion, ReviewState loadingReview,
|
||||
FleetExecutionModels.PlannedLoad capacityPlan, List<FleetBookingModels.Response> externalBookings) { }
|
||||
public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
final class FleetTripReview {
|
||||
private FleetTripReview() { }
|
||||
static FleetTripModels.ReviewState read(ObjectMapper json, String value) {
|
||||
if (value == null) return FleetTripModels.ReviewState.pending();
|
||||
try { return json.readValue(value, FleetTripModels.ReviewState.class); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("审核记录读取失败", error); }
|
||||
}
|
||||
static String reason(FleetTripModels.Review request) {
|
||||
if (!"APPROVED".equals(request.decision()) && !"REJECTED".equals(request.decision()))
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择审核通过或打回");
|
||||
String reason = request.reason() == null ? "" : request.reason().trim();
|
||||
if ("REJECTED".equals(request.decision()) && reason.isEmpty())
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请填写打回原因");
|
||||
return reason.isEmpty() ? "审核通过" : reason;
|
||||
}
|
||||
static boolean approved(ObjectMapper json, String value) { return "APPROVED".equals(read(json, value).status()); }
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetTripService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetResourceMapper resources;
|
||||
private final FleetTripHistoryMapper histories;
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final VehicleRepository vehicles;
|
||||
private final DriverService drivers;
|
||||
private final FleetWarehouseDirectory warehouses;
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
private final FleetBookingReservations bookingReservations;
|
||||
|
||||
@Autowired
|
||||
public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
|
||||
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
|
||||
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
|
||||
this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC());
|
||||
}
|
||||
FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
|
||||
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
|
||||
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
|
||||
this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles;
|
||||
this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock;
|
||||
this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations;
|
||||
}
|
||||
|
||||
public PageResult<FleetTripModels.Response> page(FleetTripModels.Query query) {
|
||||
if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status()))
|
||||
throw bad("车次状态无效");
|
||||
var filter = new LambdaQueryWrapper<FleetTripEntity>()
|
||||
.eq(query.status() != null && !query.status().isBlank(), FleetTripEntity::getStatus, query.status())
|
||||
.eq(query.warehouseId() != null, FleetTripEntity::getWarehouseId, query.warehouseId());
|
||||
if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim())
|
||||
.or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim()));
|
||||
filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId);
|
||||
var page = trips.selectPage(new Page<>(query.page(), query.size()), filter);
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetTripModels.Detail detail(String tripNo) {
|
||||
FleetTripEntity trip = required(tripNo);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
|
||||
String hash = hash(write(request));
|
||||
var existing = trips.findCreated(operator, request.idempotencyKey());
|
||||
if (existing != null) return replay(existing, hash);
|
||||
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
|
||||
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
|
||||
var trip = new FleetTripEntity();
|
||||
trip.setId(IdWorker.getId());
|
||||
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
|
||||
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
|
||||
trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
|
||||
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
|
||||
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
|
||||
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
|
||||
trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator);
|
||||
trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
|
||||
try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); }
|
||||
catch (DuplicateKeyException duplicate) {
|
||||
var concurrent = trips.lockCreated(operator, request.idempotencyKey());
|
||||
if (concurrent == null) throw conflict("车次编号冲突,请重试");
|
||||
return replay(concurrent, hash);
|
||||
}
|
||||
record(trip, "CREATE", null, operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
|
||||
FleetTripEntity trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
// All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard.
|
||||
var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
|
||||
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
|
||||
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null
|
||||
|| vehicle.maxPalletSpaces() <= 0 || vehicle.maxLoadKg().signum() <= 0 || vehicle.maxVolumeCbm().signum() <= 0)
|
||||
throw bad("请先确认车辆的托位、载重和容积");
|
||||
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))
|
||||
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
|
||||
}
|
||||
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
|
||||
bookingReservations.requireReassignmentFits(trip, vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm());
|
||||
var driver = drivers.requireDispatchable(request.driverId(), instant(trip.getExpectedDriverEndAt()));
|
||||
if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机");
|
||||
reserve("VEHICLE", request.vehicleId(), trip);
|
||||
reserve("DRIVER", request.driverId(), trip);
|
||||
if (trip.getVehicleId() != null && !trip.getVehicleId().equals(request.vehicleId())) release("VEHICLE", trip.getVehicleId(), trip.getId());
|
||||
if (trip.getDriverId() != null && !trip.getDriverId().equals(request.driverId())) release("DRIVER", trip.getDriverId(), trip.getId());
|
||||
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
|
||||
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
|
||||
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
|
||||
for (var binding : bindings.listByTrip(trip.getId())) {
|
||||
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
|
||||
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
|
||||
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()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
|
||||
FleetTripEntity trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
|
||||
bookingReservations.requireRoute(trip, request.stops());
|
||||
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
|
||||
boolean unchangedRoute = response(trip).stops().equals(request.stops());
|
||||
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
|
||||
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
|
||||
for (var binding : bindings.listByTrip(trip.getId())) {
|
||||
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
|
||||
else dispatches.lockForPlanning(binding.getDispatchNo());
|
||||
}
|
||||
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
|
||||
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
|
||||
persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
|
||||
var trip = planned(tripNo, request.recordVersion());
|
||||
String reason = FleetTripReview.reason(request);
|
||||
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
|
||||
if ("APPROVED".equals(request.decision())) {
|
||||
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
|
||||
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车");
|
||||
var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt()));
|
||||
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
|
||||
}
|
||||
var dispatch = dispatches.lockForAssignment(dispatchNo);
|
||||
var binding = bindings.lockByDispatch(dispatch.dispatchId());
|
||||
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
|
||||
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
|
||||
if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败");
|
||||
trip.setLoadingReviewJson(null);
|
||||
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
|
||||
FleetTripEntity trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
|
||||
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
|
||||
if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId());
|
||||
if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId());
|
||||
trip.setStatus("CANCELLED");
|
||||
persist(trip, request.recordVersion(), "CANCEL", request.reason(), operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
|
||||
var trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
|
||||
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
|
||||
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
|
||||
var bound = bindings.lockByDispatch(dispatch.dispatchId());
|
||||
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
|
||||
var binding = new FleetTripDispatch();
|
||||
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
|
||||
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
|
||||
binding.setBoundBy(operator); binding.setBoundAt(now());
|
||||
try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
|
||||
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
|
||||
dispatches.assign(dispatch, assignment(trip));
|
||||
persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
|
||||
var trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
|
||||
var binding = bindings.lockByDispatch(dispatch.dispatchId());
|
||||
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
|
||||
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
|
||||
if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
|
||||
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
|
||||
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
|
||||
var trip = required(tripNo);
|
||||
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
|
||||
.eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion));
|
||||
return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(),
|
||||
event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(),
|
||||
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
private void reserve(String kind, long resourceId, FleetTripEntity trip) {
|
||||
var current = resources.find(kind, resourceId);
|
||||
if (current != null) {
|
||||
if (current.tripId() == trip.getId()) return;
|
||||
throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用");
|
||||
}
|
||||
try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
|
||||
catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); }
|
||||
}
|
||||
|
||||
private void release(String kind, long resourceId, long tripId) {
|
||||
if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
|
||||
}
|
||||
|
||||
private FleetTripEntity required(String number) {
|
||||
var trip = trips.findByNo(number);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
return trip;
|
||||
}
|
||||
private FleetTripEntity planned(String number, long version) {
|
||||
var trip = trips.lockByNo(number);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
|
||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||
return trip;
|
||||
}
|
||||
private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) {
|
||||
if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容");
|
||||
return detail(trip);
|
||||
}
|
||||
private FleetTripModels.Detail detail(FleetTripEntity trip) {
|
||||
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()));
|
||||
}
|
||||
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();
|
||||
}
|
||||
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
|
||||
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
|
||||
}
|
||||
private FleetTripModels.Response response(FleetTripEntity trip) {
|
||||
List<FleetTripModels.Stop> stops;
|
||||
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
|
||||
return new FleetTripModels.Response(trip.getTripNo(), trip.getStatus(), trip.getWarehouseId().toString(), trip.getWarehouseCode(), trip.getWarehouseName(),
|
||||
text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(),
|
||||
trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()),
|
||||
instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()),
|
||||
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()));
|
||||
}
|
||||
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
|
||||
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
|
||||
try {
|
||||
if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围");
|
||||
} catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); }
|
||||
if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null))
|
||||
throw bad("车次须包含起点和目的地,最多200个站点");
|
||||
}
|
||||
private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) {
|
||||
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
|
||||
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
|
||||
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
|
||||
if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper<FleetTripDispatch>()
|
||||
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
|
||||
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
|
||||
if (trips.update(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
|
||||
throw conflict("车次已被修改,请刷新后重试");
|
||||
record(trip, action, reason, operator);
|
||||
}
|
||||
private void record(FleetTripEntity trip, String action, String reason, String operator) {
|
||||
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
|
||||
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
|
||||
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
|
||||
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
|
||||
if (histories.insert(event) != 1) throw conflict("车次历史保存失败");
|
||||
}
|
||||
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } }
|
||||
private FleetCompletionModels.ReleaseInfo releaseInfo(String value) {
|
||||
if (value == null) return null;
|
||||
var released = read(value, FleetCompletionModels.Released.class);
|
||||
return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus());
|
||||
}
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
|
||||
persist(trip, version, action, reason, operator);
|
||||
}
|
||||
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
|
||||
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
|
||||
private LocalDateTime now() { return utc(clock.instant()); }
|
||||
private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); }
|
||||
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
|
||||
private static String text(Long value) { return value == null ? null : value.toString(); }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard {
|
||||
private final FleetResourceMapper resources;
|
||||
public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; }
|
||||
@Override public void requireUnassigned(long vehicleId) {
|
||||
var assignment = resources.find("VEHICLE", vehicleId);
|
||||
if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT,
|
||||
"车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
||||
public class PlatformFleetBookingController {
|
||||
private final FleetBookingService service;
|
||||
private final FleetCustomerDirectory customers;
|
||||
public PlatformFleetBookingController(FleetBookingService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;}
|
||||
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));}
|
||||
@GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetBookingModels.ListResult> list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));}
|
||||
@PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')")
|
||||
public ApiResponse<FleetBookingModels.Result> create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));}
|
||||
@PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
|
||||
public ApiResponse<FleetBookingModels.Result> update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Update request){return ApiResponse.success(service.update(tripNo,bookingNo,operator(user),request));}
|
||||
@PostMapping("/{tripNo}/bookings/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
|
||||
public ApiResponse<FleetBookingModels.Result> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.confirm(tripNo,bookingNo,operator(user),request));}
|
||||
@PostMapping("/{tripNo}/bookings/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-cancel')")
|
||||
public ApiResponse<FleetBookingModels.Result> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.cancel(tripNo,bookingNo,operator(user),request));}
|
||||
@GetMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetBookingModels.IncomingPreview> incoming(@PathVariable String tripNo,@PathVariable String bookingNo){return ApiResponse.success(service.incoming(tripNo,bookingNo));}
|
||||
@PostMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
|
||||
public ApiResponse<FleetBookingModels.Result> incoming(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.startIncoming(tripNo,bookingNo,operator(user),request));}
|
||||
@PostMapping("/{tripNo}/bookings/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
|
||||
public ApiResponse<FleetBookingModels.Result> receipt(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(tripNo,bookingNo,operator(user),request));}
|
||||
@PostMapping("/{tripNo}/bookings/{bookingNo}/dispatch") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetBookingModels.Result> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.BindDispatch request){return ApiResponse.success(service.bindDispatch(tripNo,bookingNo,operator(user),request));}
|
||||
private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");return user.userId().toString();}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
||||
public class PlatformFleetTripController {
|
||||
private final FleetTripService service;
|
||||
private final FleetTripExecutionService execution;
|
||||
private final FleetTripCompletionService completion;
|
||||
public PlatformFleetTripController(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) {
|
||||
this.service = service; this.execution = execution; this.completion = completion;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<PageResult<FleetTripModels.Response>> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); }
|
||||
@GetMapping("/{tripNo}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetTripModels.Detail> detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); }
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
|
||||
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) {
|
||||
return ApiResponse.success(service.create(operator(user), request));
|
||||
}
|
||||
@PutMapping("/{tripNo}/plan")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
|
||||
return ApiResponse.success(service.updatePlan(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/assignment")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
|
||||
public ApiResponse<FleetTripModels.Detail> assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) {
|
||||
return ApiResponse.success(service.assign(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/cancel")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:cancel')")
|
||||
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
|
||||
return ApiResponse.success(service.cancel(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/dispatches/bind")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetTripModels.BindingCommand request) {
|
||||
return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/dispatches/unbind")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetTripModels.Detail> unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetTripModels.BindingCommand request) {
|
||||
return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request));
|
||||
}
|
||||
@GetMapping("/{tripNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<PageResult<FleetTripModels.History>> history(@PathVariable String tripNo,
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
|
||||
return ApiResponse.success(service.history(tripNo, page, size));
|
||||
}
|
||||
@PostMapping("/{tripNo}/dispatches/{dispatchNo}/review")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')")
|
||||
public ApiResponse<FleetTripModels.Detail> reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) {
|
||||
return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/loading-review")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')")
|
||||
public ApiResponse<FleetTripModels.Detail> reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetTripModels.Review request) {
|
||||
return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request));
|
||||
}
|
||||
@GetMapping("/{tripNo}/loading")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetExecutionModels.Loading> loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); }
|
||||
@GetMapping("/{tripNo}/capacity-plan")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetExecutionModels.Planning> capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); }
|
||||
@PostMapping("/{tripNo}/capacity-plan")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetExecutionModels.Planning> confirmCapacityPlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetExecutionModels.Confirm request) {
|
||||
return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/loading")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetExecutionModels.Loading> confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetExecutionModels.Confirm request) {
|
||||
return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request));
|
||||
}
|
||||
private static String operator(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
|
||||
return user.userId().toString();
|
||||
}
|
||||
@GetMapping("/{tripNo}/completion")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetCompletionModels.Inspection> completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); }
|
||||
@PostMapping("/{tripNo}/complete")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:complete')")
|
||||
public ApiResponse<FleetTripModels.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetCompletionModels.Complete request) {
|
||||
return ApiResponse.success(completion.complete(tripNo, operator(user), request));
|
||||
}
|
||||
@PostMapping("/{tripNo}/resources/release")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:release')")
|
||||
public ApiResponse<FleetTripModels.Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
@Valid @RequestBody FleetCompletionModels.Release request) {
|
||||
return ApiResponse.success(completion.release(tripNo, operator(user), request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user