fix(lastleg): route all relabel pickups directly to outbound and verify final schemas

This commit is contained in:
2026-10-15 21:05:34 +08:00
parent 4277601999
commit 607aaf74dc
27 changed files with 717 additions and 185 deletions
@@ -13,7 +13,12 @@ class AppointmentWorkSchemaContractTest {
String target=Files.readString(Path.of(path));
for(String table:List.of("ll_dispatch_appointment_allocation","ll_appointment_work_commitment","ll_appointment_work_guard","ll_appointment_work","ll_appointment_work_history"))
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
if (path.startsWith("../deploy/")) {
assertTrue(target.contains("INSERT INTO ll_appointment_work_guard (id,initialized) VALUES (1,FALSE);"));
assertFalse(target.contains("WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard"));
} else {
assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
}
}
assertTrue(source.contains("scan_cursor VARCHAR(128)"));
assertTrue(source.contains("PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key)"));
@@ -16,6 +16,32 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DispatchCandidateMapperContractTest {
@Test
void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
String sql = String.join(" ", DispatchCandidateMapper.class
.getMethod(method, String.class, String.class, String.class).getAnnotation(Select.class).value());
String eligibility = sql.substring(sql.indexOf("WHERE a.delivery_method"), sql.indexOf("<if test='customerId"));
try (var connection = DriverManager.getConnection("jdbc:h2:mem:relabel-mode-" + method + ";MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_relabel_application(delivery_method VARCHAR,status VARCHAR,fulfillment_status VARCHAR)");
statement.execute("INSERT INTO ll_relabel_application VALUES "
+ "('TRUCK','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('AMAZON_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('CUSTOMER_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('UNKNOWN','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "(NULL,'READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('EXPRESS','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH')");
// The return query also requires physical return-carton availability.
eligibility = eligibility.replace("AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL", "");
try (var rows = statement.executeQuery("SELECT delivery_method FROM ll_relabel_application a " + eligibility)) {
assertTrue(rows.next()); assertEquals("TRUCK", rows.getString(1)); assertFalse(rows.next());
}
}
}
}
@Test
void confirmedAppointmentUsesOriginalCustomerOrderAndDestinationAndNeverInventsLoadingTime() throws Exception {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-confirmed-appointment;MODE=MySQL")) {
@@ -51,6 +51,25 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.NullAndEmptySource
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"})
void directRelabelCannotResolveDispatchScopeOrBypassCandidateFiltering(String deliveryMethod) {
var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
ReflectionTestUtils.setField(service, "relabelApplications", relabels);
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
application.setCustomerId("100"); application.setStatus("READY_FOR_OUTBOUND");
application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); application.setDeliveryMethod(deliveryMethod);
when(relabels.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(relabels.lockNo("100", "RL-1")).thenReturn(application);
assertThrows(ResponseStatusException.class, () -> service.resolveCustomerScopeForRelabel("RL-1"));
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
new DispatchRequests.PreArrange(List.of(1L), null, null, null, null, "RL-1")));
verify(orders, never()).insert(any(DispatchOrder.class));
verify(candidates, never()).relabelAvailable(any(), any(), any());
verify(relabelMeasurements, never()).requireMeasured(any());
}
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
@Mock DispatchBolMapper bolLines;
@@ -44,6 +44,20 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaRelabelServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "TRUCK", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
void bindingRequirementDistinguishesOrdinaryInventoryFromDirectReturnCartons(String deliveryMethod) {
application.setDeliveryMethod(deliveryMethod); item.setOperationType("CARTON_LABEL");
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
assertTrue(service.detail("7", 900L, "FBR-1").requiresPalletBinding());
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
item.setReturnCartonId(40L);
assertEquals("TRUCK".equals(deliveryMethod), service.detail("7", 900L, "FBR-1").requiresPalletBinding());
application.setDeliveryMethod(null); item.setOperationType("SKU_LABEL");
assertEquals(false, service.detail("7", 900L, "FBR-1").requiresPalletBinding());
}
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock PdaRelabelTaskMapper tasks;
@@ -192,6 +192,7 @@ class RelabelApplicationServiceTest {
}
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock RelabelBindingGuard bindingGuard;
@Mock RelabelStatusHistoryMapper histories;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -214,6 +215,7 @@ class RelabelApplicationServiceTest {
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
ReflectionTestUtils.setField(service, "skuConversionRows", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
@@ -970,9 +972,11 @@ class RelabelApplicationServiceTest {
verify(applications, never()).updateById(application);
}
@Test
void pickupCustomerConfirmationCreatesOneExactCartonOutbound() {
RelabelApplication application = confirmationApplication("PICKUP", false);
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"PICKUP,false", "EXPRESS,false", "AMAZON_PICKUP,false", "CUSTOMER_PICKUP,false",
"PICKUP,true", "EXPRESS,true", "AMAZON_PICKUP,true", "CUSTOMER_PICKUP,true"})
void directCustomerConfirmationCreatesOneExactCartonOutboundAndReplays(String deliveryMethod, boolean linkedTransfer) {
RelabelApplication application = confirmationApplication(deliveryMethod, false);
RelabelItem source = item(31L, application.getId(), "SKU-A", 1, 1);
source.setSourceInventoryPositionId(201L);
source.setSourceInboundCode("CODE-1");
@@ -980,13 +984,21 @@ class RelabelApplicationServiceTest {
source.setDestinationFbaWarehouseCode("ONT8");
InventoryPosition position = new InventoryPosition();
position.setId(201L); position.setCustomerId("10001"); position.setPalletId(501L); position.setPalletNo("PALLET-1");
position.setOverseasWarehouseId("900");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(source));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"));
when(inventoryPositions.selectById(201L)).thenReturn(position);
if (linkedTransfer) {
var transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8");
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
}
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-pickup", null));
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-pickup", null));
@@ -1000,6 +1012,87 @@ class RelabelApplicationServiceTest {
assertEquals("BOX-1", outboundItem.getValue().getCartonMarks());
assertEquals("PALLET-1", outboundItem.getValue().getPalletNo());
verify(outboundTasks, times(1)).insert(any(OutboundPdaTask.class));
verify(bindingGuard, times(1)).requireBound(application);
}
@Test
void customerConfirmationRejectsUnboundManualCompletionBeforeAnyWrite() {
RelabelApplication application = confirmationApplication("PICKUP", false);
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
"箱唛 BOX-1 尚未完成托盘绑定")).when(bindingGuard).requireBound(application);
assertThrows(ResponseStatusException.class, () -> service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("manual-confirm", null)));
verify(pdaTasks, never()).updateWithVersion(any(), any());
verify(applications, never()).updateById(any(RelabelApplication.class));
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(outboundItems, never()).insert(any(OutboundPalletItem.class));
verify(outboundTasks, never()).insert(any(OutboundPdaTask.class));
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"SKU_LABEL,", "CARTON_AND_SKU,PICKUP", "CARTON_AND_SKU,EXPRESS",
"CARTON_AND_SKU,AMAZON_PICKUP", "CARTON_AND_SKU,CUSTOMER_PICKUP"})
void convertedReturnContentsPassRealConfirmationGuardAndRemainValidAfterConfirmation(String operation, String deliveryMethod) {
var application = confirmationApplication(deliveryMethod, false);
var source = item(31L, application.getId(), "OLD-A", 1, 1);
source.setOperationType(operation); source.setReturnCartonId(301L); source.setSourceInventoryPositionId(null);
source.setCartonMark("BOX-1"); source.setSourceCartonMark("RETURN-BOX-1");
source.setDestinationFbaWarehouseCode("ONT8");
source.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW\",\"quantity\":2}]");
when(items.selectList(any())).thenReturn(List.of(source));
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"));
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var contents = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
var conversions = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class);
var pdaHistories = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
var completed = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
completed.setAction("COMPLETE"); completed.setDetailJson("{\"roundKey\":\"INITIAL\"}");
when(pdaHistories.selectList(any())).thenReturn(List.of(completed));
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(301L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("900");
carton.setRelabelApplicationId(application.getId()); carton.setStatus("RELABEL_PENDING");
carton.setCountStatus("CONFIRMED"); carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
lenient().when(cartons.selectById(301L)).thenReturn(carton);
when(cartons.lockId(301L)).thenReturn(carton);
var content = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
content.setSkuCode("NEW"); content.setQuantity(5L);
when(contents.lockCarton(301L)).thenReturn(List.of(content));
var rows = new java.util.ArrayList<com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion>();
for (var oldSku : List.of("OLD-A", "OLD-B")) {
var row = new com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion();
row.setCustomerId("10001"); row.setApplicationId(application.getId()); row.setItemId(source.getId());
row.setCartonId(301L); row.setRoundKey("INITIAL"); row.setStatus("APPLIED");
row.setOldSkuCode(oldSku); row.setNewSkuCode("NEW"); row.setQuantity("OLD-A".equals(oldSku) ? 3L : 2L);
rows.add(row);
}
when(conversions.lockApplication("10001", application.getId())).thenReturn(rows);
var realGuard = new RelabelBindingGuard();
ReflectionTestUtils.setField(realGuard, "items", items); ReflectionTestUtils.setField(realGuard, "histories", pdaHistories);
ReflectionTestUtils.setField(realGuard, "returnCartons", cartons); ReflectionTestUtils.setField(realGuard, "returnLines", contents);
ReflectionTestUtils.setField(realGuard, "conversions", conversions); ReflectionTestUtils.setField(realGuard, "objectMapper", mapper());
var realConversions = new ReturnSkuConversionService(); ReflectionTestUtils.setField(realConversions, "conversions", conversions);
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));
var command = new RelabelRequests.CustomerConfirm("converted-confirm", null);
service.confirmCompletion("10001", "FBR-1", "customer-1", command);
service.confirmCompletion("10001", "FBR-1", "customer-1", command);
assertTrue(rows.stream().allMatch(row -> "CONFIRMED".equals(row.getStatus())));
realGuard.requireBound(application);
verify(conversions, times(2)).updateById(any(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion.class));
if ("SKU_LABEL".equals(operation)) {
assertEquals("COMPLETED", application.getStatus()); assertEquals("NOT_REQUIRED", application.getFulfillmentStatus());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
} else {
assertEquals("OUTBOUND_CREATED", application.getFulfillmentStatus());
verify(outboundOrders, times(1)).insert(any(OutboundOrder.class));
assertEquals("OUTBOUND_PENDING", carton.getStatus());
}
}
private RelabelApplication confirmationApplication(String deliveryMethod, boolean printShippingLabel) {
@@ -17,7 +17,74 @@ import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
@Test void parcelMixedCompletionRechecksAllContentsWithoutRequiringExpressPallet() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL"})
void convertedContentsRequireCurrentCustomerApplicationItemCartonAndRound(String operation) {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var history = mock(PdaRelabelHistoryMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
var contents = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
var conversions = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class);
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", history);
ReflectionTestUtils.setField(guard, "returnCartons", cartons); ReflectionTestUtils.setField(guard, "returnLines", contents);
ReflectionTestUtils.setField(guard, "conversions", conversions); ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setOverseasWarehouseId("W");
app.setStatus("CUSTOMER_CONFIRM_PENDING"); app.setDeliveryMethod("PICKUP");
var item = new RelabelItem(); item.setId(4L); item.setReturnCartonId(3L); item.setOperationType(operation);
item.setCartonMark("BOX"); item.setSkuCode("A"); item.setNewSkuCode("B"); item.setNotifiedPackageCount(3);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":3}]");
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("2"); carton.setOverseasWarehouseId("W");
var complete = new PdaRelabelHistory(); complete.setAction("COMPLETE"); complete.setDetailJson("{\"roundKey\":\"ROUND-2\"}");
var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("B"); line.setQuantity(3L);
var row = conversion("A", "B", 3L);
when(items.selectList(any())).thenReturn(List.of(item)); when(history.selectList(any())).thenReturn(List.of(complete));
when(cartons.selectById(3L)).thenReturn(carton); when(contents.lockCarton(3L)).thenReturn(List.of(line));
when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row));
assertDoesNotThrow(() -> guard.requireBound(app));
row.setStatus("CONFIRMED"); app.setStatus("READY_FOR_OUTBOUND");
assertDoesNotThrow(() -> guard.requireBound(app));
row.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setCustomerId("2");
row.setApplicationId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setApplicationId(1L);
row.setItemId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setItemId(4L);
row.setCartonId(9L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setCartonId(3L);
row.setRoundKey("ROUND-1"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setRoundKey("ROUND-2");
row.setStatus("REVERSED"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); row.setStatus("APPLIED");
line.setQuantity(2L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); line.setQuantity(3L);
carton.setOverseasWarehouseId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); carton.setOverseasWarehouseId("W");
carton.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); carton.setCustomerId("2");
when(conversions.lockApplication("2", 1L)).thenReturn(List.of());
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row));
var archive = new PdaRelabelHistory(); archive.setAction("REWORK_ARCHIVE");
when(history.selectList(any())).thenReturn(List.of(complete, archive));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
// A -> B and original B -> C must subtract both original quantities before adding targets.
when(history.selectList(any())).thenReturn(List.of(complete));
item.setOperationType("CARTON_AND_SKU");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":3},{\"skuCode\":\"B\",\"newSkuCode\":\"C\",\"quantity\":2}]");
var second = conversion("B", "C", 2L);
var target = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); target.setSkuCode("C"); target.setQuantity(2L);
when(contents.lockCarton(3L)).thenReturn(List.of(line, target));
when(conversions.lockApplication("2", 1L)).thenReturn(List.of(second, row));
assertDoesNotThrow(() -> guard.requireBound(app));
when(conversions.lockApplication("2", 1L)).thenReturn(List.of(row, second));
assertDoesNotThrow(() -> guard.requireBound(app));
second.setQuantity(5L); line.setQuantity(0L); target.setQuantity(5L);
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app), "不能把新转入 B 当作本申请原批准 B 数量");
}
private com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion conversion(String oldSku, String newSku, long quantity) {
var row = new com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion();
row.setCustomerId("2"); row.setApplicationId(1L); row.setItemId(4L); row.setCartonId(3L);
row.setRoundKey("ROUND-2"); row.setStatus("APPLIED"); row.setOldSkuCode(oldSku); row.setNewSkuCode(newSku); row.setQuantity(quantity);
return row;
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
void parcelMixedCompletionRechecksAllContentsWithoutRequiringDirectOutboundPallet(String deliveryMethod) {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
@@ -25,7 +92,7 @@ class RelabelBindingGuardTest {
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
ReflectionTestUtils.setField(guard, "returnCartons", cartons); ReflectionTestUtils.setField(guard, "returnLines", lines);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod("EXPRESS");
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod(deliveryMethod);
var item = new RelabelItem(); item.setReturnCartonId(3L); item.setOperationType("CARTON_AND_SKU");
item.setCartonMark("PARCEL-1"); item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"quantity\":3}]");
var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); parcel.setCustomerId("2"); parcel.setSourceUnitType("PARCEL");
@@ -51,14 +118,47 @@ class RelabelBindingGuardTest {
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L);
var ownedCarton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); ownedCarton.setCustomerId("2");
when(cartons.selectById(3L)).thenReturn(ownedCarton);
when(lines.lockCarton(3L)).thenReturn(List.of(stock));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
stock.setQuantity(3L);
assertDoesNotThrow(() -> guard.requireBound(app));
assertNull(ownedCarton.getPalletCode(), "纯 SKU 留仓不要求托盘,但仍校验来源归属");
stock.setSkuCode("OTHER");
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
stock.setSkuCode("A");
app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("AMAZON_PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("CUSTOMER_PICKUP"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
}
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
@Test void returnBulkSkuWithoutDeliveryDoesNotBypassPalletBinding() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("RETURN-BOX");
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("A"); item.setNotifiedPackageCount(3);
var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
stock.setSkuCode("A"); stock.setQuantity(3L);
when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
when(lines.lockCarton(3L)).thenReturn(List.of(stock));
var ownedCarton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); ownedCarton.setCustomerId("2");
when(cartons.selectById(3L)).thenReturn(ownedCarton);
var rejected = assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
assertTrue(rejected.getReason().contains("尚未绑定"));
verify(cartons).selectById(3L);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"TRUCK", "EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
void physicalBindingMustMatchLatestTargetAndCannotSurviveRework(String deliveryMethod) {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
var history = mock(PdaRelabelHistoryMapper.class);
@@ -67,7 +167,7 @@ class RelabelBindingGuardTest {
ReflectionTestUtils.setField(guard, "histories", history);
ReflectionTestUtils.setField(guard, "positions", positions);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod(deliveryMethod);
var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL");
item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(item));
@@ -81,6 +181,9 @@ class RelabelBindingGuardTest {
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
position.setPalletNo("NEW");
assertDoesNotThrow(() -> guard.requireBound(app));
position.setOverseasWarehouseId("OTHER");
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
position.setOverseasWarehouseId(null);
var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE");
when(history.selectList(any())).thenReturn(List.of(binding, rework));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
@@ -16,7 +16,9 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelDeliveryTest {
@Test void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"EXPRESS", "PICKUP", "AMAZON_PICKUP", "CUSTOMER_PICKUP"})
void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination(String deliveryMethod) {
var service = new RelabelApplicationService();
var items = mock(RelabelItemMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
@@ -30,18 +32,20 @@ class ReturnRelabelDeliveryTest {
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("EXPRESS");
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)) {
var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId());
carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
carton.setOverseasWarehouseId("WH1");
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));
}
@@ -11,7 +11,10 @@ import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
@@ -36,9 +39,12 @@ class ReturnSkuConversionPersistenceTest {
ReturnSkuConversionService service, target;
ReturnInventoryBalanceMapper balances;
DataSourceTransactionManager transactions;
ReturnSkuMySqlTestDatabase mysql;
@BeforeEach void setUp() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:sku-conversion-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
@BeforeEach void setUp(TestInfo info) throws Exception {
boolean mysqlMode = Boolean.getBoolean("returnSku.test.mysql");
var ds = mysqlMode ? (mysql = ReturnSkuMySqlTestDatabase.open(info.getDisplayName())).dataSource()
: new DriverManagerDataSource("jdbc:h2:mem:sku-conversion-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); transactions = new DataSourceTransactionManager(ds);
for (var entity : List.of(ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnSkuConversion.class, ReturnInventoryOperation.class, RelabelItem.class)) {
@@ -46,10 +52,12 @@ class ReturnSkuConversionPersistenceTest {
.filter(f -> f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class) == null
|| f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class).exist())
.map(f -> f.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
+ (f.getType() == String.class ? "VARCHAR(8000)" : f.getType() == LocalDateTime.class ? "TIMESTAMP"
+ (f.getType() == String.class ? (mysqlMode ? (f.getName().endsWith("Json") ? "TEXT" : "VARCHAR(128)") : "VARCHAR(8000)") : f.getType() == LocalDateTime.class ? "TIMESTAMP"
: f.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT")
+ (f.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"
+ (mysqlMode ? " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE="
+ (entity == ReturnInventoryCartonItem.class ? "utf8mb4_0900_ai_ci" : "utf8mb4_unicode_ci") : ""));
}
jdbc.execute("CREATE UNIQUE INDEX balance_sku ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)");
jdbc.execute("CREATE UNIQUE INDEX conversion_round ON ll_return_sku_conversion(item_id,round_key,old_sku_code,new_sku_code)");
@@ -74,6 +82,8 @@ class ReturnSkuConversionPersistenceTest {
service = (ReturnSkuConversionService) proxy.getProxy();
}
@AfterEach void cleanIsolatedMySql() throws Exception { if (mysql != null) mysql.close(); }
@ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
void partialConversionPreservesPhysicalUnitAndLocksUntilConfirmation(String type) {
fixture(type); apply(1, 11, 8, "INITIAL");
@@ -82,15 +92,66 @@ class ReturnSkuConversionPersistenceTest {
assertEquals(2, count("SELECT sku_types FROM ll_return_inventory_carton WHERE id=101"));
assertEquals(12, balance("A", "total_quantity")); assertEquals(8, balance("B", "total_quantity"));
assertEquals("BOX".equals(type) ? 1 : 0, balance("B", "carton_quantity"));
var locked = balances.page("C", "W", "B", 0, 20).get(0);
assertEquals(0, locked.getAvailableQuantity()); assertEquals(8, locked.getRelabelLockedQuantity());
// H2 verifies the persisted lock source and transaction, not MySQL's COLLATE projection.
assertEquals("RELABEL_PENDING", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101", String.class));
assertEquals(1, count("SELECT relabel_application_id FROM ll_return_inventory_carton WHERE id=101"));
assertEquals("APPLIED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
assertEquals(8, count("SELECT SUM(quantity) FROM ll_return_packing_trace WHERE target_carton_mark='UNIT-1' AND sku_code='B'"));
service.confirm(app(1));
jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',relabel_application_id=NULL WHERE id=101");
assertEquals(8, balances.page("C", "W", "B", 0, 20).get(0).getAvailableQuantity());
assertStock("B", 8, 8);
assertEquals(8, balance("B", "available_quantity"));
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101", String.class));
assertEquals("CONFIRMED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
}
@ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
@EnabledIfSystemProperty(named="returnSku.test.mysql", matches="true")
void mysqlPageLocksConvertedSkuUntilConfirmationWithoutChangingPhysicalUnit(String type) {
fixture(type);
var physical = jdbc.queryForMap("SELECT id,carton_mark,source_return_no,source_unit_type,total_quantity FROM ll_return_inventory_carton WHERE id=101");
apply(1,11,8,"INITIAL");
assertStock("A",12,0); assertStock("B",8,8);
assertEquals(12,balance("A","total_quantity")); assertEquals(8,balance("B","total_quantity"));
assertEquals("BOX".equals(type) ? 1 : 0,balance("B","carton_quantity"));
assertPageQuantity("B",0,8);
service.confirm(app(1));
jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',relabel_application_id=NULL WHERE id=101");
assertPageQuantity("B",8,0);
assertEquals(physical,jdbc.queryForMap("SELECT id,carton_mark,source_return_no,source_unit_type,total_quantity FROM ll_return_inventory_carton WHERE id=101"));
mysql.record("physicalUnit",type); mysql.record("oldSkuQuantity",12); mysql.record("newSkuQuantity",8);
mysql.record("beforeConfirmation",Map.of("available",0,"locked",8));
mysql.record("afterConfirmation",Map.of("available",8,"locked",0));
mysql.record("physicalUnitUnchanged",true);
}
@ParameterizedTest @ValueSource(strings={"cafe", "CAFÉ"})
@EnabledIfSystemProperty(named="returnSku.test.mysql", matches="true")
void mysqlPageCollatesCaseAndAccentsWithoutJoiningOtherCustomerWarehouseOrSku(String stockSku) {
fixture("BOX"); apply(1,11,8,"INITIAL");
jdbc.update("UPDATE ll_return_inventory_balance SET sku_code='Café' WHERE sku_code='B'");
jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code=? WHERE sku_code='B'",stockSku);
for (int id=401;id<=403;id++) {
String customer=id==401 ? "OTHER" : "C", warehouse=id==402 ? "OTHER" : "W", sku=id==403 ? "CAFETERIA" : "café";
jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,count_status,total_quantity,sku_types) VALUES(?,?,?,?,'RELABEL_PENDING','CONFIRMED',99,1)",id,customer,warehouse,"BOUNDARY-"+id);
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(?,?,?,?,99,0,0)",id,id,customer,sku);
}
assertPageQuantity("CAFE",0,8);
jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE'");
assertPageQuantity("café",8,0);
mysql.record("stockSku",stockSku); mysql.record("balanceSku","Café");
mysql.record("caseAndAccentJoin",true); mysql.record("foreignCustomerWarehouseAndDistinctSkuExcluded",true);
}
void assertPageQuantity(String sku,long available,long locked) {
var rows=balances.page("C","W",sku,0,20); assertEquals(1,rows.size());
assertEquals(available,rows.get(0).getAvailableQuantity());
assertEquals(locked,rows.get(0).getRelabelLockedQuantity());
var platform=balances.platformPage("C","W",sku,0,20); assertEquals(1,platform.size());
assertEquals(available,platform.get(0).getAvailableQuantity());
assertEquals(locked,platform.get(0).getRelabelLockedQuantity());
}
@Test void replayAndLaterPartialRoundDoNotConvertPriorPiecesTwice() {
fixture("BOX"); apply(1, 11, 8, "INITIAL"); apply(1, 11, 8, "INITIAL");
assertEquals(1, count("SELECT COUNT(*) FROM ll_return_sku_conversion")); assertStock("A", 12, 0);
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.nio.file.*;
import java.sql.*;
import java.util.*;
import javax.sql.DataSource;
/** Opt-in TEST only. Each invocation owns a fresh, non-configurable schema; never connects to business schemas. */
final class ReturnSkuMySqlTestDatabase implements AutoCloseable {
private final String schema="cc_sku_accept_"+UUID.randomUUID().toString().replace("-","");
private final Map<String,Object> evidence=new LinkedHashMap<>();
private DriverManagerDataSource administration;
private DataSource source;
private boolean created;
static ReturnSkuMySqlTestDatabase open(String name) throws Exception {
var database=new ReturnSkuMySqlTestDatabase();
database.evidence.put("test",name); database.evidence.put("schema",database.schema);
try { database.initialize(); return database; }
catch(Exception failure) { database.close(); throw failure; }
}
private void initialize() throws Exception {
if(!Boolean.getBoolean("returnSku.test.mysql") || !"test".equals(required("CHONGCHONG_ENV")))
throw new IllegalStateException("Explicit TEST MySQL opt-in required");
if(!schema.matches("cc_sku_accept_[a-f0-9]{32}")) throw new IllegalStateException("Unsafe isolated schema");
String host=required("CHONGCHONG_DB_HOST"),port=required("CHONGCHONG_DB_PORT");
if(!host.matches("[A-Za-z0-9.-]+") || !port.matches("[0-9]{1,5}")) throw new IllegalStateException("Invalid TEST connection settings");
String prefix="jdbc:mysql://"+host+":"+port+"/";
String options="?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&connectTimeout=10000&socketTimeout=15000";
String user=required("CHONGCHONG_DB_USERNAME"),password=required("CHONGCHONG_DB_PASSWORD");
administration=new DriverManagerDataSource(prefix+"information_schema"+options,user,password);
try(var connection=administration.getConnection();var statement=connection.createStatement()) {
if(!"information_schema".equals(connection.getCatalog()) || !"MySQL".equals(connection.getMetaData().getDatabaseProductName())
|| connection.getMetaData().getDatabaseMajorVersion()!=8) throw new IllegalStateException("Expected TEST MySQL 8 administrative read catalog");
statement.execute("CREATE DATABASE `"+schema+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
created=true;
} catch(SQLException failure) { throw new IllegalStateException("TEST isolated schema creation failed; SQLState="+failure.getSQLState()); }
source=new DriverManagerDataSource(prefix+schema+options,user,password) {
@Override public Connection getConnection() throws SQLException {
var connection=super.getConnection();
if(!created || !schema.equals(connection.getCatalog())) { connection.close(); throw new SQLException("Isolated schema boundary rejected"); }
return connection;
}
};
evidence.put("created",true); evidence.put("databaseProduct","MySQL 8");
}
DataSource dataSource() { return source; }
void record(String key,Object value) { evidence.put(key,value); }
@Override public void close() throws Exception {
try {
if(created) {
if(!schema.matches("cc_sku_accept_[a-f0-9]{32}")) throw new IllegalStateException("Unsafe cleanup schema");
try(var connection=administration.getConnection();var statement=connection.createStatement()) {
if(!"information_schema".equals(connection.getCatalog())) throw new IllegalStateException("Unexpected cleanup catalog");
statement.execute("DROP DATABASE `"+schema+"`"); created=false;
try(var result=statement.executeQuery("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='"+schema+"'")) {
result.next(); if(result.getInt(1)!=0) throw new IllegalStateException("Isolated schema cleanup not confirmed");
}
evidence.put("cleaned",true);
} catch(SQLException failure) { throw new IllegalStateException("TEST isolated schema cleanup failed; SQLState="+failure.getSQLState()); }
}
} finally {
var root=Path.of(System.getProperty("maven.multiModuleProjectDirectory","."));
var directory=root.resolve("reports/return-sku-mysql-acceptance"); Files.createDirectories(directory);
new ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(directory.resolve(schema+".json").toFile(),evidence);
}
}
private static String required(String name) {
String value=System.getenv(name);
if(value==null || value.isBlank()) throw new IllegalStateException("Missing TEST environment variable "+name);
return value;
}
}
@@ -56,6 +56,22 @@ class Phase5OverseasUpgradeSqlContractTest {
assertThat(overseas).doesNotContain("\nUPDATE ", "\nDELETE ", "ALTER TABLE ll_");
}
@Test
void transferFinalMeasurementColumnsAreCreatedBeforeAnyBusinessWrite() throws Exception {
for (String path : List.of("lastleg-service/src/main/resources/db/lastleg.sql",
"deploy/sql/海外区域统一增量-MySQL8.sql")) {
String sql = repositoryFile(path);
String carton = createStatement(sql, "ll_warehouse_transfer_carton");
for (String column : List.of("customer_carton_mark", "length_cm", "width_cm", "height_cm",
"gross_weight_kg", "counted_quantity", "count_idempotency_key", "count_request_hash",
"counted_by", "counted_at", "discrepancy_reason", "transfer_pallet_id", "sku_items_json")) {
assertThat(carton).as(path + ":" + column).contains(column);
}
assertThat(createStatement(sql, "ll_warehouse_transfer_item")).contains("declared_quantity");
assertThat(createStatement(sql, "ll_cargo_discard_item")).contains("idx_ll_cargo_discard_return_carton");
}
}
@Test
void overseasFixedEntryAndModuleBaselineBothDefineCompletePhase5Tables() throws Exception {
String baseline = repositoryFile("lastleg-service/src/main/resources/db/lastleg.sql");
@@ -26,7 +26,7 @@ class SchemaChineseCommentContractTest {
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(ll_[a-z0-9_]+)"
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB[^;]*?COMMENT\\s*=\\s*'([^']*)'\\s*;");
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB([^;]*);");
@Test
void allLastlegTablesAndColumnsHaveChineseComments() throws Exception {
@@ -104,7 +104,8 @@ class SchemaChineseCommentContractTest {
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'")
.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List<String> columns = splitTopLevel(matcher.group(2));
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
List<String> columnNames = new ArrayList<>();
@@ -6,35 +6,62 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class SelfoperatedAnalyticsSchemaContractTest {
private static final Pattern TABLE = Pattern.compile(
"(?s)CREATE TABLE (ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;");
"(?s)CREATE TABLE (?:IF NOT EXISTS )?(ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;");
private static final Set<String> EXPECTED_TABLES = Set.of(
"ll_selfop_outbound_discovery_scope", "ll_selfop_sync_scope", "ll_selfop_sync_task",
"ll_selfop_sync_run", "ll_selfop_sync_stage", "ll_selfop_outbound_order",
"ll_selfop_outbound_item", "ll_selfop_inventory_snapshot", "ll_selfop_sku_daily_sales",
"ll_selfop_warehouse_daily_sales", "ll_selfop_sku_profile", "ll_selfop_owner",
"ll_selfop_product", "ll_selfop_inventory_flow", "ll_selfop_inventory_day_version",
"ll_selfop_inventory_daily");
private static Map<String, String> tables(String sql) {
var result = new LinkedHashMap<String, String>();
var matcher = TABLE.matcher(sql);
var matcher = TABLE.matcher(sql.replace("\r\n", "\n"));
while (matcher.find()) {
assertNull(result.put(matcher.group(1), matcher.group()), "duplicate table definition");
assertNull(result.put(matcher.group(1), matcher.group().replaceFirst("CREATE TABLE IF NOT EXISTS", "CREATE TABLE")),
"duplicate table definition");
}
return result;
}
@Test
void sourceAndEmptyDatabaseBaselineContainIdenticalElevenIndependentTables() throws Exception {
void sourceAndEmptyDatabaseBaselineContainAllCurrentIndependentTables() throws Exception {
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertEquals(11, source.size());
assertEquals(EXPECTED_TABLES, source.keySet());
assertEquals(source, deployment);
for (var entry : source.entrySet()) {
var ddl = entry.getValue();
assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey());
assertTrue(ddl.contains("id BIGINT NOT NULL"));
assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL"));
assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL"));
boolean master = Set.of("ll_selfop_owner", "ll_selfop_product").contains(entry.getKey());
boolean history = Set.of("ll_selfop_inventory_flow", "ll_selfop_inventory_day_version",
"ll_selfop_inventory_daily").contains(entry.getKey());
if (master) {
assertTrue(ddl.contains("source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL"));
assertTrue(ddl.contains("customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL"));
assertTrue(ddl.contains("observed_at_utc DATETIME(6) NOT NULL"));
} else {
assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey());
}
if (!master && !history) {
assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL"), entry.getKey());
assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL"), entry.getKey());
}
assertTrue(ddl.contains("PRIMARY KEY ("), entry.getKey());
if (entry.getKey().equals("ll_selfop_inventory_daily")) {
assertTrue(ddl.contains("PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality)"));
} else if (entry.getKey().equals("ll_selfop_warehouse_daily_sales")) {
assertTrue(ddl.contains("PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date)"));
} else {
assertTrue(ddl.contains("\n id BIGINT NOT NULL"), entry.getKey());
}
assertFalse(ddl.contains("AUTO_INCREMENT"));
assertFalse(ddl.contains("tenant_id"));
assertFalse(ddl.contains("FOREIGN KEY"));
@@ -107,21 +134,34 @@ class SelfoperatedAnalyticsSchemaContractTest {
}
@Test
void readAndSyncPermissionsAreIndependentWithThreeAuthorizedAdminMenus() throws Exception {
void readSyncAndExportPermissionsAreIndependentWithFourAuthorizedAdminMenus() throws Exception {
var seed = Files.readString(Path.of("../iam/src/main/resources/db/selfoperated-analytics-permissions.sql"));
var unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertTrue(unified.contains(seed.strip()));
// Compare executable seed statements; explanatory comments and line endings are not SQL facts.
String seedSql = seed.replace("\r\n", "\n").replaceAll("(?m)^\\s*--[^\n]*", "");
String unifiedSql = unified.replace("\r\n", "\n").replaceAll("(?m)^\\s*--[^\n]*", "");
int comparedStatements = 0;
for (String statement : seedSql.split(";")) {
if (statement.strip().startsWith("INSERT INTO")) {
assertTrue(unifiedSql.contains(statement.strip() + ";"), statement);
comparedStatements++;
}
}
assertEquals(2, comparedStatements, "permission and role grant INSERT statements must both be checked");
assertTrue(seed.contains("platform:self-operated-analytics:list"));
assertTrue(seed.contains("platform:self-operated-analytics:sync"));
for (var route : new String[]{"/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) {
assertTrue(seed.contains("platform:self-operated-analytics:export"));
for (var route : new String[]{"/self-operated/overview", "/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) {
assertTrue(seed.contains(route));
}
assertTrue(seed.contains("'API',5118100,'platform:self-operated-analytics:list'"));
assertTrue(seed.contains("'API',5118130,'platform:self-operated-analytics:sync'"));
assertTrue(seed.contains("'API',5118120,'platform:self-operated-analytics:export'"));
assertFalse(seed.contains("'MERCHANT'"));
assertFalse(seed.contains("INSERT IGNORE"));
assertFalse(seed.contains("ON DUPLICATE KEY"));
assertTrue(seed.contains("(4000001,5118000,0,"));
assertTrue(seed.contains("(4000001,5118001,0,"));
assertTrue(seed.contains("(4000001,5118002,0,"));
}
}
@@ -217,6 +217,7 @@ class WarehouseTransferRelabelPersistenceTest {
ReflectionTestUtils.setField(service,"pdaTasks",tasks);
ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class));
ReflectionTestUtils.setField(service,"skuConversions",mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
ReflectionTestUtils.setField(service,"bindingGuard",mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class));
ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class));
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder();