fix(inventory): resolve returns from immutable shipment SKU facts
This commit is contained in:
+45
-10
@@ -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()) {
|
||||
|
||||
+43
-21
@@ -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}"));
|
||||
|
||||
Reference in New Issue
Block a user