feat(lastleg): query commercial sources independently of fulfillment types

This commit is contained in:
2026-10-09 16:12:02 +08:00
parent c4a7c2a22d
commit 3b5454fb99
13 changed files with 156 additions and 19 deletions
@@ -37,7 +37,10 @@ public final class LastlegOrderRequests {
Boolean detained,
LocalDateTime createdFrom,
LocalDateTime createdTo,
SailingStage sailingStage
SailingStage sailingStage,
@Size(max = 64) String businessType,
@Size(max = 128) String businessOrderNo,
@Size(max = 128) String externalBookingNo
) {
public Query {
if (page == null || page == 0) page = 1L;
@@ -74,6 +74,9 @@ public record LastlegOrderSummary(
LocalDateTime createdAt,
LocalDateTime updatedAt,
@Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序")
List<RelabelSubOrder> relabelApplications
List<RelabelSubOrder> relabelApplications,
BusinessSource businessSource
) {
@Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立")
public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { }
}
@@ -10,7 +10,7 @@ import java.util.Arrays;
final class LastlegExportColumns {
private LastlegExportColumns() { }
private static final Map<String, String> DEFINITIONS = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:履约类型,businessSource.label:业务来源,businessSource.businessOrderNo:关联商业单号,businessSource.externalBookingNo:外接预订号,inboundCode:入仓码,sourceOrderNo:来源订单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"),
Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"),
@@ -16,6 +16,25 @@ import java.math.BigDecimal;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
// Correlate both order identity and customer; never infer a business source from remarks.
String POOL_SOURCE_WHERE = " FROM ll_fleet_pool_order_link l WHERE l.lastleg_order_id=ll_order.id " +
"AND l.customer_id=ll_order.customer_id AND ll_order.order_type='WAREHOUSE_TRANSFER'";
String BUSINESS_TYPE_SQL = "COALESCE((SELECT l.business_type" + POOL_SOURCE_WHERE + "),ll_order.order_type)";
String BUSINESS_ORDER_NO_SQL = "COALESCE((SELECT l.business_order_no" + POOL_SOURCE_WHERE +
"),CASE WHEN ll_order.order_type='LOCAL_LOOSEBOARD' THEN ll_order.source_order_no END)";
String EXTERNAL_BOOKING_NO_SQL = "(SELECT b.booking_no FROM ll_fleet_external_booking b " +
"WHERE b.customer_id=ll_order.customer_id AND b.incoming_no=ll_order.source_order_no " +
"AND ll_order.order_type='WAREHOUSE_TRANSFER')";
@Select("<script>SELECT ll_order.id AS orderId," + BUSINESS_TYPE_SQL + " AS businessType," +
BUSINESS_ORDER_NO_SQL + " AS businessOrderNo," + EXTERNAL_BOOKING_NO_SQL + " AS externalBookingNo," +
"EXISTS(SELECT 1" + POOL_SOURCE_WHERE + ") AS poolSource FROM ll_order WHERE ll_order.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<BusinessSourceRow> findBusinessSources(@Param("orderIds") List<Long> orderIds);
record BusinessSourceRow(Long orderId, String businessType, String businessOrderNo,
String externalBookingNo, boolean poolSource) { }
// Manual node snapshots replace container nodes permanently for the order.
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
@@ -100,11 +100,12 @@ public class LastlegOrderService {
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(page.getRecords());
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()),
relabels.getOrDefault(order.getId(), List.of()))).toList(),
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -360,7 +361,8 @@ public class LastlegOrderService {
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"),
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of())), manualNodes, cargo, history.stream()
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()),
businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -549,6 +551,9 @@ public class LastlegOrderService {
.or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " +
"AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo))
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_SQL + "={0}", query.businessType())
.apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.businessOrderNo())
.apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo())
.apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL,
query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0)
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
@@ -623,10 +628,23 @@ public class LastlegOrderService {
return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId));
}
private Map<Long, LastlegOrderSummary.BusinessSource> businessSources(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
return orderMapper.findBusinessSources(orders.stream().map(LastlegOrder::getId).toList()).stream()
.collect(Collectors.toMap(LastlegOrderMapper.BusinessSourceRow::orderId, row -> {
String label = "US_TRUCK".equals(row.businessType()) ? "本土卡车" : LastlegOrderType.labelOf(row.businessType());
if (row.poolSource()) label += " · 拼车需求池";
else if ("LOCAL_LOOSEBOARD".equals(row.businessType())) label += " · 独立下单";
else if (StringUtils.hasText(row.externalBookingNo())) label += " · 外接来货";
return new LastlegOrderSummary.BusinessSource(row.businessType(), label, row.businessOrderNo(), row.externalBookingNo());
}));
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode, String dispatchStatus,
LastlegOrderMapper.OrderWeightRow weight,
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications) {
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications,
LastlegOrderSummary.BusinessSource businessSource) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
@@ -644,7 +662,7 @@ public class LastlegOrderService {
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications);
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource);
}
private static Integer packageDifference(LastlegOrder order) {
@@ -6,7 +6,7 @@ info:
Admin 导出统一通过 POST /api/platform/v1/files/export-tasks 创建 XLSX 任务。
File 按导出类型校验原业务 export 权限,通过内部令牌分批读取 Lastleg;Provider 不经公网 Gateway。
parameters 与对应业务列表筛选一致,不包含 page/size;进销存支持 customerName。
尾程订单支持 relabelNo 和 sailingStage,库存余额按来源入库单展开,不导出内部数据库 ID。
尾程订单支持 relabelNo、sailingStage、businessType、businessOrderNo、externalBookingNo;导出履约类型、业务来源、关联商业单号和外接预订号,与列表同一投影。库存余额按来源入库单展开;所有导出均不包含内部数据库 ID。
每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。
snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。
TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。
@@ -211,9 +211,12 @@ paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } }
- { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
@@ -233,7 +236,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -1168,6 +1171,9 @@ paths:
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters:
- { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } }
- { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } }
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}:
@@ -38,11 +38,11 @@ class LastlegOrderControllerTest {
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void orderQueryHasOneResolvableConstructorForRequestParameterBinding() {
assertEquals(17, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount());
assertEquals(20, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount());
}
@Test
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantListUsesOnlyAuthenticatedTenant() {
@@ -75,6 +75,19 @@ class LastlegExportProviderServiceTest {
assertTrue(result.columns().stream().noneMatch(c -> c.key().equals("id") || c.key().equals("customerId")));
}
@Test void exportsBusinessReferencesUsingTheSameFiltersAndProjectionAsTheList() {
var order = new ObjectMapper().findAndRegisterModules().convertValue(Map.of(
"id", "1", "orderNo", "WT-1", "orderTypeName", "仓储转运", "businessSource", Map.of(
"businessType", "LOCAL_LOOSEBOARD", "label", "本土散板 · 拼车需求池", "businessOrderNo", "LB-1", "externalBookingNo", "FB-1")), com.baiyue.lastleg.dto.LastlegOrderSummary.class);
when(orders.page(isNull(), any(), isNull())).thenReturn(new PageResult<>(List.of(order),1,1,1,1));
var result = service.batch("LAST_MILE_ORDER_LIST",request(Map.of("businessType","LOCAL_LOOSEBOARD","businessOrderNo","LB-1","externalBookingNo","FB-1"),null,null));
verify(orders).page(isNull(),argThat(q -> "LOCAL_LOOSEBOARD".equals(q.businessType()) && "LB-1".equals(q.businessOrderNo()) && "FB-1".equals(q.externalBookingNo())),isNull());
assertEquals("仓储转运",result.rows().get(0).get("orderTypeName"));
assertEquals("本土散板 · 拼车需求池",result.rows().get(0).get("businessSource.label"));
assertEquals("LB-1",result.rows().get(0).get("businessSource.businessOrderNo"));
assertEquals("FB-1",result.rows().get(0).get("businessSource.externalBookingNo"));
}
@Test void keepsCustomerScopeAndLedgerCustomerFilter() {
when(inventory.ledgers(eq("200"), any())).thenReturn(new PageResult<>(List.of(),1,1,0,0));
service.batch("OVERSEAS_INVENTORY_LEDGER", request(Map.of("customerId","200","customerName","客户"),null,null));
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class LastlegOrderBusinessSourceTest {
@Test void projectsAndFiltersFormalSourcesWithinTheOrderCustomer() throws Exception {
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(),
new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:business-source;MODE=MySQL", "sa", "")));
config.addMapper(LastlegOrderMapper.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(config, "source-test"), LastlegOrder.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) {
var db = session.getConnection(); var sql = db.createStatement();
sql.execute("CREATE TABLE ll_order (id BIGINT, customer_id VARCHAR, order_type VARCHAR, source_order_no VARCHAR)");
sql.execute("CREATE TABLE ll_fleet_pool_order_link (lastleg_order_id BIGINT, customer_id VARCHAR, business_type VARCHAR, business_order_no VARCHAR)");
sql.execute("CREATE TABLE ll_fleet_external_booking (customer_id VARCHAR, incoming_no VARCHAR, booking_no VARCHAR)");
sql.execute("INSERT INTO ll_order VALUES (1,'A','LOCAL_LOOSEBOARD','LB-DIRECT'),(2,'A','WAREHOUSE_TRANSFER','WT-POOL')," +
"(3,'A','WAREHOUSE_TRANSFER','WT-TRUCK'),(4,'A','WAREHOUSE_TRANSFER','WT-NORMAL')," +
"(5,'B','WAREHOUSE_TRANSFER','WT-POOL'),(6,'A','WAREHOUSE_TRANSFER','WT-EXTERNAL')");
sql.execute("INSERT INTO ll_fleet_pool_order_link VALUES (2,'A','LOCAL_LOOSEBOARD','LB-POOL')," +
"(3,'A','US_TRUCK','TRUCK-POOL'),(5,'B','LOCAL_LOOSEBOARD','LB-OTHER')," +
"(4,'B','LOCAL_LOOSEBOARD','LB-WRONG-CUSTOMER')");
sql.execute("INSERT INTO ll_fleet_external_booking VALUES ('A','WT-POOL','FB-POOL'),('A','WT-TRUCK','FB-TRUCK')," +
"('B','WT-POOL','FB-OTHER'),('A','WT-EXTERNAL','FB-EXTERNAL')");
var mapper = session.getMapper(LastlegOrderMapper.class);
var sources = mapper.findBusinessSources(List.of(1L,2L,3L,4L,5L,6L));
assertEquals(6, sources.size());
var service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"orderMapper",mapper);
List<LastlegOrder> orders = sources.stream().map(row -> { var o = new LastlegOrder(); o.setId(row.orderId()); return o; }).toList();
Map<Long, LastlegOrderSummary.BusinessSource> values = ReflectionTestUtils.invokeMethod(service,"businessSources",orders);
assertEquals(new LastlegOrderSummary.BusinessSource("LOCAL_LOOSEBOARD","本土散板 · 独立下单","LB-DIRECT",null),values.get(1L));
assertEquals(new LastlegOrderSummary.BusinessSource("LOCAL_LOOSEBOARD","本土散板 · 拼车需求池","LB-POOL","FB-POOL"),values.get(2L));
assertEquals(new LastlegOrderSummary.BusinessSource("US_TRUCK","本土卡车 · 拼车需求池","TRUCK-POOL","FB-TRUCK"),values.get(3L));
assertEquals(new LastlegOrderSummary.BusinessSource("WAREHOUSE_TRANSFER","仓储转运",null,null),values.get(4L));
assertEquals("FB-OTHER",values.get(5L).externalBookingNo());
assertEquals(new LastlegOrderSummary.BusinessSource("WAREHOUSE_TRANSFER","仓储转运 · 外接来货",null,"FB-EXTERNAL"),values.get(6L));
assertEquals(List.of(1L,2L),find(db,"A","LOCAL_LOOSEBOARD",null,null));
assertEquals(List.of(1L,2L,5L),find(db,null,"LOCAL_LOOSEBOARD",null,null));
assertEquals(List.of(4L,6L),find(db,"A","WAREHOUSE_TRANSFER",null,null));
assertEquals(List.of(3L),find(db,"A","US_TRUCK",null,null));
assertEquals(List.of(2L),find(db,"A",null,"LB-POOL","FB-POOL"));
assertEquals(List.of(),find(db,"A",null,"LB-OTHER",null));
assertEquals(List.of(),find(db,"A",null,null,"FB-OTHER"));
assertEquals(List.of(),find(db,"A",null,"' OR 1=1 --",null));
}
}
private List<Long> find(java.sql.Connection db,String customer,String businessType,String businessOrderNo,String bookingNo) throws Exception {
var query = new LastlegOrderRequests.Query(1L,20L,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,businessType,businessOrderNo,bookingNo);
LambdaQueryWrapper<LastlegOrder> filter = ReflectionTestUtils.invokeMethod(new LastlegOrderService(),"filter",customer,query);
var matcher = Pattern.compile("#\\{ew.paramNameValuePairs.([^}]+)}").matcher(filter.getSqlSegment());
var parameters = new ArrayList<Object>(); var bound = new StringBuilder();
while(matcher.find()) { parameters.add(filter.getParamNameValuePairs().get(matcher.group(1))); matcher.appendReplacement(bound,"?"); }
matcher.appendTail(bound);
try(var statement = db.prepareStatement("SELECT id FROM ll_order WHERE " + bound + " ORDER BY id")) {
for(int i=0;i<parameters.size();i++)statement.setObject(i+1,parameters.get(i));
var ids = new ArrayList<Long>(); try(var rows=statement.executeQuery()){while(rows.next())ids.add(rows.getLong(1));} return ids;
}
}
}
@@ -59,7 +59,7 @@ class LastlegOrderSailingStageTest {
private List<Long> find(java.sql.Connection db, String customerId, SailingStage stage, String status) throws Exception {
var query = new LastlegOrderRequests.Query(1L, 20L, null, null, null, null, null, null,
status, null, null, null, null, null, null, null, stage);
status, null, null, null, null, null, null, null, stage, null, null, null);
LambdaQueryWrapper<LastlegOrder> filter = ReflectionTestUtils.invokeMethod(
new LastlegOrderService(), "filter", customerId, query);
String where = filter.getSqlSegment();
@@ -96,7 +96,7 @@ class LastlegOrderServiceTest {
LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod(
service, "summary", order, List.of(), null, "UNSCHEDULED",
new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of());
new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null);
assertNotNull(summary);
assertEquals("BOOKING-1", summary.sourceContainerBookingId());
@@ -379,7 +379,7 @@ class LastlegOrderServiceTest {
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null);
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
@@ -536,7 +536,7 @@ class LastlegOrderServiceTest {
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
@@ -560,7 +560,7 @@ class LastlegOrderServiceTest {
resultPage.setTotal(3);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
@@ -592,7 +592,7 @@ class LastlegOrderServiceTest {
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, input, null, null, null, null, null, null));
null, null, null, null, input, null, null, null, null, null, null, null, null, null));
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);