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/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 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 `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', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', 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;拼车来源未提供时为空', 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 varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT 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 '货物件数', package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' 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 '总体积(立方米)', total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价', budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
requested_date date DEFAULT 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 '本土散板订单状态', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT 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_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id), 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_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='本土散板订单'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate ( 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.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.looseboard.entity.*; import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*; import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus; 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. */ /** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service @Service
public class FleetPoolOrderBridge { public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links; private final FleetPoolOrderRepository repository;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final FleetPoolOrderNumberGenerator numbers; private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json; private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence; private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose, /** 注入同库商业委托仓储及履约协作服务。 */
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks, public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; this.repository=repository;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources; this.appointmentSources=appointmentSources;
} }
/** 批量读取预订对应的当前商业履约阶段。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) { public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of(); if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>(); 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); return Map.copyOf(result);
} }
/** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in); 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);} if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); 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"; String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) { if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport); order.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification. // 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
order.setPalletRequired(null);order.setContainerType(null); order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount()); 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.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.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); 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 { } else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); 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.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.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); 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); return ref(row);
} }
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId, public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) { String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo); var row=repository.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;} if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId); owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致"); 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(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货"); 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())) { 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("散板商业单已存在另一尾程来源"); 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); appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
} }
/** 按真实来货开始事件推进收货状态。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) { 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()); if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
} }
/** 沿已确认实托及实占托位保存实际收货。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) { public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId); var row=requiredBooking(bookingId);
@@ -123,6 +123,7 @@ public class FleetPoolOrderBridge {
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces); row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now()); move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
} }
/** 沿真实排车关联推进商业履约。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId); 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("商业单仅实收核量后可关联发运"); if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now()); row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
} }
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId); 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("商业单当前不能解除发运关联"); if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now()); row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
} }
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) { public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId); 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("池来源已登记来货,首期不支持取消或跨车改配"); if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now()); move(row,"CANCELLED",operator,reason,LocalDateTime.now());
} }
/** 沿真实整车发车事实推进运输状态。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) { 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(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue; if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运"); if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at); move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
} }
} }
/** 核对全部签收证据后推进商业完成。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) { public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return; 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(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整"); if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue; if("COMPLETED".equals(row.getFulfillmentStage()))continue;
@@ -165,12 +170,14 @@ public class FleetPoolOrderBridge {
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at); move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
} }
} }
/** 校验商业履约边界或组装真实业务引用。 */
private boolean wholeSourceSigned(FleetPoolOrderLink row) { private boolean wholeSourceSigned(FleetPoolOrderLink row) {
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount()); return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
} }
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) { public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>(); 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) { static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType()) 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))) ||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份"); throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
} }
/** 在同一事务更新商业阶段与不可变历史。 */
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) { private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return; 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())) { if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order); order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order);
} else { } 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); order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount()); if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0: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); history(row,from,stage,operator,reason,at);
} }
/** 保存来源业务号和履约事件的不可变记录。 */
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) { private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to; String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); 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 { } else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); 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 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 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 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 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 boolean blank(String value){return value==null||value.isBlank();}
/** 校验商业履约边界或组装真实业务引用。 */
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");} private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
/** 校验商业履约边界或组装真实业务引用。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} 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.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import jakarta.validation.constraints.AssertTrue;
import com.fasterxml.jackson.annotation.JsonIgnore;
public final class LocalLooseboardRequests { public final class LocalLooseboardRequests {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardRequests() { } private LocalLooseboardRequests() { }
public record Create( 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, @NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination, @NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @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 @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Pattern(regexp = "USD|CNY") String currency,
@Size(max = 128) String contactName, @NotBlank @Size(max = 128) String originContactName,
@Size(max = 128) String contactInfo, @NotBlank @Size(max = 128) String originContactPhone,
LocalDate requestedDate, @NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark, @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( public record Update(
@NotNull @Min(0) Long version, @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, @NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination, @NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @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 @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Pattern(regexp = "USD|CNY") String currency,
@Size(max = 128) String contactName, @NotBlank @Size(max = 128) String originContactName,
@Size(max = 128) String contactInfo, @NotBlank @Size(max = 128) String originContactPhone,
LocalDate requestedDate, @NotBlank @Size(max = 128) String destinationContactName,
@Size(max = 1000) String remark) { } @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( public record Query(
@Min(1) long page, @Min(1) long page,
@@ -61,7 +84,6 @@ public final class LocalLooseboardRequests {
String orderNo, String orderNo,
String routeKeyword, String routeKeyword,
String status, String status,
String transportMode,
LocalDate requestedFrom, LocalDate requestedFrom,
LocalDate requestedTo) { } LocalDate requestedTo) { }
@@ -71,7 +93,6 @@ public final class LocalLooseboardRequests {
String orderNo, String orderNo,
String routeKeyword, String routeKeyword,
String status, String status,
String transportMode,
LocalDate requestedFrom, LocalDate requestedFrom,
LocalDate requestedTo) { } LocalDate requestedTo) { }
@@ -1,19 +1,19 @@
package com.baiyue.lastleg.looseboard.dto; package com.baiyue.lastleg.looseboard.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
public final class LocalLooseboardResponses { public final class LocalLooseboardResponses {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardResponses() { } private LocalLooseboardResponses() { }
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */ /** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */
public record Summary( public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired, Long id, String customerId, String orderNo, String palletRequired,
String transportType, String origin, String destination, String containerType, int packageCount, String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, 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) { } LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History( public record History(
@@ -1,12 +1,13 @@
package com.baiyue.lastleg.looseboard.entity; package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType; 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.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Data @Data
@@ -16,24 +17,27 @@ public class LocalLooseboardOrder {
private String customerId; private String customerId;
private Long lastlegOrderId; private Long lastlegOrderId;
private String orderNo; private String orderNo;
private String transportMode;
private String palletRequired; private String palletRequired;
private String transportType; private String transportType;
private String origin; private String origin;
private String originAddress; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress;
private String destination; private String destination;
private String destinationAddress; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress;
private String containerType; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName;
private Integer estimatedPalletCount;
private Integer packageCount; private Integer packageCount;
private BigDecimal totalWeightKg; private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm; private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount; @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount;
private String currency; private String currency;
private String contactName; private String originContactName;
private String contactInfo; private String originContactPhone;
private LocalDate requestedDate; 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 status;
private String remark; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey; private String idempotencyKey;
private String requestHash; private String requestHash;
private String createdBy; 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; 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.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; 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.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; 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.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -48,36 +42,32 @@ public class LocalLooseboardService {
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED"; 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> 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( private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED)); IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardOrderMapper orders; @Resource private LocalLooseboardRepository repository;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
@Transactional @Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId, public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) { LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
LocalLooseboardOrder replay = orders.findKey(customerId, key); LocalLooseboardOrder replay = repository.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash); if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalLooseboardOrder order = new LocalLooseboardOrder(); LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId()); order.setId(IdWorker.getId());
order.setCustomerId(customerId); order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), 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.setStatus(PENDING_ACCEPTANCE);
order.setIdempotencyKey(key); order.setIdempotencyKey(key);
order.setRequestHash(requestHash); order.setRequestHash(requestHash);
@@ -88,9 +78,9 @@ public class LocalLooseboardService {
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
order.setLastlegOrderId(lastlegOrder.getId()); order.setLastlegOrderId(lastlegOrder.getId());
try { try {
orders.insert(order); repository.insertOrder(order);
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
LocalLooseboardOrder concurrent = orders.findKey(customerId, key); LocalLooseboardOrder concurrent = repository.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash); if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception; throw exception;
} }
@@ -101,44 +91,34 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
} }
/** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) { public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query); validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>() Page<LocalLooseboardOrder> result = repository.page(query);
.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);
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList()); 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(), return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages()); 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) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId, public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) { LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, 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())); query.requestedFrom(), query.requestedTo()));
} }
/** 按客户与业务单号读取需求及历史。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) { 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(); if (order == null) throw notFound();
return response(order); return response(order);
} }
/** 锁定待接单需求并校验版本,保存变更前后快照。 */
@Transactional @Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) { LocalLooseboardRequests.Update request) {
@@ -148,27 +128,28 @@ public class LocalLooseboardService {
throw conflict("Only pending-acceptance looseboard orders can be edited"); throw conflict("Only pending-acceptance looseboard orders can be edited");
} }
Map<String, Object> before = snapshot(order); Map<String, Object> before = snapshot(order);
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), 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); Map<String, Object> after = snapshot(order);
if (before.equals(after)) return response(order); if (before.equals(after)) return response(order);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
touch(order, now); touch(order, now);
orders.updateById(order); repository.updateOrder(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now); null, changeDetail(before, after), null, now);
return response(order); return response(order);
} }
/** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */
@Transactional @Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo); LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key); LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key);
if (existing != null) { if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) { if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload"); throw conflict("Idempotency key was reused with a different looseboard status payload");
@@ -186,81 +167,95 @@ public class LocalLooseboardService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
order.setStatus(next); order.setStatus(next);
touch(order, now); touch(order, now);
orders.updateById(order); repository.updateOrder(order);
syncLastlegOrder(order, next, now); syncLastlegOrder(order, next, now);
LocalLooseboardOperation operation = new LocalLooseboardOperation(); LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS"); operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); 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); history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order); 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 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, java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) { String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) {
order.setTransportMode(norm(mode)); if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone)
order.setPalletRequired(norm(palletRequired)); || !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) {
order.setTransportType(norm(transportType)); throw badRequest("请填写起运地和目的地联系人及联系电话");
}
if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) {
throw badRequest("取货和送货时间必填,送货时间不能早于取货时间");
}
order.setProductName(trim(productName));
order.setEstimatedPalletCount(estimatedPalletCount);
order.setOrigin(origin.trim()); order.setOrigin(origin.trim());
order.setOriginAddress(trim(originAddress)); order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim()); order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress)); order.setDestinationAddress(trim(destinationAddress));
order.setContainerType(StringUtils.hasText(containerType)
? ContainerSpecificationCatalog.require(containerType).containerType()
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
order.setPackageCount(packageCount); order.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg); order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm); order.setTotalVolumeCbm(totalVolumeCbm);
order.setBudgetAmount(budgetAmount); order.setBudgetAmount(budgetAmount);
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD"); order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
order.setContactName(trim(contactName)); order.setOriginContactName(originContactName.trim());
order.setContactInfo(trim(contactInfo)); order.setOriginContactPhone(originContactPhone.trim());
order.setRequestedDate(requestedDate); order.setDestinationContactName(destinationContactName.trim());
order.setDestinationContactPhone(destinationContactPhone.trim());
order.setPickupAt(pickupAt);
order.setDeliveryAt(deliveryAt);
order.setRemark(trim(remark)); order.setRemark(trim(remark));
} }
/** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) { 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"); if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
} }
/** 锁定客户订单并拒绝池来源的普通编辑。 */
private LocalLooseboardOrder lock(String customerId, String orderNo) { private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo); LocalLooseboardOrder order = repository.lockNo(customerId, orderNo);
if (order == null) throw notFound(); if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId()); fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order; return order;
} }
/** 装配需求详情、真实承运安排与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), 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(), .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(), value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList()); value.getCreatedAt())).toList());
} }
/** 构造同客户商业订单的承运查询身份。 */
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) { 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()); return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
} }
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order))); return summary(order, fleetPool.arrangement(poolKey(order)));
} }
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), 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); value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
} }
/** 写入带操作者和变更快照的订单历史。 */
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) { String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory(); LocalLooseboardHistory history = new LocalLooseboardHistory();
@@ -275,9 +270,10 @@ public class LocalLooseboardService {
history.setChangeDetail(changeDetail); history.setChangeDetail(changeDetail);
history.setRemark(trim(remark)); history.setRemark(trim(remark));
history.setCreatedAt(now); history.setCreatedAt(now);
histories.insert(history); repository.insertHistory(history);
} }
/** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder(); LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setId(IdWorker.getId());
@@ -302,21 +298,23 @@ public class LocalLooseboardService {
order.setSourcePayloadHash("sha256:" + requestHash); order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now); order.setCreatedAt(now);
order.setUpdatedAt(now); order.setUpdatedAt(now);
lastlegOrders.insert(order); repository.insertTailOrder(order);
return order; return order;
} }
/** 在同客户边界内同步关联尾程的商业状态。 */
private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) { private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) {
if (source.getLastlegOrderId() == null) return; 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())) { if (order == null || !source.getCustomerId().equals(order.getCustomerId())) {
throw conflict("Linked tail-leg order is missing"); throw conflict("Linked tail-leg order is missing");
} }
order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status); order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status);
order.setUpdatedAt(now); order.setUpdatedAt(now);
lastlegOrders.updateById(order); repository.updateTailOrder(order);
} }
/** 追加尾程状态历史。 */
private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark, private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark,
LocalDateTime now) { LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -327,44 +325,47 @@ public class LocalLooseboardService {
history.setEventId("LOCAL_LOOSEBOARD"); history.setEventId("LOCAL_LOOSEBOARD");
history.setRemark(remark); history.setRemark(remark);
history.setCreatedAt(now); history.setCreatedAt(now);
lastlegHistories.insert(history); repository.insertTailHistory(history);
} }
/** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) { private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order 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 if (query.requestedFrom() != null && query.requestedTo() != null
&& query.requestedFrom().isAfter(query.requestedTo())) { && query.requestedFrom().isAfter(query.requestedTo())) {
throw badRequest("requestedFrom must not be after requestedTo"); throw badRequest("requestedFrom must not be after requestedTo");
} }
} }
/** 冻结本次编辑的业务字段用于差异审计。 */
private Map<String, Object> snapshot(LocalLooseboardOrder order) { private Map<String, Object> snapshot(LocalLooseboardOrder order) {
Map<String, Object> value = new LinkedHashMap<>(); Map<String, Object> value = new LinkedHashMap<>();
value.put("transportMode", order.getTransportMode());
value.put("palletRequired", order.getPalletRequired()); value.put("palletRequired", order.getPalletRequired());
value.put("transportType", order.getTransportType()); value.put("transportType", order.getTransportType());
value.put("origin", order.getOrigin()); value.put("origin", order.getOrigin());
value.put("originAddress", order.getOriginAddress()); value.put("originAddress", order.getOriginAddress());
value.put("destination", order.getDestination()); value.put("destination", order.getDestination());
value.put("destinationAddress", order.getDestinationAddress()); 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("packageCount", order.getPackageCount());
value.put("totalWeightKg", order.getTotalWeightKg()); value.put("totalWeightKg", order.getTotalWeightKg());
value.put("totalVolumeCbm", order.getTotalVolumeCbm()); value.put("totalVolumeCbm", order.getTotalVolumeCbm());
value.put("budgetAmount", order.getBudgetAmount()); value.put("budgetAmount", order.getBudgetAmount());
value.put("currency", order.getCurrency()); value.put("currency", order.getCurrency());
value.put("contactName", order.getContactName()); value.put("originContactName", order.getOriginContactName());
value.put("contactInfo", order.getContactInfo()); value.put("originContactPhone", order.getOriginContactPhone());
value.put("requestedDate", order.getRequestedDate()); 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()); value.put("remark", order.getRemark());
return value; return value;
} }
/** 生成字段前后值的不可变变更快照。 */
private String changeDetail(Map<String, Object> before, Map<String, Object> after) { private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>(); Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> { before.forEach((field, oldValue) -> {
@@ -383,6 +384,7 @@ public class LocalLooseboardService {
} }
} }
/** 计算规范请求哈希以约束创建和状态命令幂等。 */
private String hash(Object value) { private String hash(Object value) {
try { try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -392,20 +394,27 @@ public class LocalLooseboardService {
} }
} }
/** 递增锁定订单的版本并更新时间。 */
private static void touch(LocalLooseboardOrder order, LocalDateTime now) { private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1); order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now); order.setUpdatedAt(now);
} }
/** 将未初始化的版本视作初始版本。 */
private static long value(Long value) { return value == null ? 0 : value; } 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 trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 统一内部枚举和币种大小写。 */
private static String norm(String value) { return value.trim().toUpperCase(); } private static String norm(String value) { return value.trim().toUpperCase(); }
/** 返回明确的输入校验失败。 */
private static ResponseStatusException badRequest(String message) { private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
} }
/** 返回版本、幂等或状态冲突。 */
private static ResponseStatusException conflict(String message) { private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message); return new ResponseStatusException(HttpStatus.CONFLICT, message);
} }
/** 隐藏客户范围外订单并返回未找到。 */
private static ResponseStatusException notFound() { private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看"); 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 ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode 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) 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 '起运详细地址', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_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_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) 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 '预算币种', 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 '联系人联系方式', origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' 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 '创建幂等键', 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', 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 '创建时间', 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_lastleg(lastleg_order_id),
UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), 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_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='本土散板订单'; ) ENGINE=InnoDB COMMENT='本土散板订单';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
@@ -1455,7 +1455,6 @@ paths:
- { name: orderNo, in: query, schema: { type: string } } - { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } - { 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: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, 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' } } } } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
@@ -3664,25 +3663,28 @@ components:
LooseboardSummary: LooseboardSummary:
type: object type: object
description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。 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: properties:
id: { type: string, pattern: '^[0-9]+$' } id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string } customerId: { type: string }
orderNo: { type: string } orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES } palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES }
transportType: { type: string } transportType: { type: string, nullable: true }
origin: { type: string } origin: { type: string }
destination: { 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 } packageCount: { type: integer }
totalWeightKg: { type: number } totalWeightKg: { type: number }
totalVolumeCbm: { type: number } totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true } budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true } currency: { type: string, nullable: true }
contactName: { type: string, nullable: true } originContactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true } originContactPhone: { type: string, nullable: true }
requestedDate: { type: string, format: date, 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' } status: { $ref: '#/components/schemas/LooseboardStatus' }
version: { type: integer, format: int64 } version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
@@ -4086,47 +4088,49 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate: LooseboardCreate:
type: object 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: 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 } origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 } originContactName: { type: string, minLength: 1, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 } originContactPhone: { type: string, minLength: 1, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true } 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 } remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
LooseboardUpdate: LooseboardUpdate:
type: object 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: properties:
version: { type: integer, format: int64, minimum: 0 } 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 } origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 } originContactName: { type: string, minLength: 1, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 } originContactPhone: { type: string, minLength: 1, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true } 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 } remark: { type: string, nullable: true, maxLength: 1000 }
securitySchemes: securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderBridge service; FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){ @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(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
@@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest {
} }
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){ @Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op"); var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull(); assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK"); assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName());
assertThat(looseOrder.getDestinationContactName()).isNull();
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel()); assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount()); assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm()); assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull(); 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"); 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(){ @Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op"); 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 @Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception { void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception {
String baseline; String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); 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); String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) { 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(); 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(), assertTrue(block.contains("product_name varchar(255)"));
"Unknown container must remain null when MyBatis omits its insert column"); 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(), 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"); "Forecast pallets must not invent YES or NO palletizing service");
} }
@@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest {
@Test @Test
void updateRequiresIndependentEditPermission() { void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP", var v = create();
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, 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());
null, "USD", null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class, ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value()); assertEquals(403, error.getStatusCode().value());
} }
private LocalLooseboardRequests.Create create() { private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null, 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");
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
} }
private AuthenticatedUser user(Set<String> permissions) { private AuthenticatedUser user(Set<String> permissions) {
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.HashMap; import java.util.HashMap;
@@ -54,11 +55,13 @@ class LocalLooseboardServiceTest {
void setUp() { void setUp() {
service = new LocalLooseboardService(); service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders); var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); ReflectionTestUtils.setField(repository, "histories", histories);
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(repository, "operations", operations);
ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
@@ -86,31 +89,29 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId()); assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode()); assertEquals("Alice", detail.summary().originContactName());
assertEquals("40HQ", detail.summary().containerType()); 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 -> verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType()) "LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001") && value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null && value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value -> verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType()))); "CREATE".equals(value.getOperationType())));
} }
@Test @Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>(); var pickup = LocalDateTime.of(2030, 1, 2, 10, 30);
when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
saved.set(call.getArgument(0)); org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null));
return 1; org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
}); org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1)));
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1)));
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")));
} }
@Test @Test
@@ -193,27 +194,22 @@ class LocalLooseboardServiceTest {
} }
private LocalLooseboardRequests.Create create(String key) { private LocalLooseboardRequests.Create create(String key) {
return create(key, "40HQ"); 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.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);
} }
private LocalLooseboardRequests.Update update(long version) { private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", var v = create("unused");
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null); v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
} }
private LocalLooseboardOrder order(String status, long version) { private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder(); LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); 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.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now()); order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
@@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest {
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() { void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT"); AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20, 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, InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, 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") @EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration")
@Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class, @Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class,
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.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", @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.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.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")) { if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create( looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
"SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles", "洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"), "ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE, new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
MARKER, MARKER + "-LOOSEBOARD-KEY")); MARKER, MARKER + "-LOOSEBOARD-KEY"));
} }
@@ -282,11 +282,11 @@ function truckPayload(context) {
function looseboardPayload(context) { function looseboardPayload(context) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
return { return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '散板验收货物', estimatedPalletCount: 2,
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80, destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80,
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), 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), remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
} }
} }