Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
@@ -0,0 +1,160 @@
package com.baiyue.lastleg.inboundplan.pda;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReceivingContainerContractTest {
private static final Set<String> FORBIDDEN_CALLER_SCOPE = Set.of(
"customerId", "planNo", "allocationId", "warehouseId", "locationId");
@Test
void pdaRequestsNeverAcceptServerOwnedScopeFields() {
for (Class<?> type : ReceivingContainerRequests.class.getDeclaredClasses()) {
if (!type.isRecord()) continue;
Set<String> fields = Arrays.stream(type.getRecordComponents())
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
}
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
RecordComponent key = Arrays.stream(type.getRecordComponents())
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
Size size = key.getAccessor().getAnnotation(Size.class);
assertTrue(size != null && size.max() == 255, type.getSimpleName());
}
}
@Test
void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() {
PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class);
assertTrue(permission != null);
assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"
.equals(permission.value()));
}
@Test
void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException {
for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) {
TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName)
.getAnnotation(TableField.class);
assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName);
}
}
@Test
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
Transactional pda = ReceivingContainerService.class
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
.getAnnotation(Transactional.class);
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
.getAnnotation(Transactional.class);
assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED);
assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED);
}
@Test
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
String schema = resource("db/lastleg.sql").toLowerCase();
String unified = java.nio.file.Files.readString(
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet",
"ll_inbound_plan_pallet_allocation")) {
assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table);
assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table);
assertTrue(unified.contains("update " + table), table);
}
assertFalse(schema.contains("tenant_id varchar(64)"));
assertFalse(unified.contains("column_name='tenant_id'"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
assertTrue(schema.contains("平台操作账号全局唯一锁键"));
assertTrue(schema.contains("柜号全局唯一锁键"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
assertTrue(receivingSession.contains("sign_photos json"));
assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null"));
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("column_name='sign_photos'"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse"));
assertTrue(unified.contains("assert_active_receiving_session_has_warehouse"));
}
@Test
void openApiPublishesOnlyStableContainerPaths() throws IOException {
String yaml = resource("openapi/overseas-business-v1.yaml");
String prefix = "/api/pda/v1/last-leg/receiving-containers";
assertTrue(yaml.contains(prefix + "/candidates:"));
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:"));
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/complete:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
List<String> successResponses = receivingPaths.lines().map(String::trim)
.filter(line -> line.startsWith("responses: { '200':")).toList();
assertEquals(16, successResponses.size());
assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
}
private String resource(String name) throws IOException {
try (var input = getClass().getClassLoader().getResourceAsStream(name)) {
assertTrue(input != null, name);
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static String tableBlock(String schema, String table) {
int start = schema.indexOf("create table if not exists " + table + " (");
int end = schema.indexOf(") engine=innodb", start);
return start < 0 || end < 0 ? "" : schema.substring(start, end);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerControllerTest {
@Mock ReceivingContainerService service;
private ReceivingContainerController controller;
@BeforeEach
void setUp() {
controller = new ReceivingContainerController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
controller.candidates(picker());
verify(service).candidates("42", 900L);
}
@Test
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("401");
}
@Test
void platformIdentityWithoutPickerAuthorityIsForbidden() {
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
@Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION),
false, null, null, false, 900L);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpUnloadPhotoFileGatewayTest {
private HttpUnloadPhotoFileGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpUnloadPhotoFileGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build());
ReflectionTestUtils.setField(gateway, "internalToken", "internal-token");
}
@Test
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
@Test
void mismatchedPhotoMetadataMapsToBadRequest() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpWarehouseResourceGatewayTest {
private HttpWarehouseResourceGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpWarehouseResourceGateway();
ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://warehouse").build());
ReflectionTestUtils.setField(gateway, "internalToken", "test-token");
}
@Test
void resolveMapsCanonicalPalletIdInsteadOfSilentlyUsingZero() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
""", MediaType.APPLICATION_JSON));
WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1");
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
assertThat(result.warehouseId()).isEqualTo(2092040869757231105L);
assertThat(result.locationId()).isEqualTo(2092040891076878337L);
server.verify();
}
@Test
void ensureDropshipStillMapsWarehouseEntityId() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship"))
.andRespond(withSuccess("""
{"code":"200","data":{"id":2092040891261427713,"palletCode":"PAL-DROP",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":null,"version":1}}
""", MediaType.APPLICATION_JSON));
WarehouseResourceGateway.WarehousePalletReference result =
gateway.ensureDropshipPallet("2092040041967063041", 2092040869757231105L, "DROP-1");
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
server.verify();
}
}
@@ -0,0 +1,108 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPdaContainerCompletionTest {
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService outbox;
@Mock LastlegOrderMapper orders;
private InboundPdaWorkflowService service;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void twoPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() {
InboundPlan first = plan(1L, "PLAN-1");
InboundPlan second = plan(2L, "PLAN-2");
InboundPdaTask firstTask = task(11L, 1L);
InboundPdaTask secondTask = task(22L, 2L);
when(repository.lockTask("100", 1L)).thenReturn(firstTask);
when(repository.lockTask("100", 2L)).thenReturn(secondTask);
when(repository.lines(1L)).thenReturn(List.of(line(101L)));
when(repository.lines(2L)).thenReturn(List.of(line(202L)));
when(repository.allocations(1L)).thenReturn(List.of(allocation(101L)));
when(repository.allocations(2L)).thenReturn(List.of(allocation(202L)));
when(repository.cartons(any())).thenReturn(List.of());
when(repository.pallets(any())).thenReturn(List.of());
LastlegOrder source = new LastlegOrder(); source.setSourceContainerBookingId("BOOKING-1");
when(orders.selectById(101L)).thenReturn(source);
when(orders.selectById(102L)).thenReturn(source);
service.completeContainer("100", List.of(second, first));
service.completeContainer("100", List.of(first, second));
verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any());
verify(outbox, times(1)).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"),
any(Instant.class), any(Instant.class), anyLong());
verify(repository, times(2)).update(any(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class));
}
private static InboundPlan plan(long id, String planNo) {
InboundPlan value = new InboundPlan();
value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1");
value.setSourceOrderId(id == 1L ? 101L : 102L);
value.setStatus("RECEIVING");
return value;
}
private static InboundPdaTask task(long id, long planId) {
InboundPdaTask value = new InboundPdaTask();
LocalDateTime now = LocalDateTime.of(2026, 8, 24, 10, 0);
value.setId(id); value.setPlanId(planId); value.setCustomerId("100"); value.setTaskNo("TASK-" + id);
value.setStatus("RECEIVING"); value.setUnloadPhotoFileIds("[10]"); value.setUnloadPhotoUploadedAt(now);
value.setEstimatedEmptyReturnAt(now.plusDays(2)); value.setVersion(1);
return value;
}
private static InboundPlanLine line(long id) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id); value.setOrderId(2000L + id); value.setInboundCode("IN-" + id);
value.setRequiredPackageCount(1); value.setDestinationType("FBA");
return value;
}
private static InboundPlanPalletAllocation allocation(long lineId) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setId(1000L + lineId);
value.setPlanId(lineId == 101L ? 1L : 2L); value.setPalletId(3000L + lineId);
value.setLineId(lineId); value.setPickedPackageCount(1); value.setCountedPackageCount(1);
value.setActualVolumeCbm(BigDecimal.ONE);
return value;
}
}
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -50,6 +51,7 @@ class InboundPdaWorkflowServiceTest {
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock LastlegOrderMapper orders;
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
@Mock DropshipOrderService dropshipOrders;
private InboundPdaWorkflowService service;
@@ -72,6 +74,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
@@ -87,6 +90,9 @@ class InboundPdaWorkflowServiceTest {
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
LastlegOrder sourceOrder = new LastlegOrder();
sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1");
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
}
@Test
@@ -100,6 +106,27 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).insert(any(InboundPdaTask.class));
}
@Test
void taskReturnsPalletAndAllocationWorkInstructions() {
when(repository.task("100", plan.getId())).thenReturn(task);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
InboundPlanCarton expected = carton("BOX-INSTRUCTION", "EXPECTED", targetLine.getId(), "WH-A");
expected.setSkuCode("SKU-A");
when(repository.expectedCartons(plan.getId())).thenReturn(List.of(expected));
var result = service.task("100", "LIP1");
assertEquals(targetPallet.getId(), result.pallets().get(0).planPalletId());
assertEquals(target.getId(), result.allocations().get(0).allocationId());
assertEquals("RK-A", result.allocations().get(0).inboundCode());
assertEquals(3, result.allocations().get(0).plannedPackageCount());
assertEquals("BOX-INSTRUCTION", result.expectedCartons().get(0).cartonMark());
assertEquals("SKU-A", result.expectedCartons().get(0).skuCode());
assertEquals(targetPallet.getId(), result.expectedCartons().get(0).planPalletId());
}
@Test
void assignmentReservesWarehouseResourcesAndPersistsTheLock() {
targetPallet.setDestinationWarehouseId("88");
@@ -438,7 +465,8 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L)));
verify(unloadCompletedOutbox).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"),
any(), any(), anyLong());
}
@Test
@@ -450,7 +478,7 @@ class InboundPdaWorkflowServiceTest {
assertEquals("COMPLETED", result.status());
verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any());
verify(repository, never()).allocations(plan.getId());
verify(repository, never()).update(task);
}
@Test
@@ -588,6 +616,7 @@ class InboundPdaWorkflowServiceTest {
value.setCustomerId("100");
value.setPlanNo("LIP1");
value.setContainerNo("CONT-1");
value.setSourceOrderId(1001L);
value.setStatus("ASSIGNED");
return value;
}
@@ -8,7 +8,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -46,6 +48,7 @@ class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock InboundStockReceiptMapper receipts;
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -57,10 +60,12 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of());
}
@Test
@@ -85,7 +90,9 @@ class InboundStockReceiptServiceTest {
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertEquals("100", history.getValue().getCustomerId());
}
@Test
@@ -107,6 +114,30 @@ class InboundStockReceiptServiceTest {
verify(receipts, times(2)).insert(any(InboundStockReceipt.class));
}
@Test
void exactFbaSkuReceiptsPreserveSkuToPalletAttribution() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second));
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
ReceivingSkuReceipt first = skuReceipt(12L, 21L, "SKU-1", 5);
ReceivingSkuReceipt secondReceipt = skuReceipt(12L, 22L, "SKU-2", 5);
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(first, secondReceipt));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream()
.map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet()));
}
@Test
void conservesSingleSkuPackagesAndQuantityAcrossPallets() {
Fixture fixture = fixture(5, 2, 2, 11);
@@ -255,6 +286,12 @@ class InboundStockReceiptServiceTest {
allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation;
}
private ReceivingSkuReceipt skuReceipt(long lineId, long palletId, String skuCode, int packages) {
ReceivingSkuReceipt receipt = new ReceivingSkuReceipt(); receipt.setLineId(lineId); receipt.setPalletId(palletId);
receipt.setSkuCode(skuCode); receipt.setProductName(skuCode + " name"); receipt.setReceivedPackages(packages);
return receipt;
}
private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order,
LastlegOrderDetail detail, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet) {}
@@ -25,6 +25,7 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.baiyue.common.core.api.ResultCode;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxRelayTest {
@@ -78,6 +79,24 @@ class InboundUnloadCompletedOutboxRelayTest {
verify(repository, never()).published(anyLong(), anyString(), any());
}
@Test
void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception {
EventEnvelope event = event();
LastlegEventOutbox row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST,
"事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class),
contains("事件目标区域与当前事件桥区域不一致"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
private LastlegEventOutbox row(EventEnvelope event) throws Exception {
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(9001L);
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundUnloadCompletedOutboxServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final LastlegOrderMapper orders = mock(LastlegOrderMapper.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final InboundUnloadCompletedOutboxService service = new InboundUnloadCompletedOutboxService();
@BeforeEach
void setup() {
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", json);
}
@Test
void freezesContainerCompletionSeparatelyFromPhotoUpload() throws Exception {
Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z");
Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z");
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
containerCompletedAt, photoUploadedAt, 7);
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
LastlegEventOutbox row = captured.getValue();
EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt);
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt);
assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1");
assertThat(envelope.aggregateId()).isEqualTo("booking-1");
}
@Test
void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception {
InboundPlan plan = new InboundPlan();
plan.setSourceOrderId(11L);
plan.setContainerNo("MSCU1234567");
InboundPdaTask task = new InboundPdaTask();
task.setCustomerId("100");
task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00"));
task.setUnloadPhotoUploadedAt(LocalDateTime.parse("2026-08-24T08:00:00"));
task.setVersion(3);
LastlegOrder order = new LastlegOrder();
order.setSourceContainerBookingId("booking-1");
when(orders.selectById(11L)).thenReturn(order);
service.publish(plan, task, List.of(1L));
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captured.capture());
EventEnvelope envelope = json.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 payload = json.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
assertThat(payload.unloadingCompletedAt()).isEqualTo(Instant.parse("2026-08-24T09:30:00Z"));
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(Instant.parse("2026-08-24T08:00:00Z"));
}
@Test
void repeatedContainerCompletionDoesNotPublishAnotherEvent() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
service.publishContainerCompleted("100", "booking-1", "MSCU1234567",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8);
verify(mapper, never()).insert(any(LastlegEventOutbox.class));
}
@Test
void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing);
assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "TGHU7654321",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicts with another container");
}
private LastlegEventOutbox existing(String customerId, String bookingId, String containerNo) {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1(bookingId, containerNo,
Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null);
EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"),
com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN,
customerId, "CONTAINER_BOOKING", bookingId, 7, 1L,
"123e4567-e89b-12d3-a456-426614174001",
EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null,
json.valueToTree(payload), "sha256:" + "0".repeat(64));
LastlegEventOutbox row = new LastlegEventOutbox();
row.setCustomerId(customerId);
row.setAggregateId(bookingId);
row.setPayloadJson(write(envelope));
return row;
}
private String write(Object value) {
try {
return json.writeValueAsString(value);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -0,0 +1,459 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerServiceBehaviorTest {
@Mock InboundPlanMapper plans;
@Mock InboundPlanLineMapper lines;
@Mock InboundPlanPalletMapper pallets;
@Mock InboundPlanPalletAllocationMapper allocations;
@Mock InboundPlanCartonMapper cartons;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock ReceivingSkuReceiptMapper skuReceipts;
@Mock ContainerShippingGateMapper shippingGates;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
private ReceivingContainerService service;
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service, "plans", plans);
ReflectionTestUtils.setField(service, "lines", lines);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "allocations", allocations);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
}
@Test
void containerIsHiddenAndPrepareRejectedBeforePickupReady() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0);
assertThat(service.candidates("7", 100L)).isEmpty();
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("not-ready")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
@Test
void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
verify(operations).insert(saved.capture());
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@Test
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() {
InboundPlan first = plan("CONT-A");
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
}
@Test
void candidatesIncludeUnassignedAndSameWarehouseButHideOtherWarehouse() {
InboundPlan unassigned = plan("CONT-A");
InboundPlan same = plan("CONT-B"); same.setId(3L); same.setOverseasWarehouseId(100L);
InboundPlan other = plan("CONT-C"); other.setId(4L); other.setOverseasWarehouseId(200L);
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@Test
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse);
stubCurrent("FBA", "CONT-A");
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(cartons);
verifyNoInteractions(warehouse);
}
@Test
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1"));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A");
InboundPlanLine overseasLine = line("OVERSEAS");
overseasLine.setDestinationWarehouseId("900");
when(lines.selectList(any())).thenReturn(List.of(overseasLine));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
var resolved = service.resolvePallet("7",
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
assertThat(resolved.compatible()).isTrue();
assertThat(resolved.warehouseId()).isEqualTo(100L);
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
}
@Test
void containerCompletionRequiresEveryInboundCodeToBalanceExactly() {
stubCurrent("FBA", "CONT-A");
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any(), any());
}
@Test
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@Test
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
.isSameAs(failure);
verify(workflow).compensateReservation("100", 55L, failure);
}
@Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
var response = service.sign("7", "receiver", request);
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
ReceivingPhotoRole.DOOR_OPEN);
verify(unloadPhotos).validate("100", 8L, 1L);
verify(unloadPhotos).validate("100", 8L, 2L);
verify(unloadPhotos).validate("100", 8L, 3L);
}
@Test
void signRejectsDuplicateRolesBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void signRejectsMissingPhotosBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(), "missing-photos");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void signRejectsDuplicateFileIdsBeforeFileValidation() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
stubCurrent("FBA", "CONT-A");
stubSku();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
received.setReceivedPackages(2);
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
LastlegOrder order = order(destinationType);
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line));
when(orders.selectById(1L)).thenReturn(order);
}
private void stubSku() {
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 2)));
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of());
}
private static InboundPlan plan(String containerNo) {
InboundPlan value = new InboundPlan();
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
value.setStatus("SIGNED");
return value;
}
private static InboundPlanLine line(String destinationType) {
InboundPlanLine value = new InboundPlanLine();
value.setId(10L); value.setCustomerId("100"); value.setPlanId(2L); value.setOrderId(1L);
value.setInboundCode("IN-1"); value.setDestinationType(destinationType); value.setRequiredPackageCount(2);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static LastlegOrderDetail detail(String skuCode, int packages) {
LastlegOrderDetail value = new LastlegOrderDetail();
value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L);
value.setSkuCode(skuCode); value.setProductNameZh("产品一"); value.setPackageCount(packages);
return value;
}
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
private static InboundPlanCarton carton(long orderId, String cartonMark) {
InboundPlanCarton value = new InboundPlanCarton();
value.setOrderId(orderId); value.setCartonMark(cartonMark); value.setStatus("UNASSIGNED");
return value;
}
private static InboundPlanPallet planPallet() {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(20L); value.setCustomerId("100"); value.setPlanId(2L);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
return value;
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
}
private static WarehouseResourceGateway.WarehouseReference warehouseReference() {
return new WarehouseResourceGateway.WarehouseReference(100L, "SHARED-LAX", "Shared LAX", "ACTIVE");
}
private String hash(Object value) {
try {
String json = new ObjectMapper().findAndRegisterModules().copy()
.configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.writeValueAsString(value);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
}
@@ -95,6 +95,24 @@ class InboundPlanServiceTest {
assertEquals("CONT-1", result.containerNo());
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
assertEquals(2L, result.sourceOrderId());
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).insert(line.capture());
assertEquals("2090000000000000001", line.getValue().getDestinationWarehouseId());
verify(planMapper).insert(any(InboundPlan.class));
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();