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