fix(lastleg): expose and validate individual return carton picks

This commit is contained in:
2026-10-02 16:59:16 +08:00
parent 195446e532
commit e15bec3052
3 changed files with 224 additions and 26 deletions
@@ -0,0 +1,165 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Modifier;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ReturnOutboundPdaPersistenceTest {
private JdbcTemplate jdbc;
private DeliveryTransferService service;
@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)) {
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"
: field.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT")
+ (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
}
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);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
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 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);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DeliveryTransferService) proxy.getProxy();
carton(1, "C1", "WH-1", "RT-1", "BOX-A"); carton(2, "C1", "WH-1", "RT-1", "BOX-B");
carton(3, "C2", "WH-1", "RT-2", "OTHER-CUSTOMER"); carton(4, "C1", "WH-2", "RT-1", "OTHER-WAREHOUSE");
}
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);
}
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));
}
@Test void singleExistingShapeHasRealCartonScanRowWithoutInventedPalletOrPosition() {
var task = create("single", "BOX-A");
var detail = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals(1, detail.items().size());
assertEquals(1, detail.items().get(0).positions().size());
var row = detail.items().get(0).positions().get(0);
assertNull(row.inventoryPositionId()); assertNull(row.palletNo());
assertEquals("BOX-A", row.cartonMark()); assertEquals("RT-1", row.inboundCode());
assertEquals(1, row.availablePackages()); assertEquals(0, row.pickedPackages());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
}
@Test void multiplePhysicalCartonsPersistSeparateIndependentlyScannableItems() {
var task = create("multi", "BOX-A", "BOX-B");
var detail = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals(2, detail.items().size());
assertTrue(detail.items().stream().allMatch(item -> item.packageCount() == 1 && item.positions().size() == 1));
var a = detail.items().stream().filter(item -> item.positions().get(0).cartonMark().equals("BOX-A")).findFirst().orElseThrow();
service.pdaPick("WH-1", "operator", task.taskNo(), new DeliveryRequests.PdaPick(a.outboundPalletItemId(), null, 1L, "pick-a", List.of("BOX-A")));
var after = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum());
assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum());
}
@Test void wrongScanAndWrongReplayPayloadLeaveStoredInventoryUnchanged() {
var task = create("scan", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "bad", List.of("BOX-B"))));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
var valid = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-A"));
service.pdaPick("WH-1", "operator", task.taskNo(), valid);
service.pdaPick("WH-1", "operator", task.taskNo(), valid);
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-B"))));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
}
@Test void customerWarehouseAndSourceReturnMismatchNeverAppearAsPickableRows() {
var task = create("scope", "BOX-A");
assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-2", task.taskNo()));
for (String mark : List.of("OTHER-CUSTOMER", "OTHER-WAREHOUSE")) {
assertThrows(ResponseStatusException.class, () -> create("bad-" + mark, mark));
}
for (String column : List.of("customer_id", "overseas_warehouse_id", "source_return_no")) {
String original = jdbc.queryForObject("SELECT " + column + " FROM ll_return_inventory_carton WHERE id=1", String.class);
jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "='FOREIGN' WHERE id=1");
assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-1", task.taskNo()));
jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=1", original);
}
}
@Test void completedTaskDetailIsReadOnlyAndContainsNoPickableStock() {
var task = create("complete", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick", List.of("BOX-A"));
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
service.completePda("WH-1", task.taskNo(), new DeliveryRequests.PdaComplete("complete-key"));
for (int i = 0; i < 2; i++) {
var detail = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals("COMPLETED", detail.task().status());
assertTrue(detail.items().stream().flatMap(row -> row.positions().stream()).allMatch(row -> row.availablePackages() == 0));
}
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "new-key", List.of("BOX-A"))));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
}
@Test void sourceChangedAfterDetailReadCannotBeShippedByTheOldRequest() {
var task = create("source-change", "BOX-A");
var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-OTHER' WHERE id=1");
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stale-source", List.of("BOX-A"))));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(0, jdbc.queryForObject("SELECT picked_package_count FROM ll_outbound_pallet_item", Integer.class));
}
@Test void failedPickJournalRollsBackCartonAndTaskThenAllowsAnExactRetry() {
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"));
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));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
var unchanged = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals("PENDING", unchanged.task().status());
assertEquals(1, unchanged.items().get(0).positions().get(0).availablePackages());
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));
}
}