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() {
}
public enum SailingStage { BEFORE_DEPARTURE, AFTER_DEPARTURE }
public record Query(
@Min(1) Long page,
@Min(1) @Max(200) Long size,
@@ -34,7 +36,8 @@ public final class LastlegOrderRequests {
Boolean abnormal,
Boolean detained,
LocalDateTime createdFrom,
LocalDateTime createdTo
LocalDateTime createdTo,
SailingStage sailingStage
) {
public Query {
if (page == null || page == 0) page = 1L;
@@ -16,6 +16,16 @@ import java.math.BigDecimal;
@Mapper
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 " +
"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> " +
@@ -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 " +
"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(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.lxCode()), LastlegOrder::getLxCode, query.lxCode())
.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 由头程冻结货物明细聚合。
security: [{ bearerAuth: [] }]
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: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
@@ -343,6 +344,8 @@ paths:
summary: 导出筛选后的尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。
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 } } } } }
/api/platform/v1/last-leg/orders/container-summaries:
get:
@@ -1172,6 +1175,8 @@ paths:
summary: 客户分页查询自己的海外尾程订单
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
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: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}:
get:
@@ -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);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void orderQueryHasOneResolvableConstructorForRequestParameterBinding() {
assertEquals(16, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount());
assertEquals(17, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount());
}
@Test
@@ -138,7 +138,7 @@ class LastlegOrderControllerTest {
void platformOperatorCanExportAllCustomersWithoutFilters() {
byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8);
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);
var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery);
@@ -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);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
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
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, "", "PENDING_ASSIGNMENT", null, "LAX", 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);
@@ -401,7 +401,7 @@ class LastlegOrderServiceTest {
String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query(
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("\"待分配\""));
@@ -553,7 +553,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);
var result = service.page(null, query);
@@ -577,7 +577,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);
var result = service.page(null, query);
@@ -609,7 +609,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, input, null, null, null, null, null, null));
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);