fix(lastleg): account return carton outbound stock atomically

This commit is contained in:
2026-10-02 17:47:11 +08:00
parent e15bec3052
commit 4655090f76
10 changed files with 294 additions and 13 deletions
@@ -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());
}
}