feat(looseboard): require route contacts and pickup delivery times

This commit is contained in:
2026-10-19 15:11:15 +08:00
parent 21128b895f
commit dff040b509
20 changed files with 530 additions and 238 deletions
@@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
@@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest {
}
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK");
assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName());
assertThat(looseOrder.getDestinationContactName()).isNull();
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull();
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());
}
@Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op");
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class LocalLooseboardPersistenceTest {
/** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */
@Test
void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
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));
// H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。
ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
ddl = ddl.replaceAll(",\\s*\\)$", ")");
new JdbcTemplate(ds).execute(ddl);
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper);
var order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1");
order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100");
order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101");
order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus("PENDING_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64));
repository.insertOrder(order);
var saved = repository.findNo("C-1", order.getOrderNo());
assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone());
assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone());
assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt());
assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip"));
saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15));
repository.updateOrder(saved);
var updated = repository.findKey("C-1", "roundtrip");
assertEquals("+1-555-0102", updated.getDestinationContactPhone());
assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt());
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.looseboard;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class LocalLooseboardRequestsTest {
/** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */
@Test void requiresContactsAndBothLocalTimes() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var json = new ObjectMapper().findAndRegisterModules();
ObjectNode valid = json.valueToTree(request());
assertTrue(validator.validate(request()).isEmpty());
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
ObjectNode missing = valid.deepCopy(); missing.remove(field);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field);
missing.remove("idempotencyKey"); missing.put("version", 0);
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field);
}
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) {
ObjectNode blank = valid.deepCopy(); blank.put(field, " ");
assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field);
}
ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00");
assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty());
}
}
/** JSON 往返保持当地分钟,不包含已删除字段。 */
@Test void roundTripsLocalTimesAndSplitContacts() throws Exception {
var json = new ObjectMapper().findAndRegisterModules();
var valid = request(); var tree = json.valueToTree(valid);
var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class);
assertEquals(valid, restored);
for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field));
}
/** 创建具有独立预计托数和件数的最小完整需求。 */
private LocalLooseboardRequests.Create request() {
return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2",
LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1");
}
}
@@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest {
}
@Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception {
void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest {
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase();
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
assertTrue(block.contains("product_name varchar(255)"));
assertTrue(block.contains("estimated_pallet_count int"));
assertTrue(block.contains("pickup_at datetime(3)"));
assertTrue(block.contains("delivery_at datetime(3)"));
for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field));
for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*"));
org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type"));
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Forecast pallets must not invent YES or NO palletizing service");
}
@@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest {
@Test
void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP",
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO,
null, "USD", null, null, null, null);
var v = create();
var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value());
}
private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null,
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
}
private AuthenticatedUser user(Set<String> permissions) {
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.HashMap;
@@ -54,11 +55,13 @@ class LocalLooseboardServiceTest {
void setUp() {
service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "histories", histories);
ReflectionTestUtils.setField(repository, "operations", operations);
ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
@@ -86,31 +89,29 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
assertEquals("40HQ", detail.summary().containerType());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName());
assertEquals(3, detail.summary().estimatedPalletCount());
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> {
saved.set(call.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("default-type", null));
assertEquals("40HQ", detail.summary().containerType());
assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC")));
void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() {
var pickup = LocalDateTime.of(2030, 1, 2, 10, 30);
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null));
org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1)));
org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1)));
}
@Test
@@ -193,27 +194,22 @@ class LocalLooseboardServiceTest {
}
private LocalLooseboardRequests.Create create(String key) {
return create(key, "40HQ");
}
private LocalLooseboardRequests.Create create(String key, String containerType) {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", containerType, 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B",
"家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key);
}
private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null);
var v = create("unused");
return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
}
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES");
order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
@@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest {
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null);
null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, null, null, null, null, null);
@@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest {
@EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration")
@Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class,
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class})
WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
@@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest {
if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
"SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE,
"洛杉矶海外仓", "Los Angeles",
"ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
MARKER, MARKER + "-LOOSEBOARD-KEY"));
}