2026-11-06 13:33:37 +08:00
|
|
|
package com.baiyue.lastleg.looseboard;
|
|
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
|
|
|
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
|
|
|
|
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
|
|
|
|
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.port.LooseboardDispatches;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
|
|
|
|
|
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
|
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
import org.mybatis.spring.SqlSessionTemplate;
|
|
|
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
|
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
|
|
|
import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
|
import java.time.LocalDate;
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.UUID;
|
|
|
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.*;
|
|
|
|
|
import static org.mockito.Mockito.*;
|
|
|
|
|
|
|
|
|
|
class LocalLooseboardMerchantStatusTest {
|
|
|
|
|
private JdbcTemplate jdbc;
|
|
|
|
|
private LocalLooseboardRepository repository;
|
|
|
|
|
private LocalLooseboardService service;
|
|
|
|
|
|
|
|
|
|
/** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */
|
|
|
|
|
@BeforeEach
|
|
|
|
|
void setUp() throws Exception {
|
|
|
|
|
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
|
|
|
|
jdbc = new JdbcTemplate(ds);
|
|
|
|
|
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))
|
|
|
|
|
.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
|
|
|
|
|
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
|
|
|
|
|
jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")"));
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))");
|
|
|
|
|
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))");
|
|
|
|
|
var config = new MybatisConfiguration();
|
|
|
|
|
config.setMapUnderscoreToCamelCase(true);
|
|
|
|
|
config.addMapper(LocalLooseboardOrderMapper.class);
|
|
|
|
|
var pagination = new MybatisPlusInterceptor();
|
|
|
|
|
pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
|
|
|
|
config.addInterceptor(pagination);
|
|
|
|
|
var factory = new MybatisSqlSessionFactoryBean();
|
|
|
|
|
factory.setDataSource(ds); factory.setConfiguration(config);
|
|
|
|
|
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
|
|
|
|
|
repository = new LocalLooseboardRepository();
|
|
|
|
|
ReflectionTestUtils.setField(repository, "orders", mapper);
|
|
|
|
|
ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class));
|
|
|
|
|
service = new LocalLooseboardService();
|
|
|
|
|
ReflectionTestUtils.setField(service, "repository", repository);
|
|
|
|
|
ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class));
|
|
|
|
|
ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */
|
|
|
|
|
@Test
|
|
|
|
|
void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() {
|
|
|
|
|
String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND",
|
|
|
|
|
"RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"};
|
|
|
|
|
for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]);
|
|
|
|
|
var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH"));
|
|
|
|
|
assertThat(waiting.total()).isEqualTo(4);
|
|
|
|
|
assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH"));
|
|
|
|
|
for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) {
|
|
|
|
|
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]);
|
|
|
|
|
}
|
|
|
|
|
assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */
|
|
|
|
|
@Test
|
|
|
|
|
void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() {
|
|
|
|
|
order(1, "C1", "WAITING_INBOUND");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')");
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')");
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')");
|
|
|
|
|
assertStatus(1, "DISPATCHED");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'");
|
|
|
|
|
assertStatus(1, "PICKING_UP");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'");
|
|
|
|
|
assertStatus(1, "PICKING_UP");
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING");
|
|
|
|
|
assertStatus(1, "DISPATCHED");
|
|
|
|
|
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
|
|
|
|
|
assertStatus(1, "IN_TRANSIT");
|
|
|
|
|
assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND");
|
|
|
|
|
jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'");
|
|
|
|
|
assertStatus(1, "COMPLETED");
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-06 17:24:53 +08:00
|
|
|
/** 取货关联按完整客户标识匹配,忽略大小写的字段排序和长多字节前缀均不能串用其他客户事实。 */
|
|
|
|
|
@Test
|
|
|
|
|
void pickupFactsMatchTheEntireCustomerIdentityAcrossTextComparisonRules() {
|
|
|
|
|
String customer = "客".repeat(60) + "A";
|
|
|
|
|
order(1, customer, "WAITING_INBOUND");
|
|
|
|
|
jdbc.execute("ALTER TABLE ll_warehouse_transfer_order ALTER COLUMN customer_id VARCHAR_IGNORECASE(128)");
|
|
|
|
|
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1',?)", customer);
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1',?,'PICKED')", customer);
|
|
|
|
|
assertThat(service.merchantDetail(customer, "LB1").summary().status()).isEqualTo("PICKING_UP");
|
|
|
|
|
|
|
|
|
|
for (String otherCustomer : List.of("客".repeat(60) + "a", "客".repeat(60) + "B")) {
|
|
|
|
|
jdbc.update("UPDATE ll_fleet_pickup_task SET customer_id=?", otherCustomer);
|
|
|
|
|
assertThat(service.merchantDetail(customer, "LB1").summary().status()).isEqualTo("PENDING_DISPATCH");
|
|
|
|
|
assertThat(service.page(customer, query(1, 20, "PICKING_UP")).total()).isZero();
|
|
|
|
|
assertThat(service.page(customer, query(1, 20, "PENDING_DISPATCH")).total()).isEqualTo(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-06 13:33:37 +08:00
|
|
|
/** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */
|
|
|
|
|
@Test
|
|
|
|
|
void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() {
|
|
|
|
|
order(1, "C1", "WAITING_INBOUND"); plan(1, "C1");
|
|
|
|
|
dispatch(1, 11, "C1", "CANCELLED", "DELIVERING");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
dispatch(1, 12, "C1", "ACTIVE", "LOADING");
|
|
|
|
|
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')");
|
|
|
|
|
assertStatus(1, "DISPATCHED");
|
|
|
|
|
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12");
|
|
|
|
|
dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING");
|
|
|
|
|
assertStatus(1, "IN_TRANSIT");
|
|
|
|
|
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'");
|
|
|
|
|
jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'");
|
|
|
|
|
assertStatus(1, "CANCELLED");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */
|
|
|
|
|
@Test
|
|
|
|
|
void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() {
|
|
|
|
|
order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND");
|
|
|
|
|
plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')");
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
|
|
|
|
|
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')");
|
|
|
|
|
assertStatus(1, "PENDING_DISPATCH");
|
|
|
|
|
assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty();
|
|
|
|
|
assertThatThrownBy(() -> service.merchantDetail("C2", "LB1"))
|
|
|
|
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
|
|
|
|
jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'");
|
|
|
|
|
assertStatus(1, "IN_TRANSIT");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */
|
|
|
|
|
@Test
|
|
|
|
|
void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() {
|
|
|
|
|
order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING");
|
|
|
|
|
order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND");
|
|
|
|
|
var first = service.page("C1", query(1, 1, " pending_dispatch "));
|
|
|
|
|
var second = service.page("C1", query(2, 1, "PENDING_DISPATCH"));
|
|
|
|
|
assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2);
|
|
|
|
|
assertThat(second.total()).isEqualTo(2);
|
|
|
|
|
assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1);
|
|
|
|
|
assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id());
|
|
|
|
|
for (var row : List.of(first.records().get(0), second.records().get(0))) {
|
|
|
|
|
assertThat(row.customerId()).isEqualTo("C1");
|
|
|
|
|
assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status());
|
|
|
|
|
}
|
|
|
|
|
for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) {
|
|
|
|
|
assertThatThrownBy(() -> service.page("C1", query(1, 20, status)))
|
|
|
|
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */
|
|
|
|
|
private void order(long id, String customer, String status) {
|
|
|
|
|
var order = new LocalLooseboardOrder();
|
|
|
|
|
order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id);
|
|
|
|
|
order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000);
|
|
|
|
|
order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP");
|
|
|
|
|
order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5));
|
|
|
|
|
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
|
|
|
|
|
order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64));
|
|
|
|
|
order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0));
|
|
|
|
|
repository.insertOrder(order);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 建立原单到申报托盘的正式链路。 */
|
|
|
|
|
private void plan(long id, String customer) {
|
|
|
|
|
jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer);
|
|
|
|
|
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 建立可独立取消或推进的分车排车事实。 */
|
|
|
|
|
private void dispatch(long pallet, long id, String customer, String status, String stage) {
|
|
|
|
|
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
|
|
|
|
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 每个节点同时核对列表筛选总数和客户详情。 */
|
|
|
|
|
private void assertStatus(long id, String status) {
|
|
|
|
|
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status);
|
|
|
|
|
var page = service.page("C1", query(1, 20, status));
|
|
|
|
|
assertThat(page.total()).isEqualTo(1);
|
|
|
|
|
assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 用同一路线和日期边界查询客户运输进度。 */
|
|
|
|
|
private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) {
|
|
|
|
|
return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status,
|
|
|
|
|
LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6));
|
|
|
|
|
}
|
|
|
|
|
}
|