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:
+16
@@ -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:}}");
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
|
||||
+12
-6
@@ -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
|
||||
|
||||
+160
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -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;
|
||||
}
|
||||
|
||||
+38
-1
@@ -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) {}
|
||||
|
||||
+19
@@ -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);
|
||||
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+459
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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();
|
||||
|
||||
+20
-3
@@ -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) {
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
+10
-4
@@ -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) {
|
||||
|
||||
+13
@@ -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'"));
|
||||
}
|
||||
}
|
||||
|
||||
+59
-2
@@ -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) {
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
+10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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
-1
@@ -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;
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
-63
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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) { }
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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"));
|
||||
|
||||
+22
@@ -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'"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+41
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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");
|
||||
}
|
||||
}
|
||||
+36
@@ -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())");
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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
|
||||
|
||||
+59
-6
@@ -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); }
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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");
|
||||
}
|
||||
}
|
||||
+133
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -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'"));
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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,
|
||||
|
||||
+128
-106
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user