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 cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); verify(details).update(cargo.capture(), any()); assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); ArgumentCaptor carton = ArgumentCaptor.forClass(InboundPlanCarton.class); verify(cartons).update(carton.capture(), any()); assertEquals("193000000000000002", carton.getValue().getDestinationWarehouseId()); assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode()); ArgumentCaptor 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 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(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); service.page(null, query); ArgumentCaptor> 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(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 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; } }