Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeClientContractTest {
@Test
void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() {
FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class);
assertThat(contract.url())
.isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}");
}
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LastlegRestClientFactoryTest {
private final LastlegRestClientFactory factory = new LastlegRestClientFactory();
@Test
void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() {
LastlegRestClientProperties properties = new LastlegRestClientProperties();
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void rejectsUnboundedOrNonPositiveTimeouts() {
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void bindsPerClientTimeoutOverridesFromConfiguration() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.http.clients.unload-photo-file.connect-timeout", "125ms",
"baiyue.http.clients.unload-photo-file.read-timeout", "9s",
"baiyue.http.clients.pallet-physical.connect-timeout", "250ms",
"baiyue.http.clients.pallet-physical.read-timeout", "2s"));
LastlegRestClientProperties properties = new Binder(source)
.bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class))
.orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound"));
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9));
assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception {
var timeouts = new LastlegRestClientProperties.ClientTimeouts(
Duration.ofMillis(250), Duration.ofMillis(75));
assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/slow", exchange -> {
try { Thread.sleep(500); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
exchange.sendResponseHeaders(200, 0);
exchange.close();
});
server.start();
try {
RestClient client = factory.build(RestClient.builder(),
"http://127.0.0.1:" + server.getAddress().getPort(), timeouts);
assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity())
.isInstanceOf(ResourceAccessException.class);
} finally {
server.stop(0);
}
}
}
@@ -3,11 +3,14 @@ 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.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
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;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
@@ -20,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
@@ -45,6 +50,17 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED);
LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer");
assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event));
verify(loading, never()).consume(event);
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
@@ -52,6 +68,7 @@ class LastlegOrderControllerTest {
private LastlegOrderController controller() {
LastlegOrderController controller = new LastlegOrderController();
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
@@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
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.inboundplan.pda.controller.ReceivingContainerController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -26,10 +25,17 @@ 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/"));
void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception {
assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/"));
assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/"));
Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller");
String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java"))
.map(path -> {
try { return Files.readString(path); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}).collect(Collectors.joining("\n"));
assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}"));
assertFalse(source.contains("@RequestParam(required = false) String customerId"));
}
@Test
@@ -0,0 +1,160 @@
package com.baiyue.lastleg.inboundplan.pda;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReceivingContainerContractTest {
private static final Set<String> FORBIDDEN_CALLER_SCOPE = Set.of(
"customerId", "planNo", "allocationId", "warehouseId", "locationId");
@Test
void pdaRequestsNeverAcceptServerOwnedScopeFields() {
for (Class<?> type : ReceivingContainerRequests.class.getDeclaredClasses()) {
if (!type.isRecord()) continue;
Set<String> fields = Arrays.stream(type.getRecordComponents())
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
}
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
RecordComponent key = Arrays.stream(type.getRecordComponents())
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
Size size = key.getAccessor().getAnnotation(Size.class);
assertTrue(size != null && size.max() == 255, type.getSimpleName());
}
}
@Test
void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() {
PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class);
assertTrue(permission != null);
assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"
.equals(permission.value()));
}
@Test
void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException {
for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) {
TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName)
.getAnnotation(TableField.class);
assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName);
}
}
@Test
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
Transactional pda = ReceivingContainerService.class
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
.getAnnotation(Transactional.class);
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
.getAnnotation(Transactional.class);
assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED);
assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED);
}
@Test
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
String schema = resource("db/lastleg.sql").toLowerCase();
String unified = java.nio.file.Files.readString(
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet",
"ll_inbound_plan_pallet_allocation")) {
assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table);
assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table);
assertTrue(unified.contains("update " + table), table);
}
assertFalse(schema.contains("tenant_id varchar(64)"));
assertFalse(unified.contains("column_name='tenant_id'"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
assertTrue(schema.contains("平台操作账号全局唯一锁键"));
assertTrue(schema.contains("柜号全局唯一锁键"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
assertTrue(receivingSession.contains("sign_photos json"));
assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null"));
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("column_name='sign_photos'"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse"));
assertTrue(unified.contains("assert_active_receiving_session_has_warehouse"));
}
@Test
void openApiPublishesOnlyStableContainerPaths() throws IOException {
String yaml = resource("openapi/overseas-business-v1.yaml");
String prefix = "/api/pda/v1/last-leg/receiving-containers";
assertTrue(yaml.contains(prefix + "/candidates:"));
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:"));
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/complete:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
List<String> successResponses = receivingPaths.lines().map(String::trim)
.filter(line -> line.startsWith("responses: { '200':")).toList();
assertEquals(16, successResponses.size());
assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
}
private String resource(String name) throws IOException {
try (var input = getClass().getClassLoader().getResourceAsStream(name)) {
assertTrue(input != null, name);
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static String tableBlock(String schema, String table) {
int start = schema.indexOf("create table if not exists " + table + " (");
int end = schema.indexOf(") engine=innodb", start);
return start < 0 || end < 0 ? "" : schema.substring(start, end);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
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.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerControllerTest {
@Mock ReceivingContainerService service;
private ReceivingContainerController controller;
@BeforeEach
void setUp() {
controller = new ReceivingContainerController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
controller.candidates(picker());
verify(service).candidates("42", 900L);
}
@Test
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("401");
}
@Test
void platformIdentityWithoutPickerAuthorityIsForbidden() {
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
@Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION),
false, null, null, false, 900L);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpUnloadPhotoFileGatewayTest {
private HttpUnloadPhotoFileGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpUnloadPhotoFileGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build());
ReflectionTestUtils.setField(gateway, "internalToken", "internal-token");
}
@Test
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
@Test
void mismatchedPhotoMetadataMapsToBadRequest() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpWarehouseResourceGatewayTest {
private HttpWarehouseResourceGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpWarehouseResourceGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://warehouse").build());
ReflectionTestUtils.setField(gateway, "internalToken", "test-token");
}
@Test
void resolveMapsCanonicalPalletIdInsteadOfSilentlyUsingZero() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
""", MediaType.APPLICATION_JSON));
WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1");
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
assertThat(result.warehouseId()).isEqualTo(2092040869757231105L);
assertThat(result.locationId()).isEqualTo(2092040891076878337L);
server.verify();
}
@Test
void ensureDropshipStillMapsWarehouseEntityId() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship"))
.andRespond(withSuccess("""
{"code":"200","data":{"id":2092040891261427713,"palletCode":"PAL-DROP",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":null,"version":1}}
""", MediaType.APPLICATION_JSON));
WarehouseResourceGateway.WarehousePalletReference result =
gateway.ensureDropshipPallet("2092040041967063041", 2092040869757231105L, "DROP-1");
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
server.verify();
}
}
@@ -0,0 +1,108 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
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 java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPdaContainerCompletionTest {
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService outbox;
@Mock LastlegOrderMapper orders;
private InboundPdaWorkflowService service;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void twoPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() {
InboundPlan first = plan(1L, "PLAN-1");
InboundPlan second = plan(2L, "PLAN-2");
InboundPdaTask firstTask = task(11L, 1L);
InboundPdaTask secondTask = task(22L, 2L);
when(repository.lockTask("100", 1L)).thenReturn(firstTask);
when(repository.lockTask("100", 2L)).thenReturn(secondTask);
when(repository.lines(1L)).thenReturn(List.of(line(101L)));
when(repository.lines(2L)).thenReturn(List.of(line(202L)));
when(repository.allocations(1L)).thenReturn(List.of(allocation(101L)));
when(repository.allocations(2L)).thenReturn(List.of(allocation(202L)));
when(repository.cartons(any())).thenReturn(List.of());
when(repository.pallets(any())).thenReturn(List.of());
LastlegOrder source = new LastlegOrder(); source.setSourceContainerBookingId("BOOKING-1");
when(orders.selectById(101L)).thenReturn(source);
when(orders.selectById(102L)).thenReturn(source);
service.completeContainer("100", List.of(second, first));
service.completeContainer("100", List.of(first, second));
verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any());
verify(outbox, times(1)).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"),
any(Instant.class), any(Instant.class), anyLong());
verify(repository, times(2)).update(any(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class));
}
private static InboundPlan plan(long id, String planNo) {
InboundPlan value = new InboundPlan();
value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1");
value.setSourceOrderId(id == 1L ? 101L : 102L);
value.setStatus("RECEIVING");
return value;
}
private static InboundPdaTask task(long id, long planId) {
InboundPdaTask value = new InboundPdaTask();
LocalDateTime now = LocalDateTime.of(2026, 8, 24, 10, 0);
value.setId(id); value.setPlanId(planId); value.setCustomerId("100"); value.setTaskNo("TASK-" + id);
value.setStatus("RECEIVING"); value.setUnloadPhotoFileIds("[10]"); value.setUnloadPhotoUploadedAt(now);
value.setEstimatedEmptyReturnAt(now.plusDays(2)); value.setVersion(1);
return value;
}
private static InboundPlanLine line(long id) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id); value.setOrderId(2000L + id); value.setInboundCode("IN-" + id);
value.setRequiredPackageCount(1); value.setDestinationType("FBA");
return value;
}
private static InboundPlanPalletAllocation allocation(long lineId) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setId(1000L + lineId);
value.setPlanId(lineId == 101L ? 1L : 2L); value.setPalletId(3000L + lineId);
value.setLineId(lineId); value.setPickedPackageCount(1); value.setCountedPackageCount(1);
value.setActualVolumeCbm(BigDecimal.ONE);
return value;
}
}
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -50,6 +51,7 @@ class InboundPdaWorkflowServiceTest {
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock LastlegOrderMapper orders;
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
@Mock DropshipOrderService dropshipOrders;
private InboundPdaWorkflowService service;
@@ -72,6 +74,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
@@ -87,6 +90,9 @@ class InboundPdaWorkflowServiceTest {
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
LastlegOrder sourceOrder = new LastlegOrder();
sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1");
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
}
@Test
@@ -100,6 +106,27 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).insert(any(InboundPdaTask.class));
}
@Test
void taskReturnsPalletAndAllocationWorkInstructions() {
when(repository.task("100", plan.getId())).thenReturn(task);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
InboundPlanCarton expected = carton("BOX-INSTRUCTION", "EXPECTED", targetLine.getId(), "WH-A");
expected.setSkuCode("SKU-A");
when(repository.expectedCartons(plan.getId())).thenReturn(List.of(expected));
var result = service.task("100", "LIP1");
assertEquals(targetPallet.getId(), result.pallets().get(0).planPalletId());
assertEquals(target.getId(), result.allocations().get(0).allocationId());
assertEquals("RK-A", result.allocations().get(0).inboundCode());
assertEquals(3, result.allocations().get(0).plannedPackageCount());
assertEquals("BOX-INSTRUCTION", result.expectedCartons().get(0).cartonMark());
assertEquals("SKU-A", result.expectedCartons().get(0).skuCode());
assertEquals(targetPallet.getId(), result.expectedCartons().get(0).planPalletId());
}
@Test
void assignmentReservesWarehouseResourcesAndPersistsTheLock() {
targetPallet.setDestinationWarehouseId("88");
@@ -438,7 +465,8 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L)));
verify(unloadCompletedOutbox).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"),
any(), any(), anyLong());
}
@Test
@@ -450,7 +478,7 @@ class InboundPdaWorkflowServiceTest {
assertEquals("COMPLETED", result.status());
verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any());
verify(repository, never()).allocations(plan.getId());
verify(repository, never()).update(task);
}
@Test
@@ -588,6 +616,7 @@ class InboundPdaWorkflowServiceTest {
value.setCustomerId("100");
value.setPlanNo("LIP1");
value.setContainerNo("CONT-1");
value.setSourceOrderId(1001L);
value.setStatus("ASSIGNED");
return value;
}
@@ -8,7 +8,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -46,6 +48,7 @@ class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock InboundStockReceiptMapper receipts;
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -57,10 +60,12 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of());
}
@Test
@@ -85,7 +90,9 @@ class InboundStockReceiptServiceTest {
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertEquals("100", history.getValue().getCustomerId());
}
@Test
@@ -107,6 +114,30 @@ class InboundStockReceiptServiceTest {
verify(receipts, times(2)).insert(any(InboundStockReceipt.class));
}
@Test
void exactFbaSkuReceiptsPreserveSkuToPalletAttribution() {
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);
ReceivingSkuReceipt first = skuReceipt(12L, 21L, "SKU-1", 5);
ReceivingSkuReceipt secondReceipt = skuReceipt(12L, 22L, "SKU-2", 5);
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(first, secondReceipt));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream()
.map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet()));
}
@Test
void conservesSingleSkuPackagesAndQuantityAcrossPallets() {
Fixture fixture = fixture(5, 2, 2, 11);
@@ -255,6 +286,12 @@ class InboundStockReceiptServiceTest {
allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation;
}
private ReceivingSkuReceipt skuReceipt(long lineId, long palletId, String skuCode, int packages) {
ReceivingSkuReceipt receipt = new ReceivingSkuReceipt(); receipt.setLineId(lineId); receipt.setPalletId(palletId);
receipt.setSkuCode(skuCode); receipt.setProductName(skuCode + " name"); receipt.setReceivedPackages(packages);
return receipt;
}
private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order,
LastlegOrderDetail detail, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet) {}
@@ -25,6 +25,7 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.baiyue.common.core.api.ResultCode;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxRelayTest {
@@ -78,6 +79,24 @@ class InboundUnloadCompletedOutboxRelayTest {
verify(repository, never()).published(anyLong(), anyString(), any());
}
@Test
void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() 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.failure(ResultCode.BAD_REQUEST,
"事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class),
contains("事件目标区域与当前事件桥区域不一致"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
private LastlegEventOutbox row(EventEnvelope event) throws Exception {
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(9001L);
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundUnloadCompletedOutboxServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final LastlegOrderMapper orders = mock(LastlegOrderMapper.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final InboundUnloadCompletedOutboxService service = new InboundUnloadCompletedOutboxService();
@BeforeEach
void setup() {
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", json);
}
@Test
void freezesContainerCompletionSeparatelyFromPhotoUpload() throws Exception {
Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z");
Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z");
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
containerCompletedAt, photoUploadedAt, 7);
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
LastlegEventOutbox row = captured.getValue();
EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt);
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt);
assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1");
assertThat(envelope.aggregateId()).isEqualTo("booking-1");
}
@Test
void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception {
InboundPlan plan = new InboundPlan();
plan.setSourceOrderId(11L);
plan.setContainerNo("MSCU1234567");
InboundPdaTask task = new InboundPdaTask();
task.setCustomerId("100");
task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00"));
task.setUnloadPhotoUploadedAt(LocalDateTime.parse("2026-08-24T08:00:00"));
task.setVersion(3);
LastlegOrder order = new LastlegOrder();
order.setSourceContainerBookingId("booking-1");
when(orders.selectById(11L)).thenReturn(order);
service.publish(plan, task, List.of(1L));
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
EventEnvelope envelope = json.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(Instant.parse("2026-08-24T09:30:00Z"));
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(Instant.parse("2026-08-24T08:00:00Z"));
}
@Test
void repeatedContainerCompletionDoesNotPublishAnotherEvent() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8);
verify(mapper, never()).insert(any(LastlegEventOutbox.class));
}
@Test
void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "TGHU7654321",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicts with another container");
}
private LastlegEventOutbox existing(String customerId, String bookingId, String containerNo) {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1(bookingId, containerNo,
Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null);
EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"),
com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN,
customerId, "CONTAINER_BOOKING", bookingId, 7, 1L,
"123e4567-e89b-12d3-a456-426614174001",
EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null,
json.valueToTree(payload), "sha256:" + "0".repeat(64));
LastlegEventOutbox row = new LastlegEventOutbox();
row.setCustomerId(customerId);
row.setAggregateId(bookingId);
row.setPayloadJson(write(envelope));
return row;
}
private String write(Object value) {
try {
return json.writeValueAsString(value);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -0,0 +1,459 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerServiceBehaviorTest {
@Mock InboundPlanMapper plans;
@Mock InboundPlanLineMapper lines;
@Mock InboundPlanPalletMapper pallets;
@Mock InboundPlanPalletAllocationMapper allocations;
@Mock InboundPlanCartonMapper cartons;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock ReceivingSkuReceiptMapper skuReceipts;
@Mock ContainerShippingGateMapper shippingGates;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
private ReceivingContainerService service;
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service, "plans", plans);
ReflectionTestUtils.setField(service, "lines", lines);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "allocations", allocations);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
}
@Test
void containerIsHiddenAndPrepareRejectedBeforePickupReady() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0);
assertThat(service.candidates("7", 100L)).isEmpty();
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("not-ready")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
@Test
void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
verify(operations).insert(saved.capture());
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@Test
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() {
InboundPlan first = plan("CONT-A");
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
}
@Test
void candidatesIncludeUnassignedAndSameWarehouseButHideOtherWarehouse() {
InboundPlan unassigned = plan("CONT-A");
InboundPlan same = plan("CONT-B"); same.setId(3L); same.setOverseasWarehouseId(100L);
InboundPlan other = plan("CONT-C"); other.setId(4L); other.setOverseasWarehouseId(200L);
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@Test
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse);
stubCurrent("FBA", "CONT-A");
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(cartons);
verifyNoInteractions(warehouse);
}
@Test
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1"));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A");
InboundPlanLine overseasLine = line("OVERSEAS");
overseasLine.setDestinationWarehouseId("900");
when(lines.selectList(any())).thenReturn(List.of(overseasLine));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
var resolved = service.resolvePallet("7",
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
assertThat(resolved.compatible()).isTrue();
assertThat(resolved.warehouseId()).isEqualTo(100L);
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
}
@Test
void containerCompletionRequiresEveryInboundCodeToBalanceExactly() {
stubCurrent("FBA", "CONT-A");
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any(), any());
}
@Test
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@Test
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
.isSameAs(failure);
verify(workflow).compensateReservation("100", 55L, failure);
}
@Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
var response = service.sign("7", "receiver", request);
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
ReceivingPhotoRole.DOOR_OPEN);
verify(unloadPhotos).validate("100", 8L, 1L);
verify(unloadPhotos).validate("100", 8L, 2L);
verify(unloadPhotos).validate("100", 8L, 3L);
}
@Test
void signRejectsDuplicateRolesBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void signRejectsMissingPhotosBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(), "missing-photos");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void signRejectsDuplicateFileIdsBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
stubCurrent("FBA", "CONT-A");
stubSku();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
received.setReceivedPackages(2);
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
LastlegOrder order = order(destinationType);
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line));
when(orders.selectById(1L)).thenReturn(order);
}
private void stubSku() {
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 2)));
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of());
}
private static InboundPlan plan(String containerNo) {
InboundPlan value = new InboundPlan();
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
value.setStatus("SIGNED");
return value;
}
private static InboundPlanLine line(String destinationType) {
InboundPlanLine value = new InboundPlanLine();
value.setId(10L); value.setCustomerId("100"); value.setPlanId(2L); value.setOrderId(1L);
value.setInboundCode("IN-1"); value.setDestinationType(destinationType); value.setRequiredPackageCount(2);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static LastlegOrderDetail detail(String skuCode, int packages) {
LastlegOrderDetail value = new LastlegOrderDetail();
value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L);
value.setSkuCode(skuCode); value.setProductNameZh("产品一"); value.setPackageCount(packages);
return value;
}
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
private static InboundPlanCarton carton(long orderId, String cartonMark) {
InboundPlanCarton value = new InboundPlanCarton();
value.setOrderId(orderId); value.setCartonMark(cartonMark); value.setStatus("UNASSIGNED");
return value;
}
private static InboundPlanPallet planPallet() {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(20L); value.setCustomerId("100"); value.setPlanId(2L);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
return value;
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
}
private static WarehouseResourceGateway.WarehouseReference warehouseReference() {
return new WarehouseResourceGateway.WarehouseReference(100L, "SHARED-LAX", "Shared LAX", "ACTIVE");
}
private String hash(Object value) {
try {
String json = new ObjectMapper().findAndRegisterModules().copy()
.configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.writeValueAsString(value);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
}
@@ -95,6 +95,24 @@ class InboundPlanServiceTest {
assertEquals("CONT-1", result.containerNo());
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
assertEquals(2L, result.sourceOrderId());
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).insert(line.capture());
assertEquals("2090000000000000001", line.getValue().getDestinationWarehouseId());
verify(planMapper).insert(any(InboundPlan.class));
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();
@@ -4,14 +4,18 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -68,6 +72,16 @@ class InventorySchemaContractTest {
}
}
@Test
void positionQuerySqlReferencesOnlyFieldsExposedByThePositionQueryDto() throws NoSuchMethodException {
Select select = InventoryQueryMapper.class.getMethod("positions", String.class, long.class,
InventoryQueryRequests.PositionQuery.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertFalse(sql.contains("q.skuCode"));
assertFalse(sql.contains("q.warehouseLocationCode"));
assertFalse(sql.contains("sku_code"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -108,9 +122,12 @@ class InventorySchemaContractTest {
assertThrowsNoField(InventoryPosition.class, "productQuantity");
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) {
var annotation = InventoryLedger.class.getDeclaredField(field)
.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class);
assertNotNull(annotation, field);
assertFalse(annotation.exist(), field);
}
}
private static void assertThrowsNoField(Class<?> type, String fieldName) {
@@ -92,6 +92,22 @@ class InventoryCommandServiceTest {
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void registersIdOnlyOverseasDestinationWithoutInventingAWarehouseCode() {
when(balances.lockDimension("100", "WH-1", "", 101L, "IN-CODE-1")).thenReturn(null);
InventoryCommandRequests.InTransit request = new InventoryCommandRequests.InTransit(
"Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-OVERSEAS", null,
"New York warehouse", 101L, "IN-1", "IN-CODE-1", "SKU-1", "Product 1", null,
1, "ORD-1", "EVT-1", "transit-id-only", null);
service.registerInTransit("100", "SYSTEM", request);
ArgumentCaptor<InventoryBalance> inserted = ArgumentCaptor.forClass(InventoryBalance.class);
verify(balances).insert(inserted.capture());
assertEquals("DEST-OVERSEAS", inserted.getValue().getDestinationWarehouseId());
assertEquals("", inserted.getValue().getDestinationWarehouseCode());
}
@Test
void stockInTransfersPackagesFromInTransitToAvailable() {
InventoryBalance balance = balance(200L, 0);
@@ -22,19 +22,25 @@ class InventoryQueryControllerTest {
@Test
void platformBalancePageQueriesAllCustomers() {
controller.balances(user(0L, "PLATFORM"), query);
controller.balances(user(0L, "PLATFORM"), null, query);
verify(service).balances(eq(null), eq(query));
}
@Test
void platformBalancePageUsesExplicitCustomerScope() {
controller.balances(user(0L, "PLATFORM"), " 10001 ", query);
verify(service).balances(eq("10001"), eq(query));
}
@Test
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(100L, "MERCHANT"), query));
() -> controller.summary(user(100L, "MERCHANT"), null, query));
}
@Test
void platformSummaryQueriesAllCustomers() {
controller.summary(user(0L, "PLATFORM"), query);
controller.summary(user(0L, "PLATFORM"), null, query);
verify(service).dashboard(eq(null), eq(query));
}
@@ -43,7 +49,7 @@ class InventoryQueryControllerTest {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, 1L, positionQuery));
() -> controller.positions(null, 1L, null, positionQuery));
}
private static AuthenticatedUser user(long customerId, String appCode) {
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.looseboard;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -15,6 +17,8 @@ class LocalLooseboardSchemaContractTest {
}
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("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation"));
assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)"));
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
assertTrue(sql.contains("lastleg_order_id BIGINT NULL"));
@@ -22,4 +26,13 @@ class LocalLooseboardSchemaContractTest {
assertTrue(sql.contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'"));
assertTrue(sql.contains("idx_ll_loading_history_customer_order(customer_id,order_id,created_at)"));
}
@Test
void unifiedIncrementalContainsHistoryIdempotencyAndOperationLedger() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation"));
assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)"));
assertTrue(sql.contains("table_name='ll_local_looseboard_history' AND column_name='idempotency_key'"));
}
}
@@ -3,8 +3,10 @@ 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.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -22,6 +24,8 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -29,27 +33,38 @@ 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.times;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LocalLooseboardServiceTest {
@Mock LocalLooseboardOrderMapper orders;
@Mock LocalLooseboardHistoryMapper histories;
@Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Mock LastlegOrderNoGenerator lastlegOrderNoGenerator;
private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
@BeforeEach
void setUp() {
service = new LocalLooseboardService();
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);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next()).thenReturn("LMO-LOOSE-1");
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> {
LocalLooseboardOperation value = call.getArgument(0);
operationRows.put(value.getIdempotencyKey(), value);
return 1;
});
}
@Test
@@ -103,12 +118,54 @@ class LocalLooseboardServiceTest {
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var detail = service.changeStatus("100", "LB1", "7",
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted"));
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "status-1", "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())));
"STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus())
&& "7".equals(value.getOperatorId()) && "status-1".equals(value.getIdempotencyKey())));
}
@Test
void fullStatusLifecycleIsIdempotentAndCompletedCannotReverse() {
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var dispatch = new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "dispatch-1", "dispatch");
assertEquals(LocalLooseboardService.DISPATCHED,
service.changeStatus("100", "LB1", "operator-7", dispatch).summary().status());
assertEquals(1L, service.changeStatus("100", "LB1", "operator-7", dispatch).summary().version());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "dispatch-1", "different")));
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
new LocalLooseboardRequests.ChangeStatus(0L, "IN_TRANSIT", "stale-1", "stale")));
assertEquals(LocalLooseboardService.IN_TRANSIT, service.changeStatus("100", "LB1", "operator-8",
new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "transit-1", "transit"))
.summary().status());
assertEquals(LocalLooseboardService.COMPLETED, service.changeStatus("100", "LB1", "operator-9",
new LocalLooseboardRequests.ChangeStatus(2L, "COMPLETED", "complete-1", "complete"))
.summary().status());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-9",
new LocalLooseboardRequests.ChangeStatus(3L, "IN_TRANSIT", "reverse-1", "reverse")));
verify(histories, times(3)).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"STATUS".equals(value.getOperationType()) && value.getOperatorId() != null
&& value.getIdempotencyKey() != null));
}
@Test
void separateDispatchedFixtureCanCancelButCannotProgressAfterCancellation() {
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
assertEquals(LocalLooseboardService.DISPATCHED, service.changeStatus("100", "LB1", "operator-7",
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "cancel-fixture-dispatch", "dispatch"))
.summary().status());
assertEquals(LocalLooseboardService.CANCELLED, service.changeStatus("100", "LB1", "operator-7",
new LocalLooseboardRequests.ChangeStatus(1L, "CANCELLED", "cancel-1", "cancel"))
.summary().status());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
new LocalLooseboardRequests.ChangeStatus(2L, "IN_TRANSIT", "after-cancel", "invalid")));
}
private LocalLooseboardRequests.Create create(String key) {
@@ -13,6 +13,9 @@ class OverseasMasterDataOpenApiContractTest {
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("空结果不缓存")
.contains("refresh=true强制刷新")
.contains("PartnerWarehouseOption:")
.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")
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.masterdata.quote.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticPartnerWarehouseClientContractTest {
@Test
void freezesCnInternalUrlTokenHeaderAndFourFieldDto() throws Exception {
FeignClient feign = DomesticPartnerWarehouseClient.class.getAnnotation(FeignClient.class);
assertThat(feign.name()).isEqualTo("warehouse-service");
assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}");
Method method = DomesticPartnerWarehouseClient.class.getDeclaredMethod("activePartners", String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/internal/warehouses/overseas-partners");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(Arrays.stream(DomesticPartnerWarehouseClient.Option.class.getRecordComponents())
.map(component -> component.getName() + ":" + component.getType().getSimpleName()))
.containsExactly("id:Long", "warehouseCode:String", "warehouseName:String", "countryCode:String");
}
@Test
void developmentDeploymentRequiresAndPassesTheCnUrlAndSharedInternalToken() throws Exception {
String application = Files.readString(Path.of("src/main/resources/application.yml"));
String nacos = Files.readString(Path.of("../config/nacos/overseas/lastleg-service.yml"));
String common = Files.readString(Path.of("../config/nacos/overseas/baiyue-common.yml"));
String compose = Files.readString(Path.of("../deploy/docker-compose.overseas.yml"));
String deploy = Files.readString(Path.of("../deploy/scripts/deploy-services.sh"));
assertThat(application).contains("url: ${DOMESTIC_WAREHOUSE_URL:}")
.contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}");
assertThat(nacos).contains("url: ${DOMESTIC_WAREHOUSE_URL}")
.contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}");
assertThat(common).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN}");
assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}")
.contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}");
assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL")
.contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN");
}
}
@@ -3,7 +3,9 @@ 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 org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -26,5 +28,13 @@ class PlatformOverseasQuoteControllerContractTest {
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");
Method options = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("warehouseOptions"))
.findFirst().orElseThrow();
RequestParam refresh = options.getParameters()[0].getAnnotation(RequestParam.class);
assertThat(options.getParameterTypes()).containsExactly(boolean.class);
assertThat(refresh).isNotNull();
assertThat(refresh.defaultValue()).isEqualTo("false");
}
}
@@ -66,4 +66,35 @@ class PartnerWarehouseDirectoryTest {
assertThat(option.warehouseId()).isEqualTo("101"));
verify(client, times(2)).activePartners("internal-token");
}
@Test
void forcedRefreshBypassesAStillValidPositiveSnapshot() {
when(client.activePartners("internal-token"))
.thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option(
101L, "US-ONT", "安大略合作仓", "US"))))
.thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option(
102L, "US-LAX", "洛杉矶合作仓", "us"))));
assertThat(directory.options()).singleElement().satisfies(option ->
assertThat(option.warehouseId()).isEqualTo("101"));
assertThat(directory.refresh()).singleElement().satisfies(option -> {
assertThat(option.warehouseId()).isEqualTo("102");
assertThat(option.countryCode()).isEqualTo("US");
});
verify(client, times(2)).activePartners("internal-token");
}
@Test
void malformedDomesticRowsAreFilteredAndAnEmptyFilteredResultIsNotCached() {
when(client.activePartners("internal-token"))
.thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option(
101L, " ", "无编码仓", "US"))))
.thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option(
102L, "US-LAX", "洛杉矶合作仓", "US"))));
assertThat(directory.options()).isEmpty();
assertThat(directory.options()).singleElement().satisfies(option ->
assertThat(option.warehouseId()).isEqualTo("102"));
verify(client, times(2)).activePartners("internal-token");
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleApplicationServiceTest {
private VehicleRepository repository;
private VehicleApplicationService service;
@BeforeEach
void setUp() {
repository = mock(VehicleRepository.class);
service = new VehicleApplicationService(repository);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT));
}
@Test
void rejectsDecreasingOdometer() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
@Test
void rejectsUnknownStatus() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L)))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
private static VehicleModels.Update update(long version, long mileage) {
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static VehicleSnapshot vehicle(long version, long mileage) {
return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null);
}
}
@@ -1,63 +0,0 @@
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;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
class PdaPalletOperationControllerContractTest {
@Test void exposesOnlyDedicatedPdaPalletEndpointsWithCanonicalPickerAuthority() {
RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class);
assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value());
PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class);
assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value());
Set<String> paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods())
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
}
}
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
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.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PdaPalletOperationServiceTest {
@Mock private PdaPalletOperationMapper mapper;
@Mock private InventoryPositionMapper positions;
@Mock private InventoryBalanceMapper balances;
@Mock private InventoryLedgerMapper ledgers;
@Mock private PalletPhysicalGateway physical;
@Mock private PdaWarehouseService history;
private PdaPalletOperationService service;
@BeforeEach void setUp() {
service = new PdaPalletOperationService();
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
service.configureTransactions(new NoOpTransactionManager());
}
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
new PalletOperationRequests.Scan("P-1")));
verifyNoInteractions(mapper);
}
@Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() {
var source = pallet(10L, "SOURCE", 88L, 1);
var target = pallet(20L, "TARGET", 88L, 2);
when(physical.resolve("SOURCE")).thenReturn(source);
when(physical.resolve("TARGET")).thenReturn(target);
when(physical.resolveLocation(88L, target.locationId())).thenReturn(
new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(),
"STORAGE", "ACTIVE"));
PalletInventoryRow planned = row(100L, 10L, 1L, 5L);
PalletInventoryRow stale = row(100L, 10L, 2L, 5L);
when(mapper.content("88", 10L)).thenReturn(List.of(planned));
when(mapper.content("88", 20L)).thenReturn(List.of());
when(mapper.palletPackages("88", 10L)).thenReturn(5L);
when(mapper.palletPackages("88", 20L)).thenReturn(0L);
when(mapper.lockPosition("88", 100L)).thenReturn(stale);
AtomicReference<PdaPalletOperation> operation = new AtomicReference<>();
doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; })
.when(mapper).insert(any(PdaPalletOperation.class));
when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get());
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
var request = new PalletOperationRequests.Assemble("idem-1",
new PalletOperationRequests.PalletExpectation("TARGET", 2),
List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)),
List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble");
assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request));
verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class));
assertEquals("FAILED_COMPENSATED", operation.get().getStatus());
verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class));
verifyNoInteractions(ledgers, history);
}
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
id == 20L ? "IDLE" : "OCCUPIED", null, version);
}
private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,
String targetStatus) {
return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(),
pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(),
pallet.version(), pallet.version() + 1, "APPLIED", false);
}
private static PalletInventoryRow row(long positionId, long palletId, long version, long packages) {
PalletInventoryRow value = new PalletInventoryRow(); value.setCustomerId("100"); value.setCustomerName("C");
value.setBalanceId(200L); value.setPositionId(positionId); value.setInboundCode("IN-1");
value.setSkuCode("SKU-1"); value.setDestinationWarehouseCode("ONT8"); value.setPalletId(palletId);
value.setPalletNo(palletId == 10L ? "SOURCE" : "TARGET"); value.setBatchNo("DEFAULT");
value.setWarehouseLocationId(1010L); value.setWarehouseLocationCode("L-1");
value.setAvailablePackages(packages); value.setHoldPackages(0L); value.setAllocatedPackages(0L);
value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value;
}
private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager {
@Override protected Object doGetTransaction() { return new Object(); }
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { }
@Override protected void doCommit(DefaultTransactionStatus status) { }
@Override protected void doRollback(DefaultTransactionStatus status) { }
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.pda;
import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Set;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class PdaInventoryOperationContractTest {
@Test
void controllersUseOnlyCanonicalPdaIdentityAndRoutes() {
assertThat(PdaStocktakeController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/pda/v1/last-leg/stocktakes");
assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/pda/v1/last-leg/inventory-moves");
assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
}
@Test
void deviceRequestsCannotSupplyIdentityOrWarehouseScope() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorId");
for (Class<?> type : List.of(PdaStocktakeRequests.Create.class, PdaStocktakeRequests.Count.class,
PdaStocktakeRequests.Submit.class, PdaInventoryMoveRequest.class)) {
Set<String> fields = Stream.of(type.getRecordComponents()).map(component -> component.getName())
.collect(Collectors.toSet());
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
}
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import static org.assertj.core.api.Assertions.assertThat;
class PdaOperationsMapperConfigurationTest {
@Test
void scansEveryPdaWarehouseOperationMapperPackage() {
MapperScan mapperScan = PdaOperationsMapperConfiguration.class.getAnnotation(MapperScan.class);
assertThat(mapperScan.basePackages()).containsExactlyInAnyOrder(
"com.baiyue.lastleg.palletops.mapper",
"com.baiyue.lastleg.pda.move.mapper",
"com.baiyue.lastleg.pda.stocktake.mapper",
"com.baiyue.lastleg.pda.warehouse.mapper"
);
}
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.pda;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdaOperationsOpenApiContractTest {
private static final Set<String> SERVER_OWNED = Set.of(
"customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName");
@Test
void pdaOperationRequestsNeverAcceptServerOwnedScope() {
List<Class<?>> containers = List.of(PdaStocktakeRequests.class, PalletOperationRequests.class,
PdaRelabelRequests.class);
for (Class<?> container : containers) {
for (Class<?> type : container.getDeclaredClasses()) {
if (type.isRecord()) assertNoServerOwnedFields(type);
}
}
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
}
@Test
void versionedOpenApiPublishesEveryPdaWarehouseOperation() throws IOException {
String yaml = resource("openapi/overseas-pda-operations-v1.yaml");
List<String> paths = List.of(
"/api/pda/v1/last-leg/stocktakes:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:",
"/api/pda/v1/last-leg/inventory-moves:",
"/api/pda/v1/last-leg/pallet-operations/pallets/resolve:",
"/api/pda/v1/last-leg/pallet-operations/assemble:",
"/api/pda/v1/last-leg/pallet-operations/disassemble:",
"/api/pda/v1/last-leg/pallet-operations/swap:",
"/api/pda/v1/last-leg/pallet-operations/unbind:",
"/api/pda/v1/last-leg/relabel-tasks/candidates:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(19, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) {
assertFalse(yaml.contains(excluded), excluded);
}
}
private static void assertNoServerOwnedFields(Class<?> type) {
Set<String> fields = Arrays.stream(type.getRecordComponents())
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, SERVER_OWNED), type.getSimpleName());
}
private String resource(String name) throws IOException {
try (var input = getClass().getClassLoader().getResourceAsStream(name)) {
assertTrue(input != null, name);
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
@@ -0,0 +1,141 @@
package com.baiyue.lastleg.pda.move.service;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
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.ArgumentCaptor;
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.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaInventoryMoveServiceTest {
@Mock PdaInventoryMoveMapper moves;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
@Mock InventoryLedgerMapper ledgers;
@Mock WarehouseResourceGateway warehouse;
@Mock PalletPhysicalGateway physical;
@Mock PdaWarehouseService history;
private PdaInventoryMoveService service;
@BeforeEach
void setUp() {
service = new PdaInventoryMoveService(); ReflectionTestUtils.setField(service, "moves", moves);
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2));
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
position.setAvailablePackages(5L); position.setHoldPackages(1L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
when(balances.selectById(10L)).thenReturn(balance);
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false));
var response = service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"));
verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L
&& value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"),
eq(21L), anyString(), eq("库位优化"));
ArgumentCaptor<InventoryPosition> updated = ArgumentCaptor.forClass(InventoryPosition.class);
verify(positions).updateById(updated.capture());
assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L);
assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01");
assertThat(response.packageCount()).isEqualTo(6L);
assertThat(response.palletVersion()).isEqualTo(3);
assertThat(response.status()).isEqualTo("COMPLETED");
verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class));
}
@Test
void rejectsCrossWarehousePalletWithoutMovingResources() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(901L, 20L, 2));
assertThatThrownBy(() -> service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any());
}
@Test
void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3));
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
position.setAvailablePackages(5L); position.setHoldPackages(0L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
when(balances.selectById(10L)).thenReturn(balance);
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
eq(21L), anyString(), eq("恢复移库"))).thenReturn(transition(true));
var response = service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-retry", "恢复移库"));
verify(physical).apply(eq(900L), argThat(value -> value.locationId() == 20L && value.version() == 2),
eq("OCCUPIED"), eq(21L), anyString(), eq("恢复移库"));
assertThat(response.sourceLocationCode()).isEqualTo("A-01");
assertThat(response.targetLocationCode()).isEqualTo("B-01");
verify(positions).updateById(any(InventoryPosition.class));
}
private static WarehouseResourceGateway.WarehousePalletReference pallet(long warehouseId, long locationId, int version) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, locationId,
"OCCUPIED", null, version);
}
private static WarehouseResourceGateway.WarehouseLocationReference location(long id, String code) {
return new WarehouseResourceGateway.WarehouseLocationReference(id, code, "STORAGE", BigDecimal.TEN, "OCCUPIED");
}
private static PalletPhysicalGateway.Transition transition(boolean replayed) {
return new PalletPhysicalGateway.Transition("move-transition", 30L, "PAL-1", 900L,
"OCCUPIED", "OCCUPIED", 20L, 21L, 2, 3, "APPLIED", replayed);
}
}
@@ -0,0 +1,146 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
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.ArgumentCaptor;
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.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaStocktakeServiceTest {
@Mock PdaStocktakeTaskMapper tasks;
@Mock PdaStocktakeItemMapper items;
@Mock PdaStocktakeActionMapper actions;
@Mock PdaStocktakeInventoryMapper inventory;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
@Mock InventoryLedgerMapper ledgers;
@Mock WarehouseResourceGateway warehouse;
@Mock PdaWarehouseService history;
private PdaStocktakeService service;
@BeforeEach
void setUp() {
service = new PdaStocktakeService();
ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "actions", actions); ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "history", history);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void createsWarehouseBoundLocationTaskAndFreezesEveryPosition() {
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(
20L, "A-01", "STORAGE", BigDecimal.TEN, "OCCUPIED"));
StocktakeCandidate candidate = new StocktakeCandidate(); candidate.setCustomerId("100");
candidate.setBalanceId(10L); candidate.setPositionId(11L); candidate.setSkuCode("SKU-1");
candidate.setPalletId(30L); candidate.setPalletCode("PAL-1"); candidate.setLocationId(20L);
candidate.setLocationCode("A-01"); candidate.setBookPackages(8L); candidate.setPositionVersion(3L);
when(items.candidatesByLocation("900", 20L, "A-01")).thenReturn(List.of(candidate));
when(items.views(anyLong())).thenReturn(List.of());
var response = service.create(900L, "operator-1", new PdaStocktakeRequests.Create(
"location", "A-01", "create-1"));
ArgumentCaptor<PdaStocktakeTask> task = ArgumentCaptor.forClass(PdaStocktakeTask.class);
ArgumentCaptor<PdaStocktakeItem> item = ArgumentCaptor.forClass(PdaStocktakeItem.class);
verify(tasks).insert(task.capture()); verify(items).insert(item.capture());
assertThat(task.getValue().getOverseasWarehouseId()).isEqualTo(900L);
assertThat(task.getValue().getScopeType()).isEqualTo("LOCATION");
assertThat(task.getValue().getCreatedBy()).isEqualTo("operator-1");
assertThat(item.getValue().getCustomerId()).isEqualTo("100");
assertThat(item.getValue().getBookPackages()).isEqualTo(8L);
assertThat(item.getValue().getPositionVersion()).isEqualTo(3L);
assertThat(response.task().status()).isEqualTo("IN_PROGRESS");
}
@Test
void rejectsPalletFromAnotherWarehouseBeforeReadingInventory() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
30L, "PAL-1", 901L, 20L, "OCCUPIED", null, 2));
assertThatThrownBy(() -> service.create(900L, "operator-1",
new PdaStocktakeRequests.Create("PALLET", "PAL-1", "create-1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(items, never()).candidatesByPallet(anyString(), anyLong());
}
@Test
void submitsCountedDifferenceAndWritesBalancedAuditLedger() {
PdaStocktakeTask task = new PdaStocktakeTask(); task.setId(40L); task.setTaskNo("CC-40");
task.setOverseasWarehouseId(900L); task.setScopeType("PALLET"); task.setScopeCode("PAL-1");
task.setStatus("IN_PROGRESS"); task.setPositionCount(1); task.setCountedCount(1); task.setDifferenceCount(1);
task.setVersion(2L);
PdaStocktakeTask submitted = new PdaStocktakeTask(); submitted.setId(40L); submitted.setTaskNo("CC-40");
submitted.setOverseasWarehouseId(900L); submitted.setScopeType("PALLET"); submitted.setScopeCode("PAL-1");
submitted.setStatus("SUBMITTED"); submitted.setPositionCount(1); submitted.setCountedCount(1);
submitted.setDifferenceCount(1); submitted.setVersion(3L);
when(tasks.find(900L, "CC-40")).thenReturn(task, submitted);
when(tasks.lock(900L, "CC-40")).thenReturn(task); when(tasks.submit(40L, 2L, "operator-1", "复盘确认")).thenReturn(1);
PdaStocktakeItem item = new PdaStocktakeItem(); item.setId(41L); item.setTaskId(40L); item.setCustomerId("100");
item.setBalanceId(10L); item.setPositionId(11L); item.setSkuCode("SKU-1"); item.setBookPackages(5L);
item.setActualPackages(7L); item.setDifferencePackages(2L); item.setPositionVersion(3L); item.setVersion(1L);
when(items.lockAll(40L)).thenReturn(List.of(item)); when(items.views(40L)).thenReturn(List.of());
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
position.setPalletNo("PAL-1"); position.setAvailablePackages(5L); position.setHoldPackages(0L);
position.setAllocatedPackages(0L); position.setPickedPackages(0L); position.setReceivingPackages(0L);
position.setVersion(3L);
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("900"); balance.setAvailablePackages(5L); balance.setVersion(8L);
when(inventory.lockPosition(11L, "100", "900")).thenReturn(position);
when(inventory.lockBalance(10L, "100", "900")).thenReturn(balance);
var response = service.submit(900L, "operator-1", "CC-40",
new PdaStocktakeRequests.Submit(2L, "复盘确认", "submit-1"));
ArgumentCaptor<InventoryPosition> changedPosition = ArgumentCaptor.forClass(InventoryPosition.class);
ArgumentCaptor<InventoryBalance> changedBalance = ArgumentCaptor.forClass(InventoryBalance.class);
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(positions).updateById(changedPosition.capture()); verify(balances).updateById(changedBalance.capture());
verify(ledgers).insert(ledger.capture());
assertThat(changedPosition.getValue().getAvailablePackages()).isEqualTo(7L);
assertThat(changedBalance.getValue().getAvailablePackages()).isEqualTo(7L);
assertThat(ledger.getValue().getBeforePackages()).isEqualTo(5L);
assertThat(ledger.getValue().getDeltaPackages()).isEqualTo(2L);
assertThat(ledger.getValue().getAfterPackages()).isEqualTo(7L);
assertThat(ledger.getValue().getBusinessType()).isEqualTo("STOCKTAKE_ADJUST");
assertThat(response.task().status()).isEqualTo("SUBMITTED");
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.pda.warehouse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.controller.PdaWarehouseController;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PdaWarehouseContractTest {
@Test
void requestsNeverAcceptServerOwnedIdentityOrResourceIds() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "planNo", "allocationId", "operatorId");
for (Class<?> type : PdaWarehouseRequests.class.getDeclaredClasses()) {
if (!type.isRecord()) continue;
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
.collect(Collectors.toSet());
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
}
}
@Test
void controllerRequiresPickerRoleAndPdaAuthority() {
PreAuthorize permission = PdaWarehouseController.class.getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo(
"hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
}
@Test
void identityRejectsUnboundOrNonPickerPlatformAccounts() {
AuthenticatedUser unbound = user(Set.of(PdaWarehouseIdentity.PICKER_ROLE,
PdaWarehouseIdentity.OPERATE_PERMISSION), null);
assertThatThrownBy(() -> PdaWarehouseIdentity.require(unbound))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
AuthenticatedUser admin = user(Set.of(PdaWarehouseIdentity.OPERATE_PERMISSION), 900L);
assertThatThrownBy(() -> PdaWarehouseIdentity.require(admin))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
assertThat(PdaWarehouseIdentity.require(user(Set.of(PdaWarehouseIdentity.PICKER_ROLE,
PdaWarehouseIdentity.OPERATE_PERMISSION), 900L)).overseasWarehouseId()).isEqualTo(900L);
}
@Test
void baselineContainsShippingGateAndRealOperationHistory() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")).toLowerCase();
assertThat(sql).contains("create table if not exists ll_container_shipping_gate")
.contains("awaiting_pickup_ready")
.contains("receiving_available")
.contains("create table if not exists ll_pda_operation_history")
.contains("unique key uk_ll_pda_history_idempotency(operator_key,idempotency_key)");
}
@Test
void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception {
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
for (String path : Set.of("/api/pda/v1/last-leg/inventory/resolve:",
"/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:",
"/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path);
assertThat(openapi).doesNotContain("/api/pda/v1/last-leg/inbound-plans/{planNo}");
String gateway = Files.readString(Path.of("../gateway-overseas/src/main/resources/application-overseas.yml"));
assertThat(gateway).contains("/api/pda/v1/last-leg/**").contains("lb://lastleg-service");
}
private static AuthenticatedUser user(Set<String> authorities, Long warehouseId) {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA",
authorities, false, null, null, false, warehouseId);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.pda.warehouse.service;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
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.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaWarehouseServiceTest {
@Mock PdaWarehouseMapper mapper;
@Mock WarehouseResourceGateway warehouse;
private PdaWarehouseService service;
@BeforeEach
void setUp() {
service = new PdaWarehouseService();
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
}
@Test
void inventoryScanIsBoundToTokenWarehouse() {
when(mapper.scanInventory("900", "SKU-1")).thenReturn(List.of(inventory()));
var result = service.inventory(900L, new PdaWarehouseRequests.Scan("SKU-1"));
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).customerId()).isEqualTo("100");
}
@Test
void palletFromAnotherWarehouseIsRejected() {
when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
1L, "PAL-1", 901L, 2L, "OCCUPIED", null, 1));
assertThatThrownBy(() -> service.pallet(900L, new PdaWarehouseRequests.Scan("PAL-1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void locationCombinesPhysicalCapacityAndInventoryOccupation() {
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(
new WarehouseResourceGateway.WarehouseLocationReference(2L, "A-01", "STORAGE",
new BigDecimal("1000"), "OCCUPIED"));
when(mapper.inventoryByLocation("900", 2L, "A-01", "__NO_CARTON__"))
.thenReturn(List.of(inventory()));
var result = service.location(900L, new PdaWarehouseRequests.Scan("A-01"));
assertThat(result.occupiedPackages()).isEqualTo(5);
assertThat(result.inventoryPositionCount()).isEqualTo(1);
}
private static PdaInventoryProjection inventory() {
PdaInventoryProjection value = new PdaInventoryProjection(); value.setCustomerId("100");
value.setBalanceId(10L); value.setPositionId(11L); value.setSkuCode("SKU-1");
value.setInboundCode("IN-1"); value.setPalletId(12L); value.setPalletCode("PAL-1");
value.setLocationId(2L); value.setLocationCode("A-01"); value.setAvailablePackages(5L);
value.setHoldPackages(0L); value.setReceivingPackages(0L); value.setVersion(1L); value.setStatus("OCCUPIED");
return value;
}
}
@@ -31,6 +31,16 @@ class OverseasProcurementOpenApiContractTest {
}
assertTrue(yaml.contains("platform:overseas-business:truck:create"));
assertTrue(yaml.contains("platform:overseas-business:truck:edit"));
assertTrue(yaml.contains("/{orderNo}/review:"));
assertTrue(yaml.contains("/{orderNo}/receive-pallets:"));
assertTrue(yaml.contains("/{orderNo}/review-pallet-overage:"));
assertTrue(yaml.contains("/{orderNo}/allocate-pallets:"));
assertTrue(yaml.contains("/{orderNo}/prepare-dispatch:"));
assertTrue(yaml.contains("/{orderNo}/dispatch:"));
assertTrue(yaml.contains("/{orderNo}/start-transit:"));
assertTrue(yaml.contains("/{orderNo}/complete:"));
assertTrue(yaml.contains("enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED]"));
assertTrue(yaml.contains("required: [version, status, idempotencyKey]"));
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"));
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -16,8 +18,28 @@ class OverseasProcurementSchemaContractTest {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
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"));
}
@Test
void unifiedIncrementalContainsHistoryIdempotencyKeyForNewAndExistingDatabases() throws Exception {
String sql = Files.readString(
Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
assertTrue(sql.contains(
"ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains(
"ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains(
"ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL"));
assertTrue(sql.contains(
"table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'"));
}
}
@@ -40,7 +40,7 @@ class OverseasProcurementControllerTest {
@Test
void reviewRequiresExplicitPermission() {
var request = new OverseasProcurementRequests.Review("APPROVE", null);
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
assertEquals(403, error.getStatusCode().value());
@@ -56,7 +56,7 @@ class OverseasProcurementControllerTest {
@Test
void platformReviewUsesExplicitTargetTenant() {
var request = new OverseasProcurementRequests.Review("APPROVE", "ok");
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok");
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
"100", "HC1", request);
verify(service).review("100", "HC1", "7", request);
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
@@ -21,6 +23,7 @@ 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -142,6 +145,33 @@ class OverseasProcurementServiceTest {
assertEquals("Acme", values.get(0).customerName());
}
@Test
void approveReviewIsIdempotentAuditedAndTerminalCannotReverse() {
OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0);
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.Review(0L, "APPROVE", "approve-1", "approved");
var approved = service.review("100", "HC1", "operator-7", request);
assertEquals(OverseasProcurementService.APPROVED, approved.summary().status());
assertEquals(1L, approved.summary().version());
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
verify(histories).insert(org.mockito.ArgumentMatchers
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
&& "approve-1".equals(value.getIdempotencyKey())
&& OverseasProcurementService.APPROVED.equals(value.getToStatus())));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload")));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version")));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse")));
assertEquals(OverseasProcurementService.APPROVED, order.getStatus());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
OverseasProcurementOrder order = new OverseasProcurementOrder();
order.setId(1L);
@@ -155,4 +185,15 @@ class OverseasProcurementServiceTest {
order.setUpdatedAt(order.getCreatedAt());
return order;
}
@Test
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
assertNotNull(annotation, field);
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field);
}
}
}
@@ -51,6 +51,9 @@ class RelabelContractTest {
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status"));
String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"),
openApi.indexOf("components:"));
assertFalse(relabelPaths.contains("name: customerId"));
assertTrue(relabelPaths.contains("name: customerId, in: query, required: true"));
String relabelCreate = openApi.substring(openApi.indexOf(" RelabelCreate:"),
openApi.indexOf(" RelabelItem:"));
assertFalse(relabelCreate.contains("customerId"));
}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.relabel.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class RelabelApplicationControllerTest {
private final RelabelApplicationService service = mock(RelabelApplicationService.class);
private final RelabelApplicationController controller = controller();
@Test
void platformCreateUsesExplicitTargetCustomer() {
RelabelRequests.Create request = request();
controller.create(platform(), "190000000000000001", request);
verify(service).create("190000000000000001", "7", request);
}
@Test
void platformCreateRejectsMissingOrPlatformCustomer() {
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), null, request()));
assertThrows(ResponseStatusException.class, () -> controller.create(platform(), "0", request()));
}
private RelabelRequests.Create request() {
return new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1",
List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1,
"SHIP-1", null, "SKU-B", "Replace", null, null, null)));
}
private AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
}
private RelabelApplicationController controller() {
RelabelApplicationController value = new RelabelApplicationController();
ReflectionTestUtils.setField(value, "service", service);
return value;
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.relabel.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.transaction.annotation.Transactional;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdaRelabelContractTest {
@Test
void controllerUsesStablePdaPathAndExactPickerAuthority() {
assertEquals("/api/pda/v1/last-leg/relabel-tasks",
PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]);
assertEquals("hasAuthority('lastleg:pda:operate')",
PdaRelabelController.class.getAnnotation(PreAuthorize.class).value());
}
@Test
void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId",
"operatorId", "operatorName", "tenantId");
for (Class<?> type : PdaRelabelRequests.class.getDeclaredClasses()) {
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
.collect(Collectors.toSet());
assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope");
}
}
@Test
void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() {
AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L);
assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId());
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L)));
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null)));
}
@Test
void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact",
"ll_relabel_pda_operation", "ll_relabel_pda_history")) {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table));
}
assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)"));
assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)"));
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task");
int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start);
String block = sql.substring(start, end);
assertFalse(block.contains("dispatch_no"));
assertFalse(block.contains("delivery_no"));
assertFalse(block.contains("outbound_order_no"));
}
@Test
void everyStateMutationIsTransactional() throws Exception {
Class<?> service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService");
for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) {
assertTrue(Arrays.stream(service.getDeclaredMethods())
.filter(candidate -> method.equals(candidate.getName()))
.anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)),
method + " must be transactional");
}
}
private static AuthenticatedUser user(Set<String> permissions, Long warehouseId) {
return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA",
permissions, false, null, null, false, warehouseId);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.relabel.pda.service;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaRelabelServiceTest {
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock PdaRelabelTaskMapper tasks;
@Mock PdaRelabelUnitFactMapper unitFacts;
@Mock PdaRelabelOperationMapper operations;
@Mock PdaRelabelHistoryMapper histories;
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
List<PdaRelabelUnitFact> persistedFacts;
@BeforeEach
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "applicationService", applicationService);
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper());
service.initializeObjectMapper();
application = application("PENDING_PROCESSING");
item = item();
persistedFacts = new ArrayList<>();
lenient().when(operations.find(anyString(), anyString())).thenReturn(null);
lenient().when(items.selectList(any())).thenReturn(List.of(item));
lenient().when(histories.selectList(any())).thenReturn(List.of());
lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts));
lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; });
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
}
@Test
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(null);
ArgumentCaptor<PdaRelabelTask> task = ArgumentCaptor.forClass(PdaRelabelTask.class);
var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"));
verify(tasks).insert(task.capture());
assertEquals(900L, task.getValue().getOverseasWarehouseId());
assertEquals("100", task.getValue().getCustomerId());
assertEquals(PdaRelabelService.PROCESSING, result.taskStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")));
assertEquals(404, error.getStatusCode().value());
verify(tasks, never()).insert(any(PdaRelabelTask.class));
}
@Test
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(null);
ArgumentCaptor<PdaRelabelUnitFact> fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class);
var result = service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1"));
verify(unitFacts).insert(fact.capture());
assertEquals("RELABELLED", fact.getValue().getResult());
assertEquals("BOX-1", fact.getValue().getOldLabel());
assertEquals(1, result.relabelledPackages());
assertEquals(1, result.version());
}
@Test
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void oldLabelAlreadyAttributedToAnotherTaskIsRejected() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
PdaRelabelUnitFact otherTaskFact = fact("RELABELLED");
otherTaskFact.setTaskId(99L);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact);
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
task.setRelabelledPackages(1);
persistedFacts.add(fact("RELABELLED"));
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
application.setStatus("PROCESSING");
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(approved);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
assertEquals("COMPLETED", result.taskStatus());
assertEquals("COMPLETED", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
assertEquals("PROCESSING", result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null)));
verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong());
}
private RelabelApplication application(String status) {
RelabelApplication value = new RelabelApplication();
value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1");
value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900");
value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1);
value.setLabelDeadline(LocalDateTime.now().plusDays(1));
return value;
}
private RelabelItem item() {
RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100");
value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product");
value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value;
}
private PdaRelabelTask task(String status, int version) {
PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100");
value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status);
value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1);
value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0);
value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value;
}
private PdaRelabelUnitFact fact(String result) {
PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L);
value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100");
value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result);
value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value;
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class UnloadCompletedOutboxRepositoryTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void claimRechecksRetryWindowAfterCandidateSelection() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
LastlegEventOutbox.class);
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "status", "locked_at");
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.schema;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LastlegOrderDetailSchemaContractTest {
@Test
void orderDetailHasCustomerIsolationInBaselineAndRepeatableUpgrade() throws Exception {
String baseline = new ClassPathResource("db/lastleg.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
String service = Files.readString(Path.of("src", "main", "java", "com", "baiyue", "lastleg",
"service", "LastlegOrderService.java"), StandardCharsets.UTF_8);
assertThat(baseline)
.contains("CREATE TABLE IF NOT EXISTS ll_order_detail")
.contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'")
.contains("KEY idx_ll_detail_customer_order(customer_id,order_id,id)");
assertThat(unified)
.contains("table_name='ll_order_detail' AND column_name='customer_id'")
.contains("UPDATE ll_order_detail detail")
.contains("SET detail.customer_id=orders.customer_id")
.contains("MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'")
.contains("ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)");
assertThat(service)
.contains(".eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())")
.contains(".eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())");
}
}
@@ -7,6 +7,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -15,6 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Set<String> MODULE_BASELINE_ONLY_TABLES = Set.of(
"ll_us_truck_order_operation", "ll_local_looseboard_operation");
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
@@ -45,14 +49,16 @@ class SchemaChineseCommentContractTest {
List<String> columns = List.of(
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
"allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key",
"receipt_request_hash"
"allocated_pallet_count"
);
for (String column : columns) {
assertColumnHasChineseComment(baseline, column, "基线 SQL");
assertColumnHasChineseComment(incremental, column, "增量 SQL");
assertIncrementalAddColumnHasChineseComment(incremental, column);
}
for (String column : List.of("review_idempotency_key", "review_request_hash", "result_version")) {
assertColumnHasChineseComment(baseline, column, "基线 SQL");
}
}
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
@@ -90,6 +96,9 @@ class SchemaChineseCommentContractTest {
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
if (MODULE_BASELINE_ONLY_TABLES.contains(table.name())) {
continue;
}
boolean createdInLegacy = hasCommentedCreate(migration, table.name());
boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name());
assertTrue(createdInLegacy || createdInUnified
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -31,6 +33,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock InboundPlanCartonMapper cartons;
LastlegOrderService service;
@BeforeEach
@@ -39,6 +42,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
}
@Test
@@ -76,8 +80,13 @@ class LastlegOrderServiceTest {
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).update(cargo.capture(), any());
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<InboundPlanCarton> carton = ArgumentCaptor.forClass(InboundPlanCarton.class);
verify(cartons).update(carton.capture(), any());
assertEquals("wh-2", carton.getValue().getDestinationWarehouseId());
assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("100", history.getValue().getCustomerId());
assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus());
assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus());
}
@@ -92,6 +101,7 @@ class LastlegOrderServiceTest {
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
verify(details, never()).update(any(), any());
verify(cartons, never()).update(any(), any());
}
@Test
@@ -1,21 +1,30 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); var plan=new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of()); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(plan); lenient().when(inboundPlans.convertDropship(anyString(),anyLong(),anyList())).thenReturn(plan); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); var plan=new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of()); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(plan); lenient().when(inboundPlans.convertDropship(anyString(),anyLong(),anyList())).thenReturn(plan); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId"));
String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"));
String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item ("));
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); ArgumentCaptor<List<InboundPlanService.WholePalletInput>> plans=ArgumentCaptor.forClass(List.class); verify(inboundPlans).convertDropship(eq("100"),anyLong(),plans.capture()); assertEquals(List.of(new InboundPlanService.WholePalletInput("PLT-1",2)),plans.getValue()); verify(inventory).registerInTransit(eq("100"),eq("SYSTEM"),any()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); ArgumentCaptor<List<InboundPlanService.WholePalletInput>> plans=ArgumentCaptor.forClass(List.class); verify(inboundPlans).convertDropship(eq("100"),anyLong(),plans.capture()); assertEquals(List.of(new InboundPlanService.WholePalletInput("PLT-1",2)),plans.getValue()); verify(inventory).registerInTransit(eq("100"),eq("SYSTEM"),any()); }
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2);
assertEquals(1,service.consume(event));
@@ -31,6 +40,8 @@ class LoadingCompletedConsumerTest {
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event();
JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
.set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
@@ -51,6 +62,8 @@ class LoadingCompletedConsumerTest {
}
@Test void rejectsV2EventWithoutSkuCartonMapping(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
@@ -59,6 +72,46 @@ class LoadingCompletedConsumerTest {
assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数"));
verify(orders,never()).insert(any(LastlegOrder.class));
}
@Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
.set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5")));
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
.set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3")));
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2));
verify(cartons,never()).insert(any(InboundPlanCarton.class));
}
@Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2));
verify(cartons,never()).insert(any(InboundPlanCarton.class));
}
@Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
for(JsonNode order:node.path("orders")){
((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1");
JsonNode item=order.path("items").get(0);
((com.fasterxml.jackson.databind.node.ObjectNode)item).putNull("fbaTrackingNo").putNull("shipmentNo").put("destinationWarehouseName","洛杉矶海外仓");
}
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2));
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
}
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
}
@@ -71,8 +71,13 @@ class TaillegRealConsumerAcceptanceTest {
}
public long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey) { return 1L; }
public void release(String customerId, long resourceLockId) { }
public void scrap(String customerId, long resourceLockId) { }
public void scrapPallet(String customerId, long palletId) { }
public WarehousePalletReference resolvePallet(String palletCode) {
return new WarehousePalletReference(1L, palletCode, 1L, 1L,
"AVAILABLE", null, 0);
}
};
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.shipping;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import static org.assertj.core.api.Assertions.assertThat;
class ShippingMapperConfigurationTest {
@Test
void scansShippingMappers() {
MapperScan mapperScan = ShippingMapperConfiguration.class.getAnnotation(MapperScan.class);
assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.shipping.mapper");
}
}
@@ -0,0 +1,133 @@
package com.baiyue.lastleg.shipping.service;
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.FirstLegContainerShippingNodeRecordedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventInbox;
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.databind.JsonNode;
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 java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerShippingNodeConsumerTest {
@Mock LastlegEventInboxMapper inboxes;
@Mock ContainerShippingGateMapper gates;
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private ContainerShippingNodeConsumer consumer;
@BeforeEach
void setUp() {
consumer = new ContainerShippingNodeConsumer();
ReflectionTestUtils.setField(consumer, "inboxes", inboxes);
ReflectionTestUtils.setField(consumer, "gates", gates);
ReflectionTestUtils.setField(consumer, "objectMapper", json);
}
@Test
void pickupReadyMonotonicallyOpensReceivingGate() {
ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 70, 4, Instant.parse("2026-08-20T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
assertThat(consumer.consume(event)).isEqualTo(1);
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getNodeSequence()).isEqualTo(80);
assertThat(gate.getAggregateVersion()).isEqualTo(5);
verify(gates).updateById(gate);
}
@Test
void lowerNodeAndOlderAggregateAreAuditedWithoutRegression() {
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 90, 8, Instant.parse("2026-08-22T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
consumer.consume(event("123e4567-e89b-12d3-a456-426614174102", "PICKUP_READY", 80, 7,
Instant.parse("2026-08-23T10:00:00Z")));
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getNodeSequence()).isEqualTo(90);
assertThat(gate.getAggregateVersion()).isEqualTo(8);
}
@Test
void sameNodeOnlyAcceptsLaterActualTimeAndEmptyReturnedNeverClosesGate() {
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 100, 9, Instant.parse("2026-08-22T10:00:00Z"));
when(gates.lock("CB-1")).thenReturn(gate);
consumer.consume(event("123e4567-e89b-12d3-a456-426614174103", "EMPTY_RETURNED", 100, 10,
Instant.parse("2026-08-22T12:00:00Z")));
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
assertThat(gate.getActualAt()).isEqualTo(java.time.LocalDateTime.parse("2026-08-22T12:00:00"));
}
@Test
void eventIdIsIdempotentByHashAndRejectsDifferentHash() {
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash());
when(inboxes.selectById(event.eventId())).thenReturn(existing);
assertThat(consumer.consume(event)).isEqualTo(1);
verify(gates, never()).updateById(any(ContainerShippingGate.class));
existing.setPayloadHash("sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("摘要冲突");
}
@Test
void tamperedPayloadHashIsRejectedBeforeInboxMutation() {
EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(),
"sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
verify(inboxes, never()).insert(any(LastlegEventInbox.class));
}
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");
gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version);
gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt,
java.time.ZoneOffset.UTC)); return gate;
}
private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) {
FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1(
"Q-1", "100", "OB-1", "CB-1", "CONT-1", node, sequence, actualAt,
actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64));
JsonNode payloadNode = json.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1",
version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null,
payloadNode, hash);
}
}
@@ -125,6 +125,7 @@ class WarehouseTransferServiceTest {
verify(orders).updateById(tailOrder.getValue());
assertNotNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
}
@@ -162,6 +163,7 @@ class WarehouseTransferServiceTest {
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.APPROVED, result.status());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
@@ -319,7 +321,8 @@ class WarehouseTransferServiceTest {
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1");
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false);
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5);
return transfer;
}
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.trucking;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -18,8 +20,22 @@ class UsTruckOrderSchemaContractTest {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)"));
assertTrue(sql.contains("operation_type VARCHAR(64) NOT NULL"));
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
}
@Test
void unifiedIncrementalContainsOperationLedgerAndHistoryColumns() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation"));
assertTrue(sql.contains("uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)"));
assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='operation_type'"));
assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'"));
}
}
@@ -37,7 +37,7 @@ class UsTruckOrderControllerTest {
@Test
void platformStatusChangeRequiresManagePermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null);
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
@@ -45,11 +45,34 @@ class UsTruckOrderControllerTest {
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned");
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
}
@Test
void platformLifecycleCommandsUseExplicitTargetAndStatusPermission() {
var user = user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION));
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", null);
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", "overage-1", null);
var allocation = new UsTruckOrderRequests.AllocatePallets(3L, java.util.List.of(1L), "allocate-1", null);
var command = new UsTruckOrderRequests.Command(4L, "command-1", null);
platform.review(user, "100", "TK1", review);
platform.reviewPalletOverage(user, "100", "TK1", overage);
platform.allocatePallets(user, "100", "TK1", allocation);
platform.prepareDispatch(user, "100", "TK1", command);
platform.dispatch(user, "100", "TK1", command);
platform.startTransit(user, "100", "TK1", command);
platform.complete(user, "100", "TK1", command);
verify(service).review("100", "TK1", "7", review);
verify(service).reviewPalletOverage("100", "TK1", "7", overage);
verify(service).allocatePallets("100", "TK1", "7", allocation);
verify(service).prepareDispatch("100", "TK1", "7", command);
verify(service).dispatch("100", "TK1", "7", command);
verify(service).startTransit("100", "TK1", "7", command);
verify(service).complete("100", "TK1", "7", command);
}
@Test
void platformCreateUsesExplicitCustomerAndCreatePermission() {
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
@@ -2,9 +2,11 @@ package com.baiyue.lastleg.trucking.service;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,18 +18,18 @@ 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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -35,7 +37,12 @@ class UsTruckOrderServiceTest {
@Mock UsTruckOrderMapper orders;
@Mock UsTruckOrderPalletMapper pallets;
@Mock UsTruckOrderStatusHistoryMapper histories;
@Mock UsTruckOrderOperationMapper operations;
private final List<UsTruckOrderPallet> palletRows = new ArrayList<>();
private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>();
private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>();
private UsTruckOrderService service;
private UsTruckOrder order;
@BeforeEach
void setUp() {
@@ -43,113 +50,128 @@ class UsTruckOrderServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of());
}
@Test
void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() {
AtomicReference<UsTruckOrder> saved = new AtomicReference<>();
when(orders.insert(any(UsTruckOrder.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(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status());
assertEquals("USD", detail.summary().currency());
assertEquals("100", detail.summary().customerId());
}
@Test
void statusChangePersistsHistoryAndIncrementsVersion() {
UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var result = service.changeStatus("100", "TK1", "7",
new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted"));
assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status());
assertEquals(1, result.summary().version());
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
}
@Test
void staleStatusChangeIsRejected() {
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null)));
assertEquals(409, error.getStatusCode().value());
}
@Test
void staleOrdinaryUpdateIsRejectedAsConflict() {
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.PENDING_REVIEW, 2L));
UsTruckOrderRequests.Update request = new UsTruckOrderRequests.Update(1L,
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "TK1", "7", request));
assertEquals(409, error.getStatusCode().value());
}
@Test
void detailIncludesBoundPallets() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L);
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1);
pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED");
pallet.setWarehousePalletId(20L); pallet.setPackageCount(12);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet));
var detail = service.detail("100", "TK1");
assertEquals(1, detail.pallets().size());
assertEquals("PLT-001", detail.pallets().get(0).palletNo());
assertEquals(12, detail.pallets().get(0).packageCount());
}
@Test
void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L);
order.setPalletCount(1);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
List<UsTruckOrderPallet> savedPallets = new ArrayList<>();
when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets));
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
savedPallets.add(call.getArgument(0));
return 1;
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2);
when(orders.lockNo("100", "TK1")).thenAnswer(call -> order);
when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows);
when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows);
when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
historyRows.add(call.getArgument(0)); return 1;
});
var result = service.receivePallets("100", "TK1", "7",
new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets"));
assertEquals(1, result.summary().palletCount());
assertEquals(3, result.summary().receivedPalletCount());
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status());
verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class));
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1;
});
when(operations.findKey(anyString(), anyLong(), anyString()))
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> {
UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1;
});
UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory();
created.setOperationType("CREATE"); created.setOperatorId("creator"); created.setIdempotencyKey("create-1");
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
}
private UsTruckOrderRequests.Create create(String key) {
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
@Test
void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() {
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve");
var reviewed = service.review("100", "TK1", "operator-7", review);
assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status());
assertEquals(2, palletRows.size());
assertEquals(1L, reviewed.summary().version());
assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version());
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7",
new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different")));
var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2),
"receive-1", "receive overage");
var received = service.receivePallets("100", "TK1", "receiver-8", receive);
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status());
assertEquals(3, received.summary().receivedPalletCount());
assertEquals(3, palletRows.size());
assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version());
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE",
"overage-1", "approve overage");
var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage);
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status());
assertEquals(3, overageReviewed.summary().palletCount());
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
Long first = palletRows.get(0).getId();
List<Long> remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList();
var partial = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial"));
assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status());
assertEquals(1, partial.summary().allocatedPalletCount());
var allocated = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all"));
assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status());
assertEquals(3, allocated.summary().allocatedPalletCount());
assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11",
command(5L, "prepare-1")).summary().status());
assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11",
command(6L, "dispatch-1")).summary().status());
assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12",
command(7L, "transit-1")).summary().status());
var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1"));
assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status());
assertEquals(9L, completed.summary().version());
assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1"))
.summary().version());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden")));
assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12",
command(8L, "reverse-state")));
assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11",
command(1L, "stale-version")));
assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE",
"ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH",
"START_TRANSIT", "COMPLETE"),
historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList());
assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null
&& row.getIdempotencyKey() != null));
}
private UsTruckOrder order(String status, long version) {
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setStatus(status); order.setVersion(version);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
@Test
void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-partial", null));
var partial = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null));
assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status());
var exact = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null));
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status());
}
@Test
void rejectedOverageExcludesExtraPalletFromAcceptedCount() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-reject-overage", null));
service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(
1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null));
var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer",
new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null));
assertEquals(2, reviewed.summary().receivedPalletCount());
assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus());
}
private UsTruckOrderRequests.Command command(long version, String key) {
return new UsTruckOrderRequests.Command(version, key, key);
}
private UsTruckOrder order(String status, long version, int pallets) {
UsTruckOrder value = new UsTruckOrder();
value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1");
value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario");
value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0);
value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version);
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
return value;
}
}