fix(inventory): normalize FBA return SKU and match durable shipment command identity
This commit is contained in:
+4
-2
@@ -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);
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+30
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user