From db98da568974f258ae6ee07e7c801b6195abf7c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:30:04 +0800 Subject: [PATCH] fix(lastleg): restore returned inventory from shipment source --- .../service/InventoryCommandService.java | 232 +++++++++++++++++- .../command/InventoryCommandServiceTest.java | 104 +++++++- 2 files changed, 334 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index f36082193..9b7511135 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; 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.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryLedger; @@ -27,7 +29,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @@ -245,6 +249,175 @@ public class InventoryCommandService { }); } + @Transactional + public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId, + ReturnedInventoryRestoreRequest request) { + 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("Returned inventory sources are required"); + } + if (request.receipts() == null || request.receipts().isEmpty()) { + 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 sources = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) { + if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0 + || source.inventoryPositionId() == null || source.inventoryPositionId() <= 0 + || source.maxReturnPackages() <= 0) { + 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"); + } + } + Map requestedBySource = new LinkedHashMap<>(); + Map receiptTargets = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + 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"); + } + 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(normalizedKey); + operation.setOperationType("RETURN_STOCK_IN"); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replayReturned(concurrent, requestHash); + throw exception; + } + + Map lockedSources = new LinkedHashMap<>(); + Map balanceBefore = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + Locked locked = locked(customerId, source.inventoryPositionId()); + 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"); + } + long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, + 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())); + } + + long restoredPackages = 0; + List ledgerIds = new java.util.ArrayList<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + 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()); + target.setAvailablePackages(before + receipt.receivedPackages()); + target.setStatus("OCCUPIED"); + target.setVersion(value(target.getVersion()) + 1); + target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages()); + touch(balance); + balances.updateById(balance); + 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(); + } + 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(response.completedAt()); + operations.updateById(operation); + return response; + } + @Transactional public InventoryCommandResponse adjust(String customerId, String operatorId, InventoryCommandRequests.Adjustment request) { @@ -483,7 +656,63 @@ public class InventoryCommandService { } } - private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + 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())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read returned inventory idempotency result", exception); + } + } + + private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) { + return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId(); + } + + 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, @@ -524,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) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 6da77c0ff..ba5f4db32 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -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 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 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 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");