装柜回归测试

This commit is contained in:
2026-08-09 20:35:11 +08:00
parent b20cd9bd77
commit 0ce264a257
67 changed files with 3329 additions and 208 deletions
@@ -0,0 +1,78 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InternalPalletLocationControllerTest {
private static final String INTERNAL_TOKEN = "test-internal-service-token";
private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class);
private final InternalPalletLocationController controller = new InternalPalletLocationController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN);
controller.initializeToken();
}
@Test
void transfersOnlyForInternalCaller() {
PalletAllocationDtos.TransferRequest request = request();
List<PalletAllocationDtos.TransferResult> expected = List.of(new PalletAllocationDtos.TransferResult(
101L, "LR101", 201L, 1, 10001L, 20L, 30L, "TP-001", null,
40L, "A-01", null, 5, "OCCUPIED"));
when(service.transfer(request)).thenReturn(expected);
var response = controller.transfer(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).transfer(request);
}
@Test
void rejectsInvalidInternalTokenBeforeTransfer() {
assertThatThrownBy(() -> controller.transfer("invalid", request()))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
verifyNoInteractions(service);
}
@Test
void settlesPdaResourcesOnlyForInternalCaller() {
PalletAllocationDtos.SettlementRequest request = settlementRequest();
PalletAllocationDtos.SettlementResult expected = new PalletAllocationDtos.SettlementResult(List.of(), List.of());
when(service.settle(request)).thenReturn(expected);
var response = controller.settle(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).settle(request);
}
private static PalletAllocationDtos.TransferRequest request() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
"RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L,
20, 12, "RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PalletAllocationDtosValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsPublicResourceScope() {
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30);
assertThat(validator.validate(new PalletAllocationDtos.ReserveRequest(0L, 20L, "FIRSTLEG", 88L,
"RK-001", "measure-88", 9L, BigDecimal.ONE, BigDecimal.ONE, "STANDARD", expiresAt, true))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ConfirmRequest(0L, 20L, "FIRSTLEG", 88L,
9L, BigDecimal.ONE, BigDecimal.ONE))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ReleaseRequest(0L, "FIRSTLEG", 88L, 9L,
"入库补偿"))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.SelectiveReleaseRequest(0L, "FIRSTLEG", 88L,
"pda-release-88-1", 9L, "PDA 部分拣货", List.of(40L)))).isEmpty();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PalletLocationReleaseIdempotencySqlContractTest {
@Test
void containsPersistentSelectedReleaseIdempotencySchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-release-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation")
.contains("idempotency_key VARCHAR(128) NOT NULL")
.contains("request_hash CHAR(64) NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_release_idempotency")
.contains("tenant_id, source_service, business_id, idempotency_key");
}
}
@Test
void containsAtomicPdaSettlementSchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-settlement-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation")
.contains("source_business_id BIGINT UNSIGNED NOT NULL")
.contains("target_business_id BIGINT UNSIGNED NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_settlement_idempotency")
.contains("tenant_id, source_service, source_business_id, idempotency_key")
.contains("DROP INDEX uk_pallet_location_settlement_idempotency");
}
}
}
@@ -146,12 +146,17 @@ class LocationResourceServiceTest {
occupancy(201L, 101L, 9L, "A1", 501L),
occupancy(202L, 102L, 10L, "A2", 502L),
occupancy(203L, 103L, 11L, "A3", 503L));
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active);
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA partial pick", 10, List.of(10L), null));
assertThat(result).singleElement().satisfies(item -> {
@@ -163,6 +168,56 @@ class LocationResourceServiceTest {
verify(repository, never()).releaseOccupancy(active.get(2), "PDA partial pick");
}
@Test
void shouldReleaseSelectedLocationsWithPersistedAllocatedPackageCount() {
LocationOccupancyEntity first = occupancy(201L, 101L, 9L, "A1", 501L);
first.setAllocatedPackageCount(3);
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
second.setAllocatedPackageCount(7);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.locationId()).isEqualTo(10L);
assertThat(item.allocatedPackageCount()).isEqualTo(7);
});
verify(repository, never()).releaseOccupancy(first, "PDA full pick");
}
@Test
void shouldReleasePublicResourcesInAllocationOrderWhenLocationsAreNotSpecified() {
LocationOccupancyEntity allocationThree = occupancy(203L, 103L, 11L, "A3", 503L);
LocationOccupancyEntity allocationOne = occupancy(201L, 101L, 9L, "A1", 501L);
LocationOccupancyEntity allocationTwo = occupancy(202L, 102L, 10L, "A2", 502L);
List<LocationOccupancyEntity> active = List.of(allocationThree, allocationOne, allocationTwo);
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
when(repository.findOwned(0L, 88L, item.getLocationId()))
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
.thenReturn(1);
}
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA default pick", 8, List.of(), 2));
assertThat(result).extracting(item -> item.allocationNo()).containsExactly(1, 2);
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2");
assertThat(result).extracting(item -> item.releasedPackageCount()).containsExactly(3, 3);
verify(repository, never()).releaseOccupancy(allocationThree, "PDA default pick");
}
@Test
void shouldReleaseFirstNAllocationsWhenLocationsAreNotSpecified() {
List<LocationOccupancyEntity> active = List.of(
@@ -213,6 +268,52 @@ class LocationResourceServiceTest {
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(8);
occupancy.setAllocatedVolumeCbm(new BigDecimal("1.200000"));
LocationReservationEntity reservation = reservation("CONFIRMED");
reservation.setReservationNo("LR101");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1"))
.thenReturn(1);
when(repository.transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5))
.thenReturn(1);
var result = service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.reservationNo()).isEqualTo("LR101");
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(repository).transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1");
verify(repository).transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5);
verify(repository, never()).releaseOccupancy(any(), any(String.class));
verify(repository, never()).releaseReservation(any(), any(String.class));
}
@Test
void shouldRejectTransferWhenRemainingPackagesExceedAllocation() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(4);
LocationReservationEntity reservation = reservation("CONFIRMED");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
assertThatThrownBy(() -> service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5)))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository, never()).transferConfirmedReservation(any(), any(String.class), anyLong(), anyLong(), any());
verify(repository, never()).transferOccupied(any(), any(String.class), anyLong(), anyLong(), any(), anyInt());
}
private static ReserveCommand reserveCommand() {
return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
LocalDateTime.now().plusMinutes(30), List.of(
@@ -4,7 +4,14 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.PalletLocationTransferOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationReleaseOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationSettlementOperationRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -19,20 +26,28 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {PalletLocationAllocationService.class, PalletLocationAllocationServiceTest.TestConfig.class})
class PalletLocationAllocationServiceTest {
@MockBean private PalletRepository pallets;
@MockBean private LocationResourceService locations;
@MockBean private PalletLocationTransferOperationRepository transferOperations;
@MockBean private PalletLocationReleaseOperationRepository releaseOperations;
@MockBean private PalletLocationSettlementOperationRepository settlementOperations;
@Resource private PalletLocationAllocationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean PalletVolumeSplitter splitter() { return new PalletVolumeSplitter(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
@@ -106,21 +121,304 @@ class PalletLocationAllocationServiceTest {
@Test
void shouldReleaseOnlyPalletsReturnedBySelectedLocationRelease() {
var resource = new LocationDtos.SelectiveReleaseResult(
var resource = new LocationDtos.ExactSelectiveReleaseResult(
102L, 202L, 2, 20L, 30L, 40L, "A-02", 4);
when(locations.releaseSelected(any())).thenReturn(List.of(resource));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setId(701L);
operation.setOperationStatus("PROCESSING");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(4));
return true;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource));
when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"))
.thenReturn(1);
var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest(
10001L, "FIRSTLEG", 88L, 9001L, "PDA partial pick", 10, List.of(40L), null));
10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.allocationNo()).isEqualTo(2);
assertThat(item.locationId()).isEqualTo(40L);
assertThat(item.releasedPackageCount()).isEqualTo(4);
assertThat(item.allocatedPackageCount()).isEqualTo(4);
});
verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString());
}
@Test
void shouldReplayCompletedSelectedReleaseForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1,
30L, 40L, "A-01", 6));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(4);
return false;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.releaseSelected(selectiveReleaseRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedExactly(any());
verify(releaseOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSelectedReleaseWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
assertThatThrownBy(() -> service.releaseSelected(selectiveReleaseRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedExactly(any());
}
@Test
void shouldSettlePdaResourcesAtomicallyAndPersistCombinedSnapshot() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setId(801L);
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult(
101L, 201L, 1, 20L, 30L, 40L, "A-01", 8)));
when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1);
when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult(
102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02",
new BigDecimal("1.200000"), 12, "OCCUPIED")));
PalletEntity pallet = pallet(31L, "TP-002");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet);
var result = service.settle(settlementRequest());
assertThat(result.released()).singleElement().satisfies(item ->
assertThat(item.allocatedPackageCount()).isEqualTo(8));
assertThat(result.transferred()).singleElement().satisfies(item ->
assertThat(item.remainingPackageCount()).isEqualTo(12));
verify(settlementOperations).complete(org.mockito.ArgumentMatchers.eq(801L), anyString());
org.mockito.ArgumentCaptor<LocationDtos.ReservationReleaseCommand> releaseCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationReleaseCommand.class);
verify(locations).releaseSelectedReservations(releaseCommand.capture());
assertThat(releaseCommand.getValue().tenantId()).isZero();
assertThat(releaseCommand.getValue().sourceService()).isEqualTo("firstleg-service");
assertThat(releaseCommand.getValue().reservationIds()).containsExactly(101L);
org.mockito.ArgumentCaptor<LocationDtos.ReservationTransferCommand> transferCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationTransferCommand.class);
verify(locations).transferToChildByReservation(transferCommand.capture());
assertThat(transferCommand.getValue().items()).extracting(LocationDtos.ReservationTransferItem::reservationId)
.containsExactly(102L);
}
@Test
void shouldReplayCompletedPdaSettlementForSameIdempotencyKey() throws Exception {
var expected = new PalletAllocationDtos.SettlementResult(
List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1, 30L, 40L, "A-01", 8)),
List.of(new PalletAllocationDtos.TransferResult(102L, "LR102", 202L, 2,
0L, 20L, 31L, "TP-002", new BigDecimal("1.5"), 41L, "A-02",
new BigDecimal("1.2"), 12, "OCCUPIED")));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(3);
return false;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.settle(settlementRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.settle(settlementRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementFromNonFirstlegService() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "other-service", 88L, 89L, 20, 12, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verifyNoInteractions(settlementOperations);
verify(locations, never()).lockOccupiedResources(anyLong(), anyString(), anyLong());
}
@Test
void shouldRejectPdaSettlementWhenPackageCountsDoNotBalance() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 89L, 20, 11, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectChangedChildForSamePdaSettlementKey() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("request-for-another-child");
PalletAllocationDtos.SettlementRequest changedChild = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 90L, 20, 12, "RK-001-S2",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(eq(88L), eq(90L), eq("PDA-SETTLEMENT-88-1"), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(changedChild)).isInstanceOf(BizException.class);
verify(settlementOperations).lockByKey(88L, "PDA-SETTLEMENT-88-1");
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldTransferOccupiedResourceWithoutReleasingPallet() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setId(501L);
operation.setOperationStatus("PROCESSING");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(5));
return true;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
when(locations.transferToChild(any())).thenReturn(List.of(new LocationDtos.TransferResult(
101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01",
new BigDecimal("1.200000"), 5, "OCCUPIED")));
PalletEntity pallet = pallet(30L, "TP-001");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet);
var result = service.transfer(new PalletAllocationDtos.TransferRequest(
10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L,
List.of(new PalletAllocationDtos.TransferItem(40L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.palletCapacityCbm()).isEqualByComparingTo("1.500000");
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString());
}
@Test
void shouldReplayCompletedTransferForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.TransferResult(101L, "LR101", 201L, 1,
0L, 20L, 30L, "TP-001", new BigDecimal("1.5"), 40L, "A-01",
new BigDecimal("1.2"), 5, "OCCUPIED"));
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(5);
return false;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.transfer(transferRequest())).isEqualTo(expected);
verify(locations, never()).transferToChild(any());
verify(transferOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSameIdempotencyKeyWithDifferentRequestHash() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.transfer(transferRequest())).isInstanceOf(BizException.class);
verify(locations, never()).transferToChild(any());
}
private static PalletAllocationDtos.TransferRequest transferRequest() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1",
"transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
}
private static PalletAllocationDtos.SelectiveReleaseRequest selectiveReleaseRequest() {
return new PalletAllocationDtos.SelectiveReleaseRequest(10001L, "FIRSTLEG", 88L,
"pda-release-88-201", 9001L, "PDA full pick", List.of(40L));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L, 20, 12,
"RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
private static List<LocationDtos.OccupiedResource> sourceResources() {
return List.of(
new LocationDtos.OccupiedResource(101L, 201L, 1, 20L, 30L, 40L, "A-01", false, 8),
new LocationDtos.OccupiedResource(102L, 202L, 2, 20L, 31L, 41L, "A-02", false, 12));
}
private static PalletAllocationDtos.ReserveRequest reserve(long businessId) {