贴标申请,仓储转运

This commit is contained in:
2026-10-12 21:18:29 +08:00
parent 8526697b08
commit fb98b1a8ee
30 changed files with 914 additions and 48 deletions
@@ -46,7 +46,7 @@ class ReturnOutboundPdaPersistenceTest {
// Persist production entities through production MyBatis mappers, with real transactions.
for (Class<?> entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class,
OutboundPdaPick.class, DeliveryOrder.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
ReturnInventoryOutboundMovement.class, ReturnSkuConversion.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
+ (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP"
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.relabel;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class RelabelMilestonesPersistenceTest {
JdbcTemplate jdbc;
RelabelApplicationMapper mapper;
final LocalDateTime review = LocalDateTime.of(2026, 10, 12, 10, 0);
final LocalDateTime completion = review.plusHours(1);
final LocalDateTime outbound = review.plusHours(2);
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
// Keep the in-memory database alive across mapper connections.
ds.setUrl(ds.getUrl() + ";DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_relabel_application(id BIGINT, customer_id VARCHAR(128), status VARCHAR(32), relabel_no VARCHAR(64), outbound_order_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_relabel_status_history(customer_id VARCHAR(128), application_id BIGINT, from_status VARCHAR(32), to_status VARCHAR(32), created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_relabel_pda_task(customer_id VARCHAR(128), application_id BIGINT, submitted_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT, customer_id VARCHAR(128), outbound_no VARCHAR(64), status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_outbound_pda_task(customer_id VARCHAR(128), outbound_order_id BIGINT, completed_at TIMESTAMP, status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128), relabel_application_id BIGINT, relabel_no VARCHAR(64), outbound_at TIMESTAMP)");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(RelabelApplicationMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(RelabelApplicationMapper.class);
jdbc.update("INSERT INTO ll_relabel_application VALUES (1,'C','PENDING_REVIEW','RL-1',NULL)");
}
void history(String from, String to, LocalDateTime at) {
jdbc.update("INSERT INTO ll_relabel_status_history VALUES ('C',1,?,?,?)", from, to, at);
}
@Test void historicalCompletedApplicationUsesActualFactsAndCustomerScope() {
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion.plusMinutes(1));
history("READY_FOR_OUTBOUND", "COMPLETED", outbound.plusMinutes(1));
jdbc.update("INSERT INTO ll_relabel_pda_task VALUES ('C',1,?)", completion);
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED', outbound_order_no='OB-1'");
jdbc.update("INSERT INTO ll_outbound_order VALUES (2,'C','OB-1','OUTBOUNDED')");
jdbc.update("INSERT INTO ll_outbound_pda_task VALUES ('C',2,?,'COMPLETED')", outbound);
jdbc.update("INSERT INTO ll_relabel_status_history VALUES ('OTHER',1,'PENDING_REVIEW','PENDING_PROCESSING',?)", outbound);
var result = mapper.findMilestones("C", 1L);
assertEquals(review, result.reviewedAt());
assertEquals(completion, result.relabelCompletedAt());
assertEquals(outbound, result.outboundAt());
assertNull(mapper.findMilestones("OTHER", 1L));
}
@Test void pendingAndReworkDoNotInventCompletion() {
var pending = mapper.findMilestones("C", 1L);
// MyBatis returns null for a projection whose columns are all null; the service handles it.
assertNull(pending);
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion);
jdbc.update("INSERT INTO ll_relabel_pda_task VALUES ('C',1,?)", completion);
jdbc.update("UPDATE ll_relabel_application SET status='PROCESSING'");
var rework = mapper.findMilestones("C", 1L);
assertEquals(review, rework.reviewedAt());
assertNull(rework.relabelCompletedAt()); assertNull(rework.outboundAt());
}
@Test void skuOnlyCompletionIsNotOutboundAndResubmissionUsesLatestReview() {
history("PENDING_REVIEW", "REJECTED", review.minusHours(1));
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion);
history("CUSTOMER_CONFIRM_PENDING", "COMPLETED", completion.plusMinutes(5));
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED'");
var result = mapper.findMilestones("C", 1L);
assertEquals(review, result.reviewedAt()); assertEquals(completion, result.relabelCompletedAt());
assertNull(result.outboundAt());
}
@Test void truckDepartureAndLinkedTransferUseOutboundFacts() {
history("READY_FOR_OUTBOUND", "COMPLETED", outbound);
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED'");
assertEquals(outbound, mapper.findMilestones("C", 1L).outboundAt());
jdbc.update("DELETE FROM ll_relabel_status_history");
jdbc.update("UPDATE ll_relabel_application SET status='READY_FOR_OUTBOUND'");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES ('C',1,'RL-1',?)", outbound);
assertEquals(outbound, mapper.findMilestones("C", 1L).outboundAt());
}
}
@@ -52,6 +52,7 @@ class PdaRelabelServiceTest {
@Mock PdaRelabelHistoryMapper histories;
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
@@ -60,6 +61,7 @@ class PdaRelabelServiceTest {
@BeforeEach
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items);
@@ -450,6 +452,7 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
@@ -483,6 +486,7 @@ class PdaRelabelServiceTest {
assertEquals("picker", task.getOperatorName());
assertTrue(result.completionPhotoFileIds().isEmpty());
verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label");
verify(skuConversions).reverse(application);
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@@ -66,6 +66,21 @@ import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class RelabelApplicationServiceTest {
@Test void summaryReturnsMilestonesFromCustomerScopedFacts() {
var application = new RelabelApplication();
application.setId(42L); application.setCustomerId("10001");
var reviewed = LocalDateTime.of(2026, 10, 12, 10, 0);
var completed = reviewed.plusHours(1);
var outbound = reviewed.plusHours(2);
when(applications.findMilestones("10001", 42L))
.thenReturn(new RelabelResponses.Milestones(reviewed, completed, outbound));
RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of());
assertNotNull(result);
assertEquals(reviewed, result.reviewedAt());
assertEquals(completed, result.relabelCompletedAt());
assertEquals(outbound, result.outboundAt());
}
@Test void returnInventoryCandidateUsesRealProductNames() {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
@@ -85,6 +100,37 @@ class RelabelApplicationServiceTest {
assertEquals(4L, candidate.stockQuantity());
assertEquals("AVAILABLE", candidate.inventoryStatus());
assertEquals(List.of(), candidate.mergedIntoCartonMarks());
carton.setStatus("RELABEL_PENDING");
var occupied = service.candidates("C", "W", null).get(0);
assertEquals(0, occupied.availablePackages());
assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {0, 2, 5, 8})
void returnCandidatesOnlyOfferUnlabelledPieces(long completed) {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "returnCartonItems", lines);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(20L); carton.setStatus("AVAILABLE"); carton.setTotalQuantity(10L);
var first = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
first.setSkuCode("A"); first.setQuantity(5L);
var second = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
second.setSkuCode("B"); second.setQuantity(5L); second.setPackingReservedQuantity(1L);
when(cartons.findRelabelCandidates("C", "W")).thenReturn(List.of(carton));
when(lines.listCarton(20L)).thenReturn(List.of(first, second));
when(cartons.completedSkuQuantity(20L, "A")).thenReturn(5L);
when(cartons.completedSkuQuantity(20L, "B")).thenReturn(completed);
var candidates = service.candidates("C", "W", null);
if (completed >= 4) assertTrue(candidates.isEmpty());
else {
assertEquals(1, candidates.size());
assertEquals(4 - completed, candidates.get(0).availablePackages());
assertEquals(List.of(new RelabelResponses.SkuItem("B", null, (int) (4 - completed), null)), candidates.get(0).skuItems());
assertEquals(10L, candidates.get(0).stockQuantity());
}
}
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@@ -110,6 +156,8 @@ class RelabelApplicationServiceTest {
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
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));
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
lenient().when(pdaTasks.lock(any(), any())).thenAnswer(call -> {
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
@@ -1009,8 +1057,8 @@ class RelabelApplicationServiceTest {
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU"})
void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type) {
@org.junit.jupiter.params.provider.CsvSource({"CARTON_LABEL,0", "SKU_LABEL,0", "SKU_LABEL,2", "CARTON_AND_SKU,0"})
void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type, long completed) {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "returnCartonItems", lines);
@@ -1020,6 +1068,7 @@ class RelabelApplicationServiceTest {
parcel.setCountStatus("CONFIRMED"); parcel.setTotalQuantity(5L);
var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(cartons.lockId(901L)).thenReturn(parcel); when(lines.lockCarton(901L)).thenReturn(List.of(line));
when(cartons.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed);
when(cartons.markRelabelPending(anyLong(), anyLong(), anyString(), any())).thenReturn(1);
var app = application(11L, "RL-PARCEL", "10001", "parcel-key", null, "PENDING_REVIEW"); app.setLastlegOrderId(21L);
when(applications.findNo(eq("10001"), any())).thenReturn(app);
@@ -1027,7 +1076,7 @@ class RelabelApplicationServiceTest {
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null, null, null, null,
"PARCEL-1", "FBA-PARCEL", "XLX1", type,
"CARTON_LABEL".equals(type) ? List.of() : List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 5, null)), 901L);
"CARTON_LABEL".equals(type) ? List.of() : List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", (int) (5 - completed), null)), 901L);
service.create("10001", "operator", new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Contact", "19900000000", null, null, "parcel-key", List.of(detail), "SKU_LABEL".equals(type) ? null : "EXPRESS", false));
var stored = ArgumentCaptor.forClass(RelabelItem.class); verify(items).insert(stored.capture());
@@ -1054,8 +1103,9 @@ class RelabelApplicationServiceTest {
assertTrue(error.getReason().contains("独立新箱")); verify(applications, never()).insert(any(RelabelApplication.class));
}
@Test
void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {3, 5})
void returnSkuRelabelRejectsAlreadyLabelledPieces(long completed) {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartonMapper);
@@ -1068,14 +1118,15 @@ class RelabelApplicationServiceTest {
line.setSkuCode("SKU-A"); line.setQuantity(5L); line.setPackingReservedQuantity(0L);
when(cartonMapper.lockId(901L)).thenReturn(carton);
when(lineMapper.lockCarton(901L)).thenReturn(List.of(line));
when(cartonMapper.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed);
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null,
null, null, null, "RETURN-BOX-1", null, null, "SKU_LABEL",
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 6, null)), 901L);
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 3, null)), 901L);
var request = new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Li", "13800000000", null, null, "return-over-quantity", List.of(detail), null, false);
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request));
assertTrue(error.getReason().contains("超过来源箱可用数量"));
assertTrue(error.getReason().contains(completed == 5 ? "已全部换标" : "超过来源箱可用数量"));
verify(applications, never()).insert(any(RelabelApplication.class));
}
@@ -20,6 +20,7 @@ class RelabelReturnCartonCandidateTest {
@Test
void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() {
RelabelApplicationService service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "skuConversionRows", mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
@@ -42,6 +42,34 @@ import org.springframework.web.server.ResponseStatusException;
@ExtendWith(MockitoExtension.class)
class ReturnInventoryServiceTest {
@Test
void platformStockIncludesRelabelLockedQuantity() {
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
balance.setCustomerId("10001"); balance.setOverseasWarehouseId("42"); balance.setSkuCode("SKU-A");
balance.setTotalQuantity(100L); balance.setAvailableQuantity(50L); balance.setRelabelLockedQuantity(30L);
when(balances.platformCount("10001", "42", "SKU-A")).thenReturn(1L);
when(balances.platformPage("10001", "42", "SKU-A", 0L, 20L)).thenReturn(List.of(balance));
var result = service.platformStockPage(new ReturnInventoryRequests.PlatformQuery(1, 20, "10001", "42", "SKU-A", null, null, null));
assertThat(result.records().get(0).relabelLockedQuantity()).isEqualTo(30L);
assertThat(result.records().get(0).availableQuantity()).isEqualTo(50L);
}
@Test
void summarySeparatesRelabelLockedStockFromOtherUnavailableStock() {
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
balance.setOverseasWarehouseId("42"); balance.setSkuCode("SKU-A");
balance.setTotalQuantity(100L); balance.setAvailableQuantity(50L);
balance.setRelabelLockedQuantity(30L); balance.setCartonQuantity(3L);
when(balances.countPage("10001", "42", "SKU-A")).thenReturn(1L);
when(balances.page("10001", "42", "SKU-A", 0L, 20L)).thenReturn(List.of(balance));
var result = service.summaryPage("10001", new ReturnInventoryRequests.SummaryQuery(1, 20, "42", "SKU-A"));
assertThat(result.records().get(0).relabelLockedQuantity()).isEqualTo(30L);
assertThat(result.records().get(0).availableQuantity()).isEqualTo(50L);
assertThat(result.records().get(0).totalQuantity()).isEqualTo(100L);
}
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper cartonItems;
@Mock ReturnInventoryBalanceMapper balances;
@@ -60,6 +88,7 @@ class ReturnInventoryServiceTest {
@BeforeEach
void setUp() {
service = new ReturnInventoryService();
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
ReflectionTestUtils.setField(service, "balances", balances);
@@ -374,7 +403,7 @@ class ReturnInventoryServiceTest {
@Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() {
prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L);
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(1L);
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1)));
assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱");
@@ -385,7 +414,7 @@ class ReturnInventoryServiceTest {
@Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L);
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
@@ -0,0 +1,189 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.service.ReturnSkuConversionService;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
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.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
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.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** Real production SQL, contents, balances, provenance and rollback in one database transaction. */
class ReturnSkuConversionPersistenceTest {
JdbcTemplate jdbc;
ReturnSkuConversionService service, target;
ReturnInventoryBalanceMapper balances;
DataSourceTransactionManager transactions;
@BeforeEach void setUp() throws Exception {
var ds = 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)) {
var columns = Arrays.stream(entity.getDeclaredFields()).filter(f -> !Modifier.isStatic(f.getModifiers()))
.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() == 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 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)");
jdbc.execute("CREATE TABLE ll_return_packing_trace(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),operation_id BIGINT,source_carton_mark VARCHAR(128),target_carton_mark VARCHAR(128),return_no VARCHAR(64),sku_code VARCHAR(128),quantity BIGINT,created_at TIMESTAMP)");
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
for (var mapper : List.of(ReturnSkuConversionMapper.class, ReturnInventoryCartonMapper.class,
ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnPackingTraceMapper.class,
ReturnInventoryOperationMapper.class)) config.addMapper(mapper);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
target = new ReturnSkuConversionService();
ReflectionTestUtils.setField(target, "conversions", session.getMapper(ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(target, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
ReflectionTestUtils.setField(target, "contents", session.getMapper(ReturnInventoryCartonItemMapper.class));
balances = session.getMapper(ReturnInventoryBalanceMapper.class);
ReflectionTestUtils.setField(target, "balances", balances);
ReflectionTestUtils.setField(target, "traces", session.getMapper(ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(target, "operations", session.getMapper(ReturnInventoryOperationMapper.class));
ReflectionTestUtils.setField(target, "json", new ObjectMapper());
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource()));
service = (ReturnSkuConversionService) proxy.getProxy();
}
@ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
void partialConversionPreservesPhysicalUnitAndLocksUntilConfirmation(String type) {
fixture(type); apply(1, 11, 8, "INITIAL");
assertStock("A", 12, 0); assertStock("B", 8, 8);
assertEquals(20, count("SELECT total_quantity FROM ll_return_inventory_carton WHERE id=101"));
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());
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());
assertEquals("CONFIRMED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
}
@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);
service.confirm(app(1)); jdbc.update("UPDATE ll_return_inventory_carton SET relabel_application_id=2 WHERE id=101");
apply(2, 12, 5, "INITIAL");
assertStock("A", 7, 0); assertStock("B", 13, 13); assertEquals(20, balance("A", "total_quantity") + balance("B", "total_quantity"));
var repeated = item(13, 2, "B", "C", 1);
assertThrows(ResponseStatusException.class, () -> service.apply(app(2), List.of(repeated), List.of(fact(13, 1, "RELABELLED")), "INITIAL"));
assertStock("B", 13, 13);
}
@Test void fullConversionRemovesOldContentsButKeepsTheCartonAndOriginalReceipt() {
fixture("BOX"); apply(1, 11, 20, "INITIAL");
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='A'"));
assertStock("B", 20, 20); assertEquals(0, balance("A", "total_quantity")); assertEquals(0, balance("A", "carton_quantity"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_return_inventory_carton WHERE carton_mark='UNIT-1' AND source_return_no='RT-1'"));
}
@Test void conversionIntoExistingSkuKeepsItsUnlabelledPiecesSelectable() {
fixture("BOX");
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(202,101,'C','B',4,0,0)");
jdbc.update("UPDATE ll_return_inventory_carton SET total_quantity=24,sku_types=2 WHERE id=101");
balances.adjust(302, "C", "W", "B", "Goods", 4, 4, 1);
apply(1, 11, 8, "INITIAL"); assertStock("B", 12, 8); assertEquals(1, balance("B", "carton_quantity"));
assertEquals(4, count("SELECT quantity-relabelled_quantity FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
}
@Test void reworkReversesOnlyCurrentRoundAndCanBeAppliedAgain() {
fixture("PARCEL"); apply(1, 11, 8, "INITIAL"); service.reverse(app(1)); service.reverse(app(1));
assertStock("A", 20, 0); assertEquals(0, balance("B", "total_quantity"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
apply(1, 11, 8, "REWORK-1"); assertStock("A", 12, 0); assertStock("B", 8, 8);
assertEquals(2, count("SELECT COUNT(*) FROM ll_return_sku_conversion"));
service.confirm(app(1)); assertThrows(ResponseStatusException.class, () -> service.reverse(app(1)));
}
@Test void onlyActualCompletedFactsProduceInventoryAndBulkCountsPieces() {
fixture("PARCEL"); var item = item(11, 1, "A", "B", 20); item.setOperationType("RETURN_BULK_SKU_LABEL");
service.apply(app(1), List.of(item), List.of(fact(11, 8, "RELABELLED"), fact(11, 12, "EXCEPTION")), "INITIAL");
assertStock("A", 12, 0); assertStock("B", 8, 8);
}
@Test void balanceFailureRollsBackContentsLedgerAndOwnership() {
fixture("BOX"); var failing = spy(balances);
doReturn(0).when(failing).adjust(anyLong(), anyString(), anyString(), eq("B"), any(), anyLong(), anyLong(), anyLong());
ReflectionTestUtils.setField(target, "balances", failing);
assertThrows(ResponseStatusException.class, () -> apply(1, 11, 8, "INITIAL"));
assertStock("A", 20, 0); assertEquals(20, balance("A", "total_quantity"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_sku_conversion"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_packing_trace"));
}
@Test void manualCompletionOnlyConvertsSkuLinesActuallyScannedInCurrentRound() {
fixture("BOX");
var item = item(11,1,"A","B",8);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":8},{\"skuCode\":\"A\",\"newSkuCode\":\"C\",\"quantity\":4}]");
var scan = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); scan.setAction("SKU_SCAN");
scan.setDetailJson("{\"round\":\"CURRENT\",\"itemId\":\"11\",\"skuIndex\":0,\"oldSku\":\"A\",\"newSku\":\"B\",\"quantity\":8}");
var previous = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); previous.setAction("SKU_SCAN");
previous.setDetailJson("{\"round\":\"PREVIOUS\",\"itemId\":\"11\",\"skuIndex\":1,\"oldSku\":\"A\",\"newSku\":\"C\",\"quantity\":4}");
service.apply(app(1), List.of(item), List.of(fact(11,1,"EXCEPTION")), "CURRENT", List.of(scan,previous));
assertStock("A",12,0); assertStock("B",8,8);
assertEquals(0,count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='C'"));
}
@Test void foreignCustomerOrWarehouseCannotConvertStock() {
fixture("BOX"); var foreign = app(1); foreign.setCustomerId("OTHER");
assertThrows(ResponseStatusException.class, () -> service.apply(foreign, List.of(item(11,1,"A","B",8)), List.of(fact(11,1,"RELABELLED")), "INITIAL"));
foreign.setCustomerId("C"); foreign.setOverseasWarehouseId("OTHER");
assertThrows(ResponseStatusException.class, () -> service.apply(foreign, List.of(item(11,1,"A","B",8)), List.of(fact(11,1,"RELABELLED")), "INITIAL"));
assertStock("A", 20, 0);
}
void fixture(String type) {
jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,source_return_no,source_unit_type,status,count_status,total_quantity,sku_types,relabel_application_id) VALUES(101,'C','W','UNIT-1','RT-1',?,'RELABEL_PENDING','CONFIRMED',20,1,1)",type);
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,product_name_zh,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(201,101,'C','A','Goods',20,0,0)");
balances.adjust(301,"C","W","A","Goods",20,20,"BOX".equals(type)?1:0);
}
void apply(long appId, long itemId, int quantity, String round) {
service.apply(app(appId), List.of(item(itemId,appId,"A","B",quantity)), List.of(fact(itemId,1,"RELABELLED")), round);
}
RelabelApplication app(long id) { var app=new RelabelApplication(); app.setId(id); app.setCustomerId("C"); app.setOverseasWarehouseId("W"); app.setRelabelNo("RL-"+id); return app; }
RelabelItem item(long id,long appId,String oldSku,String newSku,int quantity) {
var item=new RelabelItem(); item.setId(id); item.setApplicationId(appId); item.setReturnCartonId(101L);
item.setOperationType("SKU_LABEL"); item.setSkuCode(oldSku); item.setNewSkuCode(newSku);
item.setSkuRelabelItemsJson("[{\"skuCode\":\""+oldSku+"\",\"newSkuCode\":\""+newSku+"\",\"quantity\":"+quantity+"}]"); return item;
}
PdaRelabelUnitFact fact(long itemId,int quantity,String result) { var f=new PdaRelabelUnitFact(); f.setItemId(itemId); f.setQuantity(quantity); f.setResult(result); return f; }
long count(String sql) { return jdbc.queryForObject(sql,Long.class); }
long balance(String sku,String column) { return jdbc.queryForObject("SELECT "+column+" FROM ll_return_inventory_balance WHERE sku_code=?",Long.class,sku); }
void assertStock(String sku,long quantity,long labelled) {
assertEquals(quantity,jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_carton_item WHERE sku_code=?",Long.class,sku));
assertEquals(labelled,jdbc.queryForObject("SELECT relabelled_quantity FROM ll_return_inventory_carton_item WHERE sku_code=?",Long.class,sku));
}
}
@@ -216,6 +216,7 @@ class WarehouseTransferRelabelPersistenceTest {
when(tasks.lock("7",11L)).thenReturn(task);when(tasks.updateWithVersion(any(),any())).thenReturn(1);
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,"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();