暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,32 @@
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 (customer_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"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
}
}
}
@@ -0,0 +1,374 @@
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.ArgumentMatchers.eq;
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;
@Mock InboundStockReceiptService stockReceipts;
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, "stockReceipts", stockReceipts);
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);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
activeTask();
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any());
verify(repository, never()).allocations(plan.getId());
}
@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.setCustomerId("100");
value.setPlanNo("LIP1");
value.setStatus("ASSIGNED");
return value;
}
private InboundPdaTask task() {
InboundPdaTask value = new InboundPdaTask();
value.setId(2L);
value.setPlanId(1L);
value.setCustomerId("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,214 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
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.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
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.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private InboundStockReceiptService service;
@BeforeEach
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
}
@Test
void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() {
Fixture fixture = fixture(5, 5, 5, 10);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals(5L, request.getValue().packages());
assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
verify(receipts, times(2)).insert(any(InboundStockReceipt.class));
}
@Test
void conservesSingleSkuPackagesAndQuantityAcrossPallets() {
Fixture fixture = fixture(5, 2, 2, 11);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
}
@Test
void existingTaskReceiptMakesStockInReplayANoOp() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt firstReceipt = new InboundStockReceipt();
firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId());
InboundStockReceipt secondReceipt = new InboundStockReceipt();
secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
}
@Test
void partialTaskReceiptsAreRejected() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt partial = new InboundStockReceipt();
partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
assertEquals(409, exception.getStatusCode().value());
verify(inventory, never()).stockIn(any(), any(), any());
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
}
@Test
void activatesDropshipAfterGenericStockInReceiptIsCreated() {
Fixture fixture = fixture(5, 5, 5, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<List<InboundStockReceipt>> activationReceipts = ArgumentCaptor.forClass(List.class);
verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture());
assertEquals(1, activationReceipts.getValue().size());
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId());
}
private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1");
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L);
line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST");
line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8");
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1");
order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) {
LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L);
detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity);
detail.setProductNameZh(sku + " name"); return detail;
}
private InboundPlanPallet pallet(long id, long warehousePalletId) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L);
pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId);
pallet.setWarehouseLocationId(3001L); return pallet;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) {
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id);
allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId);
allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation;
}
private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order,
LastlegOrderDetail detail, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet) {}
}
@@ -0,0 +1,137 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundFbaAppointmentServiceTest {
@Mock InboundFbaAppointmentMapper appointmentMapper;
@Mock InboundFbaAppointmentItemMapper itemMapper;
@Mock InboundFbaAppointmentQueryMapper queryMapper;
private InboundFbaAppointmentService service;
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
@BeforeEach
void setUp() {
service = new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper);
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
}
@Test
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
assertEquals(true, InboundFbaAppointmentService.class
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
.getAnnotation(Transactional.class) != null);
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
assertEquals("APT-1", result.appointmentNo());
assertEquals("ONT8", result.fbaWarehouseCode());
assertEquals(2, result.selectedCount());
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
}
@Test
void rejectsMixedFbaWarehouses() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void rejectsMissingLineFromAnotherTenant() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
}
@Test
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
verify(queryMapper, never()).lockLines(any(), any());
}
@Test
void sameAppointmentReplayReturnsExistingResult() {
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
item.setInboundCode("IN-11");
item.setFbaWarehouseCode("ONT8");
when(appointmentMapper.selectOne(any())).thenReturn(existing);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
assertEquals(99L, result.id());
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void sameAppointmentNumberWithDifferentSelectionConflicts() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
}
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock");
}
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
}
private InboundFbaAppointment appointment(Long id, String number, String fba) {
InboundFbaAppointment value = new InboundFbaAppointment();
value.setId(id);
value.setCustomerId("100");
value.setAppointmentNo(number);
value.setAppointmentAt(appointmentAt);
value.setAppointmentStatus("SUCCESS");
value.setAppointmentRemark("dock");
value.setFbaWarehouseCode(fba);
value.setSelectedCount(1);
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.setCustomerId("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)));
}
}