fix(inventory): resolve returns from immutable shipment SKU facts

This commit is contained in:
2026-09-30 13:00:40 +08:00
parent 80632ba409
commit d320e78bbc
5 changed files with 119 additions and 41 deletions
@@ -1,6 +1,9 @@
package com.baiyue.lastleg.inventory.command;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.dto.ShipmentSource;
import java.util.List;
import java.util.ArrayList;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
@@ -28,18 +31,26 @@ class DispatchShipmentSourceQueryTest {
try (var statement = connection.createStatement()) { statement.execute(sql); }
}
private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception {
String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages",
String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value())
.replace("#{outboundNo}", "?").replace("#{customerId}", "?")
.replace("#{positionId}", "?").replace("#{skuCode}", "?");
private List<ShipmentSource> sources(Connection connection, String customerId, long positionId, String outboundNo) throws Exception {
String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shipmentSources",
String.class, Long.class, String.class).getAnnotation(Select.class).value())
.replace("#{outboundNo}", "?").replace("#{customerId}", "?").replace("#{positionId}", "?");
try (var statement = connection.prepareStatement(sql)) {
statement.setString(1, outboundNo); statement.setString(2, customerId);
statement.setLong(3, positionId); statement.setString(4, sku);
try (var result = statement.executeQuery()) { result.next(); return result.getLong(1); }
statement.setLong(3, positionId);
try (var result = statement.executeQuery()) {
List<ShipmentSource> rows = new ArrayList<>();
while (result.next()) rows.add(new ShipmentSource(result.getString("skuCode"), result.getLong("shippedPackages")));
return rows;
}
}
}
private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception {
return sources(connection, customerId, positionId, outboundNo).stream()
.filter(source -> sku.equals(source.skuCode())).mapToLong(ShipmentSource::shippedPackages).sum();
}
private void scan(Connection connection) throws Exception {
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')");
@@ -104,16 +115,40 @@ class DispatchShipmentSourceQueryTest {
}
@Test
void ordinaryFbaUnspecifiedSkuMatchesExactlyAndWrongSkuNeverFallsBack() throws Exception {
void resolvesOnlyActualImmutableSkuFactsAndGroupsQuantitiesForTheExactSource() 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(List.of(new ShipmentSource("SKU-1", 8L)), sources(connection, "100", 41, "OB-1"));
execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (22,'100',11,32,41,3,'pick-2')");
execute(connection, "INSERT INTO ll_inventory_operation VALUES (72,'100','SHIP','COMPLETED','DISPATCH:DS-1:32:pick-2:SHIP')");
execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'100',41,'WH-1','SKU-1','SHIP',-3,'DS-1','DS-1','DISPATCH:DS-1:32:pick-2:SHIP:72')");
assertEquals(List.of(new ShipmentSource("SKU-1", 11L)), sources(connection, "100", 41, "OB-1"));
execute(connection, "UPDATE ll_inventory_ledger SET sku_code='MIXED' WHERE id=52");
assertEquals(2, sources(connection, "100", 41, "OB-1").size());
assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1"));
assertEquals(3, shipped(connection, "100", 41, "OB-1", "MIXED"));
assertEquals(0, shipped(connection, "100", 41, "OB-1", ""));
assertEquals(0, shipped(connection, "100", 41, "OB-1", "UNSPECIFIED"));
assertEquals(0, shipped(connection, "100", 41, "OB-1", "WRONG-SKU"));
}
}
@Test
void productionMyBatisMapperMaterializesTheCanonicalShipmentSkuAndQuantity() throws Exception {
try (Connection connection = database()) {
scan(connection); transfer(connection);
var configuration = new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment(
"source-sql", new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(),
new org.apache.ibatis.datasource.unpooled.UnpooledDataSource("org.h2.Driver",
connection.getMetaData().getURL(), "", "")));
configuration.addMapper(InventoryCommandMapper.class);
try (var session = new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession(connection)) {
assertEquals(List.of(new ShipmentSource("SKU-1", 8L)),
session.getMapper(InventoryCommandMapper.class).shipmentSources("100", 41L, "OB-1"));
}
}
}
@Test
void shipmentLedgerRequiresTheActualCompletedOperationIdentityRatherThanAPrefix() throws Exception {
try (Connection connection = database()) {
@@ -4,6 +4,7 @@ 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.dto.ShipmentSource;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
@@ -20,8 +21,8 @@ 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.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.apache.ibatis.annotations.Select;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
@@ -361,7 +362,7 @@ class InventoryCommandServiceTest {
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.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 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));
@@ -393,7 +394,7 @@ class InventoryCommandServiceTest {
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.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 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));
@@ -413,15 +414,14 @@ class InventoryCommandServiceTest {
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void ordinaryFbaReturnUsesTheStockInSkuNormalizationAndReplaysOnlyOnce(String sourceSku) {
@MethodSource("missingSkuSources")
void missingFbaSummarySkuUsesUniqueImmutableShipmentSkuAndReplaysOnlyOnce(String sourceSku, String actualSku) {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
balance.setSkuCode("UNSPECIFIED");
balance.setSkuCode("MIXED");
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(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource(actualSku, 8L)));
when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0));
when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn(
@@ -438,31 +438,51 @@ class InventoryCommandServiceTest {
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");
verify(commands).shipmentSources("100", 201L, "OUT-1");
verify(commands).restoredPackages("100", "RETURN_SOURCE:301:201", actualSku);
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers, times(1)).insert(ledger.capture());
assertEquals("UNSPECIFIED", ledger.getValue().getSkuCode());
assertEquals(actualSku, ledger.getValue().getSkuCode());
assertEquals(sourceSku, request.sources().get(0).skuCode());
assertEquals("MIXED", balance.getSkuCode());
verify(positions, times(1)).insert(any(InventoryPosition.class));
verify(balances, times(1)).updateById(any(InventoryBalance.class));
}
private static java.util.stream.Stream<Arguments> missingSkuSources() {
return java.util.stream.Stream.of("SKU-1", "MIXED", "UNSPECIFIED").flatMap(actualSku ->
java.util.stream.Stream.of(null, "", " ").map(sourceSku -> Arguments.of(sourceSku, actualSku)));
}
@Test
void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSkuOrWritingInventory() {
void nonEmptyWrongReturnSkuIsRejectedWithoutSearchingAnotherSourceOrWritingInventory() {
rejectReturnSku("WRONG-SKU", List.of(new ShipmentSource("SKU-1", 8L)));
}
@Test
void missingSummarySkuWithMultipleImmutableSkuGroupsIsRejectedWithoutInventoryMutation() {
rejectReturnSku(null, List.of(new ShipmentSource("SKU-1", 8L), new ShipmentSource("MIXED", 3L)));
}
private void rejectReturnSku(String sourceSku, List<ShipmentSource> shipments) {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
balance.setSkuCode("UNSPECIFIED");
balance.setSkuCode("MIXED");
when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0));
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shipmentSources("100", 201L, "OUT-1")).thenReturn(shipments);
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)),
List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, sourceSku, 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");
ResponseStatusException failure = assertThrows(ResponseStatusException.class,
() -> service.restoreReturnedInventory("100", "7", request));
assertEquals(sourceSku == null ? "Immutable outbound shipment SKU source is ambiguous"
: "Declared return SKU does not match an immutable shipment source", failure.getReason());
verify(commands).shipmentSources("100", 201L, "OUT-1");
verify(commands, never()).restoredPackages(any(), any(), any());
verify(positions, never()).insert(any(InventoryPosition.class));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
@@ -475,7 +495,7 @@ class InventoryCommandServiceTest {
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.shipmentSources("100", 201L, "OUT-1")).thenReturn(List.of(new ShipmentSource("SKU-1", 4L)));
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L);
assertThrows(ResponseStatusException.class,
@@ -489,14 +509,16 @@ class InventoryCommandServiceTest {
@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());
String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shipmentSources",
String.class, Long.class, String.class).getAnnotation(Select.class).value());
assertTrue(shippedSql.contains("position_id=#{positionId}"));
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'"));
assertTrue(shippedSql.contains("GROUP BY l.sku_code"));
assertFalse(shippedSql.contains("#{skuCode}"));
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}"));