fix(lastleg): filter domestic first-leg orders by sailing stage

This commit is contained in:
2026-10-08 15:12:47 +08:00
parent 136a2bc3b5
commit 27a89a6ff3
8 changed files with 112 additions and 10 deletions
@@ -18,6 +18,8 @@ public final class LastlegOrderRequests {
private LastlegOrderRequests() { private LastlegOrderRequests() {
} }
public enum SailingStage { BEFORE_DEPARTURE, AFTER_DEPARTURE }
public record Query( public record Query(
@Min(1) Long page, @Min(1) Long page,
@Min(1) @Max(200) Long size, @Min(1) @Max(200) Long size,
@@ -34,7 +36,8 @@ public final class LastlegOrderRequests {
Boolean abnormal, Boolean abnormal,
Boolean detained, Boolean detained,
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo LocalDateTime createdTo,
SailingStage sailingStage
) { ) {
public Query { public Query {
if (page == null || page == 0) page = 1L; if (page == null || page == 0) page = 1L;
@@ -16,6 +16,16 @@ import java.math.BigDecimal;
@Mapper @Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> { public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
// Manual node snapshots replace container nodes permanently for the order.
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND ll_order.source_region='FIRSTLEG' AND " +
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " +
"AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " +
"THEN 1 ELSE 0 END ELSE " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
"WHERE g.container_booking_id=ll_order.source_container_booking_id " +
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})";
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity,r.created_at " + @Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity,r.created_at " +
"FROM ll_relabel_application r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id " + "FROM ll_relabel_application r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id " +
"WHERE r.lastleg_order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "WHERE r.lastleg_order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -584,6 +584,8 @@ public class LastlegOrderService {
.or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + .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)) "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()) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.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()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
@@ -214,6 +214,7 @@ paths:
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 由头程冻结货物明细聚合。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配 FIRST_LEG 且来源 FIRSTLEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } } - { name: orderNo, in: query, schema: { type: string } }
@@ -343,6 +344,8 @@ paths:
summary: 导出筛选后的尾程订单 summary: 导出筛选后的尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。 description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters:
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配 FIRST_LEG 且来源 FIRSTLEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } } responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/orders/container-summaries: /api/platform/v1/last-leg/orders/container-summaries:
get: get:
@@ -1172,6 +1175,8 @@ paths:
summary: 客户分页查询自己的海外尾程订单 summary: 客户分页查询自己的海外尾程订单
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters:
- { name: sailingStage, in: query, description: '可选开船阶段。仅匹配 FIRST_LEG 且来源 FIRSTLEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } }
responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}: /api/merchant/v1/last-leg/orders/{id}:
get: get:
@@ -38,11 +38,11 @@ class LastlegOrderControllerTest {
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
private final LastlegOrderController controller = controller(); private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, 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);
@Test @Test
void orderQueryHasOneResolvableConstructorForRequestParameterBinding() { void orderQueryHasOneResolvableConstructorForRequestParameterBinding() {
assertEquals(16, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); assertEquals(17, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount());
} }
@Test @Test
@@ -138,7 +138,7 @@ class LastlegOrderControllerTest {
void platformOperatorCanExportAllCustomersWithoutFilters() { void platformOperatorCanExportAllCustomersWithoutFilters() {
byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8); byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8);
LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query( LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query(
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, null, null, null, null);
when(service.exportCsv(null, emptyQuery)).thenReturn(csv); when(service.exportCsv(null, emptyQuery)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery); var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery);
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller(); private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, 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);
@Test @Test
void merchantListUsesOnlyAuthenticatedTenant() { void merchantListUsesOnlyAuthenticatedTenant() {
@@ -0,0 +1,82 @@
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.LastlegOrderRequests.SailingStage;
import com.baiyue.lastleg.entity.LastlegOrder;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LastlegOrderSailingStageTest {
@Test
void filtersBeforePaginationByActualDepartureAndKeepsOtherTypesOnlyInAll() throws Exception {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "sailing-test"), LastlegOrder.class);
try (var db = DriverManager.getConnection("jdbc:h2:mem:sailing;MODE=MySQL")) {
var sql = db.createStatement();
sql.execute("CREATE TABLE ll_order (id BIGINT, customer_id VARCHAR, order_type VARCHAR, source_region VARCHAR, " +
"source_container_booking_id VARCHAR, manual_node_override BOOLEAN, current_status VARCHAR)");
sql.execute("CREATE TABLE ll_container_shipping_gate (container_booking_id VARCHAR, node_sequence INT, actual_at TIMESTAMP)");
sql.execute("CREATE TABLE ll_order_manual_node (order_id BIGINT, customer_id VARCHAR, node_code VARCHAR, node_at TIMESTAMP)");
sql.execute("INSERT INTO ll_container_shipping_gate VALUES ('loaded',30,CURRENT_TIMESTAMP)," +
"('departed',40,CURRENT_TIMESTAMP),('arrived',60,CURRENT_TIMESTAMP),('estimated',40,NULL)");
sql.execute("INSERT INTO ll_order VALUES " +
"(1,'A','FIRST_LEG','FIRSTLEG',NULL,NULL,'PENDING_ASSIGNMENT')," +
"(2,'A','FIRST_LEG','FIRSTLEG','loaded',FALSE,'INBOUND_PLANNED')," +
"(3,'A','FIRST_LEG','FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')," +
"(4,'A','FIRST_LEG','FIRSTLEG','arrived',FALSE,'INBOUND_COMPLETED')," +
"(5,'A','FIRST_LEG','FIRSTLEG','estimated',FALSE,'PENDING_ASSIGNMENT')," +
"(6,'A','FIRST_LEG','FIRSTLEG','arrived',TRUE,'PENDING_ASSIGNMENT')," +
"(7,'A','FIRST_LEG','FIRSTLEG','loaded',TRUE,'PENDING_ASSIGNMENT')," +
"(8,'B','FIRST_LEG','FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')," +
"(9,'A','FIRST_LEG','CUSTOMER','departed',FALSE,'PENDING_ASSIGNMENT')");
sql.execute("INSERT INTO ll_order_manual_node VALUES (6,'A','ESTIMATED_DEPARTURE',CURRENT_TIMESTAMP)," +
"(6,'A','ACTUAL_DEPARTURE',NULL),(6,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)," +
"(7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
int id = 10;
for (String type : List.of("ONE_CLICK_DROPSHIP", "RELABEL_APPLICATION", "WAREHOUSE_TRANSFER",
"LOCAL_LOOSEBOARD", "RETURN_RELABEL", "CARGO_DISCARD")) {
try (var insert = db.prepareStatement("INSERT INTO ll_order VALUES (?,'A',?,'FIRSTLEG','departed',FALSE,'PENDING_ASSIGNMENT')")) {
insert.setInt(1, id++); insert.setString(2, type); insert.executeUpdate();
}
}
assertEquals(List.of(1L, 2L, 5L, 6L), find(db, null, SailingStage.BEFORE_DEPARTURE, null));
assertEquals(List.of(3L, 4L, 7L, 8L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 4L, 7L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 7L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
assertEquals(15, find(db, null, null, null).size());
}
}
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);
LambdaQueryWrapper<LastlegOrder> filter = ReflectionTestUtils.invokeMethod(
new LastlegOrderService(), "filter", customerId, query);
String where = filter.getSqlSegment();
var matcher = Pattern.compile("#\\{ew.paramNameValuePairs.([^}]+)}").matcher(where);
List<Object> parameters = new ArrayList<>();
StringBuilder boundSql = new StringBuilder();
while (matcher.find()) {
parameters.add(filter.getParamNameValuePairs().get(matcher.group(1)));
matcher.appendReplacement(boundSql, "?");
}
matcher.appendTail(boundSql);
try (var statement = db.prepareStatement("SELECT id FROM ll_order" +
(where.isBlank() ? "" : " WHERE " + boundSql) + " ORDER BY id")) {
for (int i = 0; i < parameters.size(); i++) statement.setObject(i + 1, parameters.get(i));
List<Long> ids = new ArrayList<>();
try (var rows = statement.executeQuery()) { while (rows.next()) ids.add(rows.getLong(1)); }
return ids;
}
}
}
@@ -375,7 +375,7 @@ class LastlegOrderServiceTest {
@Test @Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null); null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0); resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage); when(orders.selectPage(any(), any())).thenReturn(resultPage);
@@ -401,7 +401,7 @@ class LastlegOrderServiceTest {
String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query( String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query(
1L, 20L, 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)), StandardCharsets.UTF_8); null, null, null, null, null)), StandardCharsets.UTF_8);
assertTrue(csv.contains("\"否\",\"是\"")); assertTrue(csv.contains("\"否\",\"是\""));
assertTrue(csv.contains("\"待分配\"")); assertTrue(csv.contains("\"待分配\""));
@@ -553,7 +553,7 @@ class LastlegOrderServiceTest {
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"), new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1"))); new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, 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);
var result = service.page(null, query); var result = service.page(null, query);
@@ -577,7 +577,7 @@ class LastlegOrderServiceTest {
resultPage.setTotal(3); resultPage.setTotal(3);
when(orders.selectPage(any(), any())).thenReturn(resultPage); when(orders.selectPage(any(), any())).thenReturn(resultPage);
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, 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);
var result = service.page(null, query); var result = service.page(null, query);
@@ -609,7 +609,7 @@ class LastlegOrderServiceTest {
when(orders.selectPage(any(), any())).thenReturn(resultPage); when(orders.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, 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, input, null, null, null, null, null, null));
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter = ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);