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
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.inventory.command.dto;
/** SKU and shipped quantity derived only from the selected immutable outbound shipment facts. */
public record ShipmentSource(String skuCode, long shippedPackages) { }
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inventory.command.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.command.dto.ShipmentSource;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
@@ -47,11 +48,11 @@ public interface InventoryCommandMapper {
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(-SUM(l.delta_packages),0) FROM ll_inventory_ledger l " +
@Select("SELECT l.sku_code AS skuCode, -SUM(l.delta_packages) AS shippedPackages FROM ll_inventory_ledger l " +
"JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " +
"AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " +
"WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " +
"AND l.sku_code=#{skuCode} AND l.business_type='SHIP' AND l.delta_packages<0 " +
"AND l.business_type='SHIP' AND l.delta_packages<0 " +
"AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " +
"AND l.outbound_order_no=o.dispatch_no AND EXISTS (" +
"SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " +
@@ -62,9 +63,10 @@ public interface InventoryCommandMapper {
"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(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);
"OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no)) " +
"GROUP BY l.sku_code")
List<ShipmentSource> shipmentSources(@Param("customerId") String customerId, @Param("positionId") Long positionId,
@Param("outboundNo") String outboundNo);
@Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " +
@@ -294,7 +294,7 @@ public class InventoryCommandService {
|| source.maxReturnPackages() <= 0) {
throw badRequest("A valid returned inventory source is required");
}
String sourceSku = sku(source.skuCode());
String sourceSku = trim(source.skuCode());
ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem(
source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages());
if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) {
@@ -351,6 +351,7 @@ public class InventoryCommandService {
}
Map<Long, Locked> lockedSources = new LinkedHashMap<>();
Map<Long, ReturnedInventoryRestoreRequest.SourceItem> resolvedSources = new LinkedHashMap<>();
Map<Long, Long> balanceBefore = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) {
Locked locked = locked(customerId, source.inventoryPositionId());
@@ -361,22 +362,36 @@ public class InventoryCommandService {
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());
var shipmentSources = commands.shipmentSources(customerId, source.inventoryPositionId(), outboundNo);
if (shipmentSources == null || shipmentSources.isEmpty()) {
throw conflict("Immutable outbound shipment source was not found");
}
var matchingSources = source.skuCode() == null ? shipmentSources : shipmentSources.stream()
.filter(shipment -> source.skuCode().equals(shipment.skuCode())).toList();
if (matchingSources.size() != 1) {
throw conflict(source.skuCode() == null ? "Immutable outbound shipment SKU source is ambiguous"
: "Declared return SKU does not match an immutable shipment source");
}
var shipment = matchingSources.get(0);
String actualSku = required(shipment.skuCode(), "Immutable outbound shipment SKU is missing");
var resolved = new ReturnedInventoryRestoreRequest.SourceItem(source.outboundPalletItemId(),
source.inventoryPositionId(), actualSku, source.maxReturnPackages());
long shipped = shipment.shippedPackages();
long restored = commands.restoredPackages(customerId, returnSourceIdentity(resolved), actualSku);
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);
resolvedSources.put(source.outboundPalletItemId(), resolved);
balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages()));
}
long restoredPackages = 0;
List<Long> ledgerIds = new java.util.ArrayList<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId());
ReturnedInventoryRestoreRequest.SourceItem source = resolvedSources.get(receipt.outboundPalletItemId());
Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId());
InventoryBalance balance = sourceLocked.balance();
WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet(
@@ -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}"));