This commit is contained in:
2026-09-18 09:59:04 +08:00
106 changed files with 5113 additions and 471 deletions
@@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest {
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null);
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null,
null, null, null, null, null);
}
}
@@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class DeliveryCompletedOutboxServiceTest {
@Test
@@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest {
.containsExactly("ONT8", "ACY2");
}
@Test
void skipsRelabelApplicationWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L);
LastlegOrder relabel = new LastlegOrder();
relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION");
relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1");
service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -15,7 +15,8 @@ class DispatchCandidateMapperContractTest {
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks"
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks",
"destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone"
);
@Test
@@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("pos.available_packages > 0"));
}
@Test
void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL"));
}
}
@Test
void candidateKeywordSearchIncludesSourceInboundCode() throws Exception {
Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class,
String.class, String.class, String.class, String.class);
String availableSql = String.join(" ", available.getAnnotation(Select.class).value());
assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class);
String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value());
assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
}
@Test
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
for (Method method : List.of(
@@ -190,6 +190,22 @@ class DispatchServiceTest {
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void preArrangeRejectsPalletWithoutFbaWarehouse() {
DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
"WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001",
null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED");
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals(409, exception.getStatusCode().value());
assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -307,6 +323,14 @@ class DispatchServiceTest {
verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1");
}
@Test
void relabelSourceDoesNotRequireOrQueryAnInboundPlan() {
when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of());
org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty());
verify(candidates).relabelAvailable(null, "RL-1", null);
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
}
@Test
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
@@ -29,7 +29,8 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode"));
assertTrue(schema.contains("creation_mode varchar(32)"));
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
assertTrue(schema.contains("pallet_type"));
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
@@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest {
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble",
"/relabel-disassemble", "/swap", "/unbind"), paths);
"/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
@@ -42,6 +42,17 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PdaPalletOperationServiceTest {
@Test void relabelRebindingRejectsDispatchAndOutboundOccupation() {
RelabelApplication app = new RelabelApplication();
for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) {
app.setFulfillmentStatus(status);
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
}
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
app.setOutboundOrderNo("OUT-1");
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
}
@Mock private PdaPalletOperationMapper mapper;
@Mock private InventoryPositionMapper positions;
@Mock private InventoryBalanceMapper balances;
@@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest {
}
@Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
task.setId(900L); task.setStatus("COMPLETED");
when(tasks.find("100", 600L)).thenReturn(task);
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null,
"OCCUPIED", null, 1);
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null,
@@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest {
RelabelApplication application = new RelabelApplication(); application.setId(600L);
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1");
application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application);
RelabelItem selected = relabelItem(701L, "CARTON-A", 100L);
@@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest {
relabelPalletMutations);
}
@Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED");
when(tasks.find("100", 600L)).thenReturn(task);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app);
RelabelItem item = relabelItem(701L, "CARTON-A", 101L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(relabelItems.selectById(701L)).thenReturn(item);
InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L);
InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L);
AtomicReference<InventoryPosition> target = new AtomicReference<>();
when(positions.selectById(anyLong())).thenAnswer(invocation -> {
long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get();
});
doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class));
when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> {
InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual;
PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages());
row.setHoldPackages(p.getHoldPackages()); return row;
});
var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1);
var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2);
when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst);
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
when(mapper.content("88", 10L)).thenReturn(List.of());
when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages());
when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages());
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND");
when(balances.selectById(200L)).thenReturn(balance);
AtomicReference<PdaPalletOperation> op = new AtomicReference<>();
doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class));
when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get());
var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble(
"qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity",
new PalletOperationRequests.MoveLine(100L, 1L, 4L)));
assertEquals("COMPLETED", result.status());
assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved.
assertEquals(1L, anchor.getAvailablePackages());
assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages());
assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId());
assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages()
+ actual.getHoldPackages() + target.get().getHoldPackages());
verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap());
ArgumentCaptor<InventoryLedger> entries = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers, times(4)).insert(entries.capture());
assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode())));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L);
when(tasks.find("100", 600L)).thenReturn(task);
var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}");
when(histories.selectList(any())).thenReturn(List.of(history));
assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0));
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
}
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
when(tasks.find("100", 600L)).thenReturn(task);
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
task.setOperatorKey("other");
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
}
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
@@ -61,6 +61,7 @@ class PdaRelabelServiceTest {
void setUp() {
service = new PdaRelabelService();
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);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
@@ -103,7 +104,7 @@ class PdaRelabelServiceTest {
PdaRelabelTask active = task("PROCESSING", 0);
active.setApplicationId(99L);
active.setRelabelNo("FBR-ACTIVE");
when(tasks.lockUnfinishedByOperator("7")).thenReturn(active);
when(tasks.lockProcessingByOperator("7")).thenReturn(active);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other")));
@@ -205,6 +206,36 @@ class PdaRelabelServiceTest {
assertEquals(1, result.version());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"})
void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) {
application.setStatus("PROCESSING");
item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1");
item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]");
var task = task("PROCESSING", 0);
var saved = new ArrayList<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>();
when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved));
when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; });
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a"));
assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version());
assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A")));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate")));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong")));
var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b"));
assertEquals(1, second.items().get(0).skuProcessedPackages());
if ("CARTON_AND_SKU".equals(type)) {
assertEquals(0, second.relabelledPackages());
var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last"));
assertEquals(1, last.relabelledPackages());
} else assertEquals(1, second.relabelledPackages());
assertEquals(1, persistedFacts.size());
}
@Test
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
application.setStatus("PROCESSING");
@@ -117,6 +117,7 @@ class RelabelApplicationServiceTest {
lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
}
@Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6);
@@ -144,7 +145,7 @@ class RelabelApplicationServiceTest {
verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
@@ -155,10 +156,36 @@ class RelabelApplicationServiceTest {
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus());
assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
}
@Test
void warehouseTransferRelabelReusesExistingTailOrder() {
RelabelRequests.Create request = request("key-transfer", 10, 6);
RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED");
transferOrder.setOrderType("WAREHOUSE_TRANSFER");
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(transferOrder);
service.createForExistingTailOrder("10001", "operator-1", 21L, request);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(applications).insert(application.capture());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class));
assertEquals(21L, application.getValue().getLastlegOrderId());
assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo());
assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus());
assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType());
assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus());
}
@Test
void createWithoutChargeSkipsWalletFreeze() {
RelabelRequests.Create charged = request("key-free", 10, 6);
@@ -242,27 +269,19 @@ class RelabelApplicationServiceTest {
}
@Test
void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() {
void createRejectsDifferentFbaDestinationsInOneApplication() {
RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8");
RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi",
List.of(west, east));
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request));
service.create("10001", "operator-1", request);
ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(created.capture());
assertEquals("WH-1", created.getValue().getOverseasWarehouseId());
assertEquals(2, created.getValue().getPackageCount());
assertEquals(400, exception.getStatusCode().value());
assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
}
@Test
@@ -363,6 +382,9 @@ class RelabelApplicationServiceTest {
application.setLastlegOrderId(21L);
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
relabelOrder.setOrderType("RELABEL_APPLICATION");
when(orders.selectById(21L)).thenReturn(relabelOrder);
RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("processing", "started"));
@@ -372,12 +394,33 @@ class RelabelApplicationServiceTest {
verify(applications).updateById(application);
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).updateById(updatedOrder.capture());
assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus());
assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus());
verify(histories).insert(any(RelabelStatusHistory.class));
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() {
RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash",
"PROCESSING");
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application);
when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order);
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus());
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
}
@Test
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() {
RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED");
@@ -466,7 +509,7 @@ class RelabelApplicationServiceTest {
var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class);
verify(pdaTasks).updateWithVersion(review.capture(), eq(1));
assertEquals("COMPLETED", review.getValue().getStatus());
assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision());
assertEquals("CONFIRMED", review.getValue().getReviewDecision());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
@@ -475,12 +518,36 @@ class RelabelApplicationServiceTest {
verify(applications, times(1)).updateById(application);
}
@Test
void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() {
RelabelApplication application = confirmationApplication(null, false);
RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1);
skuOnly.setOperationType("SKU_LABEL");
LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(skuOnly));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(linked);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-sku", "accepted"));
assertEquals("COMPLETED", application.getStatus());
assertEquals("NOT_REQUIRED", application.getFulfillmentStatus());
assertEquals("COMPLETED", linked.getCurrentStatus());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(outboundItems, never()).insert(any(OutboundPalletItem.class));
verify(outboundTasks, never()).insert(any(OutboundPdaTask.class));
}
@Test
void transferRelabelConfirmationReleasesTransferForDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8");
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
@@ -498,6 +565,55 @@ class RelabelApplicationServiceTest {
verify(orders).updateById(transferOrder);
}
@Test
void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted"));
assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus());
assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED",
transferOrder.getSubOrderStatus());
assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus());
}
@Test
void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6);
relabelItem.setDestinationFbaWarehouseCode("ONT8");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(relabelItem));
when(histories.selectList(any())).thenReturn(List.of());
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
when(orders.selectById(application.getLastlegOrderId())).thenReturn(
order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus()));
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted"));
assertEquals("ONT8", transfer.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
}
@Test
void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() {
RelabelApplication application = confirmationApplication("EXPRESS", true);
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
var history = mock(PdaRelabelHistoryMapper.class);
var positions = mock(InventoryPositionMapper.class);
ReflectionTestUtils.setField(guard, "items", items);
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 item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL");
item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(item));
var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L);
when(positions.findTenantPosition("2", 3L)).thenReturn(position);
when(history.selectList(any())).thenReturn(List.of());
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND");
binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}");
when(history.selectList(any())).thenReturn(List.of(binding));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
position.setPalletNo("NEW");
assertDoesNotThrow(() -> guard.requireBound(app));
var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE");
when(history.selectList(any())).thenReturn(List.of(binding, rework));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
item.setOperationType("SKU_LABEL");
assertDoesNotThrow(() -> guard.requireBound(app));
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class RelabelReturnCartonCandidateTest {
@Test
void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() {
RelabelApplicationService service = new RelabelApplicationService();
InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "inventoryBalances", balances);
ReflectionTestUtils.setField(service, "inventoryPositions", positions);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems);
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1");
carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2");
carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L);
ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem();
cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN");
cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L);
when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton));
when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem));
List<RelabelResponses.InventoryCandidate> result = service.candidates("10001", "WH-1", "RC-WH");
assertEquals(1, result.size());
assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals(9001L, result.get(0).returnCartonId());
assertEquals("RC-WH-0001", result.get(0).cartonMark());
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
}
}
@@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest {
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8).toLowerCase();
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
"ll_warehouse_transfer_receipt"}) {
@@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest {
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
assertTrue(baseline.contains("pickup_contact_name varchar(128) null"));
assertTrue(baseline.contains("pickup_contact_phone varchar(64) null"));
assertTrue(migration.contains("column_name='pickup_contact_name'"));
assertTrue(migration.contains("column_name='pickup_contact_phone'"));
assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'"));
assertTrue(migration.contains("column_name='received_package_count'"));
assertTrue(migration.contains("o.current_status='inbound_completed'"));
for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type",
"destination_binding_idempotency_key", "private_destination_address",
"private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) {
assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field);
}
for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line",
"ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) {
assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table);
}
assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready"));
assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署"));
}
}
@@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest {
verify(service).detail("100", "TR001");
}
@Test
void merchantSubmitUsesOnlyAuthenticatedTenant() {
controller.submit(user(100L, "MERCHANT"), "TR001");
verify(service).submit("100", "TR001", "1");
}
@Test
void merchantUpdateUsesOnlyAuthenticatedTenant() {
WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class);
controller.updateDraft(user(100L, "MERCHANT"), "TR001", request);
verify(service).updateDraft("100", "TR001", "1", request);
}
@Test
void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() {
controller.cartonLabel(user(100L, "MERCHANT"), "TR001");
verify(service).cartonLabel("100", "TR001");
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.multipart.MultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
class PdaWarehouseTransferControllerContractTest {
@Test
void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception {
var method = PdaWarehouseTransferController.class
.getMethod("detail", AuthenticatedUser.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}");
assertThat(method.getParameterCount()).isEqualTo(2);
}
@Test
void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception {
var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto",
AuthenticatedUser.class, String.class, MultipartFile.class);
var mapping = method.getAnnotation(PostMapping.class);
assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files");
assertThat(mapping.consumes()).containsExactly("multipart/form-data");
assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file");
}
@Test
void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception {
var method = PdaWarehouseTransferController.class.getMethod("palletLabel",
AuthenticatedUser.class, String.class, long.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{transferNo}/pallets/{warehousePalletId}/label");
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id");
}
@Test
void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception {
var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft",
AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class);
var generate = PdaWarehouseTransferController.class.getMethod("generatePallet",
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class);
var bind = PdaWarehouseTransferController.class.getMethod("bindPallet",
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class);
var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft",
AuthenticatedUser.class, String.class, long.class);
assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts");
assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate");
assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind");
assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}");
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.transfer.dto;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferCargoValidationTest {
@Test
void transferCargoAllowsSkuAndProductNameToBeOmitted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var violations = factory.getValidator().validate(
new WarehouseTransferRequests.Item(null, null, 1, null));
assertTrue(violations.isEmpty());
}
}
@Test
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem(
"CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1,
"FBA-CARTON-1", "ONT8", null));
assertTrue(violations.isEmpty());
}
}
}
@@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferPalletValidationTest {
@Test
void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() {
void palletAcceptsMeasuredDimensionsVolumeAndWeight() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
new BigDecimal("1.250000"), new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
var pallet = new WarehouseTransferRequests.Pallet(5,
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
new BigDecimal("1.250000"), new BigDecimal("420.50"));
assertTrue(validator.validate(pallet).isEmpty());
assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm());
@@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest {
void palletRejectsNonPositiveTotalVolume() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
BigDecimal.ZERO, new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
var pallet = new WarehouseTransferRequests.Pallet(5,
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
BigDecimal.ZERO, new BigDecimal("420.50"));
assertEquals(1, validator.validate(pallet).size());
}
}
@Test
void palletRejectsMissingOrNonPositiveDimensions() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet(5,
BigDecimal.ZERO, null, new BigDecimal("125"),
new BigDecimal("1.250000"), new BigDecimal("420.50"));
assertEquals(2, validator.validate(pallet).size());
}
}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.gateway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import static org.assertj.core.api.Assertions.assertThat;
class HttpTransferMeasurementPhotoFileGatewayContractTest {
@Test
void defaultsToTheFileServicesActualLocalPort() throws Exception {
var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl");
assertThat(field.getAnnotation(Value.class).value())
.isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}");
}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WarehouseTransferPalletAllocationTest {
@Test
void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() {
WarehouseTransferItem first = item(11L, 3);
WarehouseTransferItem second = item(12L, 4);
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(first, second), List.of(pallet(2), pallet(5)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2),
new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1),
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations);
}
@Test
void underReceiptConsumesDeclaredSkusInStableOrder() {
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations);
assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum());
}
@Test
void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() {
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1),
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations);
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
}
@Test
void persistedOverReceiptUsesTheSameStableAllocationRule() {
WarehouseTransferPallet first = persistedPallet(4);
WarehouseTransferPallet second = persistedPallet(3);
var allocations = WarehouseTransferService.derivePersistedPalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(first, second));
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L)
.mapToInt(value -> value.packageCount()).sum());
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L)
.mapToInt(value -> value.packageCount()).sum());
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
}
@Test
void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() {
var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2);
var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1);
assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList());
assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList());
assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null));
assertEquals(null, unknown.get(0).fbaWarehouseCode());
assertEquals(null, unknown.get(0).palletCode());
}
private static WarehouseTransferItem item(long id, int packages) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(id); item.setPackageCount(packages);
return item;
}
private static WarehouseTransferRequests.Pallet pallet(int cartons) {
return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE,
BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE);
}
private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) {
var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet();
pallet.setCartonCount(cartons);
return pallet;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
@@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper;
import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -62,14 +79,36 @@ import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest {
@Test
void customerMainStatusFiltersExpandToInternalWorkflowStates() {
assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING),
WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING));
assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED)
.containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION,
WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL,
WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED)));
assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT));
}
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferCartonMapper transferCartons;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos;
@Mock WarehouseTransferPalletMapper transferPallets;
@Mock WarehouseTransferPalletItemMapper transferPalletItems;
@Mock InboundStockReceiptMapper stockReceipts;
@Mock WarehouseTransferRelabelBindingMapper relabelBindings;
@Mock WarehouseResourceGateway warehouseResources;
@Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegInboundOrderMapper inboundOrders;
@Mock InboundPlanMapper inboundPlans;
@Mock InboundPlanLineMapper inboundPlanLines;
@Mock InboundPlanPalletMapper inboundPlanPallets;
@Mock InboundPlanPalletAllocationMapper inboundPlanAllocations;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -88,8 +127,19 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos);
ReflectionTestUtils.setField(service, "transferPallets", transferPallets);
ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings);
ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources);
ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans);
ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines);
ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets);
ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
@@ -103,6 +153,116 @@ class WarehouseTransferServiceTest {
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of());
lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of());
}
@Test
void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
pallet.setGenerateIdempotencyKey("GEN-1");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1"));
verify(warehouseResources, never()).ensurePalletBatch(any());
verify(warehouseResources, never()).confirmPalletBatch(any());
}
@Test
void scanBindingRejectsCodeOtherThanThisGeneratedPallet() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet(
77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1")));
assertEquals(409, exception.getStatusCode().value());
verify(warehouseResources, never()).resolvePallet(anyString());
}
@Test
void palletizeRejectsWhenAnyPersistedPalletIsNotBound() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
photo.setFileId(9001L);
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize(
"OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))));
assertEquals(409, exception.getStatusCode().value());
verify(warehouseResources, never()).ensurePalletBatch(any());
}
@Test
void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
transfer.setInboundCode("WTR-1");
WarehouseTransferPallet pallet = measuredPallet("BOUND");
WarehouseTransferItem item = transferItem(51L, "SKU-1", 5);
LastlegOrder tail = tailOrder();
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A");
InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING");
InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L);
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of(item));
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
photo.setFileId(9001L);
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail);
when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound);
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan);
when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine);
when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE"));
when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.palletize("OP-1", 77L, "WTR-1",
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status());
assertEquals("COMPLETED", pallet.getStatus());
assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus());
assertEquals(5, tail.getReceivedPackageCount());
assertEquals("RECEIVED", inbound.getStatus());
assertEquals(5, inbound.getPackageCount());
assertEquals("COMPLETED", inboundPlan.getStatus());
verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class));
verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any());
verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class));
verify(inboundOrders).updateById(inbound);
verify(inboundPlans).updateById(inboundPlan);
verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class));
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation> allocation =
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
verify(inboundPlanAllocations).insert(allocation.capture());
assertEquals(5, allocation.getValue().getPickedPackageCount());
verify(warehouseResources, never()).ensurePalletBatch(any());
verify(warehouseResources, never()).confirmPalletBatch(any());
}
@Test
@@ -111,7 +271,7 @@ class WarehouseTransferServiceTest {
LastlegOrder historical = historicalOrder();
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historical));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -120,23 +280,23 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}"));
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
assertNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertEquals(WarehouseTransferService.DRAFT, result.orderStatus());
}
@Test
void transferWithoutRelabelDoesNotCreateRelabelDocument() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -146,6 +306,27 @@ class WarehouseTransferServiceTest {
assertNull(result.relabelNo());
}
@Test
void transferRelabelRejectsMultipleDestinationWarehouses() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
null, null, "WH-1", null, "NONE", null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 2,
new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0),
null, null, null, "BOX-1,BOX-2", null, true,
LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK",
"single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)),
List.of(
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null),
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null)));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request));
assertEquals(400, exception.getStatusCode().value());
assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason());
verifyNoInteractions(transfers);
}
@Test
void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
@@ -155,7 +336,7 @@ class WarehouseTransferServiceTest {
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -179,7 +360,7 @@ class WarehouseTransferServiceTest {
null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null),
new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)));
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -199,7 +380,7 @@ class WarehouseTransferServiceTest {
assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode());
assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode());
assertEquals("OVERSEAS", inbound.getValue().getDestinationType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus());
assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus());
assertNull(transfer.getValue().getTrackingNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
@@ -209,7 +390,7 @@ class WarehouseTransferServiceTest {
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
void approvedTransferWithoutRelabelEntersInboundMeasuring() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
transfer.setNeedRelabel(false);
@@ -225,14 +406,134 @@ class WarehouseTransferServiceTest {
new WarehouseTransferRequests.Review("APPROVE", null));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.APPROVED, result.status());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verifyNoInteractions(inventory);
}
@Test
void approvedIncomingTransferCreatesPendingInboundPlan() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77");
transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0));
LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null));
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(inboundPlans).insert(plan.capture());
verify(inboundPlanLines).insert(line.capture());
assertEquals("PDA_PENDING", plan.getValue().getStatus());
assertEquals("WT-WTR-1", plan.getValue().getPlanNo());
assertEquals("WTR-1", line.getValue().getInboundCode());
assertEquals(5, line.getValue().getRequiredPackageCount());
assertEquals("PENDING_INBOUND", inbound.getStatus());
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
}
@Test
void merchantSubmitsDraftForReviewBeforePrintingLabels() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
transfer.setInboundCode("WTR-1");
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED");
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verify(inboundOrders).updateById(inbound);
}
@Test
void merchantUpdatesDraftAndRebuildsEditableSnapshots() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false));
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertEquals("WH-1", transfer.getOperatingWarehouseId());
assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verify(transferCartons).delete(any());
verify(transferItems).delete(any());
verify(relabelBindings).delete(any());
verify(orderDetails).delete(any());
verify(transferItems).insert(any(WarehouseTransferItem.class));
verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class));
verify(orderDetails).insert(any(LastlegOrderDetail.class));
}
@Test
void merchantCannotUpdateSubmittedTransfer() {
WarehouseTransferOrder transfer = persistedTransfer();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)));
assertEquals(409, error.getStatusCode().value());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void merchantDraftUpdateIsTenantScoped() {
when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false)));
assertEquals(404, error.getStatusCode().value());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void draftCannotPrintCartonLabelsBeforeSubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.cartonLabel("TENANT-A", "WTR-1"));
assertEquals(409, error.getStatusCode().value());
verify(transferCartons, never()).listOrder(anyString(), anyLong());
}
@Test
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
for (String decision : List.of("REJECT", "REJECTED")) {
@@ -378,6 +679,67 @@ class WarehouseTransferServiceTest {
assertEquals("RESERVED", allocation.getStatus());
}
@Test
void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
transfer.setFbaWarehouseCode(null);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null)));
assertEquals(409, exception.getStatusCode().value());
verifyNoInteractions(inventory);
verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void merchantMeasurementPhotosExposeOnlySubmittedSelection() {
WarehouseTransferOrder transfer = persistedTransfer();
WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto();
selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg");
selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0));
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected));
var result = service.measurementPhotos("TENANT-A", "WTR-1");
assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList());
verify(measurementPhotos, never()).listOrder("TENANT-A", 21L);
}
@Test
void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() {
WarehouseTransferOrder transfer = persistedTransfer();
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L));
assertEquals(404, exception.getStatusCode().value());
verifyNoInteractions(measurementPhotoFiles);
}
@Test
void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto();
draft.setFileId(102L);
var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile(
new byte[]{1}, "draft.jpg", "image/jpeg");
when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer);
when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft);
when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded);
var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L);
assertEquals("draft.jpg", result.fileName());
verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong());
}
@Test
void allocationRejectsPositionOutsideSelectedInboundOrder() {
WarehouseTransferOrder transfer = transfer("APPROVED");
@@ -427,6 +789,7 @@ class WarehouseTransferServiceTest {
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false);
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5);
transfer.setFbaWarehouseCode("ONT8");
return transfer;
}
@@ -436,6 +799,24 @@ class WarehouseTransferServiceTest {
return transfer;
}
private static WarehouseTransferPallet measuredPallet(String status) {
WarehouseTransferPallet pallet = new WarehouseTransferPallet();
pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A");
pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1");
pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00"));
pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00"));
pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000"));
pallet.setStatus(status);
return pallet;
}
private static WarehouseTransferItem transferItem(long id, String sku, int packages) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A");
item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages);
return item;
}
private static LastlegOrder tailOrder() {
LastlegOrder order = historicalOrder();
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");