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 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 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 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 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 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 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 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 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 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); } }