This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
@@ -1,7 +1,10 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
@@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -24,7 +28,9 @@ class LastlegOrderControllerTest {
@Test
void customerCannotOverrideItsTenantScope() {
controller.list(user(100L, "MERCHANT"), "200", query);
ApiResponse<PageResult<LastlegOrderSummary>> response =
controller.list(user(100L, "MERCHANT"), "200", query);
assertNotNull(response);
verify(service).page(eq("100"), eq(query));
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Phase4BrowserApiBoundaryTest {
@Test
void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() {
assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/"));
assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/"));
assertFalse(basePath(InboundPdaController.class).contains("/api/platform/"));
}
@Test
void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() {
for (Class<?> type : new Class<?>[] {
DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class
}) {
ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class);
assertTrue(condition != null, type.getSimpleName());
assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled"));
assertFalse(condition.matchIfMissing());
}
}
@Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.web.bind.annotation"))
.flatMap(annotation -> {
try {
return Arrays.stream((String[]) annotation.annotationType().getMethod("value").invoke(annotation));
} catch (ReflectiveOperationException ignored) {
return java.util.stream.Stream.empty();
}
}).collect(Collectors.toSet());
assertFalse(paths.contains("/allocate"));
assertFalse(paths.contains("/unallocate"));
assertFalse(paths.contains("/pick"));
assertFalse(paths.contains("/ship"));
}
private static String basePath(Class<?> type) {
return type.getAnnotation(RequestMapping.class).value()[0];
}
}
@@ -1,7 +1,6 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
@@ -204,32 +203,20 @@ class DeliveryTransferServiceTest {
}
@Test
void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() {
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8);
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") Page<OutboundPalletItem> page = invocation.getArgument(0);
page.setRecords(List.of(first, second));
page.setTotal(2);
return page;
});
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
PageResult<DeliveryResponses.PalletRow> pallets = detail.pallets();
assertEquals(2, pallets.total());
assertEquals(2, pallets.records().size());
assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode());
assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName());
assertEquals(12, pallets.records().get(0).packageCount());
assertEquals("IN-A", pallets.records().get(0).inboundCode());
assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode());
assertEquals(8, pallets.records().get(1).packageCount());
assertEquals("OUT-301", detail.outbound().outboundNo());
assertEquals("DEL-401", detail.delivery().deliveryNo());
assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents())
.noneMatch(component -> "pallets".equals(component.getName())));
verify(outboundPallets, never()).selectPage(any(Page.class), any());
}
@Test
@@ -10,11 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)"));
assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)"));
assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)"));
assertFalse(schema.contains("unique key uk_ll_inbound_plan_container"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.inboundplan.controller;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanPermissionContractTest {
private static final String LIST = "platform:overseas-warehouse:inbound-order:list";
@Test
void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() {
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.collect(Collectors.toMap(Method::getName, method -> method));
for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) {
Method method = methods.get(name);
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
}
}
}
@@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
@@ -42,6 +43,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@@ -52,6 +54,7 @@ class InboundStockReceiptServiceTest {
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
@@ -74,6 +77,7 @@ class InboundStockReceiptServiceTest {
assertEquals(5L, request.getValue().packages());
assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode());
assertEquals("SKU-1", request.getValue().skuCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
@@ -84,10 +88,11 @@ class InboundStockReceiptServiceTest {
}
@Test
void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() {
void splitsOneInboundOrderAcrossSkuAndPalletDimensions() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second));
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
@@ -187,6 +192,7 @@ class InboundStockReceiptServiceTest {
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
org.mockito.Mockito.lenient().when(orderDetails.selectList(any())).thenReturn(List.of(detail));
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.client.EventBridgeClient;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxRelayTest {
@Mock private UnloadCompletedOutboxRepository repository;
@Mock private EventBridgeClient client;
private ObjectMapper objectMapper;
private InboundUnloadCompletedOutboxRelay relay;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
relay = new InboundUnloadCompletedOutboxRelay();
ReflectionTestUtils.setField(relay, "repository", repository);
ReflectionTestUtils.setField(relay, "client", client);
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "leaseSeconds", 60L);
ReflectionTestUtils.setField(relay, "retrySeconds", 30L);
}
@Test
void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception {
EventEnvelope event = event();
LastlegEventOutbox row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement(
event.eventId(), "已接收", "2026-08-20T03:01:00")));
relay.relay();
verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class));
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
}
@Test
void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception {
EventEnvelope event = event();
LastlegEventOutbox row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenThrow(new IllegalStateException("bridge unavailable"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
private LastlegEventOutbox row(EventEnvelope event) throws Exception {
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(9001L);
row.setEventKey("OverseasContainerUnloaded:7001");
row.setPayloadJson(objectMapper.writeValueAsString(event));
return row;
}
private EventEnvelope event() {
var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU1234567",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null));
return new EventEnvelope("00000000-0000-4000-8000-000000000009",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"),
RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L,
"trace-12345678", "correlation-12345678", null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -22,6 +24,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -115,6 +118,35 @@ class InboundPlanServiceTest {
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void pagesInboundPlansWithinCustomerScope() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(
2, 20, "LIP", "RK", "PENDING_ASSIGNMENT", "WH-1", "ONT8", "ONT8",
"NOT_APPOINTED", null, null);
InboundPlanSummary row = new InboundPlanSummary(1L, "LIP1", 2L, "100", "Customer A",
"FBA", "RK-1", "CONT-1", "WH-1", "LA Warehouse", "ONT8", "ONT8", "ONT8",
"ONT8", 10, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", "NOT_APPOINTED", null, null);
when(planMapper.countRows("100", query)).thenReturn(21L);
when(planMapper.pageRows("100", query, 20L, 20L)).thenReturn(List.of(row));
var result = service.page("100", query);
assertEquals(21L, result.total());
assertEquals(2L, result.pages());
assertEquals("RK-1", result.records().get(0).inboundCode());
verify(planMapper).pageRows("100", query, 20L, 20L);
}
@Test
void rejectsReversedInboundPlanDateRange() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0),
LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.page("100", query));
verify(planMapper, never()).countRows(any(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
@@ -28,12 +28,13 @@ class InventorySchemaContractTest {
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)"));
assertTrue(schema.contains("inbound_order_id bigint not null"));
assertTrue(schema.contains("inbound_code varchar(128) not null"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
assertTrue(schema.contains("in_transit_packages bigint not null default 0"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("product_name_zh"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_position"));
assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)"));
@@ -52,7 +53,7 @@ class InventorySchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_inventory_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at"));
}
@@ -97,7 +98,8 @@ class InventorySchemaContractTest {
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
assertThrowsNoField(InventoryBalance.class, "skuCode");
assertNotNull(InventoryBalance.class.getDeclaredField("skuCode"));
assertNotNull(InventoryBalance.class.getDeclaredField("productNameZh"));
assertThrowsNoField(InventoryBalance.class, "availableQuantity");
assertThrowsNoField(InventoryPosition.class, "skuCode");
assertThrowsNoField(InventoryPosition.class, "productQuantity");
@@ -63,7 +63,7 @@ class InventoryCommandServiceTest {
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
@@ -71,6 +71,7 @@ class InventoryCommandServiceTest {
ArgumentCaptor<InventoryBalance> balance = ArgumentCaptor.forClass(InventoryBalance.class);
verify(balances).updateById(balance.capture());
assertEquals(12L, balance.getValue().getAvailablePackages());
assertEquals("SKU-1", balance.getValue().getSkuCode());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
verify(operations).updateById(any(InventoryOperation.class));
@@ -78,7 +79,7 @@ class InventoryCommandServiceTest {
@Test
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
@@ -104,7 +105,7 @@ class InventoryCommandServiceTest {
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
@@ -138,7 +139,8 @@ class InventoryCommandServiceTest {
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null,
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
300L, "PLT-1", null, null, null,
packages, "ORD-1", "EVT-1", key, null);
}
@@ -18,7 +18,7 @@ class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
@@ -120,12 +120,11 @@ class InventoryQueryServiceTest {
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages"));
assertFalse(csv.contains("skuCode"));
assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages"));
assertFalse(csv.contains("Quantity"));
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status);
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, null, null, status);
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.looseboard;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LocalLooseboardSchemaContractTest {
@Test
void baselineContainsIndependentLooseboardOrderAndHistory() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history"));
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.looseboard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class PlatformLocalLooseboardControllerTest {
@Mock LocalLooseboardService service;
private PlatformLocalLooseboardController controller;
@BeforeEach
void setUp() {
controller = new PlatformLocalLooseboardController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void createUsesExplicitCustomerAndOperator() {
var request = create();
controller.create(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", request);
verify(service).create("100", "7", request);
}
@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);
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");
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions);
}
}
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.looseboard.service;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LocalLooseboardServiceTest {
@Mock LocalLooseboardOrderMapper orders;
@Mock LocalLooseboardHistoryMapper histories;
private LocalLooseboardService service;
@BeforeEach
void setUp() {
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
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("key-1"));
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void ordinaryUpdateCannotChangeAcceptedOrder() {
LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "LB1", "7", update(0L)));
assertEquals(409, error.getStatusCode().value());
}
@Test
void statusTransitionWritesHistoryAndVersion() {
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var detail = service.changeStatus("100", "LB1", "7",
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted"));
assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status());
assertEquals(1L, detail.summary().version());
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus())));
}
private LocalLooseboardRequests.Create create(String key) {
return new LocalLooseboardRequests.Create("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, 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);
}
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.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());
order.setUpdatedAt(order.getCreatedAt());
return order;
}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.masterdata;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasMasterDataOpenApiContractTest {
@Test
void documentsExchangeRateAndVehicleApisWithoutDeliveryFulfillment() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:")
.contains("缓存合作仓目录30分钟")
.contains("/api/platform/v1/last-leg/master-data/exchange-rates:")
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
.contains("platform:overseas-master-data:exchange-rate:export")
.contains("platform:overseas-master-data:vehicle:status")
.contains("不包含派送履约或车辆更换历史")
.doesNotContain("deliveryOrders:")
.doesNotContain("replacementHistory:");
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.masterdata.exchange;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class MonthlyExchangeRateSqlContractTest {
@Test
void baselineContainsRateAndHistoryTablesWithOptimisticLock() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (")
.contains("UNIQUE KEY uk_ll_exchange_rate_month (rate_month)")
.contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'")
.contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history (");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.masterdata.exchange.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformMonthlyExchangeRateControllerContractTest {
@Test
void exposesStablePlatformBaseAndCanonicalAuthorities() {
RequestMapping base = PlatformMonthlyExchangeRateController.class.getAnnotation(RequestMapping.class);
assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/exchange-rates");
Map<String, String> authorities = Stream.of(PlatformMonthlyExchangeRateController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(PreAuthorize.class))
.collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value()));
assertThat(authorities.get("page")).contains("platform:overseas-master-data:exchange-rate:list");
assertThat(authorities.get("summary")).contains("platform:overseas-master-data:exchange-rate:list");
assertThat(authorities.get("detail")).contains("platform:overseas-master-data:exchange-rate:list");
assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export");
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.masterdata.exchange.service;
import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MonthlyExchangeRateServiceTest {
private MonthlyExchangeRateMapper mapper;
private MonthlyExchangeRateService service;
@BeforeEach
void setUp() {
mapper = mock(MonthlyExchangeRateMapper.class);
service = new MonthlyExchangeRateService();
ReflectionTestUtils.setField(service, "rates", mapper);
ReflectionTestUtils.setField(service, "histories", mock(MonthlyExchangeRateHistoryMapper.class));
}
@Test
void rejectsStaleVersion() {
MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setRecordVersion(4L); value.setRateMonth("2026-08");
when(mapper.lockMonth("2026-08")).thenReturn(value);
var request = new MonthlyExchangeRateDtos.Update(3L, new BigDecimal("7.18000000"), null);
assertThatThrownBy(() -> service.update("2026-08", "9", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409 CONFLICT");
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.masterdata.quote.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOverseasQuoteControllerContractTest {
@Test
void exposesStablePlatformBaseAndCanonicalAuthorities() {
RequestMapping base = PlatformOverseasQuoteController.class.getAnnotation(RequestMapping.class);
assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/overseas-quotes");
Map<String, String> authorities = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(PreAuthorize.class))
.collect(Collectors.toMap(method -> method.getName(),
method -> method.getAnnotation(PreAuthorize.class).value()));
assertThat(authorities.get("page")).contains("platform:overseas-master-data:quote:list");
assertThat(authorities.get("detail")).contains("platform:overseas-master-data:quote:list");
assertThat(authorities.get("warehouseOptions")).contains("platform:overseas-master-data:quote:list");
assertThat(authorities.get("create")).contains("platform:overseas-master-data:quote:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export");
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.masterdata.quote.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PartnerWarehouseDirectoryTest {
@Mock private DomesticPartnerWarehouseClient client;
private PartnerWarehouseDirectory directory;
@BeforeEach
void setUp() {
directory = new PartnerWarehouseDirectory();
ReflectionTestUtils.setField(directory, "client", client);
ReflectionTestUtils.setField(directory, "internalToken", "internal-token");
ReflectionTestUtils.setField(directory, "ttl", Duration.ofMinutes(30));
ReflectionTestUtils.setField(directory, "clock", Clock.fixed(
Instant.parse("2026-08-20T00:00:00Z"), ZoneOffset.UTC));
}
@Test
void cachesDomesticOptionsForThirtyMinutesAndReturnsIdsAsStrings() {
when(client.activePartners("internal-token")).thenReturn(ApiResponse.success(List.of(
new DomesticPartnerWarehouseClient.Option(Long.valueOf("9007199254740993"),
"US-LAX", "洛杉矶合作仓", "US"))));
assertThat(directory.options()).singleElement().satisfies(option -> {
assertThat(option.warehouseId()).isEqualTo("9007199254740993");
assertThat(option.warehouseCode()).isEqualTo("US-LAX");
assertThat(option.warehouseName()).isEqualTo("洛杉矶合作仓");
assertThat(option.countryCode()).isEqualTo("US");
});
directory.options();
verify(client, times(1)).activePartners("internal-token");
ReflectionTestUtils.setField(directory, "clock", Clock.fixed(
Instant.parse("2026-08-20T00:30:01Z"), ZoneOffset.UTC));
directory.options();
verify(client, times(2)).activePartners("internal-token");
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.masterdata.vehicle;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class VehicleSqlContractTest {
@Test
void baselineContainsVehicleMasterDataWithoutDeliveryHistory() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String vehicleSql = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS ll_vehicle ("));
assertThat(vehicleSql).contains("UNIQUE KEY uk_ll_vehicle_plate (plate_number)")
.contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'")
.contains("ownership VARCHAR(16) NOT NULL")
.doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_delivery")
.doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_replacement");
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformVehicleControllerContractTest {
@Test
void exposesStablePlatformBaseAndCanonicalAuthorities() {
RequestMapping base = PlatformVehicleController.class.getAnnotation(RequestMapping.class);
assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/vehicles");
Map<String, String> authorities = Stream.of(PlatformVehicleController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(PreAuthorize.class))
.collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value()));
assertThat(authorities.get("page")).contains("platform:overseas-master-data:vehicle:list");
assertThat(authorities.get("detail")).contains("platform:overseas-master-data:vehicle:list");
assertThat(authorities.get("create")).contains("platform:overseas-master-data:vehicle:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:vehicle:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:vehicle:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:vehicle:export");
}
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleServiceTest {
private VehicleMapper mapper;
private VehicleService service;
@BeforeEach
void setUp() {
mapper = mock(VehicleMapper.class);
service = new VehicleService();
ReflectionTestUtils.setField(service, "vehicles", mapper);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
}
@Test
void rejectsDecreasingOdometer() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
@Test
void rejectsUnknownStatus() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
private static VehicleDtos.Update update(long version, long mileage) {
return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static Vehicle vehicle(long version, long mileage) {
Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage);
value.setStatus("IN_SERVICE"); return value;
}
}
@@ -21,5 +21,8 @@ class OverseasProcurementInventoryBoundaryTest {
assertFalse(source.contains("stockIn("));
assertFalse(source.contains("setAvailablePackages"));
assertFalse(source.contains("availablePackages"));
assertFalse(source.contains("addOutbound"));
assertFalse(source.contains("OverseasProcurementOutbound"));
assertFalse(source.contains("outboundQuantity"));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementOpenApiContractTest {
@Test
void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception {
String yaml;
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(yaml.contains("/customer-options:"));
assertTrue(yaml.contains("/warehouse-options:"));
assertTrue(yaml.contains("显式 null 或空串表示清空"));
assertTrue(yaml.contains("platform:overseas-business:purchase:list"));
assertTrue(yaml.contains("platform:overseas-business:purchase:edit"));
assertFalse(yaml.contains("/outbounds"));
assertFalse(yaml.contains("采购出库单"));
}
@Test
void contractContainsPlatformTruckWritesAndIndependentLooseboardDomain() throws Exception {
String yaml;
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(yaml.contains("platform:overseas-business:truck:create"));
assertTrue(yaml.contains("platform:overseas-business:truck:edit"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/local-looseboard-orders:"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:list"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:create"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:edit"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:status"));
assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound"));
assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery"));
}
}
@@ -8,14 +8,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementSchemaContractTest {
@Test
void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception {
void baselineContainsOrderAndOperationHistoryButNoProcurementOutbound() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)"));
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("change_detail JSON NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT"));
}
}
@@ -62,6 +62,15 @@ class OverseasProcurementControllerTest {
verify(service).review("100", "HC1", "7", request);
}
@Test
void platformUpdatePassesAuditedOperator() {
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)),
"100", "HC1", request);
verify(service).update("100", "HC1", "7", request);
}
private AuthenticatedUser user(long tenantId, String appCode, Set<String> permissions) {
return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions);
}
@@ -3,8 +3,9 @@ package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -23,21 +24,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementOutboundMapper outbounds;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock PartnerWarehouseDirectory warehouseDirectory;
private OverseasProcurementService service;
@BeforeEach
void setUp() {
service = new OverseasProcurementService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@@ -49,7 +52,6 @@ class OverseasProcurementServiceTest {
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get());
when(outbounds.listOrder(anyString(), any())).thenReturn(List.of());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", new OverseasProcurementRequests.Create(
@@ -69,21 +71,75 @@ class OverseasProcurementServiceTest {
100L, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "HC1", request));
() -> service.update("100", "HC1", "7", request));
assertEquals(409, error.getStatusCode().value());
}
@Test
void outboundCannotExceedDerivedInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80);
void explicitNullClearsOptionalFieldsAndWritesOperationHistory() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setProductUrl("https://example.test/old");
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0);
var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null,
LocalDateTime.now(), null);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var request = new OverseasProcurementRequests.Update(0L, null, null,
new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null,
null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.addOutbound("100", "HC1", "7", request));
assertEquals(409, error.getStatusCode().value());
service.update("100", "HC1", "7", request);
assertEquals(null, order.getProductUrl());
verify(histories).insert(org.mockito.ArgumentMatchers.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
"UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl")));
}
@Test
void omittedOptionalFieldKeepsExistingValue() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setProductUrl("https://example.test/keep");
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
service.update("100", "HC1", "7", request);
assertEquals("https://example.test/keep", order.getProductUrl());
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
}
@Test
void warehouseSelectionFreezesCanonicalCandidateName() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
when(warehouseDirectory.required(10L)).thenReturn(
new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US"));
ObjectMapper mapper = new ObjectMapper();
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"),
null, null, null, null, null, null);
service.update("100", "HC1", "7", request);
assertEquals("10", order.getPurchaseWarehouseId());
assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName());
}
@Test
void customerOptionsAreDeduplicatedSnapshotsFromProcurementOrders() {
OverseasProcurementOrder option = new OverseasProcurementOrder();
option.setCustomerId("100");
option.setCustomerName("Acme");
when(orders.customerOptions("ac", 20)).thenReturn(List.of(option));
var values = service.customerOptions(" ac ", 20);
assertEquals(1, values.size());
assertEquals("100", values.get(0).customerId());
assertEquals("Acme", values.get(0).customerName());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
@@ -93,7 +149,6 @@ class OverseasProcurementServiceTest {
order.setOrderNo("HC1");
order.setStatus(status);
order.setInboundQuantity(inbound);
order.setOutboundQuantity(outbound);
order.setPurchaseQuantity(500L);
order.setVersion(0L);
order.setCreatedAt(LocalDateTime.now());
@@ -26,10 +26,13 @@ class SchemaChineseCommentContractTest {
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
String overseasUnified = Files.readString(
Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55);
assertAllChineseComments(incremental, "增量 SQL", 49);
assertCommentMigrationCovers(incremental, baselineTables);
assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10);
assertCommentMigrationCovers(incremental, overseasUnified, baselineTables);
}
@Test
@@ -78,21 +81,48 @@ class SchemaChineseCommentContractTest {
return tables;
}
private static void assertCommentMigrationCovers(String incremental, List<TableDefinition> tables) {
private static void assertCommentMigrationCovers(
String incremental,
String overseasUnified,
List<TableDefinition> tables
) {
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
assertTrue(migration.contains("table_name='" + table.name() + "'"),
boolean createdInLegacy = hasCommentedCreate(migration, table.name());
boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name());
assertTrue(createdInLegacy || createdInUnified
|| migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
if (createdInLegacy || createdInUnified) {
continue;
}
for (String column : table.columns()) {
assertTrue(migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'"),
boolean repaired = migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'");
boolean addedWithComment = hasCommentedAdd(migration, column)
|| hasCommentedAdd(overseasUnified, column);
assertTrue(repaired || addedWithComment,
"025 备注迁移缺少字段: " + table.name() + "." + column);
}
}
}
private static boolean hasCommentedCreate(String sql, String table) {
return Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + Pattern.quote(table)
+ "\\s*\\(.*?\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'\\s*;")
.matcher(sql).find();
}
private static boolean hasCommentedAdd(String sql, String column) {
return Pattern.compile(
"(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column)
+ "\\b[^;]*?COMMENT\\s+''?[^;]*[\\u4e00-\\u9fff][^;]*''?")
.matcher(sql).find();
}
private static boolean isConstraint(String definition) {
String normalized = definition.stripLeading().toUpperCase();
return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ")
@@ -7,12 +7,6 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,9 +31,6 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock DropshipCartonMapper dropshipCartons;
LastlegOrderService service;
@BeforeEach
@@ -48,35 +39,23 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
}
@Test
void detailGroupsDropshipSkuAndCartonsByPallet() {
void detailDoesNotExposeDropshipCartonsOrInternalEventIds() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1");
DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2");
when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second));
DropshipSkuInventory sku = new DropshipSkuInventory();
sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L);
when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku));
DropshipCarton carton = new DropshipCarton();
carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1");
when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton));
var response = service.detail("100", 11L);
assertEquals(2, response.dropshipPallets().size());
assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo());
assertEquals(1, response.dropshipPallets().get(0).skuInventory().size());
assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark());
assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty());
assertEquals(11L, response.order().id());
assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents())
.noneMatch(component -> "dropshipPallets".equals(component.getName())));
assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents())
.noneMatch(component -> "eventId".equals(component.getName())));
}
@Test
@@ -133,12 +112,4 @@ class LastlegOrderServiceTest {
return order;
}
private static DropshipOrderProfile profile(long id, String sourcePalletNo,
long warehousePalletId, String palletNo) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(id); profile.setSourcePalletNo(sourcePalletNo);
profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo);
profile.setStatus("RECEIVED");
return profile;
}
}
@@ -206,6 +206,7 @@ class WarehouseTransferServiceTest {
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 101L, "IN-1"));
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -237,6 +238,7 @@ class WarehouseTransferServiceTest {
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode());
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
@@ -350,7 +352,7 @@ class WarehouseTransferServiceTest {
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setInboundCode(inboundCode);
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
return balance;
}
@@ -50,6 +50,25 @@ class UsTruckOrderControllerTest {
verify(service).changeStatus("100", "TK1", "7", request);
}
@Test
void platformCreateUsesExplicitCustomerAndCreatePermission() {
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
"Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO,
null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key");
platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)),
"100", request);
verify(service).create("100", "7", request);
}
@Test
void platformUpdateUsesExplicitCustomerAndEditPermission() {
var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)),
"100", "TK1", request);
verify(service).update("100", "TK1", "7", request);
}
private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); }
private AuthenticatedUser user(long tenant, String app, Set<String> permissions) {
return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions);