This commit is contained in:
2026-08-17 09:18:03 +08:00
parent 8b768125c6
commit d6fe79b0f2
128 changed files with 7053 additions and 76 deletions
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void customerCannotOverrideItsTenantScope() {
controller.list(user(100L, "MERCHANT"), "200", query);
verify(service).page(eq("100"), eq(query));
}
@Test
void platformOperatorMustExplicitlyChooseCustomerTenant() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(0L, "PLATFORM"), null, query));
}
@Test
void platformOperatorCanQueryExplicitCustomerTenant() {
controller.list(user(0L, "PLATFORM"), "200", query);
verify(service).page(eq("200"), eq(query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of());
}
private LastlegOrderController controller() {
LastlegOrderController controller = new LastlegOrderController();
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.inboundplan;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (tenant_id, container_no)"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
assertTrue(schema.contains("warehouse_resource_lock_id"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_task"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
assertFalse(schema.contains("cabinet_no"));
}
}
}
@@ -0,0 +1,357 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
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.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPdaWorkflowServiceTest {
@Mock PdaWorkflowRepository repository;
@Mock WarehouseResourceGateway warehouse;
private InboundPdaWorkflowService service;
private InboundPlan plan;
private InboundPdaTask task;
private InboundPlanLine targetLine;
private InboundPlanLine sourceLine;
private InboundPlanPallet targetPallet;
private InboundPlanPallet sourcePallet;
private InboundPlanPallet otherWarehousePallet;
private InboundPlanPalletAllocation target;
private InboundPlanPalletAllocation source;
private InboundPlanPalletAllocation otherWarehouse;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
task = task();
targetLine = line(11L, 101L, "RK-A", "1.0000", 5);
sourceLine = line(12L, 102L, "RK-B", "0.5000", 2);
targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L);
sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L);
otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L);
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
}
@Test
void pushRequiresEveryPalletToBeAssignedAndLocked() {
targetPallet.setWarehouseResourceLockId(null);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
assertThrows(ResponseStatusException.class,
() -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker")));
verify(repository, never()).insert(any(InboundPdaTask.class));
}
@Test
void assignmentReservesWarehouseResourcesAndPersistsTheLock() {
targetPallet.setDestinationWarehouseId("88");
targetPallet.setWarehousePalletId(null);
targetPallet.setWarehouseLocationId(null);
targetPallet.setWarehouseResourceLockId(null);
targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1"))
.thenReturn(999L);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "assign-1"));
assertEquals(188L, targetPallet.getWarehousePalletId());
assertEquals(288L, targetPallet.getWarehouseLocationId());
assertEquals(999L, targetPallet.getWarehouseResourceLockId());
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("ASSIGNED", plan.getStatus());
verify(repository).update(targetPallet);
}
@Test
void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() {
targetPallet.setDestinationWarehouseId("88");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-retry"));
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(),
any(), any(), any());
assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse")));
}
@Test
void countMustPrecedePickAndPickedBoxesDriveActualVolume() {
activeTask();
target.setCountedPackageCount(0);
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker")));
service.count("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(3, "count-3", "7", "worker"));
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(2, "pick-2", "7", "worker"));
assertEquals("PICKED", result.result());
assertEquals(2, target.getPickedPackageCount());
assertEquals("0.4000", target.getActualVolumeCbm().toPlainString());
assertEquals(5, targetPallet.getActualPackageCount());
assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString());
}
@Test
void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
candidateQueries();
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(5, "pick-over", "7", "worker"));
assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result());
assertEquals(3, result.remainingPackageCount());
assertEquals(2, result.mergePackageCount());
assertEquals(1, result.candidates().size());
assertEquals(source.getId(), result.candidates().get(0).allocationId());
assertEquals(0, target.getPickedPackageCount());
verify(repository, never()).update(target);
verify(repository, never()).update(targetPallet);
verify(repository).insert(any(InboundPdaOperation.class));
}
@Test
void mergeCandidatesOnlyIncludeSameDestinationPallets() {
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
candidateQueries();
var result = service.mergeCandidates("100", "LIP1", target.getId());
assertEquals(1, result.size());
assertEquals("RK-B", result.get(0).inboundCode());
assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString());
}
@Test
void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source));
var result = service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker"));
assertEquals("MERGED", result.result());
assertEquals(sourceLine.getOrderId(), result.sourceOrderId());
assertEquals(targetLine.getOrderId(), result.targetOrderId());
assertTrue(result.sourcePalletScrapped());
assertEquals(0, sourcePallet.getActualPackageCount());
assertEquals(5, targetPallet.getActualPackageCount());
verify(warehouse).scrap("100", 902L);
ArgumentCaptor<InboundPalletMerge> merge = ArgumentCaptor.forClass(InboundPalletMerge.class);
verify(repository).insert(merge.capture());
assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId());
assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId());
assertTrue(merge.getValue().getSourcePalletScrapped());
}
@Test
void mergeCannotExceedTargetPalletVolumeCapacity() {
activeTask();
targetPallet.setActualVolumeCbm(new BigDecimal("1.9000"));
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker")));
verify(warehouse, never()).scrap(any(), anyLong());
verify(repository, never()).insert(any(InboundPalletMerge.class));
}
@Test
void completionRequiresExactPickedTotalForEveryLine() {
activeTask();
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1"));
assertFalse("COMPLETED".equals(task.getStatus()));
verify(repository, never()).update(plan);
}
@Test
void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask();
target.setPickedPackageCount(3);
InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
assertEquals("COMPLETED", plan.getStatus());
verify(repository).update(task);
verify(repository).update(plan);
}
@Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
InboundPdaOperation existing = new InboundPdaOperation();
existing.setAction("COUNT");
existing.setRequestHash("different");
when(repository.operation("100", "same-key")).thenReturn(existing);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "same-key", "7", "worker")));
}
@Test
void completedTaskStillReplaysAnAlreadyPersistedPick() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker");
var first = service.pick("100", "LIP1", target.getId(), request);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture());
task.setStatus("COMPLETED");
when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue());
var replay = service.pick("100", "LIP1", target.getId(), request);
assertEquals(first, replay);
assertEquals(1, target.getPickedPackageCount());
verify(repository, times(1)).lockAllocation(plan.getId(), target.getId());
}
private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); }
private void candidateQueries() {
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine));
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse));
}
private InboundPlan plan() {
InboundPlan value = new InboundPlan();
value.setId(1L);
value.setTenantId("100");
value.setPlanNo("LIP1");
value.setStatus("ASSIGNED");
return value;
}
private InboundPdaTask task() {
InboundPdaTask value = new InboundPdaTask();
value.setId(2L);
value.setPlanId(1L);
value.setTenantId("100");
value.setTaskNo("PDA2");
value.setStatus("PDA_PENDING");
value.setVersion(0);
return value;
}
private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id);
value.setPlanId(1L);
value.setOrderId(orderId);
value.setInboundCode(inboundCode);
value.setDestinationWarehouseId("WH-A");
value.setVolumeCbm(new BigDecimal(volume));
value.setRequiredPackageCount(packages);
return value;
}
private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages,
String actualVolume, long lockId) {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(id);
value.setPlanId(1L);
value.setPalletNo("PLT-" + id);
value.setDestinationWarehouseId(warehouseId);
value.setCapacityCbm(new BigDecimal("2.0000"));
value.setWarehousePalletId(id + 1000);
value.setWarehouseLocationId(id + 2000);
value.setWarehouseResourceLockId(lockId);
value.setAssignmentStatus("ASSIGNED");
value.setActualPackageCount(actualPackages);
value.setActualVolumeCbm(new BigDecimal(actualVolume));
return value;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId,
int planned, int counted, int picked) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setId(id);
value.setPlanId(1L);
value.setLineId(lineId);
value.setPalletId(palletId);
value.setPlannedPackageCount(planned);
value.setCountedPackageCount(counted);
value.setPickedPackageCount(picked);
value.setActualVolumeCbm(BigDecimal.ZERO);
value.setVersion(0);
return value;
}
}
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
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.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanServiceTest {
@Mock InboundPlanMapper planMapper;
@Mock InboundPlanLineMapper lineMapper;
@Mock InboundPlanPalletMapper palletMapper;
@Mock InboundPlanPalletAllocationMapper allocationMapper;
@Mock InboundPlanOrderMapper orderMapper;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
}
@Test
void convertsWholeContainerInOneTransaction() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)
.getAnnotation(Transactional.class));
when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(
order(1L, "RK-1", "1.5000", "WH-A"),
order(2L, "RK-2", "1.0000", "WH-A")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(2, plan.getValue().getPalletCount());
assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
ArgumentCaptor<InboundPlanLine> lines = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper, times(2)).insert(lines.capture());
assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(3)).insert(allocations.capture());
assertEquals(25, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum());
assertEquals(0, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum());
assertEquals(3, allocations.getAllValues().stream()
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any());
verify(orderMapper).lockContainerOrders("100", "CONT-1");
assertEquals("CONT-1", result.containerNo());
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();
existing.setId(9L);
existing.setPlanNo("LIP9");
existing.setTenantId("100");
existing.setContainerNo("CONT-1");
existing.setStatus("PLANNED");
existing.setLineCount(1);
existing.setPalletCount(1);
existing.setTotalVolumeCbm(BigDecimal.ONE);
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing);
assertEquals("LIP9", service.convert("100", 1L).planNo());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
warehouse, warehouse, warehouse, 1, 0);
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.inboundplan.service;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PalletAllocatorTest {
@Test
void roundsEachDestinationGroupUpToTwoCbmPallets() {
assertEquals(2, allocate("4.0000").pallets().size());
assertEquals(3, allocate("4.0100").pallets().size());
assertEquals(1, allocate("0.1000").pallets().size());
assertEquals(0, allocate("0.0000").pallets().size());
}
@Test
void neverMixesDestinationWarehousesAndCanSplitOneLine() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5),
new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1)));
assertEquals(3, result.pallets().size());
assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count());
assertTrue(result.allocations().stream().allMatch(item ->
item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B")));
}
@Test
void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10)));
assertEquals(List.of(4, 4, 2), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
assertEquals(10, result.allocations().stream()
.mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum());
}
@Test
void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1)));
assertEquals(List.of(0, 0, 1), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
}
private PalletAllocator.Result allocate(String volume) {
return PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),
new BigDecimal(volume).signum() == 0 ? 0 : 1)));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.security;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
class InternalServiceTokenGuardTest {
@Test void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token");
assertDoesNotThrow(()->guard.verify("secret-token"));
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.concurrent.*;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class LastlegOrderNoGeneratorTest {
@Test void actualContainerNumberIsStableAndShared() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633"));
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
}
@Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception {
LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator();
String first=beforeRestart.next();
LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator();
assertNotEquals(first,afterRestart.next());
ExecutorService pool=Executors.newFixedThreadPool(8);
try {
var tasks=IntStream.range(0,1000).mapToObj(i->(Callable<String>)afterRestart::next).toList();
Set<String> ids=pool.invokeAll(tasks).stream().map(future->{
try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); }
}).collect(java.util.stream.Collectors.toSet());
assertEquals(1000,ids.size());
} finally { pool.shutdownNow(); }
}
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
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.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
LastlegOrderService service;
@BeforeEach
void setUp() {
service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
}
@Test
void transferUpdatesOrderAndEveryCargoSnapshot() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("wh-2", order.getTransferWarehouseId());
assertEquals("LAX-02", order.getTransferWarehouseCode());
assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus());
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).update(cargo.capture(), any());
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus());
assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus());
}
@Test
void transferRejectsAnOrderAlreadyBoundToInboundPlan() {
LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1");
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
verify(details, never()).update(any(), any());
}
@Test
void batchRejectsMissingOrCrossTenantIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getStatusCode().is4xxClientError());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
private static LastlegOrder order(long id, String tenantId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setTenantId(tenantId);
order.setCurrentStatus(status);
order.setInboundOrderNo(inboundOrderNo);
return order;
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
}