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"); } /** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */ @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)); } }