feat(looseboard): require route contacts and pickup delivery times

This commit is contained in:
2026-10-19 15:11:15 +08:00
parent 21128b895f
commit dff040b509
20 changed files with 530 additions and 238 deletions
@@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
@@ -22,34 +20,32 @@ 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 FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources;
}
/** 批量读取预订对应的当前商业履约阶段。 */
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
/** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
String hash=hash(in); var prior=repository.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());
@@ -62,15 +58,16 @@ public class FleetPoolOrderBridge {
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.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification.
order.setPalletRequired(null);order.setContainerType(null);
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount());
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.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());
order.setPickupAt(in.plannedDepartureAt());
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.
repository.insertLoose(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.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
@@ -80,33 +77,36 @@ public class FleetPoolOrderBridge {
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt());
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.
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
repository.insertLink(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;}
var row=repository.lockBookingNo(bookingNo);
if(row==null){if(repository.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);
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockLoose(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);
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order);
}
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
}
/** 按真实来货开始事件推进收货状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
var row=repository.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,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
@@ -123,6 +123,7 @@ public class FleetPoolOrderBridge {
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);
@@ -130,6 +131,7 @@ public class FleetPoolOrderBridge {
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);
@@ -137,6 +139,7 @@ public class FleetPoolOrderBridge {
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);
@@ -144,19 +147,21 @@ public class FleetPoolOrderBridge {
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)) {
for(var row:repository.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(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
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;
@@ -165,12 +170,14 @@ public class FleetPoolOrderBridge {
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
/** 批量读取预订的业务编号。 */
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;
repository.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())
@@ -183,37 +190,47 @@ public class FleetPoolOrderBridge {
||("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);
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(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);
var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockTruck(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);
repository.updateTruck(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);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(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);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(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 FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 校验商业履约边界或组装真实业务引用。 */
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
/** 校验商业履约边界或组装真实业务引用。 */
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
/** 校验商业履约边界或组装真实业务引用。 */
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
/** 校验商业履约边界或组装真实业务引用。 */
private static boolean blank(String value){return value==null||value.isBlank();}
/** 校验商业履约边界或组装真实业务引用。 */
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
/** 校验商业履约边界或组装真实业务引用。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.business;
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 org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Collection;
@Repository
public class FleetPoolOrderRepository {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
/** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */
public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory;
}
/** 批量读取预订关联。 */
public List<FleetPoolOrderLink> bookings(Collection<Long> ids) { return links.bookings(ids); }
/** 锁定预订关联。 */
public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); }
/** 按预订业务号锁定关联。 */
public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); }
/** 查询预订是否属于运力池。 */
public int poolBooking(String number) { return links.poolBooking(number); }
/** 按客户和来货号锁定关联。 */
public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); }
/** 按客户和排车号锁定关联。 */
public List<FleetPoolOrderLink> lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); }
/** 保存唯一商业关联。 */
public void insertLink(FleetPoolOrderLink row) { links.insert(row); }
/** 更新已锁定商业关联。 */
public void updateLink(FleetPoolOrderLink row) { links.updateById(row); }
/** 保存散板商业主单。 */
public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); }
/** 锁定同客户散板主单。 */
public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); }
/** 保存已锁定散板事实。 */
public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); }
/** 保存卡车商业主单。 */
public void insertTruck(UsTruckOrder order) { trucks.insert(order); }
/** 锁定同客户卡车主单。 */
public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); }
/** 保存已锁定卡车事实。 */
public void updateTruck(UsTruckOrder order) { trucks.updateById(order); }
/** 保存散板履约历史。 */
public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); }
/** 保存卡车履约历史。 */
public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); }
}