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();
|
||||
|
||||
Reference in New Issue
Block a user