Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java
This commit is contained in:
2026-09-08 18:39:34 +08:00
9 changed files with 453 additions and 131 deletions
@@ -28,7 +28,6 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
@@ -253,46 +252,80 @@ public class InventoryCommandService {
@Transactional
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
ReturnedInventoryRestoreRequest request) {
String exceptionNo = required(request.exceptionNo(), "Return exception number is required");
String outboundNo = required(request.outboundNo(), "Return outbound order number is required");
String warehouseId = required(request.overseasWarehouseId(), "Return warehouse is required");
String idempotencyKey = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(customerId, idempotencyKey);
if (existing != null) return replayReturned(existing, requestHash);
if (request == null) throw badRequest("Returned inventory restore request is required");
String exceptionNo = required(request.exceptionNo(), "Exception number is required");
String outboundNo = required(request.outboundNo(), "Outbound number is required");
String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required");
long warehouseResourceId;
try {
warehouseResourceId = Long.parseLong(warehouseId);
} catch (NumberFormatException exception) {
throw badRequest("Overseas warehouse ID must be numeric");
}
if (request.inboundOrderId() == null || request.inboundOrderId() <= 0) {
throw badRequest("Inbound order ID is required");
}
if (request.sources() == null || request.sources().isEmpty()) {
throw badRequest("Return inventory sources are required");
throw badRequest("Returned inventory sources are required");
}
if (request.receipts() == null || request.receipts().isEmpty()) {
throw badRequest("Return inventory receipts are required");
throw badRequest("Returned inventory receipts are required");
}
if (request.evidenceFileIds() == null || request.evidenceFileIds().isEmpty()
|| request.evidenceFileIds().stream().anyMatch(value -> trim(value) == null)) {
throw badRequest("Return evidence file IDs are required");
}
Map<String, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
Map<Long, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) {
if (source.outboundPalletItemId() == null || source.inventoryPositionId() == null
if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0
|| source.inventoryPositionId() == null || source.inventoryPositionId() <= 0
|| source.maxReturnPackages() <= 0) {
throw badRequest("Return inventory source is incomplete");
throw badRequest("A valid returned inventory source is required");
}
String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required");
ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem(
source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages());
if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) {
throw badRequest("Returned inventory source must be unique");
}
String key = returnSourceKey(source.outboundPalletItemId(), source.inventoryPositionId());
if (sources.putIfAbsent(key, source) != null) throw badRequest("Return inventory source is duplicated");
}
Map<String, Long> receiptTotals = new LinkedHashMap<>();
Map<Long, Long> requestedBySource = new LinkedHashMap<>();
Map<String, Boolean> receiptTargets = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
if (receipt.outboundPalletItemId() == null || receipt.inventoryPositionId() == null
|| receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null
|| receipt.targetLocationId() == null || trim(receipt.targetPalletNo()) == null
|| trim(receipt.targetLocationCode()) == null) {
throw badRequest("Return inventory receipt is incomplete");
ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null
: sources.get(receipt.outboundPalletItemId());
if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) {
throw badRequest("Return receipt must match an immutable outbound inventory source");
}
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
if (!sources.containsKey(key)) throw conflict("Return inventory receipt does not match an approved source");
receiptTotals.merge(key, receipt.receivedPackages(), Math::addExact);
if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0
|| receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) {
throw badRequest("A valid returned inventory receipt is required");
}
required(receipt.targetPalletNo(), "Target pallet number is required");
required(receipt.targetLocationCode(), "Target location code is required");
String receiptTarget = receipt.outboundPalletItemId() + ":" + receipt.targetPalletId();
if (receiptTargets.putIfAbsent(receiptTarget, Boolean.TRUE) != null) {
throw badRequest("Return receipt source and target pallet must be unique");
}
requestedBySource.merge(source.outboundPalletItemId(), receipt.receivedPackages(), Long::sum);
}
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
long requested = requestedBySource.getOrDefault(source.outboundPalletItemId(), 0L);
if (requested <= 0 || requested > source.maxReturnPackages()) {
throw conflict("Returned packages exceed the approved source limit");
}
}
String normalizedKey = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey);
if (existing != null) return replayReturned(existing, requestHash);
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setCustomerId(customerId);
operation.setIdempotencyKey(idempotencyKey);
operation.setIdempotencyKey(normalizedKey);
operation.setOperationType("RETURN_STOCK_IN");
operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING");
@@ -301,64 +334,86 @@ public class InventoryCommandService {
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(customerId, idempotencyKey);
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
if (concurrent != null) return replayReturned(concurrent, requestHash);
throw exception;
}
Map<String, Locked> lockedSources = new LinkedHashMap<>();
for (Map.Entry<String, ReturnedInventoryRestoreRequest.SourceItem> entry : sources.entrySet()) {
ReturnedInventoryRestoreRequest.SourceItem source = entry.getValue();
Map<Long, Locked> lockedSources = new LinkedHashMap<>();
Map<Long, Long> balanceBefore = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
Locked locked = locked(customerId, source.inventoryPositionId());
if (!Objects.equals(locked.balance().getInboundOrderId(), request.inboundOrderId())
|| !warehouseId.equals(trim(locked.balance().getOverseasWarehouseId()))) {
throw conflict("Return inventory source does not belong to the exception inbound order and warehouse");
InventoryBalance balance = locked.balance();
if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) {
throw conflict("Returned inventory source inbound order does not match");
}
if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) {
throw conflict("Returned inventory source warehouse does not match");
}
String sourceIdentity = returnSourceIdentity(exceptionNo, source);
long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo,
sku(source.skuCode()));
long restored = commands.restoredPackages(customerId, sourceIdentity, sku(source.skuCode()));
long allowed = Math.min(source.maxReturnPackages(), shipped) - restored;
long requested = receiptTotals.getOrDefault(entry.getKey(), 0L);
if (requested > allowed) throw conflict("Return inventory receipt exceeds the remaining shipped packages");
lockedSources.put(entry.getKey(), locked);
source.skuCode());
long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode());
long requested = requestedBySource.get(source.outboundPalletItemId());
if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found");
if (restored + requested > shipped) {
throw conflict("Returned packages exceed the remaining shipped packages");
}
lockedSources.put(source.outboundPalletItemId(), locked);
balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages()));
}
List<Long> ledgerIds = new ArrayList<>();
long beforePackages = 0;
long restoredPackages = 0;
int sequence = 0;
String batchNo = ("RETURN-" + exceptionNo).substring(0, Math.min(128, 7 + exceptionNo.length()));
List<Long> ledgerIds = new java.util.ArrayList<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(key);
Locked locked = lockedSources.get(key);
InventoryBalance balance = locked.balance();
InventoryPosition target = returnPosition(customerId, balance, receipt, batchNo);
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId());
Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId());
InventoryBalance balance = sourceLocked.balance();
WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet(
receipt.targetPalletId());
if (pallet == null || pallet.palletId() != receipt.targetPalletId()
|| pallet.warehouseId() != warehouseResourceId
|| pallet.locationId() != receipt.targetLocationId()
|| !required(receipt.targetPalletNo(), "Target pallet number is required").equals(pallet.palletNo())) {
throw conflict("Target pallet does not match the returned inventory warehouse and location");
}
WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation(
warehouseResourceId, receipt.targetLocationId());
if (location == null || location.locationId() != receipt.targetLocationId()
|| !required(receipt.targetLocationCode(), "Target location code is required")
.equals(location.locationCode())) {
throw conflict("Target location does not match the returned inventory warehouse");
}
InventoryPosition target = returnedPosition(customerId, balance, exceptionNo, receipt, location);
long before = value(target.getAvailablePackages());
beforePackages = Math.addExact(beforePackages, before);
target.setAvailablePackages(Math.addExact(before, receipt.receivedPackages()));
target.setAvailablePackages(before + receipt.receivedPackages());
target.setStatus("OCCUPIED");
target.setVersion(value(target.getVersion()) + 1);
target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
balance.setAvailablePackages(Math.addExact(value(balance.getAvailablePackages()), receipt.receivedPackages()));
balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages());
touch(balance);
balances.updateById(balance);
restoredPackages = Math.addExact(restoredPackages, receipt.receivedPackages());
InventoryLedger ledger = returnLedger(customerId, operatorId, operation.getId(), balance, target,
source, exceptionNo, outboundNo, returnSourceIdentity(exceptionNo, source), idempotencyKey,
sequence++, before, receipt.receivedPackages(), request.evidenceFileIds());
ledgers.insert(ledger);
InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target,
source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before,
receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo,
balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source),
normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(),
"Exception return received with evidence " + request.evidenceFileIds().size());
ledgerIds.add(ledger.getId());
restoredPackages += receipt.receivedPackages();
}
LocalDateTime completedAt = LocalDateTime.now();
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), ledgerIds,
beforePackages, restoredPackages, Math.addExact(beforePackages, restoredPackages), false, completedAt);
long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum();
long afterPackages = balanceBefore.keySet().stream()
.mapToLong(balanceId -> value(lockedSources.values().stream()
.map(Locked::balance).filter(balance -> balance.getId().equals(balanceId)).findFirst().orElseThrow()
.getAvailablePackages())).sum();
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(),
List.copyOf(ledgerIds), beforePackages, restoredPackages, afterPackages, false, LocalDateTime.now());
operation.setStatus("COMPLETED");
operation.setResultJson(write(response));
operation.setUpdatedAt(completedAt);
operation.setUpdatedAt(response.completedAt());
operations.updateById(operation);
return response;
}
@@ -541,42 +596,6 @@ public class InventoryCommandService {
}
}
private InventoryPosition returnPosition(String customerId, InventoryBalance balance,
ReturnedInventoryRestoreRequest.Receipt receipt, String batchNo) {
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (position != null) {
if (!required(receipt.targetPalletNo(), "Return pallet number is required").equals(position.getPalletNo())
|| !Objects.equals(receipt.targetLocationId(), position.getWarehouseLocationId())
|| !required(receipt.targetLocationCode(), "Return location code is required")
.equals(position.getWarehouseLocationCode())) {
throw conflict("Return target pallet or location changed for the same exception");
}
return position;
}
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setCustomerId(customerId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setPalletId(receipt.targetPalletId());
position.setPalletNo(required(receipt.targetPalletNo(), "Return pallet number is required"));
position.setBatchNo(batchNo);
position.setWarehouseLocationId(receipt.targetLocationId());
position.setWarehouseLocationCode(required(receipt.targetLocationCode(), "Return location code is required"));
position.setAvailablePackages(0L); position.setHoldPackages(0L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setStatus("OCCUPIED");
position.setVersion(0L); position.setCreatedAt(LocalDateTime.now()); position.setUpdatedAt(position.getCreatedAt());
try {
positions.insert(position);
return position;
} catch (DuplicateKeyException exception) {
InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (concurrent != null) return concurrent;
throw exception;
}
}
private Locked locked(String customerId, Long positionId) {
InventoryPosition position = commands.lockPosition(customerId, positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
@@ -637,8 +656,46 @@ public class InventoryCommandService {
}
}
private InventoryPosition returnedPosition(String customerId, InventoryBalance balance, String exceptionNo,
ReturnedInventoryRestoreRequest.Receipt receipt,
WarehouseResourceGateway.WarehouseLocationReference location) {
String batchNo = "RETURN:" + exceptionNo;
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (position != null) {
if (!Objects.equals(position.getWarehouseLocationId(), location.locationId())
|| !Objects.equals(position.getWarehouseLocationCode(), location.locationCode())
|| !Objects.equals(position.getPalletNo(), receipt.targetPalletNo())) {
throw conflict("Returned inventory target position snapshot does not match");
}
return position;
}
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setCustomerId(customerId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setPalletId(receipt.targetPalletId());
position.setPalletNo(receipt.targetPalletNo().trim());
position.setBatchNo(batchNo);
position.setWarehouseLocationId(location.locationId());
position.setWarehouseLocationCode(location.locationCode());
position.setAvailablePackages(0L);
position.setHoldPackages(0L);
position.setAllocatedPackages(0L);
position.setPickedPackages(0L);
position.setReceivingPackages(0L);
position.setStatus("OCCUPIED");
position.setVersion(0L);
position.setCreatedAt(LocalDateTime.now());
position.setUpdatedAt(position.getCreatedAt());
positions.insert(position);
return position;
}
private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) {
if (!"RETURN_STOCK_IN".equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) {
if (!"RETURN_STOCK_IN".equals(operation.getOperationType())
|| !requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different inventory request");
}
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
@@ -647,43 +704,15 @@ public class InventoryCommandService {
try {
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read return inventory idempotency result", exception);
throw new IllegalStateException("Cannot read returned inventory idempotency result", exception);
}
}
private InventoryLedger returnLedger(String customerId, String operatorId, Long operationId,
InventoryBalance balance, InventoryPosition position,
ReturnedInventoryRestoreRequest.SourceItem source,
String exceptionNo, String outboundNo, String sourceIdentity,
String idempotencyKey, int sequence, long before, long delta,
List<String> evidenceFileIds) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId()); ledger.setCustomerId(customerId); ledger.setCustomerName(balance.getCustomerName());
ledger.setBalanceId(balance.getId()); ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); ledger.setInboundOrderId(balance.getInboundOrderId());
ledger.setPalletId(position.getPalletId()); ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); ledger.setSkuCode(sku(source.skuCode()));
ledger.setBusinessType("RETURN_STOCK_IN"); ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(exceptionNo); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setOutboundOrderNo(outboundNo);
ledger.setSourceEventId(sourceIdentity); ledger.setIdempotencyKey(idempotencyKey + ":RETURN:" + sequence + ":" + operationId);
ledger.setStatusFrom("RETURNED"); ledger.setStatusTo("AVAILABLE"); ledger.setBeforePackages(before);
ledger.setDeltaPackages(delta); ledger.setAfterPackages(Math.addExact(before, delta)); ledger.setOperatorId(trim(operatorId));
ledger.setRemark("Exception return received; evidenceCount=" + (evidenceFileIds == null ? 0 : evidenceFileIds.size()));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt());
return ledger;
private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private static String returnSourceKey(Long outboundPalletItemId, Long inventoryPositionId) {
return outboundPalletItemId + ":" + inventoryPositionId;
}
private static String returnSourceIdentity(String exceptionNo, ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN:" + exceptionNo + ":" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
private InventoryLedger ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String skuCode, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
@@ -724,6 +753,7 @@ public class InventoryCommandService {
ledger.setOccurredAt(LocalDateTime.now());
ledger.setCreatedAt(ledger.getOccurredAt());
ledgers.insert(ledger);
return ledger;
}
private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) {
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inventory.command;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
@@ -17,6 +19,7 @@ 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.apache.ibatis.annotations.Select;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -57,7 +60,7 @@ class InventoryCommandServiceTest {
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(operations.findIdempotency(any(), any())).thenReturn(null);
lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of());
}
@@ -329,6 +332,97 @@ class InventoryCommandServiceTest {
verify(operations).findIdempotency("200", "platform-hold-1");
}
@Test
void restoresReturnedInventoryFromImmutableShipmentSourceIntoVerifiedTarget() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("restoreReturnedInventory", String.class, String.class,
ReturnedInventoryRestoreRequest.class).getAnnotation(Transactional.class) != null);
InventoryBalance balance = balance(200L, 2);
balance.setOverseasWarehouseId("900");
InventoryPosition source = position(201L, 200L, 0);
when(commands.lockPosition("100", 201L)).thenReturn(source);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(1L);
when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0));
when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn(
new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE"));
ReturnedInventoryRestoreResponse result = service.restoreReturnedInventory("100", "7", returnRequest(3));
assertEquals(2, result.beforePackages());
assertEquals(3, result.restoredPackages());
assertEquals(5, result.afterPackages());
assertFalse(result.replayed());
ArgumentCaptor<InventoryPosition> target = ArgumentCaptor.forClass(InventoryPosition.class);
verify(positions).insert(target.capture());
assertEquals("RETURN:EX-1", target.getValue().getBatchNo());
assertEquals("LOC-RETURN", target.getValue().getWarehouseLocationCode());
assertEquals(3L, target.getValue().getAvailablePackages());
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType());
assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId());
assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo());
}
@Test
void returnedInventoryReplayDoesNotMutateInventoryTwice() {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
InventoryPosition source = position(201L, 200L, 0);
when(commands.lockPosition("100", 201L)).thenReturn(source);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(0L);
when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0));
when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn(
new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE"));
ReturnedInventoryRestoreRequest request = returnRequest(3);
service.restoreReturnedInventory("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue());
ReturnedInventoryRestoreResponse replay = service.restoreReturnedInventory("100", "7", request);
assertTrue(replay.replayed());
verify(positions, times(1)).insert(any(InventoryPosition.class));
verify(ledgers, times(1)).insert(any(InventoryLedger.class));
}
@Test
void rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0));
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(4L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L);
assertThrows(ResponseStatusException.class,
() -> service.restoreReturnedInventory("100", "7", returnRequest(3)));
verify(positions, never()).insert(any(InventoryPosition.class));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
verify(ledgers, never()).insert(any(InventoryLedger.class));
}
@Test
void returnRestoreSqlIsBoundToImmutableShipLedgerAndSourceIdentity() throws Exception {
String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages",
String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value());
assertTrue(shippedSql.contains("position_id=#{positionId}"));
assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}"));
assertTrue(shippedSql.contains("business_type='SHIP'"));
String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages",
String.class, String.class, String.class).getAnnotation(Select.class).value());
assertTrue(restoredSql.contains("source_event_id=#{sourceIdentity}"));
assertTrue(restoredSql.contains("business_type='RETURN_STOCK_IN'"));
}
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
@@ -342,6 +436,14 @@ class InventoryCommandServiceTest {
packages, "ORD-1", "EVT-1", key, null);
}
private ReturnedInventoryRestoreRequest returnRequest(long packages) {
return new ReturnedInventoryRestoreRequest("EX-1", "OUT-1", "900", 101L,
List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "SKU-1", 5)),
List.of(new ReturnedInventoryRestoreRequest.Receipt(301L, 201L, packages,
400L, "PLT-RETURN", 500L, "LOC-RETURN")),
"return-1", List.of("FILE-1"));
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");