diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java
new file mode 100644
index 00000000..29a2aef0
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java
@@ -0,0 +1,272 @@
+package org.springblade.transport.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.transport.pojo.entity.ExceptionDisposal;
+import org.springblade.transport.pojo.entity.LoadingManage;
+import org.springblade.transport.pojo.entity.Waybill;
+import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
+import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
+import org.springblade.transport.pojo.vo.TripStationVO;
+import org.springblade.transport.pojo.vo.TripVO;
+import org.springblade.transport.service.IDriverService;
+import org.springblade.transport.service.IDriverWaybillService;
+import org.springblade.transport.service.IExceptionDisposalService;
+import org.springblade.transport.service.ILoadingManageService;
+import org.springblade.transport.service.IRiskDisposalService;
+import org.springblade.transport.service.ITripQueryService;
+import org.springblade.transport.service.IWaybillService;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Date;
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 调度端列表与详情的运次口径:一卡一运次、整趟车的站点与货物。
+ *
+ * 卡片上的状态、站点与货物汇总都出自共享运次读服务,本类只做字段裁剪;这里钉住的是
+ * 「运次字段有没有被摆到卡片与详情上」,以及取不到运次时按运单主键与配载单主键的回落路径。
+ */
+class ManageTripListDetailTest {
+
+ private static final String LOADING_NO = "PZ-20261017-001";
+ private static final Long LOADING_ID = 2111350784070275074L;
+ private static final Long CHILD_A = 2111350784070275123L;
+ private static final Long CHILD_B = 2102780956493299713L;
+
+ private ManageWaybillServiceImpl service;
+ private IWaybillService waybillService;
+ private IExceptionDisposalService exceptionDisposalService;
+ private ITripQueryService tripQueryService;
+ private ILoadingManageService loadingManageService;
+ private IDriverWaybillService driverWaybillService;
+
+ @BeforeAll
+ static void initTableInfo() {
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+ assistant.setCurrentNamespace("test");
+ TableInfoHelper.initTableInfo(assistant, Waybill.class);
+ TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
+ TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
+ }
+
+ @BeforeEach
+ void setUp() {
+ waybillService = mock(IWaybillService.class);
+ exceptionDisposalService = mock(IExceptionDisposalService.class);
+ tripQueryService = mock(ITripQueryService.class);
+ loadingManageService = mock(ILoadingManageService.class);
+ driverWaybillService = mock(IDriverWaybillService.class);
+ service = new ManageWaybillServiceImpl(
+ waybillService,
+ mock(IDriverService.class),
+ exceptionDisposalService,
+ mock(IRiskDisposalService.class),
+ driverWaybillService,
+ tripQueryService,
+ loadingManageService);
+ when(exceptionDisposalService.list(ArgumentMatchers.>any()))
+ .thenReturn(new ArrayList<>());
+ when(waybillService.list(ArgumentMatchers.>any())).thenReturn(new ArrayList<>());
+ }
+
+ @Test
+ @DisplayName("列表一卡一运次:单号、组织类型、起经终、站点数、货物汇总都取运次字段")
+ void pageListShowsOneCardPerTrip() {
+ when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
+
+ IPage page = service.pageList(1, 10, null, null, null, null, null, null);
+
+ assertEquals(1, page.getTotal());
+ assertEquals(1, page.getRecords().size(), "四张子单只出一条卡");
+ AdminWaybillCardVO card = page.getRecords().get(0);
+ assertEquals(String.valueOf(LOADING_ID), card.getTripId());
+ assertEquals(LOADING_NO, card.getLoadingNo());
+ assertEquals(LOADING_NO, card.getTripNo());
+ assertEquals("load", card.getTransportType(), "有配载单即为公路配载");
+ assertEquals(2, card.getStatus());
+ assertEquals(4, card.getWaybillCount());
+ assertEquals(3, card.getStationCount());
+ assertEquals("南宁吴圩机场", card.getFromName());
+ assertEquals("新福兴硅科技有限公司", card.getViaName());
+ assertEquals("宾阳", card.getToName());
+ assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary());
+ assertEquals("161.84吨", card.getTotalWeight());
+ assertEquals("2026-09-23 23:23:42", card.getCreateTime(), "卡片时间取运次排序键");
+ assertEquals(Long.valueOf(CHILD_A), card.getId(), "旧 id 仍是运单主键,打卡订正等按它进入");
+ assertEquals("YD-20260825-0006", card.getWaybillNo());
+ assertFalse(card.getNeedReassign());
+ }
+
+ @Test
+ @DisplayName("详情按运次主键取:配载单号、组织类型、站点 ①②③ 与每站货物都带运单号")
+ void detailShowsWholeTripStations() {
+ when(tripQueryService.detail(any())).thenReturn(trip());
+
+ AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
+
+ assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
+ assertEquals(LOADING_NO, detail.getLoadingNo());
+ assertEquals(LOADING_NO, detail.getTripNo());
+ assertEquals("load", detail.getTransportOrgType());
+ assertEquals(2, detail.getStatus());
+ assertEquals(3, detail.getStationCount());
+ assertEquals(3, detail.getStations().size());
+ assertEquals(3, detail.getRoutePoints().size(), "点位列与站点同源");
+ assertEquals("161.84吨", detail.getTotalWeight());
+
+ TripStationVO first = detail.getStations().get(0);
+ assertEquals(1, first.getIndex());
+ assertEquals("装货", first.getOperationTypeLabel());
+ assertEquals("2102780956493299713", first.getCargos().get(0).getWaybillId(), "货物条目带所属子运单主键");
+ assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo());
+
+ TripStationVO second = detail.getStations().get(1);
+ assertEquals("装卸", second.getOperationTypeLabel());
+ assertEquals(2, second.getCargos().size());
+ assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo(), "同一站可卸一张、装另一张");
+ assertEquals("2131150784070275011", second.getCargos().get(1).getWaybillId());
+ }
+
+ @Test
+ @DisplayName("取不到运次时按运单主键与配载单主键回落:整单被拒的配载单仍能打开并重新派单")
+ void detailFallsBackWhenTripIsNotReachable() {
+ when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
+ when(waybillService.getById(CHILD_A)).thenReturn(null);
+ when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
+
+ AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
+
+ assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
+ assertEquals(LOADING_NO, detail.getLoadingNo());
+ assertEquals("load", detail.getTransportOrgType());
+ assertTrue(detail.getNeedReassign(), "整单被拒时按配载单回落,标记待重新派单");
+ assertEquals(3, detail.getStationCount(), "子单为空时站点退回配载单自己的发货/途经/到货");
+ assertEquals("南宁吴圩机场", detail.getFromName());
+ assertEquals("宾阳", detail.getToName());
+ }
+
+ @Test
+ @DisplayName("运次主键不是数字时直接拒绝,不去查库")
+ void detailRefusesUnparsableTripId() {
+ assertThrows(ServiceException.class, () -> service.detail("not-a-number"));
+ assertThrows(ServiceException.class, () -> service.detail(null));
+ }
+
+ // ------------------------------------------------------------ 夹具
+
+ private Page pageOf(long total, TripVO... trips) {
+ Page page = new Page<>(1, 10, total);
+ page.setRecords(List.of(trips));
+ return page;
+ }
+
+ /**
+ * 一趟 4 张子单的配载运次:站点与货物按本库真实样本同形(三站,第二站装卸)
+ */
+ private TripVO trip() {
+ TripVO trip = new TripVO();
+ trip.setId(String.valueOf(LOADING_ID));
+ trip.setTripNo(LOADING_NO);
+ trip.setLoadingNo(LOADING_NO);
+ trip.setTransportOrgType("load");
+ trip.setStatus(2);
+ trip.setRequireAccept(false);
+ trip.setWaybillCount(4);
+ trip.setDriverName("黄宇富");
+ trip.setVehicleNo("桂A32523");
+ trip.setDepartureName("南宁吴圩机场");
+ trip.setTransitName("新福兴硅科技有限公司");
+ trip.setArrivalName("宾阳");
+ trip.setStationCount(3);
+ trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨");
+ trip.setTotalQuantity(new BigDecimal("161.84"));
+ trip.setQuantityUnit("吨");
+ trip.setSortTime(at(1790177022000L));
+ trip.setEstimatedStartDate(LocalDate.of(2026, 8, 25));
+ trip.setEstimatedEndDate(LocalDate.of(2026, 9, 23));
+ trip.setWaybills(List.of(
+ tripWaybill(CHILD_A, "YD-20260825-0006"),
+ tripWaybill(2111350784070275124L, "YD-20260825-0007"),
+ tripWaybill(2111350784070275125L, "YD-20260825-0008"),
+ tripWaybill(CHILD_B, "YD-20260923-0008")));
+ trip.setStations(List.of(
+ station(1, "南宁吴圩机场", "load", "装货", cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30")),
+ station(2, "武宣", "load_unload", "装卸",
+ cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30"),
+ cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10")),
+ station(3, "宾阳", "unload", "卸货", cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10"))));
+ return trip;
+ }
+
+ private TripVO.TripWaybillVO tripWaybill(Long id, String waybillNo) {
+ TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
+ child.setId(String.valueOf(id));
+ child.setWaybillNo(waybillNo);
+ child.setBusinessStatus("running");
+ return child;
+ }
+
+ private TripStationVO station(int index, String name, String operationType, String label,
+ TripStationVO.TripCargoVO... cargos) {
+ TripStationVO station = new TripStationVO();
+ station.setIndex(index);
+ station.setName(name);
+ station.setAddress(name);
+ station.setOperationType(operationType);
+ station.setOperationTypeLabel(label);
+ station.setTags(new ArrayList<>());
+ station.setCargos(List.of(cargos));
+ return station;
+ }
+
+ private TripStationVO.TripCargoVO cargo(String waybillId, String waybillNo, String cargoName, String quantity) {
+ TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
+ cargo.setWaybillId(waybillId);
+ cargo.setWaybillNo(waybillNo);
+ cargo.setCargoName(cargoName);
+ cargo.setQuantity(new BigDecimal(quantity));
+ cargo.setQuantityUnit("吨");
+ return cargo;
+ }
+
+ private LoadingManage loading() {
+ LoadingManage loading = new LoadingManage();
+ loading.setId(LOADING_ID);
+ loading.setLoadingNo(LOADING_NO);
+ loading.setDriverName("黄宇富");
+ loading.setDriverPhone("17587421208");
+ loading.setVehicleNo("桂A32523");
+ loading.setDepartureAddress("南宁吴圩机场");
+ loading.setTransitAddress("武宣");
+ loading.setArrivalAddress("宾阳");
+ loading.setCargoName("石英砂A");
+ return loading;
+ }
+
+ private Date at(long millis) {
+ return new Date(millis);
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java
new file mode 100644
index 00000000..744a5548
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java
@@ -0,0 +1,358 @@
+package org.springblade.transport.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.transport.pojo.entity.LoadingManage;
+import org.springblade.transport.pojo.entity.Waybill;
+import org.springblade.transport.pojo.vo.TripQueryVO;
+import org.springblade.transport.pojo.vo.TripVO;
+import org.springblade.transport.service.ILoadingManageService;
+import org.springblade.transport.service.IWaybillService;
+
+import java.math.BigDecimal;
+import java.util.Date;
+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.assertNull;
+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.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 运次读服务的聚合边界:四档状态、排序键、空子单集合与主键下发形态。
+ *
+ * 断言都从服务出口读({@code page} / {@code detail}),不直接调判定函数:随卡片下发的
+ * {@code status} 与 {@code requireAccept} 是两个字段,只验静态判定看不出服务有没有把子单真身取回来——
+ * 库层分组行只投影主键、分组键与排序键,business_status 是 NULL。
+ */
+class TripReadServiceBoundaryTest {
+
+ private static final String PHONE = "17587421208";
+ private static final String LOADING_NO = "PZ-20261017-001";
+ /** 19 位雪花号,按数字下发会被双精度取整 */
+ private static final Long LOADING_ID = 2111350784070275074L;
+ private static final Long CHILD_A = 2111350784070275123L;
+ private static final Long CHILD_B = 2102780956493299713L;
+ private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
+ private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
+
+ private IWaybillService waybillService;
+ private ILoadingManageService loadingManageService;
+ private TripQueryServiceImpl service;
+
+ @BeforeAll
+ static void initTableInfo() {
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+ assistant.setCurrentNamespace("test");
+ TableInfoHelper.initTableInfo(assistant, Waybill.class);
+ TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
+ }
+
+ @BeforeEach
+ void setUp() {
+ waybillService = mock(IWaybillService.class);
+ loadingManageService = mock(ILoadingManageService.class);
+ service = new TripQueryServiceImpl(waybillService, loadingManageService);
+ lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
+ lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
+ }
+
+ @Test
+ @DisplayName("子单全部完成 → 运次已完成(2)")
+ void allChildrenCompleted() {
+ stubLoadedTrip(
+ child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
+ child(CHILD_B, "completed", "accepted", ACCEPT_NO, at(1787671422000L)));
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(2, trip.getStatus(), "终态且含已完成为已完成档");
+ assertEquals(2, trip.getWaybillCount());
+ assertEquals("accepted", trip.getAcceptStatus(), "任一子单已接单即整单已接单");
+ }
+
+ @Test
+ @DisplayName("子单全部取消 → 运次已取消(3),不是运输中")
+ void allChildrenCancelled() {
+ stubLoadedTrip(
+ child(CHILD_A, "cancelled", null, ACCEPT_NO, at(1787657033000L)),
+ child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(3, trip.getStatus(), "已取消子单必须进运次,否则全取消会被算成运输中");
+ }
+
+ @Test
+ @DisplayName("混合终态且含 completed → 运次已完成(2),不看取消的那张")
+ void mixedTerminalWithCompleted() {
+ stubLoadedTrip(
+ child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
+ child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(2, trip.getStatus());
+ }
+
+ @Test
+ @DisplayName("需接单且未接单 → 待接单(0)")
+ void requireAcceptWithoutAcceptance() {
+ Waybill required = child(CHILD_A, "pending", "pending", ACCEPT_YES, at(1787657033000L));
+ required.setProjectId(1L);
+ required.setProjectName("MK项目测试-1");
+ stubLoadedTrip(required);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(Boolean.TRUE, trip.getRequireAccept());
+ assertEquals("pending", trip.getAcceptStatus());
+ assertEquals(0, trip.getStatus());
+ }
+
+ @Test
+ @DisplayName("不需接单 → 运输中(1)")
+ void withoutAcceptRequirement() {
+ Waybill notRequired = child(CHILD_A, "pending", null, ACCEPT_NO, at(1787657033000L));
+ notRequired.setProjectId(2L);
+ notRequired.setProjectName("武宣建筑原料运输项目");
+ stubLoadedTrip(notRequired);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(Boolean.FALSE, trip.getRequireAccept());
+ assertEquals(1, trip.getStatus());
+ }
+
+ @Test
+ @DisplayName("跨项目子单的接单需求按 OR 合并:一张要接单整趟车都要接单")
+ void crossProjectRequirementMergesWithOr() {
+ Waybill required = child(CHILD_A, "running", null, ACCEPT_YES, at(1787657033000L));
+ required.setProjectId(1L);
+ required.setProjectName("MK项目测试-1");
+ Waybill notRequired = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
+ notRequired.setProjectId(2L);
+ notRequired.setProjectName("武宣建筑原料运输项目");
+ stubLoadedTrip(required, notRequired);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(Boolean.TRUE, trip.getRequireAccept());
+ assertEquals(0, trip.getStatus());
+ assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept());
+ assertEquals(Boolean.FALSE, trip.getWaybills().get(1).getRequireAccept());
+ }
+
+ @Test
+ @DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
+ void crossProjectWithoutAnyRequirement() {
+ Waybill first = child(CHILD_A, "running", null, null, at(1787657033000L));
+ first.setProjectId(1L);
+ Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
+ second.setProjectId(2L);
+ stubLoadedTrip(first, second);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(Boolean.FALSE, trip.getRequireAccept());
+ assertEquals(1, trip.getStatus());
+ }
+
+ @Test
+ @DisplayName("排序键取子单 create_time 的最大值,不取第一张也不取最后一张")
+ void sortTimeIsMaxChildCreateTime() {
+ Date earliest = at(1787657033000L);
+ Date latest = at(1787671422000L);
+ Date middle = at(1787663000000L);
+ stubLoadedTrip(
+ child(CHILD_A, "running", null, ACCEPT_NO, middle),
+ child(2111350784070275999L, "running", null, ACCEPT_NO, latest),
+ child(CHILD_B, "running", null, ACCEPT_NO, earliest));
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(latest, trip.getSortTime());
+ assertEquals(3, trip.getWaybillCount());
+ assertEquals(earliest, trip.getWaybills().get(0).getCreateTime(), "子单按创建时间升序摆");
+ }
+
+ @Test
+ @DisplayName("子单集合为空不抛异常:列表不给这张卡,详情直接拒绝而不是 NPE")
+ void emptyChildrenDoNotThrow() {
+ stubLoadedTrip();
+
+ IPage page = service.page(TripQueryVO.forDriver(PHONE));
+
+ assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次");
+
+ when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
+ assertThrows(ServiceException.class, () -> service.detail(detailQuery()),
+ "子单全被拒单的配载运次对司机不可见");
+ }
+
+ @Test
+ @DisplayName("子单状态为空时不抛异常:读不出状态按没走完兜底,不崩也不误判成终态")
+ void nullBusinessStatusDoesNotThrow() {
+ Waybill blank = new Waybill();
+ blank.setId(CHILD_A);
+ blank.setWaybillNo("YD-20260920-0003");
+ blank.setLoadingNo(LOADING_NO);
+ blank.setBusinessStatus(null);
+ stubLoadedTrip(blank);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals(1, trip.getStatus(), "读不出状态按没走完处理,不是终态也不抛异常");
+ assertEquals(1, trip.getWaybillCount());
+ }
+
+ @Test
+ @DisplayName("未配载运次按主键回查真身:分组行没有状态列,拿它当子单会让接单需求恒为 false")
+ void loneTripReadsStateFromFetchedWaybill() {
+ // 段一给的行只有主键、分组键与排序键,真身要靠段二回查
+ Waybill row = new Waybill();
+ row.setId(CHILD_A);
+ row.setCreateTime(at(1787657033000L));
+ stubPage(1, row);
+ Waybill bare = new Waybill();
+ bare.setId(CHILD_A);
+ bare.setWaybillNo("YD-20260920-0003");
+ bare.setBusinessStatus("pending");
+ bare.setDriverAcceptStatus("pending");
+ bare.setProcessJson(ACCEPT_YES);
+ bare.setDriverPhone(PHONE);
+ when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare));
+
+ TripVO trip = onlyTrip();
+
+ assertEquals("2111350784070275123", trip.getId());
+ assertNull(trip.getLoadingNo());
+ assertEquals(19, trip.getId().length(), "19 位雪花号的末位不被取整");
+ assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求取自回查到的真身");
+ assertEquals(0, trip.getStatus());
+ assertEquals(1, trip.getWaybillCount());
+ }
+
+ @Test
+ @DisplayName("运次主键按字符串下发:19 位雪花号的末位不被双精度取整")
+ void tripIdIsSentAsString() {
+ stubLoadedTrip(child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)));
+
+ TripVO trip = onlyTrip();
+
+ assertNotNull(trip.getId());
+ assertEquals("2111350784070275074", trip.getId());
+ assertEquals(19, trip.getId().length());
+ assertEquals("2111350784070275123", trip.getWaybills().get(0).getId());
+ assertEquals(19, trip.getWaybills().get(0).getId().length());
+ assertTrue(trip.getId() instanceof String);
+ }
+
+ @Test
+ @DisplayName("货物与合计重量按整趟车算:同名同单位合并,单位不一致时合计留空")
+ void cargoSummarySpansWholeTrip() {
+ Waybill first = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
+ first.setCargoName("石英砂A");
+ first.setQuantity(new BigDecimal("42.10"));
+ first.setQuantityUnit("吨");
+ Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
+ second.setCargoName("石英砂A");
+ second.setQuantity(new BigDecimal("47.57"));
+ second.setQuantityUnit("吨");
+ stubLoadedTrip(first, second);
+
+ TripVO trip = onlyTrip();
+
+ assertEquals("石英砂A 89.67吨", trip.getCargoSummary());
+ assertEquals(0, new BigDecimal("89.67").compareTo(trip.getTotalQuantity()));
+ assertEquals("吨", trip.getQuantityUnit());
+ }
+
+ // ------------------------------------------------------------ 夹具
+
+ @SuppressWarnings("unchecked")
+ private static Wrapper anyWaybillWrapper() {
+ return ArgumentMatchers.>any();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Wrapper anyLoadingWrapper() {
+ return ArgumentMatchers.>any();
+ }
+
+ /** 配载运次:段一给一行分组行,段二给配载单与这一组的子单 */
+ private void stubLoadedTrip(Waybill... children) {
+ Waybill row = new Waybill();
+ row.setId(children.length == 0 ? CHILD_A : children[0].getId());
+ row.setLoadingNo(LOADING_NO);
+ stubPage(1, row);
+ when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading()));
+ when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(children));
+ }
+
+ private void stubPage(long total, Waybill... rows) {
+ when(waybillService.page(any(), any())).thenAnswer(invocation -> {
+ Page page = invocation.getArgument(0);
+ page.setRecords(List.of(rows));
+ page.setTotal(total);
+ return page;
+ });
+ }
+
+ private TripVO onlyTrip() {
+ IPage page = service.page(TripQueryVO.forDriver(PHONE));
+ assertEquals(1, page.getRecords().size(), "本页应只有一个运次");
+ return page.getRecords().get(0);
+ }
+
+ private TripQueryVO detailQuery() {
+ TripQueryVO query = TripQueryVO.forDriver(PHONE);
+ query.setTripId(String.valueOf(LOADING_ID));
+ return query;
+ }
+
+ private LoadingManage loading() {
+ LoadingManage loading = new LoadingManage();
+ loading.setId(LOADING_ID);
+ loading.setLoadingNo(LOADING_NO);
+ loading.setDriverPhone(PHONE);
+ loading.setDriverName("黄宇富");
+ loading.setVehicleNo("桂A32523");
+ loading.setProjectName("武宣建筑原料运输项目");
+ return loading;
+ }
+
+ private Waybill child(Long id, String businessStatus, String acceptStatus, String processJson, Date createTime) {
+ Waybill waybill = new Waybill();
+ waybill.setId(id);
+ waybill.setWaybillNo("YD-" + id);
+ waybill.setLoadingNo(LOADING_NO);
+ waybill.setBusinessStatus(businessStatus);
+ waybill.setDriverAcceptStatus(acceptStatus);
+ waybill.setDriverPhone(PHONE);
+ waybill.setProcessJson(processJson);
+ waybill.setCreateTime(createTime);
+ return waybill;
+ }
+
+ private Date at(long millis) {
+ return new Date(millis);
+ }
+
+}