feat(fleet): add internal capacity pool and warehouse fulfillment bridge
This commit is contained in:
+10
-4
@@ -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) {
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+222
@@ -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);}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
+23
@@ -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) { }
|
||||
}
|
||||
+49
@@ -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) { }
|
||||
}
|
||||
|
||||
+6
-1
@@ -16,13 +16,16 @@ import java.util.*;
|
||||
public class FleetBookingReservations {
|
||||
private final FleetBookingMapper bookings;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private FleetCapacityOfferMapper offers;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders;
|
||||
public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; }
|
||||
public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); }
|
||||
public List<FleetBookingModels.Response> responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); }
|
||||
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
|
||||
public FleetBookingModels.Response response(FleetBookingEntity row) {
|
||||
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row));
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row),
|
||||
row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount());
|
||||
}
|
||||
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
|
||||
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
|
||||
@@ -61,10 +64,12 @@ public class FleetBookingReservations {
|
||||
}
|
||||
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
|
||||
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
|
||||
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),operator);
|
||||
}
|
||||
}
|
||||
public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){
|
||||
for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){
|
||||
if(row.getOfferId()!=null)poolOrders.unboundDispatch(row.getId(),row.getCustomerId(),dispatchNo,operator);
|
||||
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
|
||||
@@ -33,6 +34,7 @@ public class FleetBookingService {
|
||||
private final FleetBookingIncomingGateway incoming;
|
||||
private final FleetBookingQuoteContext quoteContext;
|
||||
private final FleetQuoteCandidateService quotes;
|
||||
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
|
||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
||||
@@ -61,17 +63,31 @@ public class FleetBookingService {
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return updatePool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result updatePool(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
|
||||
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
|
||||
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
|
||||
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return confirmPool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result confirmPool(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);
|
||||
var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);}
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运");
|
||||
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
|
||||
requireCompleteCargo(cargo);
|
||||
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
|
||||
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
|
||||
var source=reservations.quoteSource(row);
|
||||
@@ -84,6 +100,11 @@ public class FleetBookingService {
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
rejectPoolLegacy(bookingNo);
|
||||
return cancelPool(tripNo,bookingNo,operator,request);
|
||||
}
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetBookingModels.Result cancelPool(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
@@ -113,8 +134,9 @@ public class FleetBookingService {
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
|
||||
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);String hash=hash(request);
|
||||
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
|
||||
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
|
||||
@@ -138,6 +160,7 @@ public class FleetBookingService {
|
||||
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
|
||||
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
|
||||
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
|
||||
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator);
|
||||
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
@@ -156,6 +179,15 @@ public class FleetBookingService {
|
||||
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
|
||||
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
|
||||
}
|
||||
private void rejectPoolLegacy(String bookingNo){
|
||||
var row=bookings.byNo(bookingNo);
|
||||
if(row!=null&&row.getOfferId()!=null)throw conflict("拼车需求池意向请从需求池执行修改、成交或取消");
|
||||
}
|
||||
static void requireCompleteCargo(FleetBookingModels.Cargo cargo){
|
||||
if(cargo.packageCount()==null||cargo.packageCount()<1||cargo.palletSpaces()==null||cargo.palletSpaces()<1
|
||||
||cargo.weightKg()==null||cargo.weightKg().signum()<=0||cargo.volumeCbm()==null||cargo.volumeCbm().signum()<=0)
|
||||
throw conflict("确认承运前请补齐预计箱数、托位、重量和体积");
|
||||
}
|
||||
private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
|
||||
var cargo=reservations.cargo(row);
|
||||
return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_fleet_capacity_offer_command")
|
||||
public class FleetCapacityOfferCommand {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private Long tripId;
|
||||
private String operatorId;
|
||||
private String action;
|
||||
private String scopeNo;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@Mapper
|
||||
public interface FleetCapacityOfferCommandMapper extends BaseMapper<FleetCapacityOfferCommand> {
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer_command WHERE trip_id=#{tripId} AND operator_id=#{operatorId} AND action=#{action} AND scope_no=#{scopeNo} AND idempotency_key=#{key}")
|
||||
FleetCapacityOfferCommand find(@Param("tripId") long tripId,@Param("operatorId") String operatorId,
|
||||
@Param("action") String action,@Param("scopeNo") String scopeNo,@Param("key") String key);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_fleet_capacity_offer")
|
||||
public class FleetCapacityOfferEntity {
|
||||
@TableId(type=IdType.INPUT) private Long id;
|
||||
private String offerNo;
|
||||
private Long tripId;
|
||||
private String tripNo;
|
||||
private String status;
|
||||
private LocalDateTime cutoffAt;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactName;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactPhone;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String remark;
|
||||
private String publishedFingerprint;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private String updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@Mapper
|
||||
public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferEntity> {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id
|
||||
<where>
|
||||
<if test="salesOnly">o.status='OPEN'</if>
|
||||
<if test="q.status != null">AND o.status=#{q.status}</if>
|
||||
<if test="q.tripNo != null and q.tripNo != ''">AND o.trip_no=#{q.tripNo}</if>
|
||||
<if test="q.keyword != null and q.keyword != ''">AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%'))</if>
|
||||
<if test="q.origin != null and q.origin != ''">AND t.warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
|
||||
<if test="q.destination != null and q.destination != ''">AND JSON_SEARCH(JSON_REMOVE(t.stops_json,'$[0]'),'one',CONCAT('%',#{q.destination},'%'),NULL,'$[*].name','$[*].destinationKey') IS NOT NULL</if>
|
||||
<if test="q.departureFrom != null">AND t.planned_departure_at >= #{q.departureFrom}</if>
|
||||
<if test="q.departureTo != null">AND DATE(t.planned_departure_at) <= #{q.departureTo}</if>
|
||||
</where>
|
||||
ORDER BY o.updated_at DESC,o.id DESC
|
||||
</script>
|
||||
""")
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> pageOffers(
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> page,
|
||||
@Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}")
|
||||
FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE")
|
||||
FleetCapacityOfferEntity lock(@Param("offerNo") String offerNo);
|
||||
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE trip_id=#{tripId}")
|
||||
FleetCapacityOfferEntity byTrip(@Param("tripId") long tripId);
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** Dedicated sales projection: no driver, other customer cargo or procurement facts. */
|
||||
public final class FleetCapacityOfferModels {
|
||||
private FleetCapacityOfferModels() { }
|
||||
public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
|
||||
@Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination,
|
||||
LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status,
|
||||
Boolean canConfirm,@Size(max=64) String tripNo) {
|
||||
public Query {page=page==null?1:page;size=size==null?20:size;}
|
||||
}
|
||||
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
|
||||
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
|
||||
@Pattern(regexp="MINE|ALL") String scope) {
|
||||
public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;}
|
||||
}
|
||||
public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion,
|
||||
@Min(0) Long offerRecordVersion,@NotNull Instant cutoffAt,@Size(max=128) String contactName,
|
||||
@Size(max=128) String contactPhone,@Size(max=1000) String remark,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@Size(max=1000) String reason) { }
|
||||
public record InquiryWrite(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@Min(0) Long recordVersion,@NotNull @Positive Long customerId,
|
||||
@NotBlank @Pattern(regexp="LOCAL_LOOSEBOARD|US_TRUCK") String businessType,
|
||||
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
|
||||
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
|
||||
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
||||
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Size(max=1000) String reason) { }
|
||||
public record CustomerOption(String id,String code,String name) { }
|
||||
public record Destination(String destinationKey,String name,int deliveryIndex,TripCapacityPolicy.Load remaining) { }
|
||||
public record Segment(int fromIndex,int toIndex,String fromName,String toName,
|
||||
TripCapacityPolicy.Load planned,TripCapacityPolicy.Load reserved,TripCapacityPolicy.Load remaining) { }
|
||||
public record Actions(boolean canEdit,boolean canPublish,boolean canPause,boolean canResume,boolean canClose) { }
|
||||
public record Offer(String offerNo,String tripNo,String status,long recordVersion,long tripRecordVersion,
|
||||
String warehouseId,String warehouseName,Instant plannedDepartureAt,Instant cutoffAt,
|
||||
String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments,
|
||||
boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { }
|
||||
public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { }
|
||||
public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion,
|
||||
long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode,
|
||||
String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
|
||||
FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount,
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo,
|
||||
Instant updatedAt,InquiryActions actions) { }
|
||||
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
|
||||
public record OfferResult(Offer offer,boolean replayed) { }
|
||||
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetCapacityOfferService {
|
||||
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
|
||||
private final FleetCapacityOfferMapper offers;
|
||||
private final FleetCapacityOfferCommandMapper commands;
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetBookingMapper bookings;
|
||||
private final FleetBookingService bookingService;
|
||||
private final FleetBookingReservations reservations;
|
||||
private final FleetTripExecutionService execution;
|
||||
private final FleetCustomerDirectory customers;
|
||||
private final FleetPoolOrderBridge orders;
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
@Autowired
|
||||
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
|
||||
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
|
||||
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json){
|
||||
this(offers,commands,trips,bookings,bookingService,reservations,execution,customers,orders,json,Clock.systemUTC());
|
||||
}
|
||||
FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
|
||||
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
|
||||
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json,Clock clock){
|
||||
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
|
||||
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
|
||||
readPermission();
|
||||
var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage());
|
||||
if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围");
|
||||
var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false)))
|
||||
.filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList();
|
||||
if(query.canConfirm()!=null)return page(values,query.page(),query.size());
|
||||
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Detail detail(String number){
|
||||
readPermission();var row=offer(number,false);requireVisible(row);
|
||||
return new FleetCapacityOfferModels.Detail(project(row,trip(row.getTripNo(),false)),inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,number,null,manage()?"ALL":"MINE")));
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
|
||||
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden();
|
||||
var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId);
|
||||
if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
|
||||
if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());}
|
||||
if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status());
|
||||
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()));
|
||||
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
|
||||
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter);
|
||||
var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
|
||||
var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>();
|
||||
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList();
|
||||
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Inquiry inquiry(String number){
|
||||
readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId()));
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
|
||||
writePermission("inquiry-create");
|
||||
return customers.options(keyword).stream().map(c->new FleetCapacityOfferModels.CustomerOption(c.id(),c.code(),c.name())).toList();
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String number,FleetBookingModels.QuoteCandidatesQuery query){
|
||||
readPermission();if(!has("platform:fleet:quote:list"))throw forbidden();
|
||||
var row=offer(number,false);requireVisible(row);customers.required(query.customerId());
|
||||
return bookingService.quoteCandidates(row.getTripNo(),query);
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){
|
||||
requireManage();var trip=trip(request.tripNo(),true);
|
||||
var replay=replay(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
tripVersion(trip,request.tripRecordVersion());
|
||||
if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改");
|
||||
validateCutoff(trip,request.cutoffAt());
|
||||
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
|
||||
row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request);
|
||||
if(offers.insert(row)!=1)throw conflict("发布创建失败");
|
||||
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult update(String number,FleetCapacityOfferModels.Write request){
|
||||
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
|
||||
var replay=replay(trip,"EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if(!Objects.equals(request.tripNo(),trip.getTripNo())||"CLOSED".equals(row.getStatus()))throw conflict("关闭的发布不可修改,也不能变更所属车次");
|
||||
validateCutoff(trip,request.cutoffAt());apply(row,request);touch(row);
|
||||
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"EDIT",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.OfferResult command(String number,String action,FleetCapacityOfferModels.Command request){
|
||||
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offer(number,true);
|
||||
var replay=replay(trip,action,number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
|
||||
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if("CLOSED".equals(row.getStatus()))throw conflict("发布已关闭,不能恢复;已有意向仍可查询");
|
||||
switch(action){
|
||||
case "PUBLISH","RESUME" -> {
|
||||
if("PUBLISH".equals(action)&&!"DRAFT".equals(row.getStatus())||"RESUME".equals(action)&&!Set.of("OPEN","PAUSED").contains(row.getStatus()))throw conflict("发布状态已变化,请刷新");
|
||||
validateCutoff(trip,instant(row.getCutoffAt()));
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
var planning=execution.capacityPlan(trip.getTripNo());requirePlanning(trip,planning);
|
||||
row.setPublishedFingerprint(fingerprint(trip));row.setStatus("OPEN");
|
||||
}
|
||||
case "PAUSE" -> {if(!"OPEN".equals(row.getStatus()))throw conflict("只有开放发布可暂停");row.setStatus("PAUSED");}
|
||||
case "CLOSE" -> row.setStatus("CLOSED");
|
||||
default -> throw bad("发布操作无效");
|
||||
}
|
||||
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
|
||||
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
|
||||
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
|
||||
if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
|
||||
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
|
||||
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
|
||||
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
||||
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
|
||||
var booking=bookings.lock(trip.getId(),number);own(booking);
|
||||
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
|
||||
if(request.recordVersion()==null)throw bad("缺少意向版本");
|
||||
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
|
||||
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
|
||||
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){
|
||||
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);
|
||||
var replay=replay(trip,"CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason());
|
||||
var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo);
|
||||
if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息");
|
||||
if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代");
|
||||
if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间");
|
||||
bookingService.confirmPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),request.recordVersion(),request.idempotencyKey(),request.reason()));
|
||||
booking=bookings.lock(trip.getId(),number);
|
||||
orders.confirm(new FleetPoolOrderModels.ConfirmedBooking(booking.getId(),booking.getBookingNo(),booking.getCustomerId(),booking.getBusinessType(),trip.getWarehouseId().toString(),trip.getWarehouseName(),
|
||||
booking.getDestinationKey(),booking.getDestinationLabel(),trip.getPlannedDepartureAt(),booking.getDeliveryMode(),cargo.remark(),booking.getEstimatedArrivalAt(),cargo.cargoName(),cargo.packageCount(),booking.getExpectedPalletCount(),cargo.weightKg(),cargo.volumeCbm(),cargo.currency(),cargo.contactName(),cargo.contactPhone(),cargo.remark()),operator());
|
||||
var result=inquiryResult(row,booking);saveCommand(trip,"CONFIRM",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult cancel(String number,FleetCapacityOfferModels.InquiryCommand request){
|
||||
writePermission("inquiry-cancel");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);own(booking);
|
||||
var replay=replay(trip,"CANCEL",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
|
||||
if(!Objects.equals(booking.getRecordVersion(),request.recordVersion()))throw conflict("意向已变化,请刷新");
|
||||
if(!Set.of("DRAFT","CONFIRMED").contains(booking.getStatus())||booking.getIncomingNo()!=null)throw conflict("已登记来货或作业的意向不能在需求池取消,请保留原作业事实");
|
||||
if("CONFIRMED".equals(booking.getStatus())){requireManage();orders.cancelBeforeIncoming(booking.getId(),booking.getCustomerId(),operator(),request.reason());}
|
||||
// Cancelling an uncommitted draft remains possible after offer closure or trip departure.
|
||||
if("DRAFT".equals(booking.getStatus())&&!"PLANNED".equals(trip.getStatus())){
|
||||
booking.setStatus("CANCELLED");booking.setRecordVersion(booking.getRecordVersion()+1);booking.setUpdatedBy(operator());booking.setUpdatedAt(now());bookings.updateById(booking);
|
||||
}else bookingService.cancelPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),booking.getRecordVersion(),request.idempotencyKey(),request.reason()));
|
||||
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
|
||||
private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){
|
||||
return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false);
|
||||
}
|
||||
private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){
|
||||
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)));
|
||||
}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){
|
||||
boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus());
|
||||
boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null);
|
||||
return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()),
|
||||
new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason()));
|
||||
}
|
||||
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
|
||||
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
|
||||
String capacityBlock=planning.blockReason();
|
||||
if(capacityBlock==null&&!planning.planValid())capacityBlock="计划容量待调度核对";
|
||||
if(capacityBlock==null&&trip.getDriverId()==null)capacityBlock="请先分配司机";
|
||||
if(capacityBlock==null&&!Objects.equals(row.getPublishedFingerprint(),fingerprint(trip)))capacityBlock="发布承诺已变化,请调度重新核对并恢复接单";
|
||||
if(!manage()&&!"OPEN".equals(row.getStatus()))capacityBlock="发布未开放,仅可查看本人历史意向";
|
||||
boolean known=capacityBlock==null;
|
||||
var stops=reservations.stops(trip);var segments=new ArrayList<FleetCapacityOfferModels.Segment>();
|
||||
var destinations=new ArrayList<FleetCapacityOfferModels.Destination>();
|
||||
if(known)for(var segment:planning.availableSegments()){
|
||||
var own=planning.capacityPlan().segments().get(segment.fromIndex()).occupied();var all=segment.occupied();
|
||||
segments.add(new FleetCapacityOfferModels.Segment(segment.fromIndex(),segment.toIndex(),stops.get(segment.fromIndex()).name(),stops.get(segment.toIndex()).name(),own,
|
||||
new TripCapacityPolicy.Load(all.palletSpaces()-own.palletSpaces(),all.weightKg().subtract(own.weightKg()),all.volumeCbm().subtract(own.volumeCbm())),segment.remaining()));
|
||||
}
|
||||
for(int index=1;index<stops.size();index++)if(nonblank(stops.get(index).destinationKey())){
|
||||
TripCapacityPolicy.Load remaining=null;
|
||||
if(known){remaining=segments.get(0).remaining();for(int part=1;part<index;part++)remaining=min(remaining,segments.get(part).remaining());}
|
||||
destinations.add(new FleetCapacityOfferModels.Destination(stops.get(index).destinationKey(),stops.get(index).name(),index,remaining));
|
||||
}
|
||||
if(block==null)block=capacityBlock;
|
||||
if(block==null&&destinations.stream().noneMatch(d->positive(d.remaining())))block="可接运力已满";
|
||||
boolean editable=manage()&&!"CLOSED".equals(row.getStatus());boolean baseReady=planning.planValid()&&planning.blockReason()==null&&trip.getDriverId()!=null&&cutoffValid(trip,row.getCutoffAt(),clock.instant());
|
||||
return new FleetCapacityOfferModels.Offer(row.getOfferNo(),trip.getTripNo(),row.getStatus(),row.getRecordVersion(),trip.getRecordVersion(),String.valueOf(trip.getWarehouseId()),trip.getWarehouseName(),instant(trip.getPlannedDepartureAt()),instant(row.getCutoffAt()),row.getContactName(),row.getContactPhone(),row.getRemark(),List.copyOf(destinations),List.copyOf(segments),known,
|
||||
openBlock(row,trip,clock.instant())==null,block==null,block,new FleetCapacityOfferModels.Actions(editable,editable&&"DRAFT".equals(row.getStatus())&&baseReady,editable&&"OPEN".equals(row.getStatus()),editable&&Set.of("OPEN","PAUSED").contains(row.getStatus())&&baseReady,editable));
|
||||
}
|
||||
static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));}
|
||||
private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;}
|
||||
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
|
||||
static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){
|
||||
if(!"OPEN".equals(row.getStatus()))return "发布未开放接单";
|
||||
if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向";
|
||||
if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null;
|
||||
}
|
||||
private String fingerprint(FleetTripEntity trip){return hash(Arrays.asList(trip.getWarehouseId(),trip.getVehicleId(),trip.getDriverId(),trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm(),trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),trip.getStopsJson(),trip.getPlannedLoadJson()));}
|
||||
private void validateCutoff(FleetTripEntity trip,Instant cutoff){if(!cutoffValid(trip,cutoff==null?null:LocalDateTime.ofInstant(cutoff,ZoneOffset.UTC),clock.instant()))throw conflict("请填写晚于当前时间且不晚于计划发车的交货截止");}
|
||||
private void requireInquiryOpen(FleetCapacityOfferEntity row,FleetTripEntity trip){String reason=openBlock(row,trip,clock.instant());if(reason!=null)throw conflict(reason);}
|
||||
private void requirePlanning(FleetTripEntity trip,FleetExecutionModels.Planning plan){if(!plan.planValid()||plan.blockReason()!=null||trip.getDriverId()==null)throw conflict(plan.blockReason()==null?"请先分配司机并核对计划容量":plan.blockReason());}
|
||||
private void validateInquiry(FleetTripEntity trip,FleetCapacityOfferModels.InquiryWrite request){
|
||||
if(!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(request.businessType())||!Set.of("SELF_DELIVERY","MANUAL_PICKUP_TO_WAREHOUSE").contains(request.deliveryMode()))throw bad("业务类型或交货方式无效");
|
||||
if(FleetBookingReservations.index(reservations.stops(trip),request.cargo().destinationKey())<1)throw conflict("请选择本车已有卸货站");
|
||||
if(request.estimatedArrivalAt()!=null&&request.estimatedArrivalAt().isAfter(instant(trip.getPlannedDepartureAt())))throw conflict("预计到仓不得晚于计划发车");
|
||||
}
|
||||
private void apply(FleetCapacityOfferEntity row,FleetCapacityOfferModels.Write request){row.setCutoffAt(LocalDateTime.ofInstant(request.cutoffAt(),ZoneOffset.UTC));row.setContactName(trim(request.contactName()));row.setContactPhone(trim(request.contactPhone()));row.setRemark(trim(request.remark()));row.setUpdatedBy(operator());row.setUpdatedAt(now());}
|
||||
private void apply(FleetBookingEntity row,FleetCapacityOfferModels.InquiryWrite request){row.setBusinessType(request.businessType());row.setDeliveryMode(request.deliveryMode());row.setExpectedPalletCount(request.expectedPalletCount());row.setEstimatedArrivalAt(request.estimatedArrivalAt()==null?null:LocalDateTime.ofInstant(request.estimatedArrivalAt(),ZoneOffset.UTC));}
|
||||
private void touch(FleetCapacityOfferEntity row){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator());row.setUpdatedAt(now());if(offers.updateById(row)!=1)throw conflict("发布已变化,请刷新");}
|
||||
private <T>T replay(FleetTripEntity trip,String action,String scope,String key,Object request,Class<T> type){var command=commands.find(trip.getId(),operator(),action,scope,key);if(command==null)return null;if(!Objects.equals(command.getRequestHash(),hash(request)))throw conflict("同一请求键不能更换请求内容");try{return json.readValue(command.getResultJson(),type);}catch(Exception e){throw new IllegalStateException("原命令结果无法读取",e);}}
|
||||
private void saveCommand(FleetTripEntity trip,String action,String scope,String key,Object request,Object result){var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator());command.setAction(action);command.setScopeNo(scope);command.setIdempotencyKey(key);command.setRequestHash(hash(request));command.setResultJson(write(result));command.setCreatedAt(now());if(commands.insert(command)!=1)throw conflict("命令记录保存失败");}
|
||||
private void versions(FleetTripEntity trip,FleetCapacityOfferEntity row,Long tripVersion,Long offerVersion){tripVersion(trip,tripVersion);if(!Objects.equals(row.getRecordVersion(),offerVersion))throw conflict("发布已变化,请刷新后重新核对");}
|
||||
private void tripVersion(FleetTripEntity trip,Long version){if(!Objects.equals(trip.getRecordVersion(),version))throw conflict("车次已变化,请刷新后重新核对");}
|
||||
private FleetCapacityOfferEntity offer(String number,boolean lock){var row=lock?offers.lock(number):offers.byNo(number);if(row==null)throw missing();return row;}
|
||||
private FleetTripEntity trip(String number,boolean lock){var row=lock?trips.lockByNo(number):trips.findByNo(number);if(row==null)throw missing();return row;}
|
||||
private FleetBookingEntity inquiryRow(String number){var row=bookings.byNo(number);if(row==null||row.getOfferId()==null)throw missing();return row;}
|
||||
private void own(FleetBookingEntity row){if(!manage()&&!Objects.equals(row.getSalesOwnerUserId(),operator()))throw forbidden();}
|
||||
private void requireVisible(FleetCapacityOfferEntity row){if(!manage()&&!"OPEN".equals(row.getStatus())&&bookings.selectCount(new LambdaQueryWrapper<FleetBookingEntity>().eq(FleetBookingEntity::getOfferId,row.getId()).eq(FleetBookingEntity::getSalesOwnerUserId,operator()))==0)throw forbidden();}
|
||||
private static boolean has(String permission){var auth=SecurityContextHolder.getContext().getAuthentication();return auth!=null&&auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));}
|
||||
private static boolean manage(){return has(MANAGE);}
|
||||
private static void readPermission(){if(!has(LIST)&&!manage())throw forbidden();operator();}
|
||||
private static void writePermission(String action){if(!manage()&&(!has(LIST)||!has("platform:fleet:capacity-offer:"+action)))throw forbidden();operator();}
|
||||
private static void requireManage(){if(!manage())throw forbidden();operator();}
|
||||
private static String operator(){var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null||!(auth.getPrincipal() instanceof AuthenticatedUser user)||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");if(!Long.valueOf(0).equals(user.tenantId()))throw forbidden();return user.userId().toString();}
|
||||
private <T>PageResult<T> page(List<T> rows,int page,int size){int start=(int)Math.min(rows.size(),((long)page-1)*size);return new PageResult<>(List.copyOf(rows.subList(start,Math.min(rows.size(),start+size))),page,size,rows.size(),(rows.size()+size-1)/size);}
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("拼车数据无法保存",e);}}
|
||||
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)));}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
|
||||
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
|
||||
private static Instant instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC);}
|
||||
private static boolean contains(String value,String needle){return !nonblank(needle)||value!=null&&value.toLowerCase(Locale.ROOT).contains(needle.trim().toLowerCase(Locale.ROOT));}
|
||||
private static boolean nonblank(String value){return value!=null&&!value.isBlank();}
|
||||
private static String trim(String value){return nonblank(value)?value.trim():null;}
|
||||
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
|
||||
private static ResponseStatusException bad(String value){return new ResponseStatusException(HttpStatus.BAD_REQUEST,value);}
|
||||
private static ResponseStatusException forbidden(){return new ResponseStatusException(HttpStatus.FORBIDDEN,"无权操作该发布或意向");}
|
||||
private static ResponseStatusException missing(){return new ResponseStatusException(HttpStatus.NOT_FOUND,"发布、车次或意向不存在");}
|
||||
}
|
||||
+18
@@ -51,6 +51,8 @@ public class FleetTripExecutionService {
|
||||
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
|
||||
var trip = requireTrip(tripNo, true);
|
||||
requireVersionAndPlan(trip, request.recordVersion());
|
||||
String vehicleBlock = currentVehicleBlockReason(trip, true);
|
||||
if (vehicleBlock != null) throw conflict(vehicleBlock);
|
||||
var cargo = cargo(manifests(trip, true));
|
||||
String reason = planningBlockReason(trip, cargo);
|
||||
if (reason != null) throw conflict(reason);
|
||||
@@ -74,12 +76,28 @@ public class FleetTripExecutionService {
|
||||
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
|
||||
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
|
||||
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
|
||||
String vehicleReason = currentVehicleBlockReason(trip, false);
|
||||
if (vehicleReason != null) return vehicleReason;
|
||||
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
|
||||
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
|
||||
return "请先明确每票货物的实物托盘和卸货目的地";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Caller holds the parent trip lock before requesting a vehicle write lock. */
|
||||
public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) {
|
||||
if (trip.getVehicleId() == null) return "请先分配车辆";
|
||||
var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null);
|
||||
if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()))
|
||||
return "车辆当前不可用,请重新分配车辆并核对计划容量";
|
||||
if (!Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate())
|
||||
|| !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|
||||
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg())
|
||||
|| !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
|
||||
return "车辆容量或资料已变化,请重新分配车辆并核对计划容量";
|
||||
return null;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public FleetExecutionModels.Loading loading(String tripNo) {
|
||||
var trip = requireTrip(tripNo, false);
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/capacity-offers")
|
||||
public class PlatformFleetCapacityOfferController {
|
||||
private final FleetCapacityOfferService service;
|
||||
public PlatformFleetCapacityOfferController(FleetCapacityOfferService service){this.service=service;}
|
||||
@GetMapping @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetCapacityOfferModels.Offer>> page(@Valid FleetCapacityOfferModels.Query query){return ApiResponse.success(service.page(query));}
|
||||
@GetMapping("/{offerNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.Detail> detail(@PathVariable String offerNo){return ApiResponse.success(service.detail(offerNo));}
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> create(@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.create(request));}
|
||||
@PutMapping("/{offerNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> update(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.update(offerNo,request));}
|
||||
@PostMapping("/{offerNo}/publish") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> publish(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PUBLISH",request));}
|
||||
@PostMapping("/{offerNo}/pause") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> pause(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PAUSE",request));}
|
||||
@PostMapping("/{offerNo}/resume") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
|
||||
@PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));}
|
||||
@GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetCapacityOfferModels.Inquiry>> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));}
|
||||
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.Inquiry> inquiry(@PathVariable String bookingNo){return ApiResponse.success(service.inquiry(bookingNo));}
|
||||
@PostMapping("/{offerNo}/inquiries") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> createInquiry(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.createInquiry(offerNo,request));}
|
||||
@PutMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-edit'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> updateInquiry(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.updateInquiry(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> confirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.confirm(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-cancel'))")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> cancel(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.cancel(bookingNo,request));}
|
||||
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
|
||||
public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));}
|
||||
@GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+15
-3
@@ -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,
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+14
@@ -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; }
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+9
-3
@@ -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();
|
||||
|
||||
@@ -1131,6 +1131,45 @@ CREATE TABLE ll_fleet_trip (
|
||||
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
|
||||
|
||||
CREATE TABLE ll_fleet_capacity_offer (
|
||||
id BIGINT NOT NULL COMMENT '内部主键',
|
||||
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
|
||||
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
|
||||
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
|
||||
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
|
||||
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
|
||||
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
|
||||
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
|
||||
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
|
||||
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
|
||||
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
|
||||
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
|
||||
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
|
||||
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
|
||||
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
|
||||
|
||||
CREATE TABLE ll_fleet_capacity_offer_command (
|
||||
id BIGINT NOT NULL COMMENT '内部主键',
|
||||
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
|
||||
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
|
||||
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
|
||||
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
|
||||
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
|
||||
|
||||
CREATE TABLE ll_fleet_external_booking (
|
||||
id BIGINT NOT NULL COMMENT '内部主键',
|
||||
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
|
||||
@@ -1160,11 +1199,21 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
|
||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
|
||||
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
|
||||
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
|
||||
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
|
||||
KEY idx_fleet_booking_offer(offer_id,status,id),
|
||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
||||
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
|
||||
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||
@@ -1175,6 +1224,44 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
|
||||
|
||||
CREATE TABLE ll_fleet_pool_order_link (
|
||||
id BIGINT NOT NULL COMMENT '内部主键',
|
||||
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
|
||||
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '商业客户隔离范围',
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
|
||||
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
|
||||
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
|
||||
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
|
||||
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
|
||||
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
|
||||
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
|
||||
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
|
||||
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
|
||||
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
|
||||
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
|
||||
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
|
||||
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
|
||||
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
|
||||
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
|
||||
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
|
||||
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
|
||||
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
|
||||
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
|
||||
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
|
||||
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
|
||||
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
|
||||
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
|
||||
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
|
||||
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
|
||||
|
||||
CREATE TABLE ll_fleet_resource_assignment (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
|
||||
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -340,11 +340,11 @@ components:
|
||||
schemas:
|
||||
BookingCargo:
|
||||
type: object
|
||||
required: [destinationKey, packageCount]
|
||||
required: [destinationKey]
|
||||
properties:
|
||||
destinationKey: {type: string, maxLength: 160}
|
||||
cargoName: {type: string, nullable: true, maxLength: 255}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
packageCount: {type: integer, nullable: true, minimum: 1, description: 询价未知可为空,确认承运前必须补齐正数箱数}
|
||||
palletSpaces: {type: integer, nullable: true, minimum: 1}
|
||||
weightKg: {type: number, nullable: true, minimum: 0.001}
|
||||
volumeCbm: {type: number, nullable: true, minimum: 0.001}
|
||||
@@ -437,6 +437,8 @@ components:
|
||||
Booking:
|
||||
type: object
|
||||
properties:
|
||||
offerNo: {type: string, nullable: true, description: 来源拼车发布号,池来源的成交和取消必须经专用接口}
|
||||
expectedPalletCount: {type: integer, nullable: true, minimum: 1, description: 预计实托数,不等同托位}
|
||||
bookingNo: {type: string}
|
||||
tripNo: {type: string}
|
||||
customerId: {type: string}
|
||||
|
||||
@@ -3353,6 +3353,7 @@ components:
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } }
|
||||
TruckSummary:
|
||||
type: object
|
||||
description: 卡车商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。列表批量查询承运摘要,不逐行请求车次。
|
||||
required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
@@ -3377,8 +3378,10 @@ components:
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
|
||||
TruckDetail:
|
||||
type: object
|
||||
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源手工状态入口禁止旁路实际履约。
|
||||
required: [summary, pallets, histories]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/TruckSummary' }
|
||||
@@ -3407,6 +3410,7 @@ components:
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } }
|
||||
LooseboardSummary:
|
||||
type: object
|
||||
description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。
|
||||
required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
@@ -3430,8 +3434,10 @@ components:
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
|
||||
LooseboardDetail:
|
||||
type: object
|
||||
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。
|
||||
required: [summary, histories]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/LooseboardSummary' }
|
||||
@@ -3440,6 +3446,34 @@ components:
|
||||
remark: { type: string, nullable: true }
|
||||
createdBy: { type: string, nullable: true }
|
||||
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
|
||||
FleetArrangement:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 公司内部拼车成交后的客户隔离承运摘要。无发布池内部ID、他人货物、司机私人信息或采购成本;agreedFreightAmount是约定运费,不代表已收款。未知数量保持null。
|
||||
required: [sourceType, businessType, businessOrderNo, bookingNo, tripNo, plannedDepartureAt, originWarehouseName, destinationLabel, deliveryMode, fulfillmentStage, stageOccurredAt, readOnlyReason]
|
||||
properties:
|
||||
sourceType: { type: string, enum: [FLEET_CAPACITY_OFFER] }
|
||||
businessType: { type: string, enum: [LOCAL_LOOSEBOARD, US_TRUCK] }
|
||||
businessOrderNo: { type: string }
|
||||
bookingNo: { type: string }
|
||||
tripNo: { type: string }
|
||||
plannedDepartureAt: { type: string, format: date-time, description: UTC Instant,明确包含Z,不将数据库UTC墙钟值当成本地时间 }
|
||||
originWarehouseName: { type: string }
|
||||
destinationLabel: { type: string }
|
||||
deliveryMode: { type: string, enum: [SELF_DELIVERY, MANUAL_PICKUP_TO_WAREHOUSE] }
|
||||
deliveryInstructions: { type: string, nullable: true }
|
||||
fulfillmentStage: { type: string, enum: [WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
stageOccurredAt: { type: string, format: date-time, description: 真实履约阶段时间,按业务运行时区转换为Instant }
|
||||
agreedFreightAmount: { type: number, nullable: true }
|
||||
currency: { type: string, nullable: true, enum: [CNY, USD] }
|
||||
incomingNo: { type: string, nullable: true }
|
||||
dispatchNo: { type: string, nullable: true }
|
||||
deliveryNo: { type: string, nullable: true }
|
||||
signedPackages: { type: integer, minimum: 0, nullable: true }
|
||||
unsignedPackages: { type: integer, minimum: 0, nullable: true }
|
||||
returnedPackages: { type: integer, minimum: 0, nullable: true }
|
||||
exceptionSummary: { type: string, nullable: true }
|
||||
readOnlyReason: { type: string }
|
||||
BusinessHistory:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3807,7 +3841,7 @@ components:
|
||||
contactInfo: { type: string, maxLength: 128 }
|
||||
requestedDate: { type: string, format: date }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
LooseboardStatusChange:
|
||||
type: object
|
||||
required: [version, status, idempotencyKey]
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
volatile Runnable beforeFirstSourceRead = () -> {};
|
||||
final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>();
|
||||
Fixture() {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ class DeliveryDocumentServiceTest {
|
||||
DeliveryOrder order;
|
||||
@BeforeEach void setUp() {
|
||||
service = new DeliveryDocumentService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
ReflectionTestUtils.setField(service,"orders",orders);
|
||||
ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders);
|
||||
ReflectionTestUtils.setField(service,"revisions",revisions);
|
||||
|
||||
+1
@@ -66,6 +66,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchPdaWorkflowService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.mapper.*;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||
import com.baiyue.lastleg.trucking.mapper.*;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetPoolOrderBridgeTest {
|
||||
FleetPoolOrderMapper links=mock(FleetPoolOrderMapper.class);
|
||||
LocalLooseboardOrderMapper loose=mock(LocalLooseboardOrderMapper.class);
|
||||
LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class);
|
||||
UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class);
|
||||
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
|
||||
LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
|
||||
DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence);
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of());
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
|
||||
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
|
||||
when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row));
|
||||
doAnswer(a->{row=a.getArgument(0);return 1;}).when(links).insert(any(FleetPoolOrderLink.class));
|
||||
doAnswer(a->{looseOrder=a.getArgument(0);return 1;}).when(loose).insert(any(LocalLooseboardOrder.class));
|
||||
doAnswer(a->{truckOrder=a.getArgument(0);return 1;}).when(trucks).insert(any(UsTruckOrder.class));
|
||||
when(loose.lockNo("9","LOCAL-001")).thenAnswer(a->looseOrder);
|
||||
when(trucks.lockNo("9","LOCAL-001")).thenAnswer(a->truckOrder);
|
||||
}
|
||||
ConfirmedBooking input(String type){return new ConfirmedBooking(7,"FB-7","9",type,"92","起运仓","FBA:ONT8","ONT8",
|
||||
LocalDateTime.of(2030,1,1,12,0),"SELF_DELIVERY","客户自送",null,"货物",4,2,
|
||||
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
|
||||
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
|
||||
FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of(
|
||||
new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE),
|
||||
new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);}
|
||||
void dispatch(String type){service.confirm(input(type),"op");incoming();service.receiving("9","WT-7","pda");
|
||||
service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");}
|
||||
DeliveryOrder delivery(int signed){var d=new DeliveryOrder();d.setId(71L);d.setDispatchOrderId(70L);d.setDeliveryGroupKey("CODE:ONT8");d.setCustomerId("9");d.setDispatchNo("DISPATCH-7");d.setDeliveryNo("DELIVERY-7");
|
||||
d.setStatus("DELIVERED");d.setPackageCount(4);d.setSignedPackages(signed);d.setSignedAt(LocalDateTime.now());
|
||||
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",4,(long)signed,d.getSignedAt(),d.getSignedAt().minusHours(1))));
|
||||
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null)));
|
||||
return d;}
|
||||
@Test void looseboardConfirmationCreatesOnlyCommercialRecordAndReplaysWithoutAnotherOrder(){
|
||||
var result=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
assertThat(result.businessOrderNo()).isEqualTo("LOCAL-001");assertThat(looseOrder.getLastlegOrderId()).isNull();
|
||||
assertThat(looseOrder.getBudgetAmount()).isNull();assertThat(row.getIncomingNo()).isNull();
|
||||
assertThat(looseOrder.getStatus()).isEqualTo("WAITING_INBOUND");
|
||||
assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(result);
|
||||
verify(loose,times(1)).insert(any(LocalLooseboardOrder.class));verifyNoInteractions(trucks);
|
||||
assertThatThrownBy(()->service.confirm(input("US_TRUCK"),"op")).hasMessageContaining("不同商业订单");
|
||||
}
|
||||
@Test void truckKeepsForecastPhysicalPalletsSeparateFromActualSpaces(){
|
||||
dispatch("US_TRUCK");
|
||||
assertThat(truckOrder.getPalletCount()).isEqualTo(2);assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);
|
||||
assertThat(truckOrder.getAllocatedPalletCount()).isEqualTo(2);assertThat(row.getActualPalletSpaces()).isEqualTo(3);
|
||||
assertThat(truckOrder.getBudgetAmount()).isNull();assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH");
|
||||
verifyNoInteractions(loose);
|
||||
}
|
||||
@Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){
|
||||
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
assertThatThrownBy(()->service.bindIncoming("FB-7","another","WT-7",77,"92","FBA:ONT8","op")).hasMessageContaining("客户");
|
||||
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-7",77,"93","FBA:ONT8","op")).hasMessageContaining("仓库");
|
||||
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:LAX9","op")).hasMessageContaining("目的地");
|
||||
incoming();incoming();assertThat(looseOrder.getLastlegOrderId()).isEqualTo(77L);
|
||||
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-8",78,"92","FBA:ONT8","op")).hasMessageContaining("其他来货");
|
||||
}
|
||||
@Test void cancellationOnlyBeforeIncomingAndIsIdempotent(){
|
||||
service.confirm(input("US_TRUCK"),"op");service.cancelBeforeIncoming(7,"9","op","取消承运");
|
||||
service.cancelBeforeIncoming(7,"9","op","取消承运");assertThat(truckOrder.getStatus()).isEqualTo("CANCELLED");
|
||||
verify(truckHistory,times(2)).insert(any(UsTruckOrderStatusHistory.class));
|
||||
assertThatThrownBy(this::incoming).hasMessageContaining("不能登记来货");
|
||||
}
|
||||
@Test void incomingBlocksCancellationAndWrongReceiptIdentityOrDuplicatePhysicalPallets(){
|
||||
service.confirm(input("US_TRUCK"),"op");incoming();
|
||||
assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","取消")).hasMessageContaining("已登记来货");
|
||||
var r=receipt();var wrong=new FleetBookingIncomingGateway.Evidence("OTHER",r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),r.pallets(),4);
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,wrong,3,"op")).hasMessageContaining("来源");
|
||||
var duplicate=new FleetBookingIncomingGateway.Evidence(r.incomingNo(),r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),List.of(r.pallets().get(0),r.pallets().get(0)),4);
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,duplicate,3,"op")).hasMessageContaining("重复");
|
||||
}
|
||||
@Test void actualDepartureAndFullSignatureDriveLooseboardExactlyOnce(){
|
||||
dispatch("LOCAL_LOOSEBOARD");
|
||||
assertThatThrownBy(()->service.departed("9","DISPATCH-7",Map.of(77L,3),"op",LocalDateTime.now())).hasMessageContaining("完整货量");
|
||||
service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
assertThat(looseOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
service.signed(delivery(2),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(looseOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(looseOrder.getStatus()).isEqualTo("COMPLETED");
|
||||
long version=row.getRecordVersion();service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(row.getRecordVersion()).isEqualTo(version);
|
||||
}
|
||||
@Test void truckCompletesOnlyFromItsWholeActualShipment(){
|
||||
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
assertThatThrownBy(()->service.signed(delivery(4),Map.of(77L,3L),"op",LocalDateTime.now())).hasMessageContaining("来源数量不完整");
|
||||
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");
|
||||
long version=row.getRecordVersion();service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(row.getRecordVersion()).isEqualTo(version);
|
||||
}
|
||||
@Test void truckRequiresActualForecastPalletCountWithoutSubstitutingSpacesOrOne(){
|
||||
var p=input("US_TRUCK");var missing=new ConfirmedBooking(p.bookingId(),p.bookingNo(),p.customerId(),p.businessType(),p.warehouseId(),
|
||||
p.warehouseName(),p.destinationKey(),p.destinationLabel(),p.plannedDepartureAt(),p.deliveryMode(),p.deliveryInstructions(),
|
||||
p.estimatedArrivalAt(),p.cargoName(),p.packageCount(),null,p.weightKg(),p.volumeCbm(),p.currency(),p.contactName(),p.contactPhone(),p.remark());
|
||||
assertThatThrownBy(()->service.confirm(missing,"op")).hasMessageContaining("独立实托数");
|
||||
verifyNoInteractions(trucks,loose,links);
|
||||
}
|
||||
@Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedCommercialOrder(){
|
||||
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
var d=delivery(6);d.setPackageCount(8);
|
||||
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",8,6L,d.getSignedAt(),d.getSignedAt().minusHours(1))));
|
||||
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null),
|
||||
new SourceDeliveryCompletionPolicy.Pick(2,70,"CODE:ONT8",78,4,2L,null,null,null)));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,78,2,d.getSignedAt().minusMinutes(5))));
|
||||
service.signed(d,Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");
|
||||
}
|
||||
@Test void thisSourceReturnOrMissingHistoryCannotBeCountedAsWholeSignature(){
|
||||
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
var d=delivery(2);
|
||||
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,77,2,d.getSignedAt().minusMinutes(5))));
|
||||
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
d=delivery(4);when(evidence.dispatchIds("9",77)).thenReturn(List.of());
|
||||
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
@Test void bindingDoesNotMeanDepartureAndCannotCompleteBeforeActualDeparture(){
|
||||
dispatch("US_TRUCK");assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH");
|
||||
assertThatThrownBy(()->service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now())).hasMessageContaining("实际出库");
|
||||
service.unboundDispatch(7,"9","DISPATCH-7","op");assertThat(row.getDispatchNo()).isNull();
|
||||
assertThat(truckOrder.getStatus()).isEqualTo("WAITING_CONSOLIDATION");
|
||||
}
|
||||
@Test void allLifecycleMethodsRequireCallersExistingTransactionAndNoParentServicesAreInjected(){
|
||||
for(var method:FleetPoolOrderBridge.class.getDeclaredMethods())if(Set.of("confirm","bindIncoming","receiving","acceptedReceipt","boundDispatch","unboundDispatch","departed","signed","cancelBeforeIncoming").contains(method.getName()))
|
||||
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
|
||||
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName()))
|
||||
.noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory"));
|
||||
}
|
||||
@Test void poolOnlyCallbacksCannotSucceedWithoutTheirCommercialLink(){
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,receipt(),1,"op")).hasMessageContaining("缺少唯一商业订单关联");
|
||||
assertThatThrownBy(()->service.boundDispatch(7,"9","DISPATCH-7","op")).hasMessageContaining("缺少唯一商业订单关联");
|
||||
assertThatThrownBy(()->service.unboundDispatch(7,"9","DISPATCH-7","op")).hasMessageContaining("缺少唯一商业订单关联");
|
||||
assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","cancel")).hasMessageContaining("缺少唯一商业订单关联");
|
||||
when(links.poolBooking("FB-7")).thenReturn(1);
|
||||
assertThatThrownBy(this::incoming).hasMessageContaining("缺少唯一商业订单关联");
|
||||
verifyNoInteractions(loose,trucks);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetPoolOrderQueriesTest {
|
||||
FleetPoolArrangementRow row(){var r=new FleetPoolArrangementRow();r.setCustomerId("9");r.setBusinessType("US_TRUCK");r.setBusinessOrderId(1L);
|
||||
r.setBusinessOrderNo("LOCAL-1");r.setBookingNo("FB-1");r.setActualPackageCount(4);r.setFulfillmentStage("IN_TRANSIT");return r;}
|
||||
@Test void batchSummaryIsOneQueryAndRejectsForeignCustomerRows(){
|
||||
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
|
||||
var keys=List.of(new OrderKey("9","US_TRUCK",1),new OrderKey("9","US_TRUCK",2));
|
||||
when(mapper.arrangements(any())).thenReturn(List.of(row()));
|
||||
assertThat(service.arrangements(keys)).hasSize(1);verify(mapper,times(1)).arrangements(any());
|
||||
var foreign=row();foreign.setCustomerId("OTHER");when(mapper.arrangements(any())).thenReturn(List.of(foreign));
|
||||
assertThatThrownBy(()->service.arrangements(keys)).hasMessageContaining("客户范围");
|
||||
}
|
||||
@Test void independentOrdersAndEmptyPagesRemainUnlinked(){
|
||||
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
|
||||
assertThat(service.arrangements(List.of())).isEmpty();verifyNoInteractions(mapper);
|
||||
assertThat(service.arrangement(new OrderKey("9","US_TRUCK",1))).isNull();
|
||||
}
|
||||
@Test void partialReturnsDoNotPretendAllSignedAndUnknownCountsStayUnknown(){
|
||||
var r=row();r.setDeliveryStatus("DELIVERED");r.setDeliveryPackages(4);r.setSignedPackages(3);r.setReturnedPackages(1);r.setDeliveryReturnedPackages(1);
|
||||
var result=FleetPoolOrderQueries.project(r);assertThat(result.signedPackages()).isEqualTo(3);assertThat(result.unsignedPackages()).isEqualTo(1);
|
||||
assertThat(result.returnedPackages()).isEqualTo(1);assertThat(result.exceptionSummary()).isNotBlank();
|
||||
r.setDeliveryReturnedPackages(0);assertThat(FleetPoolOrderQueries.project(r).signedPackages()).isNull();
|
||||
r.setActualPackageCount(null);assertThat(FleetPoolOrderQueries.project(r).unsignedPackages()).isNull();
|
||||
}
|
||||
@Test void signedOrderRetainsHistoricalSignatureAfterLaterReturn(){
|
||||
var r=row();r.setFulfillmentStage("COMPLETED");r.setReturnedPackages(1);
|
||||
var result=FleetPoolOrderQueries.project(r);assertThat(result.signedPackages()).isEqualTo(4);assertThat(result.unsignedPackages()).isZero();
|
||||
assertThat(result.exceptionSummary()).isNotBlank();
|
||||
}
|
||||
@Test void businessAndIncomingOldCommandsFailClosedForPoolSources(){
|
||||
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
|
||||
when(mapper.linked("9","US_TRUCK",1)).thenReturn(1);when(mapper.linkedIncoming("9","WT-1")).thenReturn(1);
|
||||
assertThatThrownBy(()->service.requireStandalone("9","US_TRUCK",1)).hasMessageContaining("承运安排");
|
||||
assertThatThrownBy(()->service.requireIndependentIncoming("9","WT-1")).hasMessageContaining("不支持");
|
||||
}
|
||||
@Test void publicSummaryHasNoInternalIdsOrPrivateFleetAndCustomerFields(){
|
||||
var names=Arrays.stream(Arrangement.class.getRecordComponents()).map(c->c.getName()).toList();
|
||||
assertThat(names).doesNotContain("bookingId","offerId","customerId","driverPhone","purchasePrice","remainingCapacity");
|
||||
assertThat(names).contains("bookingNo","businessOrderNo","signedPackages","returnedPackages","readOnlyReason");
|
||||
}
|
||||
@Test void plannedTripUtcAndLocalBusinessStageAreReturnedAsUnambiguousInstants(){
|
||||
var r=row();r.setPlannedDepartureAt(java.time.LocalDateTime.of(2030,1,1,8,0));r.setStageOccurredAt(java.time.LocalDateTime.of(2030,1,1,9,0));
|
||||
var result=FleetPoolOrderQueries.project(r);
|
||||
assertThat(result.plannedDepartureAt()).isEqualTo(java.time.Instant.parse("2030-01-01T08:00:00Z"));
|
||||
assertThat(result.stageOccurredAt()).isEqualTo(r.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class FleetCapacityOfferServiceTest {
|
||||
private final FleetCapacityOfferMapper offers=mock(FleetCapacityOfferMapper.class);
|
||||
private final FleetTripMapper trips=mock(FleetTripMapper.class);
|
||||
private final FleetTripExecutionService execution=mock(FleetTripExecutionService.class);
|
||||
private final FleetBookingReservations reservations=mock(FleetBookingReservations.class);
|
||||
private final Instant now=Instant.parse("2026-10-07T01:00:00Z");
|
||||
private FleetCapacityOfferService service;
|
||||
@BeforeEach void setup(){
|
||||
var permissions=Set.of(FleetCapacityOfferService.LIST);
|
||||
var user=new AuthenticatedUser(101L,0L,0L,101L,"sales","ADMIN","WEB",permissions);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
|
||||
service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations,
|
||||
execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC));
|
||||
}
|
||||
@AfterEach void clear(){SecurityContextHolder.clearContext();}
|
||||
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
|
||||
var rows=List.of(offer(1),offer(2));var page=new Page<FleetCapacityOfferEntity>(3,20,2000);page.setRecords(rows);
|
||||
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(page);
|
||||
for(var row:rows){var trip=trip(row.getTripNo());when(trips.findByNo(row.getTripNo())).thenReturn(trip);
|
||||
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("ONT8",null,"FBA:ONT8")));
|
||||
when(execution.capacityPlan(row.getTripNo())).thenReturn(new FleetExecutionModels.Planning(row.getTripNo(),1L,List.of(),"hash",null,false,"计划待核对",List.of()));}
|
||||
var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null);
|
||||
var result=service.page(query);
|
||||
assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2);
|
||||
var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true));
|
||||
assertThat(captor.getValue().getCurrent()).isEqualTo(3);assertThat(captor.getValue().getSize()).isEqualTo(20);
|
||||
verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips);
|
||||
}
|
||||
@Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){
|
||||
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101));
|
||||
assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null))).hasMessageContaining("100");
|
||||
verifyNoInteractions(execution,trips);
|
||||
}
|
||||
@Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){
|
||||
var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN");
|
||||
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull();
|
||||
assertThat(FleetCapacityOfferService.openBlock(offer,trip,offer.getCutoffAt().toInstant(ZoneOffset.UTC))).contains("截止");
|
||||
trip.setPlannedDepartureAt(offer.getCutoffAt().minusMinutes(1));
|
||||
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).contains("截止");
|
||||
trip.setStatus("IN_TRANSIT");assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).contains("已发车");
|
||||
}
|
||||
@Test void destinationCapacityUsesIndependentMinimumForEveryDimension(){
|
||||
var a=new TripCapacityPolicy.Load(12,new BigDecimal("600"),new BigDecimal("8"));
|
||||
var b=new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("3"));
|
||||
var result=FleetCapacityOfferService.min(a,b);assertThat(result.palletSpaces()).isEqualTo(4);
|
||||
assertThat(result.weightKg()).isEqualByComparingTo("600");assertThat(result.volumeCbm()).isEqualByComparingTo("3");
|
||||
}
|
||||
private FleetCapacityOfferEntity offer(long id){var row=new FleetCapacityOfferEntity();row.setId(id);row.setOfferNo("FO-"+id);row.setTripNo("FT-"+id);row.setTripId(id);row.setStatus("OPEN");row.setRecordVersion(0L);row.setCutoffAt(LocalDateTime.ofInstant(now.plusSeconds(1800),ZoneOffset.UTC));return row;}
|
||||
private FleetTripEntity trip(String number){var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo(number);trip.setStatus("PLANNED");trip.setWarehouseId(92L);trip.setWarehouseName("起点");trip.setRecordVersion(1L);trip.setPlannedDepartureAt(LocalDateTime.ofInstant(now.plusSeconds(3600),ZoneOffset.UTC));return trip;}
|
||||
}
|
||||
+1
@@ -57,6 +57,7 @@ class FleetTripAuthorizationTest {
|
||||
}
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); }
|
||||
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
|
||||
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
|
||||
@Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); }
|
||||
|
||||
+111
-2
@@ -64,6 +64,8 @@ class FleetTripPersistenceTest {
|
||||
private FleetTripExecutionService execution;
|
||||
private FleetTripCompletionService completion;
|
||||
private FleetBookingService bookingService;
|
||||
private FleetCapacityOfferService capacityOffers;
|
||||
private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders;
|
||||
private FleetBookingIncomingGateway incoming;
|
||||
private FleetBookingQuoteContext quoteContext;
|
||||
private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates;
|
||||
@@ -87,7 +89,7 @@ class FleetTripPersistenceTest {
|
||||
jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
|
||||
"ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
|
||||
"ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
|
||||
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
|
||||
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
|
||||
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
|
||||
@@ -96,7 +98,7 @@ class FleetTripPersistenceTest {
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
|
||||
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
|
||||
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
|
||||
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
|
||||
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
|
||||
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||
@@ -115,6 +117,10 @@ class FleetTripPersistenceTest {
|
||||
vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class);
|
||||
var bookingMapper = session.getMapper(FleetBookingMapper.class);
|
||||
var reservations = new FleetBookingReservations(bookingMapper, json);
|
||||
poolOrders=mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class);
|
||||
when(poolOrders.businessOrderNumbers(anyCollection())).thenReturn(java.util.Map.of());
|
||||
ReflectionTestUtils.setField(reservations,"offers",session.getMapper(FleetCapacityOfferMapper.class));
|
||||
ReflectionTestUtils.setField(reservations,"poolOrders",poolOrders);
|
||||
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
|
||||
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
|
||||
var workflow = new DispatchPdaWorkflowService();
|
||||
@@ -128,6 +134,7 @@ class FleetTripPersistenceTest {
|
||||
ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard);
|
||||
ReflectionTestUtils.setField(workflow, "objectMapper", json);
|
||||
ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class));
|
||||
ReflectionTestUtils.setField(workflow,"fleetPool",poolOrders);
|
||||
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
|
||||
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
|
||||
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
|
||||
@@ -141,9 +148,111 @@ class FleetTripPersistenceTest {
|
||||
quoteContext=mock(FleetBookingQuoteContext.class);
|
||||
quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class);
|
||||
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class);
|
||||
ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders);
|
||||
capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),
|
||||
session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class);
|
||||
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
|
||||
}
|
||||
|
||||
@Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() {
|
||||
var offer=publishedOffer();poolLogin(101,false);
|
||||
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first"));
|
||||
poolLogin(102,false);
|
||||
assertThatThrownBy(()->capacityOffers.inquiry(first.inquiry().bookingNo())).hasMessageContaining("无权");
|
||||
assertThatThrownBy(()->capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"ALL"))).hasMessageContaining("无权");
|
||||
assertThatThrownBy(()->capacityOffers.confirm(first.inquiry().bookingNo(),poolCommand(first.inquiry(),"sales-confirm"))).hasMessageContaining("无权");
|
||||
poolLogin(101,false);
|
||||
assertThat(capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"MINE")).records()).extracting(FleetCapacityOfferModels.Inquiry::bookingNo).containsExactly(first.inquiry().bookingNo());
|
||||
assertThat(jdbc.queryForObject("SELECT sales_owner_user_id FROM ll_fleet_external_booking WHERE booking_no=?",String.class,first.inquiry().bookingNo())).isEqualTo("101");
|
||||
}
|
||||
|
||||
@Test void poolConfirmSerializesLastSpaceAndOriginalSuccessfulCommandReplaysAfterClose() throws Exception {
|
||||
var offer=publishedOffer();poolLogin(101,false);
|
||||
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"pool-first"));
|
||||
poolLogin(102,false);
|
||||
var second=capacityOffers.createInquiry(offer.offerNo(),poolDraft(first.offer(),8,"pool-second"));
|
||||
poolLogin(1,true);
|
||||
var firstCurrent=capacityOffers.inquiry(first.inquiry().bookingNo());
|
||||
var secondCurrent=capacityOffers.inquiry(second.inquiry().bookingNo());
|
||||
var firstRequest=poolCommand(firstCurrent,"pool-confirm-first");var secondRequest=poolCommand(secondCurrent,"pool-confirm-second");
|
||||
var latch=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
java.util.concurrent.Callable<Boolean> a=()->poolConfirmAttempt(latch,firstCurrent.bookingNo(),firstRequest);
|
||||
java.util.concurrent.Callable<Boolean> b=()->poolConfirmAttempt(latch,secondCurrent.bookingNo(),secondRequest);
|
||||
try {var fa=workers.submit(a);var fb=workers.submit(b);latch.countDown();assertThat(List.of(fa.get(15,TimeUnit.SECONDS),fb.get(15,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);}
|
||||
finally {workers.shutdownNow();}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_external_booking WHERE status='CONFIRMED'",Integer.class)).isEqualTo(1);
|
||||
var current=capacityOffers.detail(offer.offerNo()).offer();assertThat(current.destinations().get(0).remaining().palletSpaces()).isEqualTo(4);
|
||||
String winner=jdbc.queryForObject("SELECT booking_no FROM ll_fleet_external_booking WHERE status='CONFIRMED'",String.class);
|
||||
String loser=winner.equals(firstCurrent.bookingNo())?secondCurrent.bookingNo():firstCurrent.bookingNo();
|
||||
var loserRow=capacityOffers.inquiry(loser);
|
||||
assertThatThrownBy(()->capacityOffers.confirm(loser,poolCommand(loserRow,"retry-rejected-space"))).hasMessageContaining("超过车辆");
|
||||
capacityOffers.command(offer.offerNo(),"CLOSE",new FleetCapacityOfferModels.Command(current.tripRecordVersion(),current.recordVersion(),"pool-close-test",null));
|
||||
var original=winner.equals(firstCurrent.bookingNo())?firstRequest:secondRequest;
|
||||
assertThat(capacityOffers.confirm(winner,original).replayed()).isTrue();
|
||||
assertThatThrownBy(()->capacityOffers.confirm(winner,new FleetCapacityOfferModels.InquiryCommand(original.tripRecordVersion(),original.offerRecordVersion(),original.recordVersion(),original.idempotencyKey(),"changed"))).hasMessageContaining("不能更换");
|
||||
verify(poolOrders,times(1)).confirm(any(),eq("1"));
|
||||
poolLogin(winner.equals(firstCurrent.bookingNo())?101:102,false);
|
||||
assertThat(capacityOffers.inquiry(winner).status()).isEqualTo("CONFIRMED");
|
||||
}
|
||||
|
||||
@Test void poolCommercialFailureRollsBackReservationAndOldBookingEndpointCannotBypassPool() {
|
||||
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"bridge-rollback-create"));
|
||||
doThrow(new IllegalStateException("bridge unavailable")).when(poolOrders).confirm(any(),anyString());
|
||||
assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"bridge-rollback-command"))).hasMessageContaining("bridge unavailable");
|
||||
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT");
|
||||
assertThat(capacityOffers.detail(offer.offerNo()).offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_capacity_offer_command WHERE action='CONFIRM'",Integer.class)).isZero();
|
||||
assertThatThrownBy(()->bookingService.confirm(offer.tripNo(),draft.inquiry().bookingNo(),"1",new FleetBookingModels.Command(draft.inquiry().tripRecordVersion(),0L,"old-pool-bypass",null))).hasMessageContaining("需求池");
|
||||
}
|
||||
|
||||
@Test void liveVehicleChangesBlockCommitButDoNotFabricateDraftQuantityOrCapacity() {
|
||||
var offer=publishedOffer();var currentTrip=service.detail(offer.tripNo()).trip();
|
||||
jdbc.update("UPDATE ll_vehicle SET max_pallet_spaces=20 WHERE id=?",Long.parseLong(currentTrip.vehicleId()));
|
||||
var current=capacityOffers.detail(offer.offerNo()).offer();
|
||||
assertThat(current.capacityKnown()).isFalse();assertThat(current.canConfirm()).isFalse();assertThat(current.canCreateInquiry()).isTrue();
|
||||
assertThat(current.destinations()).allMatch(row->row.remaining()==null);
|
||||
var unknown=new FleetBookingModels.Cargo("FBA:ONT8","待核货物",null,null,null,null,"PALLET",null,"USD",null,null,null);
|
||||
var draft=capacityOffers.createInquiry(offer.offerNo(),new FleetCapacityOfferModels.InquiryWrite(current.tripRecordVersion(),current.recordVersion(),null,9L,"US_TRUCK","SELF_DELIVERY",null,null,unknown,null,"unknown-pool-cargo"));
|
||||
assertThat(draft.inquiry().cargo().packageCount()).isNull();assertThat(draft.inquiry().expectedPalletCount()).isNull();
|
||||
assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"changed-vehicle-confirm"))).hasMessageContaining("车辆容量");
|
||||
}
|
||||
|
||||
@Test void poolCancellationReleasesOnlyUnstartedBookingAndPreservesActualIncoming(){
|
||||
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"cancel-pool-draft"));
|
||||
var confirmed=capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"cancel-pool-confirm"));
|
||||
var cancelled=capacityOffers.cancel(confirmed.inquiry().bookingNo(),poolCommand(confirmed.inquiry(),"cancel-pool-safe"));
|
||||
assertThat(cancelled.inquiry().status()).isEqualTo("CANCELLED");
|
||||
assertThat(cancelled.offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12);
|
||||
verify(poolOrders).cancelBeforeIncoming(anyLong(),eq("9"),eq("1"),isNull());
|
||||
var next=capacityOffers.createInquiry(offer.offerNo(),poolDraft(cancelled.offer(),8,"cancel-incoming-draft"));
|
||||
var reserved=capacityOffers.confirm(next.inquiry().bookingNo(),poolCommand(next.inquiry(),"cancel-incoming-confirm"));
|
||||
jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no='WT-STARTED' WHERE booking_no=?",reserved.inquiry().bookingNo());
|
||||
assertThatThrownBy(()->capacityOffers.cancel(reserved.inquiry().bookingNo(),poolCommand(reserved.inquiry(),"cancel-incoming-refused"))).hasMessageContaining("已登记来货");
|
||||
assertThat(jdbc.queryForObject("SELECT incoming_no FROM ll_fleet_external_booking WHERE booking_no=?",String.class,reserved.inquiry().bookingNo())).isEqualTo("WT-STARTED");
|
||||
verify(poolOrders,times(1)).cancelBeforeIncoming(anyLong(),anyString(),anyString(),any());
|
||||
}
|
||||
|
||||
private boolean poolConfirmAttempt(CountDownLatch latch,String booking,FleetCapacityOfferModels.InquiryCommand request) throws Exception {
|
||||
poolLogin(1,true);try {latch.await(5,TimeUnit.SECONDS);capacityOffers.confirm(booking,request);return true;}catch(ResponseStatusException conflict){return false;}finally{SecurityContextHolder.clearContext();}
|
||||
}
|
||||
private FleetCapacityOfferModels.Offer publishedOffer(){
|
||||
poolLogin(1,true);String trip=readyTrip();var plan=execution.capacityPlan(trip);
|
||||
var measured=new FleetExecutionModels.Measurement("901",18,new BigDecimal("100"),new BigDecimal("1"),1);
|
||||
var confirmed=execution.confirmCapacityPlan(trip,"1",new FleetExecutionModels.Confirm(plan.recordVersion(),plan.manifestHash(),List.of(measured),"自有18托位"));
|
||||
var created=capacityOffers.create(new FleetCapacityOfferModels.Write(trip,confirmed.recordVersion(),null,departure.minusSeconds(60),"调度","10086",null,"offer-create-test"));
|
||||
return capacityOffers.command(created.offer().offerNo(),"PUBLISH",new FleetCapacityOfferModels.Command(confirmed.recordVersion(),0L,"offer-publish-test",null)).offer();
|
||||
}
|
||||
private FleetCapacityOfferModels.InquiryWrite poolDraft(FleetCapacityOfferModels.Offer offer,int spaces,String key){
|
||||
var cargo=new FleetBookingModels.Cargo("FBA:ONT8","外接货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD","验收联系人","10086",null);
|
||||
return new FleetCapacityOfferModels.InquiryWrite(offer.tripRecordVersion(),offer.recordVersion(),null,9L,"LOCAL_LOOSEBOARD","SELF_DELIVERY",departure.minusSeconds(120),2,cargo,null,key);
|
||||
}
|
||||
private static FleetCapacityOfferModels.InquiryCommand poolCommand(FleetCapacityOfferModels.Inquiry row,String key){return new FleetCapacityOfferModels.InquiryCommand(row.tripRecordVersion(),row.offerRecordVersion(),row.recordVersion(),key,null);}
|
||||
private static void poolLogin(long id,boolean manager){
|
||||
var permissions=manager?java.util.Set.of(FleetCapacityOfferService.MANAGE):java.util.Set.of(FleetCapacityOfferService.LIST,"platform:fleet:capacity-offer:inquiry-create","platform:fleet:capacity-offer:inquiry-edit","platform:fleet:capacity-offer:inquiry-cancel");
|
||||
var user=new com.baiyue.common.security.AuthenticatedUser(id,0L,0L,id,"same-display-name","ADMIN","WEB",permissions);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList()));
|
||||
}
|
||||
|
||||
@Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() {
|
||||
var request = createRequest("creation-one");
|
||||
var first = service.create("1", request);
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ class LocalLooseboardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new LocalLooseboardService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
|
||||
+2
@@ -15,7 +15,9 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class FleetIncomingAdapterTest {
|
||||
@Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() {
|
||||
var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
var detail=mock(WarehouseTransferResponses.Detail.class);
|
||||
when(detail.lastlegOrderId()).thenReturn(71L);
|
||||
when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8");
|
||||
when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of());
|
||||
when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail);
|
||||
|
||||
+2
@@ -190,6 +190,8 @@ class WarehouseTransferServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new WarehouseTransferService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPoolQueries", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator);
|
||||
lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any()))
|
||||
.thenAnswer(call -> {
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ class UsTruckOrderServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new UsTruckOrderService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "pallets", pallets);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
|
||||
Reference in New Issue
Block a user