fix(lastleg): preserve return relabel delivery metadata and measurements

This commit is contained in:
2026-10-15 21:40:37 +08:00
parent 607aaf74dc
commit b8739eebf0
10 changed files with 224 additions and 28 deletions
@@ -30,6 +30,18 @@ class DeliverySchemaContractTest {
}
}
@Test
void directOutboundSnapshotsKeepUnknownVolumeNullableAcrossBothBaselines() throws Exception {
for (Path path : java.util.List.of(Path.of("src/main/resources/db/lastleg.sql"), Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
String sql = Files.readString(path, StandardCharsets.UTF_8);
for (String table : java.util.List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_delivery_order")) {
var matcher = java.util.regex.Pattern.compile("(?is)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(sql);
assertTrue(matcher.find(), table);
String column = "ll_delivery_order".equals(table) ? "total_volume_cbm" : "volume_cbm";
assertTrue(java.util.regex.Pattern.compile("(?i)" + column + " DECIMAL\\(18,4\\) NULL COMMENT").matcher(matcher.group()).find(), table + " keeps unknown volume NULL");
}
}
}
@Test
void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception {
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.toLowerCase();
@@ -311,6 +311,38 @@ class DeliveryTransferServiceTest {
assertEquals(1, detail.skus().get(0).packageCount());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void relabelDeliveryDetailCarriesFrozenPhysicalMeasurementOnFirstSkuOnly(boolean measured) {
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(501L); delivery.setCustomerId("TENANT-A");
delivery.setDeliveryNo("DL-501"); delivery.setOutboundOrderId(301L); delivery.setOutboundNo("OUT-301");
delivery.setOverseasWarehouseId("WH-1"); delivery.setCustomerName("客户快照"); delivery.setOverseasWarehouseName("仓库快照");
delivery.setDeliveryGroupKey("RETURN_RELABEL:ABE4"); delivery.setDestinationWarehouseCode("ABE4");
delivery.setStatus("WAIT_DELIVERY");
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "relabel-1"); outbound.setOutboundType("RELABEL_OUTBOUND");
OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC-1"); item.setDestinationWarehouseCode("ABE4");
if (measured) { item.setGrossWeightKg(BigDecimal.ONE); item.setVolumeCbm(new BigDecimal("0.008")); }
ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L);
carton.setWeight(new BigDecimal("99")); // Reading the current carton must not replace the frozen outbound measurement.
var first = new ReturnInventoryCartonItem(); first.setSkuCode("SKU-A"); first.setQuantity(3L);
var second = new ReturnInventoryCartonItem(); second.setSkuCode("SKU-B"); second.setQuantity(2L);
when(deliveryOrders.findByNo("TENANT-A", "DL-501")).thenReturn(delivery);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.findCustomerMark("TENANT-A", "RC-1")).thenReturn(carton);
when(returnCartonItems.listCarton(901L)).thenReturn(List.of(first, second));
var detail = service.deliveryDetail("TENANT-A", "DL-501");
assertEquals("客户快照", detail.order().customerName()); assertEquals("仓库快照", detail.order().overseasWarehouseName());
assertEquals(2, detail.skus().size()); assertEquals(1, detail.skus().stream().mapToInt(row -> row.packageCount()).sum());
assertEquals(5L, detail.skus().stream().mapToLong(row -> row.productQuantity()).sum());
assertEquals(measured ? BigDecimal.ONE : null, detail.skus().get(0).weightKg());
assertEquals(measured ? new BigDecimal("0.008") : null, detail.skus().get(0).volumeCbm());
assertEquals(0, detail.skus().get(1).packageCount());
assertNull(detail.skus().get(1).weightKg()); assertNull(detail.skus().get(1).volumeCbm());
}
@Test
void returnCartonPickDelegatesInventoryDeductionOnce() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1");
@@ -1078,6 +1078,14 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "bindingGuard", realGuard); ReflectionTestUtils.setField(service, "skuConversions", realConversions);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "deliveryOrders", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper.class));
if (!"SKU_LABEL".equals(operation)) {
var customers = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
var warehouses = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory.class);
ReflectionTestUtils.setField(service, "customerDirectory", customers);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouses);
when(customers.find("10001")).thenReturn(new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("10001", "C", "客户", null, null));
when(warehouses.required(900L, "OVERSEAS")).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.WarehouseOption("900", "WH", "海外仓", "OVERSEAS"));
}
var command = new RelabelRequests.CustomerConfirm("converted-confirm", null);
service.confirmCompletion("10001", "FBR-1", "customer-1", command);
@@ -1,6 +1,11 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.fleet.driver.DriverModels;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
@@ -12,13 +17,82 @@ import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.math.BigDecimal;
import org.mockito.ArgumentCaptor;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelDeliveryTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination(String deliveryMethod) {
void directNativeRelabelSnapshotsNamesAndCountsMeasuredBoxesOncePerDestination(String deliveryMethod) {
var f = fixture(deliveryMethod);
f.create();
verify(f.deliveries, times(2)).insert(any(DeliveryOrder.class));
var rows = ArgumentCaptor.forClass(DeliveryOrder.class);
verify(f.deliveries, times(2)).insert(rows.capture());
for (var row : rows.getAllValues()) {
assertEquals("测试客户", row.getCustomerName()); assertEquals("洛杉矶测试仓", row.getOverseasWarehouseName());
assertEquals("100", row.getOverseasWarehouseId()); assertEquals(deliveryMethod, row.getDeliveryMethod());
assertEquals("WAIT_DELIVERY", row.getStatus()); assertEquals(f.application.getOutboundOrderNo(), row.getOutboundNo());
int boxes = "A".equals(row.getDestinationWarehouseCode()) ? 2 : 1;
assertEquals(boxes, row.getPackageCount());
assertEquals(0, BigDecimal.valueOf(boxes).compareTo(row.getTotalWeightKg()));
assertEquals(0, new BigDecimal("0.008").multiply(BigDecimal.valueOf(boxes)).compareTo(row.getTotalVolumeCbm()));
}
var outbound = ArgumentCaptor.forClass(OutboundOrder.class);
verify(f.outbounds).insert(outbound.capture());
assertEquals(3, outbound.getValue().getPackageCount());
assertEquals(0, new BigDecimal("3").compareTo(outbound.getValue().getGrossWeightKg()));
assertEquals(0, new BigDecimal("0.024").compareTo(outbound.getValue().getVolumeCbm()));
var physical = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(f.outboundItems, times(3)).insert(physical.capture());
assertTrue(physical.getAllValues().stream().allMatch(row -> row.getPackageCount() == 1
&& BigDecimal.ONE.compareTo(row.getGrossWeightKg()) == 0 && new BigDecimal("0.008").compareTo(row.getVolumeCbm()) == 0));
verify(f.customers, times(1)).find("C1"); verify(f.warehouses, times(1)).required(100L, "OVERSEAS");
}
@Test
void incompleteMeasurementStaysUnknownWithoutBlockingAndDoesNotContaminateOtherDestination() {
var f = fixture("AMAZON_PICKUP");
f.cartonRows.get(0).setWeight(null); f.cartonRows.get(0).setHeight(null);
f.create();
var rows = ArgumentCaptor.forClass(DeliveryOrder.class); verify(f.deliveries, times(2)).insert(rows.capture());
var a = rows.getAllValues().stream().filter(row -> "A".equals(row.getDestinationWarehouseCode())).findFirst().orElseThrow();
var b = rows.getAllValues().stream().filter(row -> "B".equals(row.getDestinationWarehouseCode())).findFirst().orElseThrow();
assertNull(a.getTotalWeightKg()); assertNull(a.getTotalVolumeCbm());
assertEquals(0, BigDecimal.ONE.compareTo(b.getTotalWeightKg()));
assertEquals(0, new BigDecimal("0.008").compareTo(b.getTotalVolumeCbm()));
var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(f.outbounds).insert(outbound.capture());
assertNull(outbound.getValue().getGrossWeightKg()); assertNull(outbound.getValue().getVolumeCbm());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"customer-name", "customer-id", "warehouse-name", "warehouse-id", "unavailable"})
void invalidDirectorySnapshotDoesNotCreateAnyDocument(String failure) {
var f = fixture("CUSTOMER_PICKUP");
if (failure.startsWith("customer")) when(f.customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer(
"customer-id".equals(failure) ? "OTHER" : "C1", "C", "customer-name".equals(failure) ? " " : "客户", null, null));
else if (failure.startsWith("warehouse")) when(f.warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption(
"warehouse-id".equals(failure) ? "200" : "100", "WH", "warehouse-name".equals(failure) ? " " : "仓库", "OVERSEAS"));
else when(f.customers.find("C1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE));
assertThrows(ResponseStatusException.class, f::create);
verify(f.outbounds, never()).insert(any(OutboundOrder.class));
verifyNoInteractions(f.deliveries, f.outboundItems);
}
@Test
void existingOutboundReplayDoesNotRefreshNamesOrMeasurements() {
var f = fixture("PICKUP"); var existing = new OutboundOrder(); existing.setOutboundNo("OUT-EXISTING");
when(f.outbounds.findByIdempotency("C1", "RELABEL:1:OUTBOUND")).thenReturn(existing);
f.create();
assertEquals("OUT-EXISTING", f.application.getOutboundOrderNo());
verifyNoInteractions(f.customers, f.warehouses, f.deliveries, f.outboundItems);
}
private Fixture fixture(String deliveryMethod) {
var service = new RelabelApplicationService();
var items = mock(RelabelItemMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
@@ -26,28 +100,36 @@ class ReturnRelabelDeliveryTest {
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveries);
ReflectionTestUtils.setField(service, "outboundOrders", mock(OutboundOrderMapper.class));
ReflectionTestUtils.setField(service, "outboundItems", mock(OutboundPalletItemMapper.class));
var outbounds = mock(OutboundOrderMapper.class); var outboundItems = mock(OutboundPalletItemMapper.class);
ReflectionTestUtils.setField(service, "outboundOrders", outbounds);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
var customers = mock(FleetCustomerDirectory.class); var warehouses = mock(FleetWarehouseDirectory.class);
ReflectionTestUtils.setField(service, "customerDirectory", customers); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouses);
when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null));
when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS"));
ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class));
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1");
application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod(deliveryMethod);
var a = source(10L, "A"); var b = source(11L, "B");
when(items.selectList(any())).thenReturn(List.of(a, b));
for (var item : List.of(a, b)) {
application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod);
var sources = List.of(source(10L, "A"), source(11L, "B"), source(12L, "A"));
when(items.selectList(any())).thenReturn(sources);
var cartonRows = new java.util.ArrayList<ReturnInventoryCarton>();
for (var item : sources) {
var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId());
carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
carton.setOverseasWarehouseId("WH1");
carton.setOverseasWarehouseId("100"); carton.setCountStatus("CONFIRMED"); carton.setSourceUnitType("PALLET");
carton.setWeight(BigDecimal.ONE); carton.setLength(new BigDecimal("20"));
carton.setWidth(new BigDecimal("20")); carton.setHeight(new BigDecimal("20"));
carton.setTotalQuantity(5L); carton.setSkuTypes(2); cartonRows.add(carton);
when(cartons.lockId(carton.getId())).thenReturn(carton);
}
ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now());
verify(deliveries, times(2)).insert(any(DeliveryOrder.class));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "A".equals(d.getDestinationWarehouseCode())
&& d.getPackageCount() == 1 && "WAIT_DELIVERY".equals(d.getStatus())
&& deliveryMethod.equals(d.getDeliveryMethod())
&& application.getOutboundOrderNo().equals(d.getOutboundNo())));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "B".equals(d.getDestinationWarehouseCode()) && d.getPackageCount() == 1));
return new Fixture(service, application, customers, warehouses, deliveries, outbounds, outboundItems, cartonRows);
}
private record Fixture(RelabelApplicationService service, RelabelApplication application, FleetCustomerDirectory customers,
FleetWarehouseDirectory warehouses, DeliveryOrderMapper deliveries, OutboundOrderMapper outbounds,
OutboundPalletItemMapper outboundItems, List<ReturnInventoryCarton> cartonRows) {
void create() { ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now()); }
}
private RelabelItem source(long id, String destination) {
var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(id);