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
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -1487,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
requested_date date DEFAULT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
@@ -1516,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate (
@@ -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); }
}
@@ -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) { }
@@ -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(
@@ -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;
@@ -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); }
}
@@ -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, "本土散板订单不存在或无权查看");
}
@@ -2865,16 +2865,17 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2883,7 +2884,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id),
UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
KEY idx_ll_local_looseboard_list(customer_id,status,created_at),
KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date(customer_id,pickup_at,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
@@ -1455,7 +1455,6 @@ paths:
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
- { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
@@ -3664,25 +3663,28 @@ components:
LooseboardSummary:
type: object
description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。
required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES }
transportType: { type: string }
transportType: { type: string, nullable: true }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer }
totalWeightKg: { type: number }
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, nullable: true }
originContactPhone: { type: string, nullable: true }
destinationContactName: { type: string, nullable: true }
destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
status: { $ref: '#/components/schemas/LooseboardStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
@@ -4086,47 +4088,49 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate:
type: object
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
properties:
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
LooseboardUpdate:
type: object
required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm]
required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
productName: { type: string, nullable: true, maxLength: 255 }
estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
destinationContactName: { type: string, minLength: 1, maxLength: 128 }
destinationContactPhone: { type: string, minLength: 1, maxLength: 128 }
pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 }
remark: { type: string, nullable: true, maxLength: 1000 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
@@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest {
}
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK");
assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName());
assertThat(looseOrder.getDestinationContactName()).isNull();
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull();
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());
}
@Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op");
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class LocalLooseboardPersistenceTest {
/** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */
@Test
void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start));
// H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。
ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
ddl = ddl.replaceAll(",\\s*\\)$", ")");
new JdbcTemplate(ds).execute(ddl);
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper);
var order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1");
order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100");
order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101");
order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus("PENDING_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64));
repository.insertOrder(order);
var saved = repository.findNo("C-1", order.getOrderNo());
assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone());
assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone());
assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt());
assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip"));
saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15));
repository.updateOrder(saved);
var updated = repository.findKey("C-1", "roundtrip");
assertEquals("+1-555-0102", updated.getDestinationContactPhone());
assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt());
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.looseboard;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class LocalLooseboardRequestsTest {
/** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */
@Test void requiresContactsAndBothLocalTimes() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var json = new ObjectMapper().findAndRegisterModules();
ObjectNode valid = json.valueToTree(request());
assertTrue(validator.validate(request()).isEmpty());
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
ObjectNode missing = valid.deepCopy(); missing.remove(field);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field);
missing.remove("idempotencyKey"); missing.put("version", 0);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field);
}
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) {
ObjectNode blank = valid.deepCopy(); blank.put(field, " ");
assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field);
}
ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00");
assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty());
}
}
/** JSON 往返保持当地分钟,不包含已删除字段。 */
@Test void roundTripsLocalTimesAndSplitContacts() throws Exception {
var json = new ObjectMapper().findAndRegisterModules();
var valid = request(); var tree = json.valueToTree(valid);
var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class);
assertEquals(valid, restored);
for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field));
}
/** 创建具有独立预计托数和件数的最小完整需求。 */
private LocalLooseboardRequests.Create request() {
return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2",
LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1");
}
}
@@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest {
}
@Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception {
void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase();
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
assertTrue(block.contains("product_name varchar(255)"));
assertTrue(block.contains("estimated_pallet_count int"));
assertTrue(block.contains("pickup_at datetime(3)"));
assertTrue(block.contains("delivery_at datetime(3)"));
for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field));
for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*"));
org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type"));
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Forecast pallets must not invent YES or NO palletizing service");
}
@@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest {
@Test
void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP",
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO,
null, "USD", null, null, null, null);
var v = create();
var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value());
}
private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null,
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
}
private AuthenticatedUser user(Set<String> permissions) {
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.HashMap;
@@ -54,11 +55,13 @@ class LocalLooseboardServiceTest {
void setUp() {
service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "histories", histories);
ReflectionTestUtils.setField(repository, "operations", operations);
ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
@@ -86,31 +89,29 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
assertEquals("40HQ", detail.summary().containerType());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName());
assertEquals(3, detail.summary().estimatedPalletCount());
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> {
saved.set(call.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("default-type", null));
assertEquals("40HQ", detail.summary().containerType());
assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC")));
void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() {
var pickup = LocalDateTime.of(2030, 1, 2, 10, 30);
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1)));
org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1)));
}
@Test
@@ -193,27 +194,22 @@ class LocalLooseboardServiceTest {
}
private LocalLooseboardRequests.Create create(String key) {
return create(key, "40HQ");
}
private LocalLooseboardRequests.Create create(String key, String containerType) {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", containerType, 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B",
"家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key);
}
private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null);
var v = create("unused");
return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
}
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES");
order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
@@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest {
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null);
null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null);
@@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest {
@EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration")
@Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class,
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class})
WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
@@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest {
if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
"SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE,
"洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
MARKER, MARKER + "-LOOSEBOARD-KEY"));
}
@@ -282,11 +282,11 @@ function truckPayload(context) {
function looseboardPayload(context) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
productName: '散板验收货物', estimatedPalletCount: 2,
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001',
destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00',
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
}
}