feat(looseboard): require route contacts and pickup delivery times
This commit is contained in:
+55
-38
@@ -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);}
|
||||
}
|
||||
|
||||
+56
@@ -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); }
|
||||
}
|
||||
+41
-20
@@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
public final class LocalLooseboardRequests {
|
||||
/** 仅作为散板请求或响应模型的命名空间。 */
|
||||
private LocalLooseboardRequests() { }
|
||||
|
||||
public record Create(
|
||||
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
|
||||
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
|
||||
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
|
||||
@NotBlank @Size(max = 255) String origin,
|
||||
@Size(max = 500) String originAddress,
|
||||
@NotBlank @Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Size(max = 32) String containerType,
|
||||
@Size(max = 255) String productName,
|
||||
@NotNull @Min(0) Integer estimatedPalletCount,
|
||||
@NotNull @Min(0) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||
@DecimalMin("0.00") BigDecimal budgetAmount,
|
||||
@Size(min = 3, max = 3) String currency,
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 128) String contactInfo,
|
||||
LocalDate requestedDate,
|
||||
@Pattern(regexp = "USD|CNY") String currency,
|
||||
@NotBlank @Size(max = 128) String originContactName,
|
||||
@NotBlank @Size(max = 128) String originContactPhone,
|
||||
@NotBlank @Size(max = 128) String destinationContactName,
|
||||
@NotBlank @Size(max = 128) String destinationContactPhone,
|
||||
@NotNull LocalDateTime pickupAt,
|
||||
@NotNull LocalDateTime deliveryAt,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {
|
||||
/** 取送时间均须填写,送货不能早于取货。 */
|
||||
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
|
||||
public boolean isScheduleValid() {
|
||||
return validSchedule(pickupAt, deliveryAt);
|
||||
}
|
||||
}
|
||||
|
||||
public record Update(
|
||||
@NotNull @Min(0) Long version,
|
||||
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
|
||||
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
|
||||
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
|
||||
@NotBlank @Size(max = 255) String origin,
|
||||
@Size(max = 500) String originAddress,
|
||||
@NotBlank @Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Size(max = 32) String containerType,
|
||||
@Size(max = 255) String productName,
|
||||
@NotNull @Min(0) Integer estimatedPalletCount,
|
||||
@NotNull @Min(0) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||
@DecimalMin("0.00") BigDecimal budgetAmount,
|
||||
@Size(min = 3, max = 3) String currency,
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 128) String contactInfo,
|
||||
LocalDate requestedDate,
|
||||
@Size(max = 1000) String remark) { }
|
||||
@Pattern(regexp = "USD|CNY") String currency,
|
||||
@NotBlank @Size(max = 128) String originContactName,
|
||||
@NotBlank @Size(max = 128) String originContactPhone,
|
||||
@NotBlank @Size(max = 128) String destinationContactName,
|
||||
@NotBlank @Size(max = 128) String destinationContactPhone,
|
||||
@NotNull LocalDateTime pickupAt,
|
||||
@NotNull LocalDateTime deliveryAt,
|
||||
@Size(max = 1000) String remark) {
|
||||
/** 取送时间均须填写,送货不能早于取货。 */
|
||||
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
|
||||
public boolean isScheduleValid() {
|
||||
return validSchedule(pickupAt, deliveryAt);
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一校验平台及客户新增、编辑的需求时间。 */
|
||||
public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) {
|
||||
return pickup != null && delivery != null && !delivery.isBefore(pickup);
|
||||
}
|
||||
|
||||
public record Query(
|
||||
@Min(1) long page,
|
||||
@@ -61,7 +84,6 @@ public final class LocalLooseboardRequests {
|
||||
String orderNo,
|
||||
String routeKeyword,
|
||||
String status,
|
||||
String transportMode,
|
||||
LocalDate requestedFrom,
|
||||
LocalDate requestedTo) { }
|
||||
|
||||
@@ -71,7 +93,6 @@ public final class LocalLooseboardRequests {
|
||||
String orderNo,
|
||||
String routeKeyword,
|
||||
String status,
|
||||
String transportMode,
|
||||
LocalDate requestedFrom,
|
||||
LocalDate requestedTo) { }
|
||||
|
||||
|
||||
+5
-5
@@ -1,19 +1,19 @@
|
||||
package com.baiyue.lastleg.looseboard.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class LocalLooseboardResponses {
|
||||
/** 仅作为散板请求或响应模型的命名空间。 */
|
||||
private LocalLooseboardResponses() { }
|
||||
|
||||
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */
|
||||
/** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */
|
||||
public record Summary(
|
||||
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
|
||||
String transportType, String origin, String destination, String containerType, int packageCount,
|
||||
Long id, String customerId, String orderNo, String palletRequired,
|
||||
String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount,
|
||||
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
|
||||
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
|
||||
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
|
||||
|
||||
public record History(
|
||||
|
||||
+14
-10
@@ -1,12 +1,13 @@
|
||||
package com.baiyue.lastleg.looseboard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@@ -16,24 +17,27 @@ public class LocalLooseboardOrder {
|
||||
private String customerId;
|
||||
private Long lastlegOrderId;
|
||||
private String orderNo;
|
||||
private String transportMode;
|
||||
private String palletRequired;
|
||||
private String transportType;
|
||||
private String origin;
|
||||
private String originAddress;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress;
|
||||
private String destination;
|
||||
private String destinationAddress;
|
||||
private String containerType;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName;
|
||||
private Integer estimatedPalletCount;
|
||||
private Integer packageCount;
|
||||
private BigDecimal totalWeightKg;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private BigDecimal budgetAmount;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount;
|
||||
private String currency;
|
||||
private String contactName;
|
||||
private String contactInfo;
|
||||
private LocalDate requestedDate;
|
||||
private String originContactName;
|
||||
private String originContactPhone;
|
||||
private String destinationContactName;
|
||||
private String destinationContactPhone;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt;
|
||||
private String status;
|
||||
private String remark;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.looseboard.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.looseboard.entity.*;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import org.springframework.util.StringUtils;
|
||||
import com.baiyue.lastleg.looseboard.mapper.*;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class LocalLooseboardRepository {
|
||||
@Resource private LocalLooseboardOrderMapper orders;
|
||||
@Resource private LocalLooseboardHistoryMapper histories;
|
||||
@Resource private LocalLooseboardOperationMapper operations;
|
||||
@Resource private LastlegOrderMapper lastlegOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||
/** 按客户和创建幂等键读取订单。 */
|
||||
public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); }
|
||||
/** 按客户和业务单号读取订单。 */
|
||||
public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); }
|
||||
/** 锁定同客户订单保护编辑及状态推进。 */
|
||||
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
|
||||
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
|
||||
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
|
||||
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
|
||||
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
||||
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
||||
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
||||
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
||||
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
||||
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
|
||||
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
|
||||
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
|
||||
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
|
||||
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
}
|
||||
/** 规范查询文字,缺失值不参与筛选。 */
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
/** 保存散板需求。 */
|
||||
public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); }
|
||||
/** 更新已锁定需求。 */
|
||||
public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); }
|
||||
/** 读取同客户订单历史。 */
|
||||
public List<LocalLooseboardHistory> histories(String customer, Long id) { return histories.listOrder(customer, id); }
|
||||
/** 保存不可变需求历史。 */
|
||||
public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); }
|
||||
/** 读取状态命令幂等记录。 */
|
||||
public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); }
|
||||
/** 保存命令幂等记录。 */
|
||||
public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); }
|
||||
/** 保存唯一尾程来源。 */
|
||||
public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); }
|
||||
/** 读取关联尾程来源。 */
|
||||
public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); }
|
||||
/** 更新关联尾程事实。 */
|
||||
public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); }
|
||||
/** 保存尾程状态历史。 */
|
||||
public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); }
|
||||
}
|
||||
+82
-73
@@ -1,23 +1,17 @@
|
||||
package com.baiyue.lastleg.looseboard.service;
|
||||
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -48,36 +42,32 @@ public class LocalLooseboardService {
|
||||
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, "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),
|
||||
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
|
||||
IN_TRANSIT, Set.of(COMPLETED));
|
||||
|
||||
@Resource private LocalLooseboardOrderMapper orders;
|
||||
@Resource private LocalLooseboardHistoryMapper histories;
|
||||
@Resource private LocalLooseboardOperationMapper operations;
|
||||
@Resource private LastlegOrderMapper lastlegOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||
@Resource private LocalLooseboardRepository repository;
|
||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||
LocalLooseboardRequests.Create request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
LocalLooseboardOrder replay = orders.findKey(customerId, key);
|
||||
LocalLooseboardOrder replay = repository.findKey(customerId, key);
|
||||
if (replay != null) return replay(customerId, replay, requestHash);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalLooseboardOrder order = new LocalLooseboardOrder();
|
||||
order.setId(IdWorker.getId());
|
||||
order.setCustomerId(customerId);
|
||||
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
|
||||
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
|
||||
apply(order, request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
|
||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
|
||||
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
|
||||
order.setStatus(PENDING_ACCEPTANCE);
|
||||
order.setIdempotencyKey(key);
|
||||
order.setRequestHash(requestHash);
|
||||
@@ -88,9 +78,9 @@ public class LocalLooseboardService {
|
||||
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
|
||||
order.setLastlegOrderId(lastlegOrder.getId());
|
||||
try {
|
||||
orders.insert(order);
|
||||
repository.insertOrder(order);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
|
||||
LocalLooseboardOrder concurrent = repository.findKey(customerId, key);
|
||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||
throw exception;
|
||||
}
|
||||
@@ -101,44 +91,34 @@ 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>()
|
||||
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
||||
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
||||
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
||||
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
||||
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
||||
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
|
||||
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
|
||||
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
|
||||
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
|
||||
.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);
|
||||
Page<LocalLooseboardOrder> result = repository.page(query);
|
||||
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,
|
||||
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
|
||||
query.orderNo(), query.routeKeyword(), query.status(),
|
||||
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);
|
||||
LocalLooseboardOrder order = repository.findNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
return response(order);
|
||||
}
|
||||
|
||||
/** 锁定待接单需求并校验版本,保存变更前后快照。 */
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Update request) {
|
||||
@@ -148,27 +128,28 @@ public class LocalLooseboardService {
|
||||
throw conflict("Only pending-acceptance looseboard orders can be edited");
|
||||
}
|
||||
Map<String, Object> before = snapshot(order);
|
||||
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
|
||||
apply(order, request.origin(),
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
|
||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
|
||||
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
|
||||
Map<String, Object> after = snapshot(order);
|
||||
if (before.equals(after)) return response(order);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
repository.updateOrder(order);
|
||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||
null, changeDetail(before, after), null, now);
|
||||
return response(order);
|
||||
}
|
||||
|
||||
/** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.ChangeStatus request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
|
||||
LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key);
|
||||
if (existing != null) {
|
||||
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with a different looseboard status payload");
|
||||
@@ -186,81 +167,95 @@ public class LocalLooseboardService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setStatus(next);
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
repository.updateOrder(order);
|
||||
syncLastlegOrder(order, next, now);
|
||||
LocalLooseboardOperation operation = new LocalLooseboardOperation();
|
||||
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
|
||||
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
|
||||
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
|
||||
operation.setCreatedAt(now); operations.insert(operation);
|
||||
operation.setCreatedAt(now); repository.insertOperation(operation);
|
||||
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
|
||||
return response(order);
|
||||
}
|
||||
|
||||
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
|
||||
/** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */
|
||||
private void apply(LocalLooseboardOrder order,
|
||||
String origin, String originAddress, String destination, String destinationAddress,
|
||||
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
|
||||
String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg,
|
||||
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
|
||||
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
|
||||
order.setTransportMode(norm(mode));
|
||||
order.setPalletRequired(norm(palletRequired));
|
||||
order.setTransportType(norm(transportType));
|
||||
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) {
|
||||
if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone)
|
||||
|| !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) {
|
||||
throw badRequest("请填写起运地和目的地联系人及联系电话");
|
||||
}
|
||||
if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) {
|
||||
throw badRequest("取货和送货时间必填,送货时间不能早于取货时间");
|
||||
}
|
||||
order.setProductName(trim(productName));
|
||||
order.setEstimatedPalletCount(estimatedPalletCount);
|
||||
order.setOrigin(origin.trim());
|
||||
order.setOriginAddress(trim(originAddress));
|
||||
order.setDestination(destination.trim());
|
||||
order.setDestinationAddress(trim(destinationAddress));
|
||||
order.setContainerType(StringUtils.hasText(containerType)
|
||||
? ContainerSpecificationCatalog.require(containerType).containerType()
|
||||
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
|
||||
order.setPackageCount(packageCount);
|
||||
order.setTotalWeightKg(totalWeightKg);
|
||||
order.setTotalVolumeCbm(totalVolumeCbm);
|
||||
order.setBudgetAmount(budgetAmount);
|
||||
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
|
||||
order.setContactName(trim(contactName));
|
||||
order.setContactInfo(trim(contactInfo));
|
||||
order.setRequestedDate(requestedDate);
|
||||
order.setOriginContactName(originContactName.trim());
|
||||
order.setOriginContactPhone(originContactPhone.trim());
|
||||
order.setDestinationContactName(destinationContactName.trim());
|
||||
order.setDestinationContactPhone(destinationContactPhone.trim());
|
||||
order.setPickupAt(pickupAt);
|
||||
order.setDeliveryAt(deliveryAt);
|
||||
order.setRemark(trim(remark));
|
||||
}
|
||||
|
||||
/** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */
|
||||
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
|
||||
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
|
||||
return detail(customerId, order.getOrderNo());
|
||||
}
|
||||
|
||||
/** 锁定客户订单并拒绝池来源的普通编辑。 */
|
||||
private LocalLooseboardOrder lock(String customerId, String orderNo) {
|
||||
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
|
||||
LocalLooseboardOrder order = repository.lockNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 装配需求详情、真实承运安排与不可变历史。 */
|
||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
||||
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
|
||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||
histories.listOrder(order.getCustomerId(), order.getId()).stream()
|
||||
repository.histories(order.getCustomerId(), order.getId()).stream()
|
||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||
value.getChangeDetail(), value.getRemark(),
|
||||
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.getPalletRequired(), order.getTransportType(), order.getOrigin(),
|
||||
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
|
||||
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
|
||||
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
|
||||
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
|
||||
}
|
||||
|
||||
/** 写入带操作者和变更快照的订单历史。 */
|
||||
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
|
||||
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
|
||||
LocalLooseboardHistory history = new LocalLooseboardHistory();
|
||||
@@ -275,9 +270,10 @@ public class LocalLooseboardService {
|
||||
history.setChangeDetail(changeDetail);
|
||||
history.setRemark(trim(remark));
|
||||
history.setCreatedAt(now);
|
||||
histories.insert(history);
|
||||
repository.insertHistory(history);
|
||||
}
|
||||
|
||||
/** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */
|
||||
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
|
||||
LastlegOrder order = new LastlegOrder();
|
||||
order.setId(IdWorker.getId());
|
||||
@@ -302,21 +298,23 @@ public class LocalLooseboardService {
|
||||
order.setSourcePayloadHash("sha256:" + requestHash);
|
||||
order.setCreatedAt(now);
|
||||
order.setUpdatedAt(now);
|
||||
lastlegOrders.insert(order);
|
||||
repository.insertTailOrder(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 在同客户边界内同步关联尾程的商业状态。 */
|
||||
private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) {
|
||||
if (source.getLastlegOrderId() == null) return;
|
||||
LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId());
|
||||
LastlegOrder order = repository.tailOrder(source.getLastlegOrderId());
|
||||
if (order == null || !source.getCustomerId().equals(order.getCustomerId())) {
|
||||
throw conflict("Linked tail-leg order is missing");
|
||||
}
|
||||
order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status);
|
||||
order.setUpdatedAt(now);
|
||||
lastlegOrders.updateById(order);
|
||||
repository.updateTailOrder(order);
|
||||
}
|
||||
|
||||
/** 追加尾程状态历史。 */
|
||||
private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark,
|
||||
LocalDateTime now) {
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
|
||||
@@ -327,44 +325,47 @@ public class LocalLooseboardService {
|
||||
history.setEventId("LOCAL_LOOSEBOARD");
|
||||
history.setRemark(remark);
|
||||
history.setCreatedAt(now);
|
||||
lastlegHistories.insert(history);
|
||||
repository.insertTailHistory(history);
|
||||
}
|
||||
|
||||
/** 校验状态与取货日期筛选边界。 */
|
||||
private void validateQuery(LocalLooseboardRequests.Query query) {
|
||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
|
||||
throw badRequest("Unsupported looseboard order status");
|
||||
}
|
||||
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
|
||||
throw badRequest("Unsupported looseboard transport mode");
|
||||
}
|
||||
if (query.requestedFrom() != null && query.requestedTo() != null
|
||||
&& query.requestedFrom().isAfter(query.requestedTo())) {
|
||||
throw badRequest("requestedFrom must not be after requestedTo");
|
||||
}
|
||||
}
|
||||
|
||||
/** 冻结本次编辑的业务字段用于差异审计。 */
|
||||
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("transportMode", order.getTransportMode());
|
||||
value.put("palletRequired", order.getPalletRequired());
|
||||
value.put("transportType", order.getTransportType());
|
||||
value.put("origin", order.getOrigin());
|
||||
value.put("originAddress", order.getOriginAddress());
|
||||
value.put("destination", order.getDestination());
|
||||
value.put("destinationAddress", order.getDestinationAddress());
|
||||
value.put("containerType", order.getContainerType());
|
||||
value.put("productName", order.getProductName());
|
||||
value.put("estimatedPalletCount", order.getEstimatedPalletCount());
|
||||
value.put("packageCount", order.getPackageCount());
|
||||
value.put("totalWeightKg", order.getTotalWeightKg());
|
||||
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
|
||||
value.put("budgetAmount", order.getBudgetAmount());
|
||||
value.put("currency", order.getCurrency());
|
||||
value.put("contactName", order.getContactName());
|
||||
value.put("contactInfo", order.getContactInfo());
|
||||
value.put("requestedDate", order.getRequestedDate());
|
||||
value.put("originContactName", order.getOriginContactName());
|
||||
value.put("originContactPhone", order.getOriginContactPhone());
|
||||
value.put("destinationContactName", order.getDestinationContactName());
|
||||
value.put("destinationContactPhone", order.getDestinationContactPhone());
|
||||
value.put("pickupAt", order.getPickupAt());
|
||||
value.put("deliveryAt", order.getDeliveryAt());
|
||||
value.put("remark", order.getRemark());
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 生成字段前后值的不可变变更快照。 */
|
||||
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
|
||||
Map<String, Object> changes = new LinkedHashMap<>();
|
||||
before.forEach((field, oldValue) -> {
|
||||
@@ -383,6 +384,7 @@ public class LocalLooseboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算规范请求哈希以约束创建和状态命令幂等。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
@@ -392,20 +394,27 @@ public class LocalLooseboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 递增锁定订单的版本并更新时间。 */
|
||||
private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
|
||||
order.setVersion(value(order.getVersion()) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
}
|
||||
|
||||
/** 将未初始化的版本视作初始版本。 */
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
/** 规范选填文字,空白保存为未知。 */
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
/** 统一内部枚举和币种大小写。 */
|
||||
private static String norm(String value) { return value.trim().toUpperCase(); }
|
||||
/** 返回明确的输入校验失败。 */
|
||||
private static ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
/** 返回版本、幂等或状态冲突。 */
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
/** 隐藏客户范围外订单并返回未找到。 */
|
||||
private static ResponseStatusException notFound() {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user