暂存提交

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,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)));
}
}