Files
baiyue-firstleg/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java
T

449 lines
23 KiB
Java

package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.dto.WarehousePalletBatchRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.entity.WarehousePalletBatchEntity;
import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity;
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.mapper.WarehousePalletBatchItemMapper;
import com.baiyue.warehouse.mapper.WarehousePalletBatchMapper;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
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.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
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.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehousePalletBatchServiceTest {
private static final long WAREHOUSE = 201L;
private static final long CUSTOMER_A = 101L;
private static final long CUSTOMER_B = 102L;
@Mock WarehousePalletBatchMapper batches;
@Mock WarehousePalletBatchItemMapper items;
@Mock WarehouseResourceService resources;
@Mock WarehouseResourceLockService locks;
@Mock WarehouseResourceLockRepository lockRecords;
@Mock WarehouseResourceRepository repository;
private WarehousePalletBatchService service;
@BeforeEach
void setUp() {
service = new WarehousePalletBatchService();
ReflectionTestUtils.setField(service, "batches", batches);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "resources", resources);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "lockRecords", lockRecords);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test
void reservesPublicPalletsButIsolatesEachCrossCustomerLock() {
when(batches.insertIgnore(any())).thenReturn(1);
stubNewResources();
var response = service.ensureReserved(request());
assertThat(response.status()).isEqualTo("RESERVED");
assertThat(response.items()).extracting(item -> item.customerId()).containsExactly(CUSTOMER_A, CUSTOMER_B);
ArgumentCaptor<Long> scope = ArgumentCaptor.forClass(Long.class);
verify(resources, times(2)).createPallet(scope.capture(), anyLong(), any(PalletRequests.SaveRequest.class));
assertThat(scope.getAllValues()).containsOnly(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE);
verify(batches, never()).lockByKey(WAREHOUSE, "container-1");
ArgumentCaptor<WarehouseResourceLockRequests.ReserveRequest> reservations =
ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class);
verify(locks, times(2)).reserve(reservations.capture());
assertThat(reservations.getAllValues()).extracting(value -> value.customerId())
.containsExactly(CUSTOMER_A, CUSTOMER_B);
assertThat(reservations.getAllValues()).extracting(value -> value.businessId())
.containsExactly("container:plan-a:1", "container:plan-b:2");
}
@Test
void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() {
when(batches.insertIgnore(any())).thenReturn(1);
when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L);
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
when(locks.reserve(any())).thenAnswer(call -> {
WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0);
return lockResponse(value.customerId(), 501L);
});
var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-generated", List.of(
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:generated:1", "PLAN-G", 1,
"STANDARD", null, null)));
var response = service.ensureReserved(request);
ArgumentCaptor<PalletRequests.SaveRequest> saves = ArgumentCaptor.forClass(PalletRequests.SaveRequest.class);
verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE),
org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture());
assertThat(saves.getValue().palletCode()).isNull();
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301");
}
@Test
void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() {
AtomicReference<WarehousePalletBatchEntity> inserted = new AtomicReference<>();
when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> inserted.get());
when(batches.insertIgnore(any())).thenAnswer(call -> {
WarehousePalletBatchEntity losingInsert = call.getArgument(0);
WarehousePalletBatchEntity winner = batch(losingInsert.getId(), "RESERVED", 1);
winner.setRequestHash(losingInsert.getRequestHash());
inserted.set(winner);
return 0;
});
when(items.lockByBatch(anyLong())).thenReturn(List.of(item(1L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED")));
var response = service.ensureReserved(request());
assertThat(response.replayed()).isTrue();
assertThat(response.batchId()).isEqualTo(inserted.get().getId());
InOrder order = org.mockito.Mockito.inOrder(batches);
order.verify(batches).insertIgnore(any());
order.verify(batches).lockByKey(WAREHOUSE, "container-1");
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
verify(locks, never()).reserve(any());
}
@Test
void sameBatchKeyWithDifferentPayloadIsRejectedAsA0409() {
WarehousePalletBatchEntity existing = batch(11L, "RESERVED", 1);
existing.setRequestHash("different");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(existing);
assertThatThrownBy(() -> service.ensureReserved(request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("different pallet request");
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
}
@Test
void existingBusinessBindingIsRejectedBeforeAnyPalletIsCreated() {
when(batches.insertIgnore(any())).thenReturn(1);
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "container:plan-a:1"))
.thenReturn(new WarehousePalletBindingEntity());
assertThatThrownBy(() -> service.ensureReserved(request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("already bound");
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
}
@Test
void confirmValidatesAndBindsEveryItemInItsOwnCustomerScope() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED"));
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(batchItems);
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, batchItems.get(0)));
when(lockRecords.find(CUSTOMER_B, 502L)).thenReturn(lock(CUSTOMER_B, batchItems.get(1)));
var response = service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"));
assertThat(response.status()).isEqualTo("CONFIRMED");
assertThat(response.items()).extracting(value -> value.status()).containsOnly("CONFIRMED");
verify(repository).lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1");
verify(repository).lockPalletBinding(CUSTOMER_B, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-2");
ArgumentCaptor<WarehousePalletBindingEntity> bindings = ArgumentCaptor.forClass(WarehousePalletBindingEntity.class);
verify(repository, times(2)).insertPalletBinding(bindings.capture());
assertThat(bindings.getAllValues()).extracting(WarehousePalletBindingEntity::getCustomerId)
.containsExactly(CUSTOMER_A, CUSTOMER_B);
verify(lockRecords, never()).close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"));
}
@Test
void repeatedConfirmIsIdempotentAndDoesNotBindAgain() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item));
var command = new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1");
service.confirm(command);
var response = service.confirm(command);
assertThat(response.replayed()).isTrue();
verify(lockRecords, times(1)).find(CUSTOMER_A, 501L);
verify(repository, times(1)).insertPalletBinding(any());
}
@Test
void terminalConfirmRejectsDifferentCommandPayload() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item));
service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"));
assertThatThrownBy(() -> service.confirm(
new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-2")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different payload");
}
@Test
void confirmedBatchKeepsLockActiveForSubsequentPhysicalRelease() {
assertPhysicalLifecycleAfterConfirm("RELEASED");
}
@Test
void confirmedBatchKeepsLockActiveForSubsequentPhysicalScrap() {
assertPhysicalLifecycleAfterConfirm("SCRAPPED");
}
@Test
void compensationReleasesOnlyThisBatchUsingEachCustomerScope() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED"));
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(batchItems);
var response = service.compensate(
new WarehousePalletBatchRequests.Compensate(WAREHOUSE, "container-1", "compensate-1", "lastleg failed"));
assertThat(response.status()).isEqualTo("COMPENSATED");
verify(locks).release(CUSTOMER_A, 501L);
verify(locks).release(CUSTOMER_B, 502L);
assertThat(batchItems).extracting(WarehousePalletBatchItemEntity::getStatus).containsOnly("RELEASED");
}
@Test
void compensationRejectsConfirmedOrAlreadyBoundResources() {
WarehousePalletBatchEntity confirmed = batch(11L, "CONFIRMED", 1);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(confirmed);
when(items.lockByBatch(11L)).thenReturn(List.of());
var command = new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed");
assertThatThrownBy(() -> service.compensate(command)).isInstanceOf(BizException.class);
verify(locks, never()).release(anyLong(), anyLong());
}
@Test
void compensationRejectsBindingToThisBatchPallet() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
binding.setPalletId(301L);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1"))
.thenReturn(binding);
assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed")))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be compensated");
verify(locks, never()).release(anyLong(), anyLong());
}
@Test
void compensationAllowsUnrelatedOldBindingToDifferentPallet() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
WarehousePalletBindingEntity oldBinding = new WarehousePalletBindingEntity();
oldBinding.setPalletId(999L);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1"))
.thenReturn(oldBinding);
service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed"));
verify(locks).release(CUSTOMER_A, 501L);
}
@Test
void terminalCompensationOnlyReplaysIdenticalCommandAndReason() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
var command = new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed");
service.compensate(command);
assertThat(service.compensate(command).replayed()).isTrue();
assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "different reason")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different payload");
verify(locks, times(1)).release(CUSTOMER_A, 501L);
}
@Test
void sameRequestAfterCompensationReusesPalletAndCreatesNextAttemptLocks() {
WarehousePalletBatchEntity batch = batch(11L, "COMPENSATED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RELEASED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RELEASED"));
AtomicInteger calls = new AtomicInteger();
when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> {
if (batch.getRequestHash() == null) {
return null;
}
return batch;
});
when(batches.insertIgnore(any())).thenAnswer(call -> {
WarehousePalletBatchEntity candidate = call.getArgument(0);
batch.setRequestHash(candidate.getRequestHash());
return 0;
});
when(items.lockByBatch(11L)).thenReturn(batchItems);
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302"));
when(locks.reserve(any())).thenAnswer(call -> lockResponse(
((WarehouseResourceLockRequests.ReserveRequest) call.getArgument(0)).customerId(),
601L + calls.getAndIncrement()));
var response = service.ensureReserved(request());
assertThat(response.attemptNo()).isEqualTo(2);
assertThat(response.items()).extracting(value -> value.palletId()).containsExactly(301L, 302L);
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
ArgumentCaptor<WarehouseResourceLockRequests.ReserveRequest> reservations =
ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class);
verify(locks, times(2)).reserve(reservations.capture());
assertThat(reservations.getAllValues()).extracting(value -> value.reservationKey())
.allMatch(key -> key.contains(":2:"));
}
private void stubNewResources() {
AtomicInteger created = new AtomicInteger();
when(resources.createPallet(anyLong(), anyLong(), any())).thenAnswer(call -> 301L + created.getAndIncrement());
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302"));
when(locks.reserve(any())).thenAnswer(call -> {
WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0);
return lockResponse(value.customerId(), 500L + value.palletId() - 300L);
});
}
private void assertPhysicalLifecycleAfterConfirm(String terminalStatus) {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
WarehouseResourceLockEntity active = lock(CUSTOMER_A, item);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(active);
service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"));
assertThat(batch.getStatus()).isEqualTo("CONFIRMED");
assertThat(item.getStatus()).isEqualTo("CONFIRMED");
assertThat(active.getStatus()).isEqualTo("ACTIVE");
verify(lockRecords, never()).close(active, "CONFIRMED");
WarehouseLocationEntity virtual = new WarehouseLocationEntity();
virtual.setLocationType("VIRTUAL");
virtual.setStatus("ACTIVE");
when(lockRecords.findLocation(CUSTOMER_A, WAREHOUSE, 401L)).thenReturn(virtual);
when(lockRecords.close(active, terminalStatus)).thenReturn(1);
WarehouseResourceLockService physicalLifecycle = new WarehouseResourceLockService();
ReflectionTestUtils.setField(physicalLifecycle, "repository", lockRecords);
if ("RELEASED".equals(terminalStatus)) {
when(lockRecords.releasePalletAtVirtualLocation(active)).thenReturn(1);
assertThat(physicalLifecycle.release(CUSTOMER_A, 501L).status()).isEqualTo("RELEASED");
} else {
when(lockRecords.scrapPalletAtVirtualLocation(active)).thenReturn(1);
assertThat(physicalLifecycle.scrap(CUSTOMER_A, 501L).status()).isEqualTo("SCRAPPED");
}
}
private static WarehousePalletBatchRequests.EnsureReserved request() {
return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of(
new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8", "CONT-ONT8-001"),
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9", "CONT-LAX9-001")));
}
private static WarehousePalletBatchEntity batch(long id, String status, int attempt) {
WarehousePalletBatchEntity entity = new WarehousePalletBatchEntity();
entity.setId(id);
entity.setWarehouseId(WAREHOUSE);
entity.setBatchKey("container-1");
entity.setStatus(status);
entity.setAttemptNo(attempt);
return entity;
}
private static WarehousePalletBatchItemEntity item(long id, long customerId, long palletId,
long locationId, long lockId, int sequence, String status) {
WarehousePalletBatchItemEntity entity = new WarehousePalletBatchItemEntity();
entity.setId(id);
entity.setBatchId(11L);
entity.setCustomerId(customerId);
entity.setWarehouseId(WAREHOUSE);
entity.setBusinessKey("business-" + sequence);
entity.setInboundPlanNo("PLAN-" + sequence);
entity.setSequenceNo(sequence);
entity.setPalletId(palletId);
entity.setPalletCode("PAL-" + palletId);
entity.setLocationId(locationId);
entity.setResourceLockId(lockId);
entity.setStatus(status);
return entity;
}
private static WarehousePalletEntity pallet(long id, long locationId, String code) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setId(id);
entity.setWarehouseId(WAREHOUSE);
entity.setLocationId(locationId);
entity.setPalletCode(code);
entity.setStatus("OCCUPIED");
return entity;
}
private static WarehouseResourceLockEntity lock(long customerId, WarehousePalletBatchItemEntity item) {
WarehouseResourceLockEntity entity = new WarehouseResourceLockEntity();
entity.setId(item.getResourceLockId());
entity.setCustomerId(customerId);
entity.setWarehouseId(WAREHOUSE);
entity.setPalletId(item.getPalletId());
entity.setLocationId(item.getLocationId());
entity.setBusinessType("INBOUND_PLAN_AUTO");
entity.setBusinessId(item.getBusinessKey());
entity.setStatus("ACTIVE");
return entity;
}
private static WarehouseResourceLockResponse lockResponse(long customerId, long lockId) {
return new WarehouseResourceLockResponse(lockId, customerId, WAREHOUSE, 0L, 0L,
"INBOUND_PLAN_AUTO", "business", "reservation", "ACTIVE", null, null);
}
}