feat(fleet): add internal capacity pool and warehouse fulfillment bridge
This commit is contained in:
@@ -14,6 +14,12 @@ public class FleetBookingEntity {
|
||||
private String customerId;
|
||||
private String customerCode;
|
||||
private String customerName;
|
||||
private Long offerId;
|
||||
private String salesOwnerUserId;
|
||||
private String businessType;
|
||||
private String deliveryMode;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private Integer expectedPalletCount;
|
||||
private String status;
|
||||
private String cargoJson;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson;
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
|
||||
@Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}")
|
||||
FleetBookingEntity byNo(@Param("number") String number);
|
||||
@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}")
|
||||
|
||||
@@ -10,7 +10,7 @@ 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,
|
||||
@Min(1) Integer 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) { }
|
||||
@@ -39,7 +39,7 @@ public final class FleetBookingModels {
|
||||
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,
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource) { }
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String offerNo,Integer expectedPalletCount) { }
|
||||
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
|
||||
public record ListResult(long tripRecordVersion, List<Response> records) { }
|
||||
}
|
||||
|
||||
+6
-1
@@ -16,13 +16,16 @@ import java.util.*;
|
||||
public class FleetBookingReservations {
|
||||
private final FleetBookingMapper bookings;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private FleetCapacityOfferMapper offers;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders;
|
||||
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),quoteSource(row));
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row),
|
||||
row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount());
|
||||
}
|
||||
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,
|
||||
@@ -61,10 +64,12 @@ public class FleetBookingReservations {
|
||||
}
|
||||
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
|
||||
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
|
||||
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),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)){
|
||||
if(row.getOfferId()!=null)poolOrders.unboundDispatch(row.getId(),row.getCustomerId(),dispatchNo,operator);
|
||||
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
|
||||
@@ -33,6 +34,7 @@ public class FleetBookingService {
|
||||
private final FleetBookingIncomingGateway incoming;
|
||||
private final FleetBookingQuoteContext quoteContext;
|
||||
private final FleetQuoteCandidateService quotes;
|
||||
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
|
||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
||||
@@ -61,17 +63,31 @@ public class FleetBookingService {
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return updatePool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result updatePool(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());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){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return confirmPool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result confirmPool(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,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);}
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
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);
|
||||
requireCompleteCargo(cargo);
|
||||
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);
|
||||
@@ -84,6 +100,11 @@ public class FleetBookingService {
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return cancelPool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result cancelPool(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());
|
||||
@@ -113,8 +134,9 @@ public class FleetBookingService {
|
||||
}
|
||||
@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);
|
||||
var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,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);}
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
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);
|
||||
@@ -138,6 +160,7 @@ public class FleetBookingService {
|
||||
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);
|
||||
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator);
|
||||
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
@@ -156,6 +179,15 @@ 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 void rejectPoolLegacy(String bookingNo){
|
||||
var row=bookings.byNo(bookingNo);
|
||||
if(row!=null&&row.getOfferId()!=null)throw conflict("拼车需求池意向请从需求池执行修改、成交或取消");
|
||||
}
|
||||
static void requireCompleteCargo(FleetBookingModels.Cargo cargo){
|
||||
if(cargo.packageCount()==null||cargo.packageCount()<1||cargo.palletSpaces()==null||cargo.palletSpaces()<1
|
||||
||cargo.weightKg()==null||cargo.weightKg().signum()<=0||cargo.volumeCbm()==null||cargo.volumeCbm().signum()<=0)
|
||||
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);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_fleet_capacity_offer_command")
|
||||
public class FleetCapacityOfferCommand {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private Long tripId;
|
||||
private String operatorId;
|
||||
private String action;
|
||||
private String scopeNo;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@Mapper
|
||||
public interface FleetCapacityOfferCommandMapper extends BaseMapper<FleetCapacityOfferCommand> {
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer_command WHERE trip_id=#{tripId} AND operator_id=#{operatorId} AND action=#{action} AND scope_no=#{scopeNo} AND idempotency_key=#{key}")
|
||||
FleetCapacityOfferCommand find(@Param("tripId") long tripId,@Param("operatorId") String operatorId,
|
||||
@Param("action") String action,@Param("scopeNo") String scopeNo,@Param("key") String key);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_fleet_capacity_offer")
|
||||
public class FleetCapacityOfferEntity {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private String offerNo;
|
||||
private Long tripId;
|
||||
private String tripNo;
|
||||
private String status;
|
||||
private LocalDateTime cutoffAt;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactName;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactPhone;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String remark;
|
||||
private String publishedFingerprint;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private String updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@Mapper
|
||||
public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferEntity> {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id
|
||||
<where>
|
||||
<if test="salesOnly">o.status='OPEN'</if>
|
||||
<if test="q.status != null">AND o.status=#{q.status}</if>
|
||||
<if test="q.tripNo != null and q.tripNo != ''">AND o.trip_no=#{q.tripNo}</if>
|
||||
<if test="q.keyword != null and q.keyword != ''">AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%'))</if>
|
||||
<if test="q.origin != null and q.origin != ''">AND t.warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
|
||||
<if test="q.destination != null and q.destination != ''">AND JSON_SEARCH(JSON_REMOVE(t.stops_json,'$[0]'),'one',CONCAT('%',#{q.destination},'%'),NULL,'$[*].name','$[*].destinationKey') IS NOT NULL</if>
|
||||
<if test="q.departureFrom != null">AND t.planned_departure_at >= #{q.departureFrom}</if>
|
||||
<if test="q.departureTo != null">AND DATE(t.planned_departure_at) <= #{q.departureTo}</if>
|
||||
</where>
|
||||
ORDER BY o.updated_at DESC,o.id DESC
|
||||
</script>
|
||||
""")
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> pageOffers(
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> page,
|
||||
@Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}")
|
||||
FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE")
|
||||
FleetCapacityOfferEntity lock(@Param("offerNo") String offerNo);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE trip_id=#{tripId}")
|
||||
FleetCapacityOfferEntity byTrip(@Param("tripId") long tripId);
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** Dedicated sales projection: no driver, other customer cargo or procurement facts. */
|
||||
public final class FleetCapacityOfferModels {
|
||||
private FleetCapacityOfferModels() { }
|
||||
public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
|
||||
@Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination,
|
||||
LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status,
|
||||
Boolean canConfirm,@Size(max=64) String tripNo) {
|
||||
public Query {page=page==null?1:page;size=size==null?20:size;}
|
||||
}
|
||||
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
|
||||
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
|
||||
@Pattern(regexp="MINE|ALL") String scope) {
|
||||
public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;}
|
||||
}
|
||||
public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion,
|
||||
@Min(0) Long offerRecordVersion,@NotNull Instant cutoffAt,@Size(max=128) String contactName,
|
||||
@Size(max=128) String contactPhone,@Size(max=1000) String remark,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@Size(max=1000) String reason) { }
|
||||
public record InquiryWrite(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@Min(0) Long recordVersion,@NotNull @Positive Long customerId,
|
||||
@NotBlank @Pattern(regexp="LOCAL_LOOSEBOARD|US_TRUCK") String businessType,
|
||||
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
|
||||
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
|
||||
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Size(max=1000) String reason) { }
|
||||
public record CustomerOption(String id,String code,String name) { }
|
||||
public record Destination(String destinationKey,String name,int deliveryIndex,TripCapacityPolicy.Load remaining) { }
|
||||
public record Segment(int fromIndex,int toIndex,String fromName,String toName,
|
||||
TripCapacityPolicy.Load planned,TripCapacityPolicy.Load reserved,TripCapacityPolicy.Load remaining) { }
|
||||
public record Actions(boolean canEdit,boolean canPublish,boolean canPause,boolean canResume,boolean canClose) { }
|
||||
public record Offer(String offerNo,String tripNo,String status,long recordVersion,long tripRecordVersion,
|
||||
String warehouseId,String warehouseName,Instant plannedDepartureAt,Instant cutoffAt,
|
||||
String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments,
|
||||
boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { }
|
||||
public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { }
|
||||
public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion,
|
||||
long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode,
|
||||
String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
|
||||
FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount,
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo,
|
||||
Instant updatedAt,InquiryActions actions) { }
|
||||
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
|
||||
public record OfferResult(Offer offer,boolean replayed) { }
|
||||
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
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.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetCapacityOfferService {
|
||||
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
|
||||
private final FleetCapacityOfferMapper offers;
|
||||
private final FleetCapacityOfferCommandMapper commands;
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetBookingMapper bookings;
|
||||
private final FleetBookingService bookingService;
|
||||
private final FleetBookingReservations reservations;
|
||||
private final FleetTripExecutionService execution;
|
||||
private final FleetCustomerDirectory customers;
|
||||
private final FleetPoolOrderBridge orders;
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
@Autowired
|
||||
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
|
||||
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
|
||||
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json){
|
||||
this(offers,commands,trips,bookings,bookingService,reservations,execution,customers,orders,json,Clock.systemUTC());
|
||||
}
|
||||
FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
|
||||
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
|
||||
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json,Clock clock){
|
||||
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
|
||||
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
|
||||
readPermission();
|
||||
var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage());
|
||||
if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围");
|
||||
var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false)))
|
||||
.filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList();
|
||||
if(query.canConfirm()!=null)return page(values,query.page(),query.size());
|
||||
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Detail detail(String number){
|
||||
readPermission();var row=offer(number,false);requireVisible(row);
|
||||
return new FleetCapacityOfferModels.Detail(project(row,trip(row.getTripNo(),false)),inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,number,null,manage()?"ALL":"MINE")));
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
|
||||
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden();
|
||||
var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId);
|
||||
if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
|
||||
if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());}
|
||||
if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status());
|
||||
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()));
|
||||
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
|
||||
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter);
|
||||
var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
|
||||
var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>();
|
||||
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList();
|
||||
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Inquiry inquiry(String number){
|
||||
readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId()));
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
|
||||
writePermission("inquiry-create");
|
||||
return customers.options(keyword).stream().map(c->new FleetCapacityOfferModels.CustomerOption(c.id(),c.code(),c.name())).toList();
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String number,FleetBookingModels.QuoteCandidatesQuery query){
|
||||
readPermission();if(!has("platform:fleet:quote:list"))throw forbidden();
|
||||
var row=offer(number,false);requireVisible(row);customers.required(query.customerId());
|
||||
return bookingService.quoteCandidates(row.getTripNo(),query);
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){
|
||||
requireManage();var trip=trip(request.tripNo(),true);
|
||||
var replay=replay(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
tripVersion(trip,request.tripRecordVersion());
|
||||
if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改");
|
||||
validateCutoff(trip,request.cutoffAt());
|
||||
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
|
||||
row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request);
|
||||
if(offers.insert(row)!=1)throw conflict("发布创建失败");
|
||||
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult update(String number,FleetCapacityOfferModels.Write request){
|
||||
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
|
||||
var replay=replay(trip,"EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if(!Objects.equals(request.tripNo(),trip.getTripNo())||"CLOSED".equals(row.getStatus()))throw conflict("关闭的发布不可修改,也不能变更所属车次");
|
||||
validateCutoff(trip,request.cutoffAt());apply(row,request);touch(row);
|
||||
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"EDIT",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult command(String number,String action,FleetCapacityOfferModels.Command request){
|
||||
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offer(number,true);
|
||||
var replay=replay(trip,action,number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if("CLOSED".equals(row.getStatus()))throw conflict("发布已关闭,不能恢复;已有意向仍可查询");
|
||||
switch(action){
|
||||
case "PUBLISH","RESUME" -> {
|
||||
if("PUBLISH".equals(action)&&!"DRAFT".equals(row.getStatus())||"RESUME".equals(action)&&!Set.of("OPEN","PAUSED").contains(row.getStatus()))throw conflict("发布状态已变化,请刷新");
|
||||
validateCutoff(trip,instant(row.getCutoffAt()));
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
var planning=execution.capacityPlan(trip.getTripNo());requirePlanning(trip,planning);
|
||||
row.setPublishedFingerprint(fingerprint(trip));row.setStatus("OPEN");
|
||||
}
|
||||
case "PAUSE" -> {if(!"OPEN".equals(row.getStatus()))throw conflict("只有开放发布可暂停");row.setStatus("PAUSED");}
|
||||
case "CLOSE" -> row.setStatus("CLOSED");
|
||||
default -> throw bad("发布操作无效");
|
||||
}
|
||||
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
|
||||
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
|
||||
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
|
||||
if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
|
||||
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
|
||||
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
|
||||
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
|
||||
var booking=bookings.lock(trip.getId(),number);own(booking);
|
||||
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
|
||||
if(request.recordVersion()==null)throw bad("缺少意向版本");
|
||||
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
|
||||
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
|
||||
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){
|
||||
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);
|
||||
var replay=replay(trip,"CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason());
|
||||
var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo);
|
||||
if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息");
|
||||
if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代");
|
||||
if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间");
|
||||
bookingService.confirmPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),request.recordVersion(),request.idempotencyKey(),request.reason()));
|
||||
booking=bookings.lock(trip.getId(),number);
|
||||
orders.confirm(new FleetPoolOrderModels.ConfirmedBooking(booking.getId(),booking.getBookingNo(),booking.getCustomerId(),booking.getBusinessType(),trip.getWarehouseId().toString(),trip.getWarehouseName(),
|
||||
booking.getDestinationKey(),booking.getDestinationLabel(),trip.getPlannedDepartureAt(),booking.getDeliveryMode(),cargo.remark(),booking.getEstimatedArrivalAt(),cargo.cargoName(),cargo.packageCount(),booking.getExpectedPalletCount(),cargo.weightKg(),cargo.volumeCbm(),cargo.currency(),cargo.contactName(),cargo.contactPhone(),cargo.remark()),operator());
|
||||
var result=inquiryResult(row,booking);saveCommand(trip,"CONFIRM",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult cancel(String number,FleetCapacityOfferModels.InquiryCommand request){
|
||||
writePermission("inquiry-cancel");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);own(booking);
|
||||
var replay=replay(trip,"CANCEL",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if(!Objects.equals(booking.getRecordVersion(),request.recordVersion()))throw conflict("意向已变化,请刷新");
|
||||
if(!Set.of("DRAFT","CONFIRMED").contains(booking.getStatus())||booking.getIncomingNo()!=null)throw conflict("已登记来货或作业的意向不能在需求池取消,请保留原作业事实");
|
||||
if("CONFIRMED".equals(booking.getStatus())){requireManage();orders.cancelBeforeIncoming(booking.getId(),booking.getCustomerId(),operator(),request.reason());}
|
||||
// Cancelling an uncommitted draft remains possible after offer closure or trip departure.
|
||||
if("DRAFT".equals(booking.getStatus())&&!"PLANNED".equals(trip.getStatus())){
|
||||
booking.setStatus("CANCELLED");booking.setRecordVersion(booking.getRecordVersion()+1);booking.setUpdatedBy(operator());booking.setUpdatedAt(now());bookings.updateById(booking);
|
||||
}else bookingService.cancelPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),booking.getRecordVersion(),request.idempotencyKey(),request.reason()));
|
||||
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
|
||||
private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){
|
||||
return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false);
|
||||
}
|
||||
private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){
|
||||
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)));
|
||||
}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){
|
||||
boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus());
|
||||
boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null);
|
||||
return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()),
|
||||
new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason()));
|
||||
}
|
||||
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
|
||||
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
|
||||
String capacityBlock=planning.blockReason();
|
||||
if(capacityBlock==null&&!planning.planValid())capacityBlock="计划容量待调度核对";
|
||||
if(capacityBlock==null&&trip.getDriverId()==null)capacityBlock="请先分配司机";
|
||||
if(capacityBlock==null&&!Objects.equals(row.getPublishedFingerprint(),fingerprint(trip)))capacityBlock="发布承诺已变化,请调度重新核对并恢复接单";
|
||||
if(!manage()&&!"OPEN".equals(row.getStatus()))capacityBlock="发布未开放,仅可查看本人历史意向";
|
||||
boolean known=capacityBlock==null;
|
||||
var stops=reservations.stops(trip);var segments=new ArrayList<FleetCapacityOfferModels.Segment>();
|
||||
var destinations=new ArrayList<FleetCapacityOfferModels.Destination>();
|
||||
if(known)for(var segment:planning.availableSegments()){
|
||||
var own=planning.capacityPlan().segments().get(segment.fromIndex()).occupied();var all=segment.occupied();
|
||||
segments.add(new FleetCapacityOfferModels.Segment(segment.fromIndex(),segment.toIndex(),stops.get(segment.fromIndex()).name(),stops.get(segment.toIndex()).name(),own,
|
||||
new TripCapacityPolicy.Load(all.palletSpaces()-own.palletSpaces(),all.weightKg().subtract(own.weightKg()),all.volumeCbm().subtract(own.volumeCbm())),segment.remaining()));
|
||||
}
|
||||
for(int index=1;index<stops.size();index++)if(nonblank(stops.get(index).destinationKey())){
|
||||
TripCapacityPolicy.Load remaining=null;
|
||||
if(known){remaining=segments.get(0).remaining();for(int part=1;part<index;part++)remaining=min(remaining,segments.get(part).remaining());}
|
||||
destinations.add(new FleetCapacityOfferModels.Destination(stops.get(index).destinationKey(),stops.get(index).name(),index,remaining));
|
||||
}
|
||||
if(block==null)block=capacityBlock;
|
||||
if(block==null&&destinations.stream().noneMatch(d->positive(d.remaining())))block="可接运力已满";
|
||||
boolean editable=manage()&&!"CLOSED".equals(row.getStatus());boolean baseReady=planning.planValid()&&planning.blockReason()==null&&trip.getDriverId()!=null&&cutoffValid(trip,row.getCutoffAt(),clock.instant());
|
||||
return new FleetCapacityOfferModels.Offer(row.getOfferNo(),trip.getTripNo(),row.getStatus(),row.getRecordVersion(),trip.getRecordVersion(),String.valueOf(trip.getWarehouseId()),trip.getWarehouseName(),instant(trip.getPlannedDepartureAt()),instant(row.getCutoffAt()),row.getContactName(),row.getContactPhone(),row.getRemark(),List.copyOf(destinations),List.copyOf(segments),known,
|
||||
openBlock(row,trip,clock.instant())==null,block==null,block,new FleetCapacityOfferModels.Actions(editable,editable&&"DRAFT".equals(row.getStatus())&&baseReady,editable&&"OPEN".equals(row.getStatus()),editable&&Set.of("OPEN","PAUSED").contains(row.getStatus())&&baseReady,editable));
|
||||
}
|
||||
static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));}
|
||||
private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;}
|
||||
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
|
||||
static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){
|
||||
if(!"OPEN".equals(row.getStatus()))return "发布未开放接单";
|
||||
if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向";
|
||||
if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null;
|
||||
}
|
||||
private String fingerprint(FleetTripEntity trip){return hash(Arrays.asList(trip.getWarehouseId(),trip.getVehicleId(),trip.getDriverId(),trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm(),trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),trip.getStopsJson(),trip.getPlannedLoadJson()));}
|
||||
private void validateCutoff(FleetTripEntity trip,Instant cutoff){if(!cutoffValid(trip,cutoff==null?null:LocalDateTime.ofInstant(cutoff,ZoneOffset.UTC),clock.instant()))throw conflict("请填写晚于当前时间且不晚于计划发车的交货截止");}
|
||||
private void requireInquiryOpen(FleetCapacityOfferEntity row,FleetTripEntity trip){String reason=openBlock(row,trip,clock.instant());if(reason!=null)throw conflict(reason);}
|
||||
private void requirePlanning(FleetTripEntity trip,FleetExecutionModels.Planning plan){if(!plan.planValid()||plan.blockReason()!=null||trip.getDriverId()==null)throw conflict(plan.blockReason()==null?"请先分配司机并核对计划容量":plan.blockReason());}
|
||||
private void validateInquiry(FleetTripEntity trip,FleetCapacityOfferModels.InquiryWrite request){
|
||||
if(!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(request.businessType())||!Set.of("SELF_DELIVERY","MANUAL_PICKUP_TO_WAREHOUSE").contains(request.deliveryMode()))throw bad("业务类型或交货方式无效");
|
||||
if(FleetBookingReservations.index(reservations.stops(trip),request.cargo().destinationKey())<1)throw conflict("请选择本车已有卸货站");
|
||||
if(request.estimatedArrivalAt()!=null&&request.estimatedArrivalAt().isAfter(instant(trip.getPlannedDepartureAt())))throw conflict("预计到仓不得晚于计划发车");
|
||||
}
|
||||
private void apply(FleetCapacityOfferEntity row,FleetCapacityOfferModels.Write request){row.setCutoffAt(LocalDateTime.ofInstant(request.cutoffAt(),ZoneOffset.UTC));row.setContactName(trim(request.contactName()));row.setContactPhone(trim(request.contactPhone()));row.setRemark(trim(request.remark()));row.setUpdatedBy(operator());row.setUpdatedAt(now());}
|
||||
private void apply(FleetBookingEntity row,FleetCapacityOfferModels.InquiryWrite request){row.setBusinessType(request.businessType());row.setDeliveryMode(request.deliveryMode());row.setExpectedPalletCount(request.expectedPalletCount());row.setEstimatedArrivalAt(request.estimatedArrivalAt()==null?null:LocalDateTime.ofInstant(request.estimatedArrivalAt(),ZoneOffset.UTC));}
|
||||
private void touch(FleetCapacityOfferEntity row){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator());row.setUpdatedAt(now());if(offers.updateById(row)!=1)throw conflict("发布已变化,请刷新");}
|
||||
private <T>T replay(FleetTripEntity trip,String action,String scope,String key,Object request,Class<T> type){var command=commands.find(trip.getId(),operator(),action,scope,key);if(command==null)return null;if(!Objects.equals(command.getRequestHash(),hash(request)))throw conflict("同一请求键不能更换请求内容");try{return json.readValue(command.getResultJson(),type);}catch(Exception e){throw new IllegalStateException("原命令结果无法读取",e);}}
|
||||
private void saveCommand(FleetTripEntity trip,String action,String scope,String key,Object request,Object result){var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator());command.setAction(action);command.setScopeNo(scope);command.setIdempotencyKey(key);command.setRequestHash(hash(request));command.setResultJson(write(result));command.setCreatedAt(now());if(commands.insert(command)!=1)throw conflict("命令记录保存失败");}
|
||||
private void versions(FleetTripEntity trip,FleetCapacityOfferEntity row,Long tripVersion,Long offerVersion){tripVersion(trip,tripVersion);if(!Objects.equals(row.getRecordVersion(),offerVersion))throw conflict("发布已变化,请刷新后重新核对");}
|
||||
private void tripVersion(FleetTripEntity trip,Long version){if(!Objects.equals(trip.getRecordVersion(),version))throw conflict("车次已变化,请刷新后重新核对");}
|
||||
private FleetCapacityOfferEntity offer(String number,boolean lock){var row=lock?offers.lock(number):offers.byNo(number);if(row==null)throw missing();return row;}
|
||||
private FleetTripEntity trip(String number,boolean lock){var row=lock?trips.lockByNo(number):trips.findByNo(number);if(row==null)throw missing();return row;}
|
||||
private FleetBookingEntity inquiryRow(String number){var row=bookings.byNo(number);if(row==null||row.getOfferId()==null)throw missing();return row;}
|
||||
private void own(FleetBookingEntity row){if(!manage()&&!Objects.equals(row.getSalesOwnerUserId(),operator()))throw forbidden();}
|
||||
private void requireVisible(FleetCapacityOfferEntity row){if(!manage()&&!"OPEN".equals(row.getStatus())&&bookings.selectCount(new LambdaQueryWrapper<FleetBookingEntity>().eq(FleetBookingEntity::getOfferId,row.getId()).eq(FleetBookingEntity::getSalesOwnerUserId,operator()))==0)throw forbidden();}
|
||||
private static boolean has(String permission){var auth=SecurityContextHolder.getContext().getAuthentication();return auth!=null&&auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));}
|
||||
private static boolean manage(){return has(MANAGE);}
|
||||
private static void readPermission(){if(!has(LIST)&&!manage())throw forbidden();operator();}
|
||||
private static void writePermission(String action){if(!manage()&&(!has(LIST)||!has("platform:fleet:capacity-offer:"+action)))throw forbidden();operator();}
|
||||
private static void requireManage(){if(!manage())throw forbidden();operator();}
|
||||
private static String operator(){var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null||!(auth.getPrincipal() instanceof AuthenticatedUser user)||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");if(!Long.valueOf(0).equals(user.tenantId()))throw forbidden();return user.userId().toString();}
|
||||
private <T>PageResult<T> page(List<T> rows,int page,int size){int start=(int)Math.min(rows.size(),((long)page-1)*size);return new PageResult<>(List.copyOf(rows.subList(start,Math.min(rows.size(),start+size))),page,size,rows.size(),(rows.size()+size-1)/size);}
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("拼车数据无法保存",e);}}
|
||||
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)));}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
|
||||
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
|
||||
private static Instant instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC);}
|
||||
private static boolean contains(String value,String needle){return !nonblank(needle)||value!=null&&value.toLowerCase(Locale.ROOT).contains(needle.trim().toLowerCase(Locale.ROOT));}
|
||||
private static boolean nonblank(String value){return value!=null&&!value.isBlank();}
|
||||
private static String trim(String value){return nonblank(value)?value.trim():null;}
|
||||
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);}
|
||||
private static ResponseStatusException forbidden(){return new ResponseStatusException(HttpStatus.FORBIDDEN,"无权操作该发布或意向");}
|
||||
private static ResponseStatusException missing(){return new ResponseStatusException(HttpStatus.NOT_FOUND,"发布、车次或意向不存在");}
|
||||
}
|
||||
+18
@@ -51,6 +51,8 @@ public class FleetTripExecutionService {
|
||||
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
|
||||
var trip = requireTrip(tripNo, true);
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
String vehicleBlock = currentVehicleBlockReason(trip, true);
|
||||
if (vehicleBlock != null) throw conflict(vehicleBlock);
|
||||
var cargo = cargo(manifests(trip, true));
|
||||
String reason = planningBlockReason(trip, cargo);
|
||||
if (reason != null) throw conflict(reason);
|
||||
@@ -74,12 +76,28 @@ public class FleetTripExecutionService {
|
||||
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
|
||||
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
|
||||
String vehicleReason = currentVehicleBlockReason(trip, false);
|
||||
if (vehicleReason != null) return vehicleReason;
|
||||
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
|
||||
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
|
||||
return "请先明确每票货物的实物托盘和卸货目的地";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Caller holds the parent trip lock before requesting a vehicle write lock. */
|
||||
public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) {
|
||||
if (trip.getVehicleId() == null) return "请先分配车辆";
|
||||
var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null);
|
||||
if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()))
|
||||
return "车辆当前不可用,请重新分配车辆并核对计划容量";
|
||||
if (!Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate())
|
||||
|| !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|
||||
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg())
|
||||
|| !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
|
||||
return "车辆容量或资料已变化,请重新分配车辆并核对计划容量";
|
||||
return null;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Loading loading(String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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 jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/capacity-offers")
|
||||
public class PlatformFleetCapacityOfferController {
|
||||
private final FleetCapacityOfferService service;
|
||||
public PlatformFleetCapacityOfferController(FleetCapacityOfferService service){this.service=service;}
|
||||
@GetMapping @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetCapacityOfferModels.Offer>> page(@Valid FleetCapacityOfferModels.Query query){return ApiResponse.success(service.page(query));}
|
||||
@GetMapping("/{offerNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.Detail> detail(@PathVariable String offerNo){return ApiResponse.success(service.detail(offerNo));}
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> create(@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.create(request));}
|
||||
@PutMapping("/{offerNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> update(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.update(offerNo,request));}
|
||||
@PostMapping("/{offerNo}/publish") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> publish(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PUBLISH",request));}
|
||||
@PostMapping("/{offerNo}/pause") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> pause(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PAUSE",request));}
|
||||
@PostMapping("/{offerNo}/resume") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
|
||||
@PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));}
|
||||
@GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetCapacityOfferModels.Inquiry>> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));}
|
||||
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.Inquiry> inquiry(@PathVariable String bookingNo){return ApiResponse.success(service.inquiry(bookingNo));}
|
||||
@PostMapping("/{offerNo}/inquiries") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> createInquiry(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.createInquiry(offerNo,request));}
|
||||
@PutMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-edit'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> updateInquiry(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.updateInquiry(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> confirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.confirm(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-cancel'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> cancel(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.cancel(bookingNo,request));}
|
||||
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
|
||||
public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));}
|
||||
@GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));}
|
||||
}
|
||||
Reference in New Issue
Block a user