feat(lastleg): connect appointment followups and fleet intake

This commit is contained in:
2026-10-09 22:04:49 +08:00
parent 1202cafadf
commit 0d75e311d0
54 changed files with 2315 additions and 25 deletions
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway;
import org.springframework.stereotype.Component;
import java.util.List;
/** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */
@Component
public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway {
private final FleetPoolSourceResolver sources;
public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;}
@Override
public List<Source> resolve(List<SourceRef> refs) {
return sources.resolve(refs.stream().map(r->new FleetPoolSourceResolver.SourceRef(r.customerId(),r.bookingNo())).toList())
.stream().map(s->new Source(s.customerId(),s.bookingNo(),s.tripNo(),s.bookingStatus(),s.warehouseId(),s.destinationKey(),s.lastlegOrderId(),s.packageCount(),s.recordVersion())).toList();
}
}
@@ -39,6 +39,13 @@ public class FleetPoolOrderBridge {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
}
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
@@ -7,6 +7,15 @@ import java.util.List;
@Mapper
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
@Select({"<script>SELECT b.customer_id,b.booking_no,b.trip_no,b.status AS booking_status,",
"t.warehouse_id,b.destination_key,b.destination_label,b.incoming_no,",
"l.lastleg_order_id,l.business_order_no,l.fulfillment_stage,b.record_version,",
"b.cargo_json,b.expected_pallet_count,b.estimated_arrival_at",
"FROM ll_fleet_external_booking b JOIN ll_fleet_trip t ON t.id=b.trip_id",
"LEFT JOIN ll_fleet_pool_order_link l ON l.booking_id=b.id AND l.customer_id=b.customer_id",
"WHERE b.offer_id IS NOT NULL AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(b.customer_id=#{s.customerId} AND b.booking_no=#{s.bookingNo})</foreach></script>"})
List<FleetPoolSourceResolver.SourceRow> resolveSources(@Param("sources") List<FleetPoolSourceResolver.SourceRef> sources);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE")
FleetPoolOrderLink lockBooking(long id);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE")
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.fleet.business;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Internal read boundary for appointment planning; caller must authorize every customer/source. */
@Service
public class FleetPoolSourceResolver {
public record SourceRef(String customerId, String bookingNo) { }
public record Source(String customerId, String bookingNo, String tripNo, String bookingStatus,
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, Integer packageCount, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
public record SourceRow(String customerId, String bookingNo, String tripNo, String bookingStatus,
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion,
String cargoJson, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
private final com.fasterxml.jackson.databind.ObjectMapper json;
private final FleetPoolOrderMapper links;
public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; }
@Transactional(readOnly = true)
public List<Source> resolve(List<SourceRef> sources) {
if (sources == null || sources.isEmpty()) return List.of();
if (sources.size() > 100 || sources.stream().anyMatch(s -> s == null || s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank()))
throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条");
return links.resolveSources(sources.stream().distinct().toList()).stream().map(row -> {
Integer packages;
try {
var value=json.readTree(row.cargoJson()).get("packageCount");
if(value==null||value.isNull()) packages=null;
else { if(!value.isIntegralNumber()||!value.canConvertToInt())throw new IllegalStateException("预报箱数格式无效"); packages=value.intValue(); }
} catch(java.io.IOException e){throw new IllegalStateException("货物预报读取失败",e);}
return new Source(row.customerId(),row.bookingNo(),row.tripNo(),row.bookingStatus(),row.warehouseId(),row.destinationKey(),row.destinationLabel(),row.incomingNo(),row.lastlegOrderId(),row.businessOrderNo(),row.fulfillmentStage(),row.recordVersion(),packages,row.expectedPalletCount(),row.estimatedArrivalAt());
}).toList();
}
}
@@ -11,6 +11,8 @@ public interface FleetBookingIncomingGateway {
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) { }
record Source(String customerId,String incomingNo) { }
java.util.Map<Source,String> statuses(List<Source> sources);
Evidence create(String operator, Incoming incoming);
Evidence inspect(String customerId,String incomingNo,boolean lock);
Evidence confirm(String customerId,String incomingNo,String operator,String key);
@@ -35,12 +35,17 @@ public class FleetBookingService {
private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes;
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
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;
}
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
}
@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)
@@ -125,9 +130,12 @@ public class FleetBookingService {
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 arrival=request.estimatedArrivalAt()!=null?request.estimatedArrivalAt():row.getEstimatedArrivalAt()==null?null:row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC);
if(arrival==null)throw conflict("请确认预计到仓时间,不自动推算到仓安排");
if(arrival.isAfter(trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC)))throw conflict("预计到仓不得晚于计划发车时间");
var instructions=trim(request.deliveryInstructions())!=null?request.deliveryInstructions():reservations.cargo(row).remark();
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()));
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),instructions,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);
@@ -169,12 +177,48 @@ public class FleetBookingService {
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("请先确认外接实收货量和运费");
if(row.getOfferId()!=null) {
var accepted=reservations.receipt(row);
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence);
}
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);
}
@Transactional
public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) {
var trip=trip(tripNo,true);String hash=hash(request);
var previous=commands.find(trip.getId(),operator,"BOOKING_ARRANGE",bookingNo,request.idempotencyKey());
if(previous!=null) {
if(!hash.equals(previous.getRequestHash()))throw conflict("排入本车请求内容不一致");
try {var saved=json.readValue(previous.getResultJson(),FleetBookingModels.Result.class);return new FleetBookingModels.Result(saved.tripRecordVersion(),saved.booking(),true);}
catch(java.io.IOException e){throw new IllegalStateException("原排入结果读取失败",e);}
}
String resourceBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认全部实收货量和运费");
var accepted=reservations.receipt(row);
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
if(resourceBlock!=null)throw conflict(resourceBlock);
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo));
row=booking(trip,bookingNo);
if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源");
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false);
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator);
command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash);
command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command);
return result;
}
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("来货单引用不一致");
@@ -21,7 +21,7 @@ public final class FleetCapacityOfferModels {
}
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) {
@Pattern(regexp="MINE|ALL|TODO") 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,
@@ -50,12 +50,15 @@ public final class FleetCapacityOfferModels {
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 Progress(String stage,String label,String reservation,String owner,String primaryAction,
String message,List<String> missingFields) { }
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) { }
Instant updatedAt,InquiryActions actions,Progress progress,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage appointmentCoverage) { }
public record Fulfillment(FleetBookingModels.Response booking,FleetBookingModels.IncomingPreview preview,Instant plannedDepartureAt) { }
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
public record OfferResult(Offer offer,boolean replayed) { }
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
@@ -37,6 +37,7 @@ public class FleetCapacityOfferService {
private final FleetPoolOrderBridge orders;
private final ObjectMapper json;
private final Clock clock;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage;
@Autowired
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
@@ -67,23 +68,52 @@ public class FleetCapacityOfferService {
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden();
readPermission();if(!"MINE".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("MINE".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()));
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()).or().like(FleetBookingEntity::getTripNo,query.keyword()));
boolean todo="TODO".equals(query.scope());
if(todo)filter.in(FleetBookingEntity::getStatus,"DRAFT","CONFIRMED","RECEIVED");
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter);
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(todo?1:query.page(),todo?100:query.size()),filter);
if(todo&&result.getTotal()>100)throw bad("待办实时核对最多100条,请按客户或登记号缩小范围");
var incomingStatuses=bookingService.incomingStatuses(result.getRecords());
var coverage=coverageByBooking(result.getRecords());
var stages=orders.fulfillmentStages(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
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();
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));}),incomingStatuses.get(new FleetBookingIncomingGateway.Source(row.getCustomerId(),row.getIncomingNo())),stages.get(row.getId()),coverage.get(row.getBookingNo()))).filter(v->!todo||"调度".equals(v.progress().owner())).toList();
if(todo)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.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,isolation=Isolation.REPEATABLE_READ)
public FleetCapacityOfferModels.Fulfillment fulfillment(String number) {
readPermission();var row=inquiryRow(number);own(row);
var trip=trip(row.getTripNo(),false);
return new FleetCapacityOfferModels.Fulfillment(reservations.response(row),
bookingService.incoming(row.getTripNo(),number),instant(trip.getPlannedDepartureAt()));
}
@Transactional
public FleetBookingModels.Result arrangeIncoming(String number,FleetBookingModels.StartIncoming request) {
requireManage();var row=inquiryRow(number);
return bookingService.startIncoming(row.getTripNo(),number,operator(),request);
}
@Transactional
public FleetBookingModels.Result acceptReceipt(String number,FleetBookingModels.AcceptReceipt request) {
requireManage();var row=inquiryRow(number);
return bookingService.acceptReceipt(row.getTripNo(),number,operator(),request);
}
@Transactional
public FleetBookingModels.Result arrangeDispatch(String number,FleetBookingModels.Command request) {
requireManage();var row=inquiryRow(number);
return bookingService.arrangeDispatch(row.getTripNo(),number,operator(),request);
}
@Transactional(readOnly=true)
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
writePermission("inquiry-create");
@@ -165,6 +195,16 @@ public class FleetCapacityOfferService {
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult saveAndConfirm(String number,FleetCapacityOfferModels.InquiryWrite request) {
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
var replay=replay(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
if(replay!=null)return replayed(replay);
var saved=updateInquiry(number,request);
var result=confirm(number,new FleetCapacityOfferModels.InquiryCommand(saved.offer().tripRecordVersion(),saved.offer().recordVersion(),saved.inquiry().recordVersion(),request.idempotencyKey(),"核对资料并确认承运"));
saveCommand(trip,"SAVE_CONFIRM",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);
@@ -172,7 +212,10 @@ public class FleetCapacityOfferService {
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);
var cargo=reservations.cargo(booking);
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
if(!missing.isEmpty())throw conflict("确认前还需:"+String.join("、",missing));
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("请填写不晚于交货截止的预计到仓时间");
@@ -197,18 +240,32 @@ public class FleetCapacityOfferService {
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
}
private Map<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage> coverageByBooking(List<FleetBookingEntity> rows) {
var refs=rows.stream().filter(r->r.getDestinationKey()!=null&&r.getDestinationKey().startsWith("FBA:"))
.map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.getCustomerId(),"BOOKING:"+r.getBookingNo(),r.getDestinationKey())).toList();
var result=new HashMap<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage>();
if(refs.isEmpty())return result;
for(var c:appointmentCoverage.query(refs))result.put(c.sourceKey().substring("BOOKING:".length()),c);
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)));
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)),bookingService.incomingStatuses(List.of(booking)).get(new FleetBookingIncomingGateway.Source(booking.getCustomerId(),booking.getIncomingNo())),orders.fulfillmentStages(List.of(booking.getId())).get(booking.getId()),coverageByBooking(List.of(booking)).get(booking.getBookingNo()));
}
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer,String incomingStatus,String fulfillmentStage,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage coverage){
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);
var cargo=reservations.cargo(booking);
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
String block=draft?FleetInquiryProgress.capacityBlock(cargo,offer):null;
boolean canEdit=owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit")));
boolean canConfirm=manage()&&draft&&offer.canConfirm()&&missing.isEmpty()&&block==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()));
new FleetCapacityOfferModels.InquiryActions(canEdit,canConfirm,canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),block),
FleetInquiryProgress.project(booking,missing,block,canEdit,canConfirm,incomingStatus,fulfillmentStage),coverage);
}
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.PageResult;
/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */
public interface FleetDispatchPlanningGateway {
record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { }
PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size);
void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt);
String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator);
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.fleet.trip;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/** Derived handling guidance, never a second writable booking state machine. */
final class FleetInquiryProgress {
private FleetInquiryProgress() { }
static List<String> missing(FleetBookingEntity booking, FleetBookingModels.Cargo cargo,
FleetCapacityOfferModels.Offer offer) {
var fields = new ArrayList<String>();
if (cargo.packageCount() == null || cargo.packageCount() < 1) fields.add("预计箱数");
if (cargo.palletSpaces() == null || cargo.palletSpaces() < 1) fields.add("占用标准托位");
if (!positive(cargo.weightKg())) fields.add("预计重量");
if (!positive(cargo.volumeCbm())) fields.add("预计体积");
if ("US_TRUCK".equals(booking.getBusinessType()) &&
(booking.getExpectedPalletCount() == null || booking.getExpectedPalletCount() < 1)) fields.add("预计实托数");
if (cargo.unitPrice() == null || cargo.unitPrice().signum() < 0) fields.add("约定价格");
if (!List.of("CNY", "USD").contains(cargo.currency() == null ? "" : cargo.currency())) fields.add("币种(CNY或USD)");
if (blank(cargo.cargoName())) fields.add("货物名称");
if (blank(cargo.contactName())) fields.add("联系人");
if (blank(cargo.contactPhone())) fields.add("联系方式");
if (booking.getEstimatedArrivalAt() == null) fields.add("预计到仓时间");
else if (booking.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC).isAfter(offer.cutoffAt())) fields.add("预计到仓时间(不得晚于最晚送仓)");
return List.copyOf(fields);
}
static String capacityBlock(FleetBookingModels.Cargo cargo, FleetCapacityOfferModels.Offer offer) {
if (!offer.canConfirm()) return offer.blockReason();
var destination = offer.destinations().stream().filter(d -> d.destinationKey().equals(cargo.destinationKey())).findFirst();
if (destination.isEmpty()) return "卸货站已变化,请调度核对路线";
var remaining = destination.get().remaining();
if (remaining == null) return "余位待调度核对";
if (cargo.palletSpaces() != null && cargo.palletSpaces() > remaining.palletSpaces()
|| cargo.weightKg() != null && cargo.weightKg().compareTo(remaining.weightKg()) > 0
|| cargo.volumeCbm() != null && cargo.volumeCbm().compareTo(remaining.volumeCbm()) > 0)
return "本票预报超过所选目的地余位,请调度核对";
return null;
}
static FleetCapacityOfferModels.Progress project(FleetBookingEntity booking, List<String> missing,
String block, boolean canEdit, boolean canConfirm, String incomingStatus, String fulfillmentStage) {
if ("IN_TRANSIT".equals(fulfillmentStage)) return progress("IN_TRANSIT", "已发车", "运输中", "派送", "VIEW", "按实际派送进度跟进", List.of());
if ("COMPLETED".equals(fulfillmentStage)) return progress("COMPLETED", "已完成", "运输已完成", "无待办", "VIEW", "实际货物已全部签收", List.of());
return switch (booking.getStatus()) {
case "DRAFT" -> {
if (!canEdit && !canConfirm && block != null)
yield progress("BLOCKED", "登记暂不可处理", "尚未保位", "调度", "VIEW", block, missing);
if (!missing.isEmpty()) yield progress("NEEDS_DETAILS", "待补资料", "尚未保位", "销售", canEdit ? "EDIT" : "VIEW", "确认前还需:" + String.join("、", missing), missing);
if (block != null) yield progress("RECHECK", "待调度核对", "尚未保位", "调度", "VIEW", block, missing);
yield progress("AWAITING_CONFIRMATION", "待调度确认", "尚未保位", "调度", canConfirm ? "CONFIRM" : "VIEW", "保存后调度可见,确认承运成功后才保位", missing);
}
case "CONFIRMED" -> booking.getIncomingNo() == null
? progress("AWAITING_INCOMING", "尚未安排来货", "已保留货位", "调度", "INCOMING", "确定来货安排后再生成来货单", List.of())
: incomingStatus != null && List.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(incomingStatus)
? progress("AWAITING_RECEIPT_CHECK", "实收完成待核量", "已保留货位", "调度", "RECEIPT", "核对预报与实收差异、容量及约定运费", List.of())
: progress("RECEIVING", "已安排来货", "已保留货位", "仓库", "VIEW", incomingStatus == null ? "收货进度暂不可确认,请打开本票进度核对" : "等待仓库实际收货,销售跟进客户送仓", List.of());
case "RECEIVED" -> progress("READY_TO_BIND", "实收已核对", "已保留货位", "调度", "BIND", "确认排入原车次,仍需后续审核和实际作业", List.of());
case "BOUND" -> progress("BOUND", "已排入本车", "已转为本车货物", "审核 / 仓库", "VIEW", "排入不等于已装车或发车,请查看关联排车作业", List.of());
default -> progress("CANCELLED", "已取消", "未保留货位", "无待办", "VIEW", "原登记保留供追溯", List.of());
};
}
private static FleetCapacityOfferModels.Progress progress(String stage, String label, String reservation,
String owner, String action, String message, List<String> missing) {
return new FleetCapacityOfferModels.Progress(stage, label, reservation, owner, action, message, missing);
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; }
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** Shared by dispatch and appointment consumers; all business writes commit together. */
@Service
public class FleetTripPreparationService {
public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion,
@Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId,
@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
private final FleetTripService service;
private final FleetTripMapper trips;
private final FleetCapacityOfferCommandMapper commands;
private final ObjectMapper json;
public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) {
this.service=service;this.trips=trips;this.commands=commands;this.json=json;
}
@Transactional
public FleetTripModels.Detail prepare(String operator,Prepare request) {
boolean create=request.tripNo()==null||request.tripNo().isBlank();
if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); }
permission("platform:fleet:trip:edit");
if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择");
if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机");
if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划");
var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo());
if(!create&&initial==null)throw conflict("所选车次不存在");
String payload=write(request);
if(initial!=null) {
var locked=trips.lockByNo(initial.getTripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次");
}
FleetTripModels.Detail detail;
if(create) {
var p=request.plan();
detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey()));
// Concurrent same-key create may have committed while waiting for its unique constraint.
var locked=trips.lockByNo(detail.trip().tripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源"));
} else {
detail=service.detail(request.tripNo());
if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对");
}
for(String number:request.dispatchNos().stream().sorted().toList())
detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次"));
var row=trips.findByNo(detail.trip().tripNo());
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator);
command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload));
command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command);
return detail;
}
private static void permission(String name) {
var auth=SecurityContextHolder.getContext().getAuthentication();
if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限");
}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -190,6 +190,15 @@ public class FleetTripService {
return detail(trip);
}
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
var trip=trips.findByNo(tripNo);
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
}
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
@@ -45,4 +45,16 @@ public class PlatformFleetCapacityOfferController {
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));}
@GetMapping("/inquiries/{bookingNo}/fulfillment") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.Fulfillment> fulfillment(@PathVariable String bookingNo){return ApiResponse.success(service.fulfillment(bookingNo));}
@PostMapping("/inquiries/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')")
public ApiResponse<FleetBookingModels.Result> arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> saveAndConfirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.saveAndConfirm(bookingNo,request));}
}
@@ -17,6 +17,13 @@ import org.springframework.web.server.ResponseStatusException;
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetTripController {
@jakarta.annotation.Resource private FleetTripPreparationService preparation;
@PostMapping("/prepare")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody FleetTripPreparationService.Prepare request) {
return ApiResponse.success(preparation.prepare(operator(user),request));
}
private final FleetTripService service;
private final FleetTripExecutionService execution;
private final FleetTripCompletionService completion;
@@ -50,6 +57,13 @@ public class PlatformFleetTripController {
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));
}
@GetMapping("/{tripNo}/dispatch-candidates")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetDispatchPlanningGateway.Candidate>> dispatchCandidates(@PathVariable String tripNo,
@RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page,
@RequestParam(defaultValue="20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size));
}
@PostMapping("/{tripNo}/dispatches/bind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,