package com.baiyue.lastleg.service; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.dto.ContainerArrivalTimeView; 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.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; import com.baiyue.lastleg.model.LastlegOrderStatus; import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; 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.time.LocalDate; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; 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.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; 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 OverseasFeeSourceService feeSources; @Mock LastlegOrderOperationLogMapper operations; @Mock LastlegOrderManualNodeMapper manualNodes; @Mock InboundPlanCartonMapper cartons; @Mock FbaWarehouseValidator fbaWarehouses; @Mock InventoryCommandService inventory; @Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Mock WarehouseResourceGateway warehouseResourceGateway; @Mock ContainerShippingGateMapper containerShippingGates; @Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; LastlegOrderService service; @Mock com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Test void exposesWarehouseTransferNumberAsTailOrderNumber() { LastlegOrder order = new LastlegOrder(); order.setOrderNo("WT-WH-20260911-020226"); order.setOrderType("WAREHOUSE_TRANSFER"); order.setSourceOrderNo("WT-WH-20260911-020225"); assertEquals("WT-WH-20260911-020225", LastlegOrderService.displayOrderNo(order)); } @Test void returnTailOrderUsesSourceReturnNumber() { LastlegOrder order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); order.setOrderNo("RETU-OLD"); order.setSourceOrderNo("RT-1"); assertEquals("RT-1", LastlegOrderService.displayOrderNo(order)); } @Test void exposesStableContainerBookingIdInOrderSummary() { LastlegOrder order = new LastlegOrder(); order.setId(11L); order.setOrderNo("LMO-1"); order.setSourceContainerBookingId("BOOKING-1"); LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null, null); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); assertEquals(new java.math.BigDecimal("30"), summary.grossWeightKg()); assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg()); } @BeforeEach void setUp() { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); service = new LastlegOrderService(); ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "feeSources", feeSources); ReflectionTestUtils.setField(service, "operationLogMapper", operations); ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes); 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, "receivingWarehouseOperations", receivingWarehouseOperations); ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations); ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates); } @Test void changesExpectedWarehouseArrivalForTheWholeContainerBeforePlanning() { LastlegOrder first = new LastlegOrder(); first.setArrivalTimeVersion(0); LastlegOrder second = new LastlegOrder(); second.setArrivalTimeVersion(0); LocalDateTime estimatedAt = LocalDateTime.of(2026, 9, 25, 15, 31); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second)); when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0))); ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); assertEquals(estimatedAt, result.estimatedWarehouseArrivalAt()); ArgumentCaptor operation = ArgumentCaptor.forClass(ContainerArrivalTimeOperation.class); verify(containerArrivalTimeOperations).insert(operation.capture()); assertEquals(1, operation.getValue().getResultVersion()); } @Test void rejectsExpectedWarehouseArrivalChangesAfterAnyPlanExists() { LastlegOrder order = new LastlegOrder(); order.setArrivalTimeVersion(0); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order)); when(orders.countContainerPlans("BOOKING-1")).thenReturn(1L); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-locked", new LastlegOrderRequests.ChangeExpectedWarehouseArrival( LocalDateTime.of(2026, 9, 25, 15, 31), 0, null), "admin")); assertEquals(409, error.getStatusCode().value()); assertEquals("该柜已生成入库计划,预计到仓时间不可修改", error.getReason()); verify(orders, never()).updateContainerExpectedWarehouseArrival(any(), any(), anyInt()); } @Test void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() { LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null); LastlegOrder second = order(2L, "200", "PENDING_ASSIGNMENT", null); for (LastlegOrder value : List.of(first, second)) { value.setReceivingWarehouseVersion(0); value.setOverseasWarehouseId("193000000000200003"); value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶仓"); } var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900", "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0, 0, null, true, null); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second)); when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn( new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE")); when(orders.updateContainerReceivingWarehouse("BOOKING-1", "900", "US-NYC-01", "纽约仓", 0)) .thenReturn(2); when(orders.findContainerProgress("BOOKING-1")).thenReturn(updated); var result = service.changeReceivingWarehouse("BOOKING-1", "change-1", new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, "改派纽约"), "admin"); assertEquals("US-NYC-01", result.overseasWarehouseCode()); assertTrue(result.allPlansReady()); assertTrue(result.canChangeReceivingWarehouse()); verify(inventory).rebindReceivingWarehouse("100", 1L, 900L, "US-NYC-01", "纽约仓", "admin", "change-1"); verify(inventory).rebindReceivingWarehouse("200", 2L, 900L, "US-NYC-01", "纽约仓", "admin", "change-1"); verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L); verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class)); } @Test void permanentlyLocksReceivingWarehouseAfterPdaSessionExists() { LastlegOrder order = order(1L, "100", "PENDING_ASSIGNMENT", null); order.setReceivingWarehouseVersion(0); order.setOverseasWarehouseId("193000000000200003"); order.setOverseasWarehouseCode("US-LAX-01"); order.setOverseasWarehouseName("洛杉矶仓"); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order)); when(orders.countReceivingSessions("BOOKING-1")).thenReturn(1L); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.changeReceivingWarehouse("BOOKING-1", "change-2", new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, null), "admin")); assertEquals(409, error.getStatusCode().value()); verifyNoInteractions(warehouseResourceGateway); } @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(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of()); when(operations.selectList(any())).thenReturn(List.of()); when(manualNodes.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 returnTailDetailReadsReceiptFactsWithOwnedCustomerAndSourceReturnNumber() { ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", null); order.setOrderType("RETURN_RELABEL"); order.setSourceOrderNo("RT-SOURCE"); when(orders.selectOne(any())).thenReturn(order); var receiptDetail = new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail(null, null, List.of(), List.of(), List.of(), List.of()); when(returnReceipts.detail("100", "RT-SOURCE")).thenReturn(receiptDetail); assertSame(receiptDetail, service.detail("100", 11L).returnReceipt()); verify(returnReceipts).detail("100", "RT-SOURCE"); } @Test void detailReadsContainerNodeFromTheContainerShippingGate() { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); order.setSourceContainerBookingId("BOOKING-1"); ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("BOOKING-1"); gate.setStandardNode("PICKUP_READY"); when(orders.selectOne(any())).thenReturn(order); when(details.selectList(any())).thenReturn(List.of()); when(histories.selectList(any())).thenReturn(List.of()); when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of()); when(operations.selectList(any())).thenReturn(List.of()); when(manualNodes.selectList(any())).thenReturn(List.of()); when(containerShippingGates.selectById("BOOKING-1")).thenReturn(gate); var response = service.detail("100", 11L); assertEquals("PICKUP_READY", response.order().containerNode()); } @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( "CUSTOMS_CLEARANCE", "清关费", "RECEIVABLE", "USD", new BigDecimal("420.00"), LocalDate.of(2026, 9, 9), null, null, null, null, null, null, null, List.of(), null, null, null, null, null); OverseasFeeSource draft = new OverseasFeeSource(); draft.setSourceFeeId(701L); draft.setCustomerId("100"); draft.setFeeItemName("清关费"); draft.setOriginalCurrency("USD"); draft.setOriginalAmount(new BigDecimal("420.00")); when(feeSources.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft); when(feeSources.submit(701L, "platform_admin", "fee-key:submit")).thenReturn(draft); var fee = service.createFee("100", 11L, request, "platform_admin", "fee-key"); assertEquals("100", fee.getCustomerId()); assertEquals("清关费", fee.getFeeItemName()); verify(feeSources).create(eq("platform_admin"), eq("fee-key"), any()); verify(feeSources).submit(701L, "platform_admin", "fee-key:submit"); 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, null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, 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 pageWarehouseFilterMatchesFrozenWarehouseName() { assertWarehouseFilter("洛杉矶仓", "洛杉矶仓", true); } @Test void pageWarehouseFilterMatchesFrozenWarehouseCodeIgnoringCaseAndSpaces() { assertWarehouseFilter(" us-lax-01 ", "us-lax-01", true); } @Test void pageWarehouseFilterMatchesPartialFrozenWarehouseCode() { assertWarehouseFilter("LAX-0", "LAX-0", true); } @Test void pageWarehouseFilterReturnsEmptyPageWhenNothingMatches() { assertWarehouseFilter("WAREHOUSE-NOT-FOUND", "WAREHOUSE-NOT-FOUND", false); } @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 containerProgressDetailKeepsInvalidHistoricalContainerReadableAndBlocked() { 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); when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(List.of()); var result = service.containerProgressDetail("BOOKING-1"); assertFalse(result.summary().allPlansReady()); assertFalse(result.summary().canChangeReceivingWarehouse()); assertEquals("来源柜真实集装箱号缺失、格式无效或存在漂移", result.summary().blockingReason()); assertEquals(result.summary().blockingReason(), result.summary().receivingWarehouseLockReason()); verify(orders).findContainerProgressItems("BOOKING-1"); } @Test void containerProgressPageKeepsMissingWarehouseSnapshotFromBreakingHealthyContainers() { var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null); var healthy = summary(false, 3, "存在3票尚未生成有效入库计划"); var missingWarehouse = new ContainerPlanProgressSummary("BOOKING-2", "MSCU6639870", null, null, null, 0, true, null, 2, 2, 0, 0, 0, 2, 0, LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划"); when(orders.countContainerProgress(query)).thenReturn(2L); when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(healthy, missingWarehouse)); var result = service.containerProgressPage(query); assertEquals(2, result.records().size()); assertSame(healthy, result.records().get(0)); assertEquals("柜内接柜仓业务快照不一致", result.records().get(1).blockingReason()); assertFalse(result.records().get(1).allPlansReady()); assertTrue(result.records().get(1).canChangeReceivingWarehouse()); } @Test void containerProgressPageKeepsReadyAndPdaLockedContainersVisible() { var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null); var ready = summary(true, 0, null); var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061", "193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false, "海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0, 0, LocalDateTime.of(2026, 10, 4, 9, 0), true, null); when(orders.countContainerProgress(query)).thenReturn(2L); when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked)); var result = service.containerProgressPage(query); assertEquals(List.of(ready, locked), result.records()); assertTrue(result.records().get(0).allPlansReady()); assertTrue(result.records().get(0).canChangeReceivingWarehouse()); assertFalse(result.records().get(1).canChangeReceivingWarehouse()); assertEquals("海外PDA已建立接柜会话,接柜仓已永久锁定", result.records().get(1).receivingWarehouseLockReason()); } private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) { return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003", "US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0, 15 - pending, 15 - pending, pending, 0, 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, 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 pageDoesNotExposeBusinessOrderNumbersAsInboundCodes() { LastlegOrder relabel = order(11L, "100", "PENDING_ASSIGNMENT", null); relabel.setOrderType("RELABEL_APPLICATION"); relabel.setInboundCode("RL-WH-20260906-701570"); LastlegOrder looseboard = order(12L, "100", "PENDING_ASSIGNMENT", null); looseboard.setOrderType("LOCAL_LOOSEBOARD"); looseboard.setInboundCode("LB-WH-20260906-187457"); LastlegOrder firstleg = order(13L, "100", "PENDING_ASSIGNMENT", null); firstleg.setOrderType("FIRST_LEG"); firstleg.setInboundCode("NYEXX2609060063"); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); resultPage.setRecords(List.of(relabel, looseboard, firstleg)); resultPage.setTotal(3); when(orders.selectPage(any(), any())).thenReturn(resultPage); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); var result = service.page(null, query); assertNull(result.records().get(0).inboundCode()); assertNull(result.records().get(1).inboundCode()); assertEquals("NYEXX2609060063", result.records().get(2).inboundCode()); } 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; } @SuppressWarnings({"rawtypes", "unchecked"}) private void assertWarehouseFilter(String input, String normalized, boolean matched) { var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); LastlegOrder row = order(11L, "100", "PENDING_ASSIGNMENT", null); row.setOverseasWarehouseCode("US-LAX-01"); row.setOverseasWarehouseName("洛杉矶仓"); resultPage.setRecords(matched ? List.of(row) : List.of()); resultPage.setTotal(matched ? 1 : 0); when(orders.selectPage(any(), any())).thenReturn(resultPage); var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, null, null, null, null, input, null, null, null, null, null, null, null, null, null)); ArgumentCaptor> filter = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); verify(orders).selectPage(any(), filter.capture()); var wrapper = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) filter.getValue(); String warehouseSql = wrapper.getExpression().getNormal().getSqlSegment(); assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_code))")); assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_name))")); assertTrue(wrapper.getParamNameValuePairs().containsValue(normalized), wrapper.getParamNameValuePairs().toString()); assertEquals(matched ? 1 : 0, result.total()); assertEquals(matched ? 1 : 0, result.records().size()); } }