Files
baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
T

356 lines
18 KiB
Java

package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
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 java.time.LocalDateTime;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
// Service tests keep remote master-data validation and persistence independently observable here.
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderFeeMapper fees;
@Mock LastlegOrderOperationLogMapper operations;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
LastlegOrderService service;
@BeforeEach
void setUp() {
service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeMapper", fees);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
new FbaWarehouseValidator.VerifiedFbaWarehouse(
Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2),
"US", "CA"));
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
}
@Test
void detailDoesNotExposeDropshipCartonsOrInternalEventIds() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
when(fees.selectList(any())).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
var response = service.detail("100", 11L);
assertEquals(11L, response.order().id());
assertEquals("100", response.order().customerId());
assertEquals("ONE_CLICK_DROPSHIP", response.order().orderType());
assertEquals("一件代发", response.order().orderTypeName());
assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents())
.noneMatch(component -> "dropshipPallets".equals(component.getName())));
assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents())
.noneMatch(component -> "eventId".equals(component.getName())));
}
@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), "193000000000000002", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("193000000000000002", 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<InboundPlanCarton> carton = ArgumentCaptor.forClass(InboundPlanCarton.class);
verify(cartons).update(carton.capture(), any());
assertEquals("193000000000000002", carton.getValue().getDestinationWarehouseId());
assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("100", history.getValue().getCustomerId());
assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus());
assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus());
}
@Test
void createFeePersistsFeeAndOperationForOwnedOrder() {
when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null));
var request = new LastlegOrderRequests.CreateFee(
"清关费", "USD", new BigDecimal("420.00"), "PENDING_REVIEW", "报关代理");
var fee = service.createFee("100", 11L, request, "platform_admin");
assertEquals("100", fee.getCustomerId());
assertEquals("清关费", fee.getFeeItem());
assertEquals(new BigDecimal("420.00"), fee.getAmount());
verify(fees).insert(any(com.baiyue.lastleg.entity.LastlegOrderFee.class));
verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class));
}
@Test
void palletMergeMarkIsPersistedAndAuditedForPdaReminder() {
LastlegOrder order = order(11L, "100", "INBOUND_PLANNED", "IB-1");
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updatePalletMergeFlag(any(), anyLong(), any(), any())).thenReturn(1);
service.setPalletMergeRequired("100", 11L,
new LastlegOrderRequests.SetPalletMergeRequired(true), "platform_admin");
verify(orders).updatePalletMergeFlag(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq(11L), org.mockito.ArgumentMatchers.eq("REQUIRED"), any());
ArgumentCaptor<com.baiyue.lastleg.entity.LastlegOrderOperationLog> operation =
ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class);
verify(operations).insert(operation.capture());
assertEquals("标记合托", operation.getValue().getOperationNode());
assertTrue(operation.getValue().getResult().contains("PDA"));
}
@Test
void completedPhysicalMergeCannotBeUnmarked() {
LastlegOrder order = order(11L, "100", "INBOUND_PLANNED", "IB-1");
order.setPalletMergeFlag("MERGED");
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
assertThrows(ResponseStatusException.class, () -> service.setPalletMergeRequired("100", 11L,
new LastlegOrderRequests.SetPalletMergeRequired(false), "platform_admin"));
verify(orders, never()).updatePalletMergeFlag(any(), anyLong(), any(), any());
}
@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), "193000000000000002", "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());
verify(cartons, never()).update(any(), any());
}
@Test
void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "193000000000000002", "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));
}
@Test
void forgedFbaSelectionIsRejectedBeforeLockingOrWritingOrders() {
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "193000000000000002", "FAKE", "伪造仓库", null);
when(fbaWarehouses.required("193000000000000002", "FAKE", "伪造仓库"))
.thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
"所选FBA仓不存在或已停用"));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getMessage().contains("所选FBA仓不存在或已停用"));
verify(orders, never()).selectList(any());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
"", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
service.page(null, query);
ArgumentCaptor<com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>> page =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.extension.plugins.pagination.Page.class);
verify(orders).selectPage(page.capture(), any());
assertEquals(3, page.getValue().getCurrent());
assertEquals(25, page.getValue().getSize());
}
@Test
void containerProgressPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null);
var row = summary(false, 3, "存在3票尚未生成有效入库计划");
when(orders.countContainerProgress(query)).thenReturn(11L);
when(orders.pageContainerProgress(query, 10, 10)).thenReturn(List.of(row));
var result = service.containerProgressPage(query);
assertEquals(2, result.page());
assertEquals(10, result.size());
assertEquals(11, result.total());
assertEquals(2, result.pages());
assertEquals(List.of(row), result.records());
}
@Test
void containerProgressPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null,
LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.containerProgressPage(query));
verify(orders, never()).countContainerProgress(any());
}
@Test
void containerProgressDetailReturnsEveryOrderWithoutPagination() {
var summary = summary(true, 0, null);
var items = List.of(
new ContainerPlanProgressDetail.Item(1L, "100", "LMO-1", "RK-1", "INBOUND_PLANNED",
101L, "LIP-1", "PENDING_ASSIGNMENT", true, LocalDateTime.of(2026, 10, 3, 9, 0)),
new ContainerPlanProgressDetail.Item(2L, "200", "LMO-1", "RK-2", "INBOUND_PLANNED",
102L, "LIP-2", "PDA_PENDING", true, LocalDateTime.of(2026, 10, 3, 9, 0)));
when(orders.findContainerProgress("BOOKING-1")).thenReturn(summary);
when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(items);
var result = service.containerProgressDetail(" BOOKING-1 ");
assertEquals(summary, result.summary());
assertEquals(items, result.orders());
verify(orders).findContainerProgressItems("BOOKING-1");
}
@Test
void containerProgressDetailFailsClosedWhenContainerNumberFactHasDrifted() {
var drifted = new ContainerPlanProgressSummary("BOOKING-1", null, 2, 2, 0,
2, 2, 0, LocalDateTime.of(2026, 10, 3, 9, 0), false, "柜号缺失或不一致");
when(orders.findContainerProgress("BOOKING-1")).thenReturn(drifted);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.containerProgressDetail("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).findContainerProgressItems(any());
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
}
@Test
void pageReturnsPalletMergeRelatedInboundNumbers() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1");
order.setPalletMergeFlag("MERGED");
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
resultPage.setRecords(List.of(order));
resultPage.setTotal(1);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of(
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
assertEquals("PMG-1", result.records().get(0).palletMergeBatchId());
assertEquals(List.of("INBOUND-2", "INBOUND-3"), result.records().get(0).palletMergeInboundNos());
}
@Test
void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() {
LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(true, "等待质检"), "operator");
assertTrue(order.getDetained());
verify(orders).updateById(order);
ArgumentCaptor<com.baiyue.lastleg.entity.LastlegOrderOperationLog> operation =
ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class);
verify(operations).insert(operation.capture());
assertEquals("暂扣", operation.getValue().getOperationNode());
assertEquals("等待质检", operation.getValue().getResult());
verify(detainedOutbox).publish(eq(order), eq(true), eq("等待质检"), isNull(), eq("operator"), any(), anyLong());
verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any());
}
@Test
void releasingAfterInboundMovesAllOrderHeldInventoryToAvailable() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IP-1");
order.setOrderNo("LM-1");
order.setDetained(true);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(false, "质检通过"), "operator");
verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过");
assertEquals(false, order.getDetained());
verify(detainedOutbox).publish(eq(order), eq(false), eq("质检通过"), isNull(), eq("operator"), any(), anyLong());
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setCustomerId(customerId);
order.setCurrentStatus(status);
order.setInboundOrderNo(inboundOrderNo);
order.setOrderNo("LM-" + id);
order.setSourceInboundId(String.valueOf(1000 + id));
order.setInboundCode("IN-" + id);
return order;
}
}