feat(fleet): add internal capacity pool and warehouse fulfillment bridge

This commit is contained in:
2026-10-07 16:46:56 +08:00
parent 564cfa107c
commit 001219460f
57 changed files with 3820 additions and 38 deletions
@@ -26,6 +26,7 @@ import java.util.*;
@Service
public class DeliveryDocumentService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private DeliveryOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private DeliveryDocumentRevisionMapper revisions;
@@ -114,6 +115,7 @@ public class DeliveryDocumentService {
orders.updateById(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
}
@@ -158,12 +160,16 @@ public class DeliveryDocumentService {
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
}
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs) { }
private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs,
Map<Long,Long> sourcePackages) { }
private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) {
var related = new TreeMap<Long, LastlegOrder>();
Map<Long,Long> sourcePackages = new TreeMap<>();
if (delivery.getDispatchOrderId() != null) {
var ids = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
.filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey()))
var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
.filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList();
for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact);
var ids = actualPicks.stream()
.map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList();
if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) {
if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致");
@@ -198,7 +204,7 @@ public class DeliveryDocumentService {
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail);
}
return new Completion(List.copyOf(current),proofs);
return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages));
}
private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); }
private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) {
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@@ -275,6 +276,10 @@ public class DispatchPdaWorkflowService {
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now);
Map<Long,Integer> sourcePackages = new TreeMap<>();
for (var item : dispatchItems) if (item.getOrderId() != null)
sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact);
fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.business;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Internal query row. Never returned directly to clients. */
@Data
public class FleetPoolArrangementRow extends FleetPoolOrderLink {
private String tripNo;
private LocalDateTime plannedDepartureAt;
private String originWarehouseName;
private String destinationLabel;
private BigDecimal agreedFreightAmount;
private String currency;
private String deliveryNo;
private Integer deliveryPackages;
private Integer signedPackages;
private Integer returnedPackages;
private Integer deliveryReturnedPackages;
private String deliveryStatus;
}
@@ -0,0 +1,222 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LastlegOrderNoGenerator numbers;
private final ObjectMapper json;
private final DeliverySourceEvidenceMapper deliveryEvidence;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence) {
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(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setPalletRequired("YES");order.setTransportType(transport);
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount());
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order.
} else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0);
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
trucks.insert(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
@Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致");
if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货");
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,FleetBookingIncomingGateway.Evidence evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
if(evidence==null||!Objects.equals(row.getIncomingNo(),evidence.incomingNo())
||!Objects.equals(row.getWarehouseId(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey())
||!"READY_FOR_DISPATCH".equals(evidence.status())||evidence.pallets().isEmpty()||evidence.receivedPackages()<1||palletSpaces<1)throw conflict("商业单实收回传缺少唯一完整来源");
var unique=new HashSet<String>();int packages=0;
for(var pallet:evidence.pallets()){if(pallet.warehousePalletId()==null||!unique.add(pallet.warehousePalletId())||pallet.packages()<1)throw conflict("实收托盘重复或货量无效");packages=Math.addExact(packages,pallet.packages());}
if(packages!=evidence.receivedPackages())throw conflict("实收总箱数不守恒");
if("WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())) {
if(!Objects.equals(row.getActualPackageCount(),packages)||!Objects.equals(row.getActualPalletCount(),unique.size())||!Objects.equals(row.getActualPalletSpaces(),palletSpaces))throw conflict("已确认实收内容变化");return;
}
if(!Set.of("WAITING_INBOUND","RECEIVING").contains(row.getFulfillmentStage()))throw conflict("商业单当前不允许重复核量");
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
if("PENDING_DISPATCH".equals(row.getFulfillmentStage())&&Objects.equals(row.getDispatchNo(),dispatchNo))return;
if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
if(row.getDispatchNo()==null&&"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage()))return;
if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId);
if("CANCELLED".equals(row.getFulfillmentStage()))return;
if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) {
for(var row:links.lockDispatch(customerId,dispatchNo)) {
if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void signed(DeliveryOrder delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.getDispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.getCustomerId(),delivery.getDispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
if(!"IN_TRANSIT".equals(row.getFulfillmentStage()))throw conflict("商业单须实际出库后才能签收完成");
if("DELIVERED".equals(delivery.getStatus())&&delivery.getSignedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.getDeliveryNo(),at);
}
}
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
var dispatchIds=deliveryEvidence.dispatchIds(row.getCustomerId(),row.getLastlegOrderId());
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds) {
if(dispatchId==null||dispatchId<=0)return false;
var d=deliveryEvidence.deliveries(row.getCustomerId(),dispatchId);
var p=deliveryEvidence.picks(row.getCustomerId(),dispatchId);
var r=deliveryEvidence.returns(row.getCustomerId(),dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(row.getIncomingNo(),
row.getLastlegOrderId(),row.getActualPackageCount()),deliveries,picks,returns,true).fullySigned();
}
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType())
||blank(in.bookingNo())||blank(in.customerId())||"0".equals(in.customerId())||blank(in.warehouseId())||blank(in.warehouseName())
||blank(in.destinationKey())||(!in.destinationKey().startsWith("FBA:")&&!in.destinationKey().startsWith("PRIVATE:"))
||blank(in.destinationLabel())||in.plannedDepartureAt()==null||in.packageCount()==null||in.packageCount()<1
||blank(in.contactName())||blank(in.contactPhone())||in.currency()==null||!Set.of("USD","CNY").contains(in.currency())
||in.deliveryMode()==null||!Set.of("SELF_DELIVERY","MANUAL_PICKUP_TO_WAREHOUSE").contains(in.deliveryMode())
||in.weightKg()==null||in.weightKg().signum()<=0||in.volumeCbm()==null||in.volumeCbm().signum()<=0
||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
}
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return;
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount());
trucks.updateById(order);
}
history(row,from,stage,operator,reason,at);
}
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h);
} else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h);
}
}
private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
private static boolean blank(String value){return value==null||value.isBlank();}
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_pool_order_link")
public class FleetPoolOrderLink {
@TableId(type=IdType.INPUT) private Long id;
private Long bookingId;
private String bookingNo;
private String customerId;
private String businessType;
private Long businessOrderId;
private String businessOrderNo;
private String warehouseId;
private String destinationKey;
private String deliveryMode;
private String deliveryInstructions;
private String incomingNo;
private Long lastlegOrderId;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo;
private String fulfillmentStage;
private LocalDateTime stageOccurredAt;
private Integer actualPalletCount;
private Integer actualPackageCount;
private Integer actualPalletSpaces;
private Long recordVersion;
private String creationRequestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.Collection;
import java.util.List;
@Mapper
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
@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")
FleetPoolOrderLink lockBookingNo(String no);
@Select("SELECT COUNT(*) FROM ll_fleet_external_booking WHERE booking_no=#{no} AND offer_id IS NOT NULL")
int poolBooking(String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no} FOR UPDATE")
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE")
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}")
int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select({"<script>SELECT * FROM ll_fleet_pool_order_link WHERE booking_id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>"})
List<FleetPoolOrderLink> bookings(@Param("ids") Collection<Long> ids);
@Select({"<script>SELECT l.*,b.trip_no,t.planned_departure_at,t.warehouse_name AS origin_warehouse_name,",
"b.destination_label,b.agreed_amount AS agreed_freight_amount,JSON_UNQUOTE(JSON_EXTRACT(b.cargo_json,'$.currency')) AS currency,",
"d.delivery_no,d.package_count AS delivery_packages,d.signed_packages,d.status AS delivery_status,",
"(SELECT COALESCE(SUM(x.delta_packages),0) FROM ll_inventory_ledger x JOIN ll_exception_order e ON e.customer_id=x.customer_id AND e.exception_no=x.business_order_no",
"WHERE e.customer_id=l.customer_id AND e.delivery_order_id=d.id AND e.last_mile_order_id=l.lastleg_order_id AND x.business_type='RETURN_STOCK_IN' AND x.delta_packages>0",
"AND x.outbound_order_no=d.outbound_no AND EXISTS (SELECT 1 FROM ll_exception_return_item i WHERE i.customer_id=e.customer_id AND i.exception_order_id=e.id",
"AND x.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id))) AS returned_packages,",
"(SELECT COALESCE(SUM(x.delta_packages),0) FROM ll_inventory_ledger x JOIN ll_exception_order e ON e.customer_id=x.customer_id AND e.exception_no=x.business_order_no",
"WHERE e.customer_id=l.customer_id AND e.delivery_order_id=d.id AND x.business_type='RETURN_STOCK_IN' AND x.delta_packages>0",
"AND x.outbound_order_no=d.outbound_no AND EXISTS (SELECT 1 FROM ll_exception_return_item i WHERE i.customer_id=e.customer_id AND i.exception_order_id=e.id",
"AND x.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id))) AS delivery_returned_packages",
"FROM ll_fleet_pool_order_link l JOIN ll_fleet_external_booking b ON b.id=l.booking_id AND b.customer_id=l.customer_id",
"JOIN ll_fleet_trip t ON t.id=b.trip_id LEFT JOIN ll_order o ON o.id=l.lastleg_order_id AND o.customer_id=l.customer_id",
"LEFT JOIN ll_delivery_order d ON d.delivery_no=o.delivery_order_no AND d.customer_id=l.customer_id",
"WHERE <foreach collection='keys' item='k' open='(' separator=' OR ' close=')'>",
"(l.customer_id=#{k.customerId} AND l.business_type=#{k.businessType} AND l.business_order_id=#{k.businessOrderId})",
"</foreach></script>"})
List<FleetPoolArrangementRow> arrangements(@Param("keys") Collection<FleetPoolOrderModels.OrderKey> keys);
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.business;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
public final class FleetPoolOrderModels {
private FleetPoolOrderModels() { }
public record ConfirmedBooking(long bookingId, String bookingNo, String customerId, String businessType,
String warehouseId, String warehouseName, String destinationKey, String destinationLabel,
LocalDateTime plannedDepartureAt, String deliveryMode, String deliveryInstructions,
LocalDateTime estimatedArrivalAt, String cargoName, Integer packageCount, Integer expectedPalletCount,
BigDecimal weightKg, BigDecimal volumeCbm, String currency, String contactName, String contactPhone,
String remark) { }
public record BusinessOrderRef(String businessType, String businessOrderNo) { }
public record OrderKey(String customerId, String businessType, long businessOrderId) { }
public record Arrangement(String sourceType, String businessType, String businessOrderNo, String bookingNo,
String tripNo, Instant plannedDepartureAt, String originWarehouseName, String destinationLabel,
String deliveryMode, String deliveryInstructions, String fulfillmentStage, Instant stageOccurredAt,
BigDecimal agreedFreightAmount, String currency, String incomingNo, String dispatchNo, String deliveryNo,
Integer signedPackages, Integer unsignedPackages, Integer returnedPackages, String exceptionSummary,
String readOnlyReason) { }
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.fleet.business;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Only callers' already-authorized order keys are accepted; one batch query for a whole page. */
@Service
public class FleetPoolOrderQueries {
private final FleetPoolOrderMapper links;
public FleetPoolOrderQueries(FleetPoolOrderMapper links){this.links=links;}
public Map<OrderKey,Arrangement> arrangements(Collection<OrderKey> keys) {
if(keys==null||keys.isEmpty())return Map.of();
var allowed=Set.copyOf(keys);var result=new HashMap<OrderKey,Arrangement>();
for(var row:links.arrangements(allowed)) {
var key=new OrderKey(row.getCustomerId(),row.getBusinessType(),row.getBusinessOrderId());
if(!allowed.contains(key))throw new IllegalStateException("商业摘要客户范围不匹配");
if(result.put(key,project(row))!=null)throw new IllegalStateException("商业委托存在多个履约投影");
}
return result;
}
public Arrangement arrangement(OrderKey key){return arrangements(List.of(key)).get(key);}
public void requireStandalone(String customer,String type,long id) {
if(links.linked(customer,type,id)>0)throw conflict("此订单由拼车承运实际履约推进,请查看承运安排并从来货、排车或签收入口操作");
}
public void requireIndependentIncoming(String customer,String no) {
if(links.linkedIncoming(customer,no)>0)throw conflict("此来货属于已确认拼车承运,首期不支持独立取消、拒绝或变更承运来源");
}
static Arrangement project(FleetPoolArrangementRow row) {
Integer actual=row.getActualPackageCount(),signed=null,returned=row.getReturnedPackages();
if("COMPLETED".equals(row.getFulfillmentStage()))signed=actual;
else if(row.getSignedPackages()==null)signed=0;
else if("DELIVERED".equals(row.getDeliveryStatus())&&actual!=null&&returned!=null
&&row.getDeliveryPackages()!=null&&row.getDeliveryReturnedPackages()!=null
&&row.getSignedPackages()+row.getDeliveryReturnedPackages()==row.getDeliveryPackages()) signed=Math.max(0,actual-returned);
Integer unsigned=actual==null||signed==null?null:Math.max(0,actual-signed);
String exception=returned!=null&&returned>0?"存在实际退回货物,请核对派送及退回记录":
Set.of("RETURN_PENDING","PARTIALLY_RETURNED","RETURNED","EXCEPTION").contains(Objects.requireNonNullElse(row.getDeliveryStatus(),""))?"派送存在异常,尚未证明全部签收":
"DELIVERED".equals(row.getDeliveryStatus())&&!"COMPLETED".equals(row.getFulfillmentStage())?"实际来源签收尚未核齐,请核对拣货和退回记录":null;
return new Arrangement("FLEET_CAPACITY_OFFER",row.getBusinessType(),row.getBusinessOrderNo(),row.getBookingNo(),
row.getTripNo(),row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),row.getOriginWarehouseName(),row.getDestinationLabel(),
row.getDeliveryMode(),row.getDeliveryInstructions(),row.getFulfillmentStage(),row.getStageOccurredAt()==null?null:row.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant(),
row.getAgreedFreightAmount(),row.getCurrency(),row.getIncomingNo(),row.getDispatchNo(),row.getDeliveryNo(),
signed,unsigned,returned,exception,"此订单由拼车承运实际履约推进,请从来货、排车和签收入口操作");
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -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) { }
}
@@ -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);
}
}
@@ -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);
@@ -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;
}
@@ -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);
}
@@ -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;
}
@@ -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 &gt;= #{q.departureFrom}</if>
<if test="q.departureTo != null">AND DATE(t.planned_departure_at) &lt;= #{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);
}
@@ -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) { }
}
@@ -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,"发布、车次或意向不存在");}
}
@@ -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);
@@ -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));}
}
@@ -13,7 +13,7 @@ public final class LocalLooseboardResponses {
String transportType, String origin, String destination, String containerType, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt) { }
LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
@@ -41,12 +41,13 @@ import java.util.Set;
@Service
public class LocalLooseboardService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED);
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
@@ -100,6 +101,7 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo());
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
@@ -116,11 +118,13 @@ public class LocalLooseboardService {
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
/** Customer-scoped query that ignores any client-supplied customer filter. */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
@@ -128,6 +132,7 @@ public class LocalLooseboardService {
query.requestedFrom(), query.requestedTo()));
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
@@ -227,6 +232,7 @@ public class LocalLooseboardService {
private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order;
}
@@ -240,13 +246,19 @@ public class LocalLooseboardService {
value.getCreatedAt())).toList());
}
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) {
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
}
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
@@ -18,6 +18,7 @@ import java.util.*;
@Component
public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
private final WarehouseTransferService service;
private final WarehouseTransferOrderMapper orders;
private final ObjectMapper json;
@@ -36,6 +37,8 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
List.of(new WarehouseTransferRequests.Item(null,input.cargo().cargoName(),input.cargo().packageCount(),null)),null);
var created=service.createAndSubmit(input.customerId(),operator,request);
var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货"));
fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(),
approved.operatingWarehouseId(),key,operator);
return evidence(approved);
}
@Override @Transactional(propagation=Propagation.MANDATORY)
@@ -88,6 +88,8 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER";
public static final String DRAFT = "DRAFT";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -273,6 +275,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Create request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited");
}
@@ -642,6 +645,7 @@ public class WarehouseTransferService {
}
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (Set.of("REJECT", REJECTED).contains(decision)) fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
if (next.equals(transfer.getStatus()) || APPROVED.equals(next)
&& Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH,
@@ -712,6 +716,7 @@ public class WarehouseTransferService {
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg());
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
return detail(customerId, transferNo);
}
@@ -869,6 +874,7 @@ public class WarehouseTransferService {
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"PDA receiving measurement and palletization completed", now);
fleetPool.receiving(customerId, transferNo, operatorId);
return detail(customerId, transferNo);
}
@@ -1037,6 +1043,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Allocate request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(transfer.getAllocationIdempotencyKey())) {
@@ -1146,6 +1153,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Outbound request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus())
@@ -1927,6 +1935,12 @@ public class WarehouseTransferService {
throw new IllegalStateException(exception);
}
}
private static String pdaOperatorId() {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof com.baiyue.common.security.AuthenticatedUser user)
return user.userId() == null ? user.username() : user.userId().toString();
return auth == null ? null : auth.getName();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); }
private static long packages(Long value) { return value == null ? 0L : value; }
@@ -14,7 +14,7 @@ public final class UsTruckOrderResponses {
int receivedPalletCount, int allocatedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) {}
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
@@ -40,6 +40,7 @@ import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -106,6 +107,7 @@ public class UsTruckOrderService {
return detail(customerId, order.getOrderNo());
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
@@ -122,10 +124,12 @@ public class UsTruckOrderService {
.le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) {
UsTruckOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
@@ -397,7 +401,7 @@ public class UsTruckOrderService {
}
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
@@ -413,7 +417,9 @@ public class UsTruckOrderService {
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
}
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(UsTruckOrder o) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(o.getCustomerId(), "US_TRUCK", o.getId()); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return summary(o, fleetPool.arrangement(poolKey(o))); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); }
private void history(UsTruckOrder o, String operationType, String from, String to, String operator,
String idempotencyKey, String remark, LocalDateTime at) {
UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory();