fix(inventory): normalize FBA return SKU and match durable shipment command identity

This commit is contained in:
2026-09-30 12:32:59 +08:00
parent 2f0b202590
commit 5cb2a5145f
4 changed files with 96 additions and 4 deletions
@@ -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);
@@ -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) {
@@ -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"));
}
}
}
@@ -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<InventoryOperation> 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<InventoryLedger> 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());