Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-26 16:05:24 +08:00
357 changed files with 7739 additions and 3505 deletions
@@ -304,16 +304,18 @@ class ReceivingContainerServiceBehaviorTest {
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");
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2092476334483931138"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "2")), "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);
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
.containsExactly("2092476334483931138", "2", "3");
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
verify(unloadPhotos).validate("100", 8L, 2L);
verify(unloadPhotos).validate("100", 8L, 3L);
}
@@ -322,9 +324,9 @@ class ReceivingContainerServiceBehaviorTest {
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");
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-sign");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -345,9 +347,9 @@ class ReceivingContainerServiceBehaviorTest {
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");
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "1"),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-files");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -41,6 +42,7 @@ class InboundPlanServiceTest {
@Mock InboundPlanPalletMapper palletMapper;
@Mock InboundPlanPalletAllocationMapper allocationMapper;
@Mock InboundPlanOrderMapper orderMapper;
@Mock InventoryBalanceMapper inventoryBalanceMapper;
private InboundPlanService service;
@@ -52,6 +54,7 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper);
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
@@ -89,6 +92,9 @@ class InboundPlanServiceTest {
assertEquals(1, allocations.getAllValues().stream()
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
verify(orderMapper).lockOrder("100", 2L);
assertEquals(2L, result.sourceOrderId());
@@ -133,6 +139,7 @@ class InboundPlanServiceTest {
assertEquals(1L, result.sourceOrderId());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
}
@Test
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MasterDataMapperConfigurationTest {
@Test
@@ -14,4 +15,11 @@ class MasterDataMapperConfigurationTest {
assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.masterdata");
assertThat(mapperScan.annotationClass()).isEqualTo(Mapper.class);
}
@Test
void broadExchangeMapperScanMustNotBeReintroduced() {
assertThatThrownBy(() -> Class.forName(
"com.baiyue.lastleg.masterdata.exchange.ExchangeMapperConfiguration"))
.isInstanceOf(ClassNotFoundException.class);
}
}
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
@@ -24,4 +26,12 @@ class PdaPalletOperationControllerContractTest {
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
String sql = String.join("", PdaPalletOperationMapper.class.getMethod("content", String.class, long.class)
.getAnnotation(Select.class).value());
assertTrue(sql.contains("receiving_packages)>0"));
assertFalse(sql.contains("<"));
assertFalse(sql.contains(">"));
}
}
@@ -3,6 +3,9 @@ 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.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
@@ -15,6 +18,7 @@ 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.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionDefinition;
@@ -23,6 +27,7 @@ import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
@@ -94,6 +99,46 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(ledgers, history);
}
@Test void inventoryLedgerStoresStateSemanticsInsteadOfLongPalletCodes() {
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("88"); balance.setInboundOrderId(300L); balance.setInboundCode("IN-1");
InventoryPosition position = new InventoryPosition(); position.setId(100L); position.setPalletId(20L);
position.setPalletNo("PDAOPS-ADMIN-API-20260825095854036-SWAP-TARGET");
position.setBatchNo("DEFAULT"); position.setWarehouseLocationCode("PDAOPS-LONG-LOCATION-CODE");
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(400L);
operation.setOperationType("PALLET_SWAP");
ReflectionTestUtils.invokeMethod(service, "ledger", identity(), operation, balance, position,
"idem:out", 5L, -5L, 0L,
"PDAOPS-ADMIN-API-20260825095854036-SOURCE",
"PDAOPS-ADMIN-API-20260825095854036-TARGET", "swap");
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertEquals("OCCUPIED", ledger.getValue().getStatusFrom());
assertEquals("OCCUPIED", ledger.getValue().getStatusTo());
assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode());
}
@Test void exactReplayReturnsStoredResultBeforeValidatingChangedPalletVersions() throws Exception {
var request = new PalletOperationRequests.Swap("idem-replay",
new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), "swap");
PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(500L);
existing.setOperationType("PALLET_SWAP"); existing.setStatus("COMPLETED");
existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request));
var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation(
500L, "PALLET_SWAP", "idem-replay", "COMPLETED", false, LocalDateTime.now(), List.of());
existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored));
when(mapper.findOperation("7", "idem-replay")).thenReturn(existing);
var replay = service.swap(identity(), request);
assertTrue(replay.replayed());
assertEquals(500L, replay.operationId());
verifyNoInteractions(physical);
}
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,
@@ -4,6 +4,8 @@ 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 com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -38,4 +40,11 @@ class PdaInventoryOperationContractTest {
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
}
}
@Test
void stocktakeAnnotationSqlUsesNativeComparisonOperators() throws Exception {
String sql = String.join("", PdaStocktakeItemMapper.class.getMethod("differences", long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("difference_packages<>0").doesNotContain("&lt;", "&gt;");
}
}
@@ -60,9 +60,10 @@ class PdaInventoryMoveServiceTest {
@Test
void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() {
String targetLocationCode = "PDAOPS-ADMIN-API-20260825095854036-MOVE";
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"));
when(warehouse.resolveLocation(900L, targetLocationCode)).thenReturn(location(21L, targetLocationCode));
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");
@@ -76,7 +77,7 @@ class PdaInventoryMoveServiceTest {
eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false));
var response = service.move(900L, "operator-1",
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"));
new PdaInventoryMoveRequest("PAL-1", targetLocationCode, 2, "move-1", "库位优化"));
verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L
&& value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"),
@@ -84,11 +85,16 @@ class PdaInventoryMoveServiceTest {
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(updated.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode);
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));
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertThat(ledger.getValue().getStatusFrom()).isEqualTo("OCCUPIED");
assertThat(ledger.getValue().getStatusTo()).isEqualTo("OCCUPIED");
assertThat(ledger.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode);
verify(moves).updateById(any(PdaInventoryMove.class));
}
@Test
@@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest {
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
}
@Test void missingServerConfigurationFailsClosed() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertEquals(503, assertThrows(ResponseStatusException.class,
() -> guard.verify("any-token")).getStatusCode().value());
}
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.security;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LastlegSecurityContractTest {
@Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception {
String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql");
String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql");
String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql");
String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java");
String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java");
String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java");
String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java");
assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox",
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
"CREATE TABLE IF NOT EXISTS ll_security_projection",
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
"permission_version BIGINT");
for (String sql : new String[]{baseline, migration}) {
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0",
"('AUTHORIZATION','1000001',0,NULL,0,0",
"baseline-platform-admin-user-v0",
"baseline-platform-admin-authorization-v0")
.doesNotContain("baseline-merchant-admin");
}
assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id",
"default_app_code");
assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'")
.doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security",
"USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox",
"CREATE TABLE IF NOT EXISTS overseas_security_projection");
assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY");
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
.doesNotContain("LoadingCompletedConsumer");
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?",
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
.doesNotContain("Long tokenVersion");
}
@Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception {
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java");
assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED",
"FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型")
.doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED",
"IAM_PERMISSION_VERSION_CHANGED");
}
@Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception {
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java");
String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java");
String application = read("lastleg-service/src/main/resources/application.yml");
assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")",
"@PostMapping(\"/events\")", "@PostMapping(\"/validate\")",
"com.baiyue.lastleg.security.InternalServiceTokenGuard")
.doesNotContain("/internal/overseas-security/v1");
assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED");
assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}")
.doesNotContain("phase1-local-internal-token");
}
private static String read(String relative) throws Exception {
Path path = Path.of(relative);
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.security;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class SecurityProjectionArchitectureTest {
private static final String LASTLEG = "com.baiyue.lastleg..";
private static final String CONTROLLER = "com.baiyue.lastleg.security.controller..";
private static final String DTO = "com.baiyue.lastleg.security.dto..";
private static final String SERVICE = "com.baiyue.lastleg.security.service..";
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.importPackages("com.baiyue.lastleg");
@Test
void projectionInternalsDoNotDependOnLastlegBusinessSlices() {
classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(LASTLEG)
.or(resideInAPackage(CONTROLLER))
.or(resideInAPackage(DTO))
.or(resideInAPackage(SERVICE))
.or(resideInAPackage("com.baiyue.lastleg.security")))
.check(PRODUCTION_CLASSES);
}
@Test
void businessSlicesDoNotDependOnProjectionInternals() {
noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE)
.should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
.check(PRODUCTION_CLASSES);
}
@Test
void dtoAndServicesStayIndependentFromInternalWebAdapter() {
noClasses().that().resideInAnyPackage(DTO, SERVICE)
.should().dependOnClassesThat().resideInAPackage(CONTROLLER)
.check(PRODUCTION_CLASSES);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.security.service;
import com.baiyue.common.core.exception.BizException;
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.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LastlegSecurityProjectionTransportTest {
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
@Test
void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() {
EventEnvelope envelope = envelope();
String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope);
assertThat(stored).hasSize(64).doesNotStartWith("sha256:");
assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored);
}
@Test
void rejectsTamperedPayloadBeforeInboxMutation() {
EventEnvelope source = envelope();
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(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered))
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash");
}
private EventEnvelope envelope() {
var value = new IamSecurityEventsV1.PermissionVersionChanged(
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
var payload = objectMapper.valueToTree(value);
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0",
"AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678",
null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.security.service;
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
class LastlegSecurityValidationServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc);
@Test void disabledUserFailsClosed() {
doReturn(List.of("DISABLED")).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
.isEqualTo(FailureReason.USER_DISABLED);
}
@Test void disabledCustomerFailsClosed() {
doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
.isEqualTo(FailureReason.TENANT_DISABLED);
}
@Test void missingUserProjectionFailsClosed() {
doReturn(List.of()).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
}
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue();
}
@Test void permissionVersionMustExactlyMatchReliableProjection() {
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
List.of("ACTIVE"), List.of(9L)).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason())
.isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY);
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue();
}
@Test void missingPermissionProjectionFailsClosed() {
doReturn(List.of("ACTIVE"), List.of()).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason())
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.security.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SecurityProjectionMonotonicityTest {
@Test void appliesFirstAndNewerVersions() {
assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1"))
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2"))
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
}
@Test void ignoresOlderAndSameHashVersions() {
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2"))
.isEqualTo(SecurityProjectionMonotonicity.Decision.STALE);
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3"))
.isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION);
}
@Test void rejectsSameVersionWithDifferentHash() {
assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b"))
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突");
}
}
@@ -1,10 +1,10 @@
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.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.*;
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.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; 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; @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()); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @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,"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)); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); 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)); }
@@ -17,7 +17,7 @@ class LoadingCompletedConsumerTest {
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),"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 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()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); 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()); }
@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"));