From 5cb2a5145ffe6f48615044d7ccb7131f3143a62f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:32:59 +0800 Subject: [PATCH] fix(inventory): normalize FBA return SKU and match durable shipment command identity --- .../mapper/InventoryCommandMapper.java | 6 +- .../service/InventoryCommandService.java | 2 +- .../DispatchShipmentSourceQueryTest.java | 31 +++++++++- .../command/InventoryCommandServiceTest.java | 61 +++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 1438b0a7b..dac3cac41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -56,10 +56,12 @@ public interface InventoryCommandMapper { "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "JOIN ll_inventory_operation op ON op.customer_id=p.customer_id " + + "AND op.operation_type='SHIP' AND op.status='COMPLETED' " + + "AND op.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':',p.dispatch_item_id,':',p.idempotency_key,':SHIP') " + "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + - "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':'," + - "p.dispatch_item_id,':',p.idempotency_key,':SHIP'))) " + + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))") long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); 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 540f25419..d606f263b 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 @@ -294,7 +294,7 @@ public class InventoryCommandService { || source.maxReturnPackages() <= 0) { throw badRequest("A valid returned inventory source is required"); } - String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + String sourceSku = sku(source.skuCode()); ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java index f96cd1be2..f79401126 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -18,6 +18,7 @@ class DispatchShipmentSourceQueryTest { execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); + execute(connection, "CREATE TABLE ll_inventory_operation (id BIGINT PRIMARY KEY, customer_id VARCHAR, operation_type VARCHAR, status VARCHAR, idempotency_key VARCHAR)"); execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); return connection; @@ -40,7 +41,8 @@ class DispatchShipmentSourceQueryTest { } private void scan(Connection connection) throws Exception { - execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_operation VALUES (71,'100','SHIP','COMPLETED','DISPATCH:DS-1:31:pick-1:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP:71')"); } private void transfer(Connection connection) throws Exception { @@ -100,4 +102,31 @@ class DispatchShipmentSourceQueryTest { assertEquals(3, shipped(connection, "100", 41, "OB-1", "SKU-1")); } } + + @Test + void ordinaryFbaUnspecifiedSkuMatchesExactlyAndWrongSkuNeverFallsBack() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "UPDATE ll_inventory_ledger SET sku_code='UNSPECIFIED'"); + assertEquals(8, shipped(connection, "100", 41, "OB-1", "UNSPECIFIED")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "WRONG-SKU")); + } + } + + @Test + void shipmentLedgerRequiresTheActualCompletedOperationIdentityRatherThanAPrefix() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "UPDATE ll_inventory_ledger SET idempotency_key='DISPATCH:DS-1:31:pick-1:SHIP:72'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_ledger SET idempotency_key='DISPATCH:DS-1:31:pick-1:SHIP:71'"); + execute(connection, "UPDATE ll_inventory_operation SET customer_id='200'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_operation SET customer_id='100',status='PROCESSING'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_inventory_operation SET status='COMPLETED',operation_type='PICK'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } } 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 8184fccfd..4e0aa51c8 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 @@ -19,6 +19,9 @@ 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.apache.ibatis.annotations.Select; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -409,6 +412,63 @@ class InventoryCommandServiceTest { verify(ledgers, times(1)).insert(any(InventoryLedger.class)); } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void ordinaryFbaReturnUsesTheStockInSkuNormalizationAndReplaysOnlyOnce(String sourceSku) { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + balance.setSkuCode("UNSPECIFIED"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED")).thenReturn(8L); + 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 base = returnRequest(1); + ReturnedInventoryRestoreRequest request = new ReturnedInventoryRestoreRequest(base.exceptionNo(), base.outboundNo(), + base.overseasWarehouseId(), base.inboundOrderId(), + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, sourceSku, 5)), + base.receipts(), base.idempotencyKey(), base.evidenceFileIds()); + + ReturnedInventoryRestoreResponse first = service.restoreReturnedInventory("100", "7", request); + assertEquals(1, first.restoredPackages()); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue()); + assertTrue(service.restoreReturnedInventory("100", "7", request).replayed()); + verify(commands).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); + verify(commands).restoredPackages("100", "RETURN_SOURCE:301:201", "UNSPECIFIED"); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(1)).insert(ledger.capture()); + assertEquals("UNSPECIFIED", ledger.getValue().getSkuCode()); + verify(positions, times(1)).insert(any(InventoryPosition.class)); + verify(balances, times(1)).updateById(any(InventoryBalance.class)); + } + + @Test + void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSkuOrWritingInventory() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + balance.setSkuCode("UNSPECIFIED"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + ReturnedInventoryRestoreRequest base = returnRequest(1); + ReturnedInventoryRestoreRequest request = new ReturnedInventoryRestoreRequest(base.exceptionNo(), base.outboundNo(), + base.overseasWarehouseId(), base.inboundOrderId(), + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "WRONG-SKU", 5)), + base.receipts(), base.idempotencyKey(), base.evidenceFileIds()); + + assertThrows(ResponseStatusException.class, () -> service.restoreReturnedInventory("100", "7", request)); + verify(commands).shippedPackages("100", 201L, "OUT-1", "WRONG-SKU"); + verify(commands, never()).shippedPackages("100", 201L, "OUT-1", "UNSPECIFIED"); + 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 rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() { InventoryBalance balance = balance(200L, 0); @@ -435,6 +495,7 @@ class InventoryCommandServiceTest { assertTrue(shippedSql.contains("o.outbound_no=#{outboundNo}")); assertTrue(shippedSql.contains("p.inventory_position_id=l.position_id")); assertTrue(shippedSql.contains("p.idempotency_key,':SHIP'")); + assertTrue(shippedSql.contains("l.idempotency_key=CONCAT(op.idempotency_key,':',op.id)")); assertTrue(shippedSql.contains("business_type='SHIP'")); String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", String.class, String.class, String.class).getAnnotation(Select.class).value());