fix(lastleg): restore fulfillment safeguards and reconcile return regressions

This commit is contained in:
2026-10-08 18:08:42 +08:00
parent c1ddb658bd
commit 3a9ee57e53
12 changed files with 409 additions and 770 deletions
@@ -123,6 +123,7 @@ class DeliveryTransferServiceTest {
@Mock ReturnInventoryCartonMapper returnCartons;
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
@Mock ReturnInventoryBalanceMapper returnBalances;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
private DeliveryTransferService service;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@@ -164,7 +165,8 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
ReflectionTestUtils.setField(service, "returnStock", returnStock);
org.mockito.Mockito.lenient().when(returnStock.matchesReturnSource(any(), any())).thenCallRealMethod();
}
@Test
@@ -172,7 +174,9 @@ class DeliveryTransferServiceTest {
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1");
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
ReturnInventoryCartonItem content = new ReturnInventoryCartonItem();
content.setSkuCode("SKU-A"); content.setQuantity(1L);
when(returnCartonItems.lockCarton(first.getId())).thenReturn(List.of(content));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-1"), null, "return-1"));
@@ -252,16 +256,14 @@ class DeliveryTransferServiceTest {
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "wrong-sku", List.of(), "SKU-B"))).getStatusCode().value());
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "correct-sku", List.of(), "SKU-A"));
assertEquals("OUTBOUNDED", carton.getStatus());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"),
any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
assertEquals("PICKED", item.getStatus());
}
@Test
@@ -291,7 +293,7 @@ class DeliveryTransferServiceTest {
}
@Test
void returnCartonPickDeductsEverySkuOnce() {
void returnCartonPickDelegatesInventoryDeductionOnce() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1");
outbound.setCustomerId("TENANT-A"); outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
@@ -309,17 +311,12 @@ class DeliveryTransferServiceTest {
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1")));
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
assertEquals("OUTBOUNDED", carton.getStatus());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"),
any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
assertEquals("PICKED", item.getStatus());
}
@@ -350,36 +347,33 @@ class DeliveryTransferServiceTest {
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1,RC-2");
item.setPackageCount(2); item.setPickedPackageCount(0);
item.setPackageCount(1); item.setPickedPackageCount(0); item.setCartonMarks("RC-1");
OutboundPalletItem secondItem = new OutboundPalletItem(); secondItem.setId(802L); secondItem.setCustomerId("TENANT-A");
secondItem.setOutboundOrderId(301L); secondItem.setPalletNo("PAL-1"); secondItem.setCartonMarks("RC-2");
secondItem.setPackageCount(1); secondItem.setPickedPackageCount(0);
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); first.setId(901L); first.setStatus("OUTBOUND_PENDING");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1"); second.setId(902L); second.setStatus("OUTBOUND_PENDING");
ReturnInventoryCartonItem firstSku = new ReturnInventoryCartonItem(); firstSku.setSkuCode("SKU-A"); firstSku.setQuantity(3L);
ReturnInventoryCartonItem secondSku = new ReturnInventoryCartonItem(); secondSku.setSkuCode("SKU-B"); secondSku.setQuantity(2L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item, secondItem));
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.selectById(802L)).thenReturn(secondItem);
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "U-1", "PDA-701",
"PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-1"))).getStatusCode().value());
verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong());
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.lockMark("RC-2")).thenReturn(second);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(firstSku));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(secondSku));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPickPallet("WH-1", "U-1", "PDA-701", "PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-2"));
assertEquals("OUTBOUNDED", first.getStatus()); assertEquals("OUTBOUNDED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
assertEquals(1, item.getPickedPackageCount()); assertEquals(1, secondItem.getPickedPackageCount());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-1"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
verify(returnStock).shipCarton(org.mockito.ArgumentMatchers.eq("TENANT-A"), org.mockito.ArgumentMatchers.eq("WH-1"), any(), org.mockito.ArgumentMatchers.eq("RC-2"), org.mockito.ArgumentMatchers.eq("OUT-301"), anyLong());
}
private static ReturnInventoryCarton returnCarton(String mark, String palletCode) {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(Integer.toUnsignedLong(mark.hashCode()));
carton.setCustomerId("TENANT-A"); carton.setOverseasWarehouseId("WH-1");
carton.setSourceReturnNo("RT-1"); carton.setSourceUnitType("BOX");
carton.setCartonMark(mark); carton.setPalletCode(palletCode); carton.setStatus("AVAILABLE");
@@ -135,6 +135,7 @@ class ReturnOutboundClosureTest {
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() {
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1);
when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
@@ -45,7 +45,7 @@ class ReturnOutboundPdaPersistenceTest {
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, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
OutboundPdaPick.class, DeliveryOrder.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() + " "
@@ -56,13 +56,16 @@ class ReturnOutboundPdaPersistenceTest {
}
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.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, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class));
ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class));
@@ -77,7 +80,7 @@ class ReturnOutboundPdaPersistenceTest {
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));
when(numbers.next(anyString(), 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()));
@@ -119,11 +122,34 @@ class ReturnOutboundPdaPersistenceTest {
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 wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() {
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound(
"recipient", "WH-1", "RT-1", List.of("BOX-A", "BOX-B"), null, "pallet-create", null, "PALLET"));
var items = service.pdaTaskDetail("WH-1", task.taskNo()).items();
assertEquals(2, items.size());
assertTrue(items.stream().allMatch(item -> item.packageCount() == 1 && "PAL-1".equals(item.palletNo())));
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
new DeliveryRequests.PdaPick(items.get(0).outboundPalletItemId(), null, 1L, "partial", List.of("BOX-A"))));
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id=3");
var before = stockSnapshot();
assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "operator", task.taskNo(),
"PAL-1", new DeliveryRequests.PdaPalletPick("whole")));
assertEquals(before, stockSnapshot());
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code=NULL WHERE id=3");
service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole"));
var after = stockSnapshot();
service.pdaPickPallet("WH-1", "operator", task.taskNo(), "PAL-1", new DeliveryRequests.PdaPalletPick("whole"));
assertEquals(after, stockSnapshot());
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(10L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
}
@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));
assertEquals("OUTBOUND_PENDING", 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);
@@ -165,7 +191,7 @@ class ReturnOutboundPdaPersistenceTest {
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("OUTBOUND_PENDING", 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));
}
@@ -177,7 +203,7 @@ class ReturnOutboundPdaPersistenceTest {
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));
assertEquals("OUTBOUND_PENDING", 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());
@@ -94,7 +94,8 @@ class FleetTripPersistenceTest {
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
jdbc.execute(match.group().replace(" JSON", " LONGTEXT")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var configuration = new MybatisConfiguration();
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
@@ -18,7 +18,7 @@ class OverseasMasterDataOpenApiContractTest {
.contains("PartnerWarehouseOption:")
.contains("/api/platform/v1/last-leg/master-data/exchange-rates:")
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
.contains("platform:overseas-master-data:exchange-rate:export")
.doesNotContain("/api/platform/v1/last-leg/master-data/exchange-rates/export:")
.contains("platform:overseas-master-data:vehicle:status")
.contains("不包含派送履约或车辆更换历史")
.doesNotContain("deliveryOrders:")
@@ -24,6 +24,6 @@ class PlatformMonthlyExchangeRateControllerContractTest {
assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create");
assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit");
assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status");
assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export");
assertThat(authorities).doesNotContainKey("export"); // Export is served by the File download center.
}
}
@@ -53,7 +53,7 @@ class LastlegOrderSailingStageTest {
assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
assertEquals(15, find(db, null, null, null).size());
assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded.
}
}