fix(lastleg): account return carton outbound stock atomically
This commit is contained in:
+120
-3
@@ -6,7 +6,11 @@ import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.*;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -33,13 +37,16 @@ import static org.mockito.Mockito.*;
|
||||
class ReturnOutboundPdaPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private DeliveryTransferService service;
|
||||
private ReturnInventoryMovementMapper movementMapper;
|
||||
private ReturnInventoryBalanceMapper balanceMapper;
|
||||
|
||||
@BeforeEach void setUp() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:return-pda-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
// Persist production entities through production MyBatis mappers, with real transactions.
|
||||
for (Class<?> entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class,
|
||||
OutboundPdaPick.class, ReturnInventoryCarton.class)) {
|
||||
OutboundPdaPick.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
|
||||
ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
|
||||
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||
.map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
|
||||
+ (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP"
|
||||
@@ -49,15 +56,26 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
}
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
|
||||
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class)) configuration.addMapper(mapper);
|
||||
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class,
|
||||
ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnInventoryMovementMapper.class)) configuration.addMapper(mapper);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
movementMapper = session.getMapper(ReturnInventoryMovementMapper.class);
|
||||
balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class);
|
||||
var target = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
|
||||
ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class));
|
||||
ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class));
|
||||
ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
var stock = new ReturnInventoryService();
|
||||
ReflectionTestUtils.setField(stock, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "cartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "balances", session.getMapper(ReturnInventoryBalanceMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "movements", session.getMapper(ReturnInventoryMovementMapper.class));
|
||||
var stockProxy = new ProxyFactory(stock); stockProxy.setProxyTargetClass(true);
|
||||
stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy());
|
||||
var numbers = mock(LastlegOrderNoGenerator.class);
|
||||
when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
|
||||
ReflectionTestUtils.setField(target, "orderNoGenerator", numbers);
|
||||
@@ -69,6 +87,12 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
}
|
||||
private void carton(long id, String customer, String warehouse, String returnNo, String mark) {
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_return_no,carton_mark,status,total_quantity) VALUES(?,?,?,?,?,'AVAILABLE',5)", id, customer, warehouse, returnNo, mark);
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET sku_types=1 WHERE id=?", id);
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity) VALUES(?,?,?,'SKU-A',5)", id, id, customer);
|
||||
if (jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity+5,available_quantity=available_quantity+5,carton_quantity=carton_quantity+1 WHERE customer_id=? AND overseas_warehouse_id=? AND sku_code='SKU-A'", customer, warehouse) == 0) {
|
||||
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(?,?,?,'SKU-A',5,5,1)", id, customer, warehouse);
|
||||
}
|
||||
|
||||
}
|
||||
private com.baiyue.lastleg.delivery.dto.DeliveryResponses.PdaTask create(String key, String... marks) {
|
||||
return service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", List.of(marks), "fixture", key));
|
||||
@@ -150,6 +174,7 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
var task = create("rollback", "BOX-A");
|
||||
var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "rollback-pick", List.of("BOX-A"));
|
||||
var stockBefore = stockSnapshot();
|
||||
jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')");
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request));
|
||||
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
|
||||
@@ -157,9 +182,101 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
var unchanged = service.pdaTaskDetail("WH-1", task.taskNo());
|
||||
assertEquals("PENDING", unchanged.task().status());
|
||||
assertEquals(1, unchanged.items().get(0).positions().get(0).availablePackages());
|
||||
assertEquals(stockBefore, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_outbound_pda_pick DROP CONSTRAINT fail_pick");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), request);
|
||||
assertEquals("OUTBOUNDED", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
|
||||
}
|
||||
private java.util.Map<String, Object> stockSnapshot() {
|
||||
var result = new java.util.LinkedHashMap<String, Object>();
|
||||
for (String table : List.of("ll_return_inventory_balance", "ll_return_inventory_carton", "ll_return_inventory_carton_item",
|
||||
"ll_return_inventory_outbound_movement", "ll_outbound_pda_pick", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_order")) {
|
||||
result.put(table, jdbc.queryForList("SELECT * FROM " + table + " ORDER BY id"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Test void outboundDeductsSkuQuantityNotPackageCountAndReplayDoesNotDeductAgain() {
|
||||
var task = create("stock", "BOX-A");
|
||||
var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stock-pick", List.of("BOX-A"));
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_carton WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_outbound_movement", Long.class));
|
||||
var ledger = movementMapper.platformPage("C1", "WH-1", "SKU-A", 0, 20);
|
||||
assertEquals(1, ledger.size()); assertEquals("OUTBOUND", ledger.get(0).movementType());
|
||||
assertEquals(task.outboundNo(), ledger.get(0).referenceNo());
|
||||
assertEquals(-5L, ledger.get(0).totalDelta()); assertEquals(-5L, ledger.get(0).availableDelta());
|
||||
assertEquals(0, movementMapper.platformCount("C2", "WH-1", "SKU-A"));
|
||||
assertEquals(0, movementMapper.platformCount("C1", "WH-2", "SKU-A"));
|
||||
var after = stockSnapshot(); service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(after, stockSnapshot());
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
|
||||
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "another-key", List.of("BOX-A"))));
|
||||
assertEquals(after, stockSnapshot());
|
||||
}
|
||||
|
||||
@Test void insufficientStockWrongCodeAndSourceDriftAreZeroWriteFailures() {
|
||||
var task = create("insufficient", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "try", List.of("BOX-A"));
|
||||
for (String field : List.of("total_quantity", "available_quantity", "carton_quantity")) {
|
||||
long original = jdbc.queryForObject("SELECT " + field + " FROM ll_return_inventory_balance WHERE id=1", Long.class);
|
||||
jdbc.update("UPDATE ll_return_inventory_balance SET " + field + "=0 WHERE id=1");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.update("UPDATE ll_return_inventory_balance SET " + field + "=? WHERE id=1", original);
|
||||
}
|
||||
var before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
|
||||
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "wrong", List.of("BOX-B"))));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='FOREIGN' WHERE id=1"); before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
}
|
||||
|
||||
@Test void outboundSkuJournalFailureRollsBackAllStockAndTaskFacts() {
|
||||
var task = create("journal", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "journal-pick", List.of("BOX-A"));
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT fail_sku_journal CHECK(quantity < 0)");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement DROP CONSTRAINT fail_sku_journal");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_outbound_movement", Integer.class));
|
||||
}
|
||||
|
||||
@Test void secondSkuJournalFailureRollsBackTheAlreadyDeductedFirstSku() {
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET total_quantity=7,sku_types=2 WHERE id=1");
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity) VALUES(10,1,'C1','SKU-B',2)");
|
||||
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',2,2,1)");
|
||||
var task = create("multi-sku", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "multi-sku-pick", List.of("BOX-A"));
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT fail_second_sku CHECK(sku_code <> 'SKU-B')");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement DROP CONSTRAINT fail_second_sku");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_outbound_movement", Integer.class));
|
||||
assertEquals(7L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
|
||||
assertEquals(0L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
|
||||
assertEquals(0L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
|
||||
assertEquals(7L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_carton_item WHERE carton_id=1", Long.class));
|
||||
}
|
||||
|
||||
@Test void balanceUpdateCannotCrossCustomerOrWarehouseEvenWithAnExistingRowId() {
|
||||
var before = stockSnapshot();
|
||||
assertEquals(0, balanceMapper.deductOutbound(1, "C2", "WH-1", 1));
|
||||
assertEquals(0, balanceMapper.deductOutbound(1, "C1", "WH-2", 1));
|
||||
assertEquals(before, stockSnapshot());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user