feat(fleet): bind quote versions and reconcile actual return quantities

This commit is contained in:
2026-10-03 13:32:55 +08:00
parent cb6c54ffe5
commit a6fd0ba3c5
60 changed files with 1363 additions and 75 deletions
@@ -16,6 +16,7 @@ public class FleetBookingEntity {
private String customerName;
private String status;
private String cargoJson;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson;
private String destinationKey;
private String destinationLabel;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
@@ -14,8 +15,13 @@ public final class FleetBookingModels {
@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) { }
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
public record QuoteCandidatesQuery(@NotNull @Positive Long customerId,@NotBlank @Size(max=160) String destinationKey,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotBlank @Pattern(regexp="PALLET|ORDER") String priceBasis,
@Min(1) Integer page,@Min(1) @Max(100) Integer size) {
public QuoteCandidatesQuery {page=page==null?1:page;size=size==null?20:size;}
}
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,
@@ -32,7 +38,8 @@ public final class FleetBookingModels {
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) { }
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt,
FleetQuoteCandidateModels.SourceSnapshot quoteSource) { }
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
public record ListResult(long tripRecordVersion, List<Response> records) { }
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
/** Resolve quote eligibility from the trip and authoritative warehouse facts, never browser hints. */
@Component
public class FleetBookingQuoteContext {
private final FleetWarehouseClient warehouses;
private final FleetBookingReservations reservations;
private final String token;
public FleetBookingQuoteContext(FleetWarehouseClient warehouses, FleetBookingReservations reservations,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.warehouses = warehouses; this.reservations = reservations; this.token = token;
}
public FleetQuoteCandidateModels.Context resolve(FleetTripEntity trip, long customerId,
String destinationKey, String currency, String priceBasis) {
if (customerId <= 0 || trip.getWarehouseId() == null || trip.getPlannedDepartureAt() == null
|| FleetBookingReservations.index(reservations.stops(trip), destinationKey) < 1)
throw conflict("请选择本车已有卸货站和委托客户");
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4)
throw conflict("该卸货站没有报价库目的仓码,请手填本次成交价");
if (!Set.of("CNY", "USD").contains(currency == null ? "" : currency)
|| !Set.of("PALLET", "ORDER").contains(priceBasis == null ? "" : priceBasis))
throw conflict("请选择报价币种及计价方式");
FleetWarehouseClient.Detail detail;
try {
var response = warehouses.activeDetail(token, trip.getWarehouseId());
if (response == null || !response.successful() || response.data() == null) throw unavailable();
detail = response.data();
} catch (ResponseStatusException e) { throw e; }
catch (RuntimeException e) { throw unavailable(); }
var warehouse = detail.warehouse();
if (warehouse == null || !trip.getWarehouseId().equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status()) || !Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType() == null ? "" : warehouse.warehouseType()))
throw conflict("起运仓资料已变化,请重新核对");
var cities = new TreeSet<String>();
if (detail.addresses() != null) for (var address : detail.addresses()) {
if (address != null && trip.getWarehouseId().equals(address.warehouseId()) && Long.valueOf(0).equals(address.tenantId())
&& address.city() != null && !address.city().isBlank()) cities.add(address.city().trim().toLowerCase(Locale.ROOT));
}
if (cities.size() != 1) throw conflict("起运仓城市尚未明确,请完善仓库资料或手填本次成交价");
ZoneId zone;
try { zone = ZoneId.of(warehouse.timezone() == null ? "" : warehouse.timezone().trim()); }
catch (DateTimeException e) { throw conflict("起运仓时区尚未明确,请完善仓库资料或手填本次成交价"); }
var date = trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).atZone(zone).toLocalDate();
return new FleetQuoteCandidateModels.Context(customerId, cities.first(), destinationKey.substring(4), currency, date, priceBasis);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "起运仓资料暂不可用,请重试"); }
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -21,8 +22,9 @@ public class FleetBookingReservations {
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));
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row));
}
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
@@ -2,6 +2,10 @@ package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
import org.springframework.security.core.context.SecurityContextHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
@@ -27,12 +31,21 @@ public class FleetBookingService {
private final FleetCustomerDirectory customers;
private final ObjectMapper json;
private final FleetBookingIncomingGateway incoming;
private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
this.quoteContext=quoteContext;this.quotes=quotes;
}
@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(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String tripNo,FleetBookingModels.QuoteCandidatesQuery query){
var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis());
return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size()));
}
@Transactional
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
var trip=trip(tripNo,true);String hash=hash(request);
@@ -41,7 +54,7 @@ public class FleetBookingService {
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);
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());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);
@@ -50,7 +63,7 @@ public class FleetBookingService {
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);
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());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){
@@ -61,6 +74,8 @@ public class FleetBookingService {
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 source=reservations.quoteSource(row);
if(source!=null)row.setQuoteSourceJson(write(resolveQuote(trip,row,new FleetQuoteCandidateModels.QuoteRef(source.quoteNo(),source.recordVersion(),source.priceItem()))));
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
reservations.segments(trip,plan.capacityPlan(),null,row);
@@ -141,6 +156,19 @@ public class FleetBookingService {
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 FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
var cargo=reservations.cargo(row);
return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference);
}
private void applyQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
if(reference!=null || row.getQuoteSourceJson()!=null)requireQuotePermission();
row.setQuoteSourceJson(reference==null?null:write(resolveQuote(trip,row,reference)));
}
private static void requireQuotePermission(){
var authentication=SecurityContextHolder.getContext().getAuthentication();
if(authentication==null || authentication.getAuthorities().stream().noneMatch(a->"platform:fleet:quote:list".equals(a.getAuthority())))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"引用或清除报价来源需要报价读取权限");
}
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("请选择本车已有卸货站,外接货首版从本起运仓装车");
@@ -7,7 +7,7 @@ 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) { }
String status, int packages, Instant terminalAt, int signedPackages, int returnedPackages, int remainingPackages) { }
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,
@@ -117,7 +117,9 @@ public class FleetTripCompletionService {
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
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.packages() <= 0 || outcome.remainingPackages() != 0
|| outcome.signedPackages() < 0 || outcome.returnedPackages() < 0
|| (long) outcome.signedPackages() + outcome.returnedPackages() != outcome.packages() || outcome.terminalAt() == null
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
}
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import jakarta.validation.Valid;
@@ -20,6 +22,8 @@ public class PlatformFleetBookingController {
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));}
@GetMapping("/{tripNo}/bookings/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:trip:list') and hasAuthority('platform:fleet:quote:list')")
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String tripNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(tripNo,query));}
@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')")