feat: complete overseas pda warehouse operations

This commit is contained in:
2026-08-25 11:20:36 +08:00
parent 45a3b9adfd
commit 042d0b738a
69 changed files with 4679 additions and 4 deletions
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.relabel.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.transaction.annotation.Transactional;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdaRelabelContractTest {
@Test
void controllerUsesStablePdaPathAndExactPickerAuthority() {
assertEquals("/api/pda/v1/last-leg/relabel-tasks",
PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]);
assertEquals("hasAuthority('lastleg:pda:operate')",
PdaRelabelController.class.getAnnotation(PreAuthorize.class).value());
}
@Test
void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() {
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId",
"operatorId", "operatorName", "tenantId");
for (Class<?> type : PdaRelabelRequests.class.getDeclaredClasses()) {
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
.collect(Collectors.toSet());
assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope");
}
}
@Test
void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() {
AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L);
assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId());
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L)));
assertThrows(ResponseStatusException.class,
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null)));
}
@Test
void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact",
"ll_relabel_pda_operation", "ll_relabel_pda_history")) {
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table));
}
assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)"));
assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)"));
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task");
int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start);
String block = sql.substring(start, end);
assertFalse(block.contains("dispatch_no"));
assertFalse(block.contains("delivery_no"));
assertFalse(block.contains("outbound_order_no"));
}
@Test
void everyStateMutationIsTransactional() throws Exception {
Class<?> service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService");
for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) {
assertTrue(Arrays.stream(service.getDeclaredMethods())
.filter(candidate -> method.equals(candidate.getName()))
.anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)),
method + " must be transactional");
}
}
private static AuthenticatedUser user(Set<String> permissions, Long warehouseId) {
return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA",
permissions, false, null, null, false, warehouseId);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.relabel.pda.service;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PdaRelabelServiceTest {
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock PdaRelabelTaskMapper tasks;
@Mock PdaRelabelUnitFactMapper unitFacts;
@Mock PdaRelabelOperationMapper operations;
@Mock PdaRelabelHistoryMapper histories;
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
List<PdaRelabelUnitFact> persistedFacts;
@BeforeEach
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "applicationService", applicationService);
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper());
service.initializeObjectMapper();
application = application("PENDING_PROCESSING");
item = item();
persistedFacts = new ArrayList<>();
lenient().when(operations.find(anyString(), anyString())).thenReturn(null);
lenient().when(items.selectList(any())).thenReturn(List.of(item));
lenient().when(histories.selectList(any())).thenReturn(List.of());
lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts));
lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; });
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
}
@Test
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(null);
ArgumentCaptor<PdaRelabelTask> task = ArgumentCaptor.forClass(PdaRelabelTask.class);
var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"));
verify(tasks).insert(task.capture());
assertEquals(900L, task.getValue().getOverseasWarehouseId());
assertEquals("100", task.getValue().getCustomerId());
assertEquals(PdaRelabelService.PROCESSING, result.taskStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")));
assertEquals(404, error.getStatusCode().value());
verify(tasks, never()).insert(any(PdaRelabelTask.class));
}
@Test
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(null);
ArgumentCaptor<PdaRelabelUnitFact> fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class);
var result = service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1"));
verify(unitFacts).insert(fact.capture());
assertEquals("RELABELLED", fact.getValue().getResult());
assertEquals("BOX-1", fact.getValue().getOldLabel());
assertEquals(1, result.relabelledPackages());
assertEquals(1, result.version());
}
@Test
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void oldLabelAlreadyAttributedToAnotherTaskIsRejected() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
PdaRelabelUnitFact otherTaskFact = fact("RELABELLED");
otherTaskFact.setTaskId(99L);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact);
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task")));
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
task.setRelabelledPackages(1);
persistedFacts.add(fact("RELABELLED"));
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
application.setStatus("PROCESSING");
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(approved);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
assertEquals("COMPLETED", result.taskStatus());
assertEquals("COMPLETED", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
}
@Test
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("WAIT_REVIEW", 2);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var result = service.review("8", "reviewer", 900L, "FBR-1",
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
assertEquals("PROCESSING", result.taskStatus());
assertEquals("PROCESSING", result.applicationStatus());
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 0);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null)));
verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong());
}
private RelabelApplication application(String status) {
RelabelApplication value = new RelabelApplication();
value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1");
value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900");
value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1);
value.setLabelDeadline(LocalDateTime.now().plusDays(1));
return value;
}
private RelabelItem item() {
RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100");
value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product");
value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value;
}
private PdaRelabelTask task(String status, int version) {
PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100");
value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status);
value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1);
value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0);
value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value;
}
private PdaRelabelUnitFact fact(String result) {
PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L);
value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100");
value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result);
value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value;
}
}