From ba2b0a3a8b2d13ec6e1b8c780da6a33385dd71de Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:10:25 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8B=E5=8F=91=E9=85=8D=E8=BD=BD=E5=8D=95=E3=80=81?= =?UTF-8?q?=E5=AD=90=E8=BF=90=E5=8D=95=E4=B8=8E=E5=8F=AF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=AB=99=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/vo/ExceptionDisposalVO.java | 37 ++ .../impl/ExceptionDisposalServiceImpl.java | 209 ++++++++++- .../ExceptionDisposalDetailPayloadTest.java | 325 ++++++++++++++++++ 3 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java index 77839038..d120aa54 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java @@ -30,6 +30,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; +import java.io.Serializable; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -93,4 +94,40 @@ public class ExceptionDisposalVO extends ExceptionDisposal { @Schema(description = "货物信息:{name, weight}") private Map cargo; + @TableField(exist = false) + @Schema(description = "关联运单的配载单号,没有配载单时为空") + private String loadingNo; + + @TableField(exist = false) + @Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列") + private List childWaybillNos; + + @TableField(exist = false) + @Schema(description = "可调整的路线站点,少于两个站时为空") + private List routeNodes; + + /** + * 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名 + */ + @Data + @Schema(description = "路线站点") + public static class RouteNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站点类型:start 首站、transit 中间站、end 末站") + private String kind; + + @Schema(description = "站点地址") + private String address; + + @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号") + private List tags; + + @Schema(description = "该站装卸的子运单项目名,按标记顺序去重") + private List projects; + + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 09bc49e0..14d0166d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -61,10 +61,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -605,8 +607,7 @@ public class ExceptionDisposalServiceImpl int childCount = childWaybillCount(loading.getWaybillIdsJson()); for (Station station : stations) { for (String tag : station.tags()) { - String digits = tag.replaceAll("\\D+", ""); - int order = Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + int order = tagOrder(tag); if (order < 1 || order > childCount) { throw new ServiceException("路线节点未关联子运单"); } @@ -631,7 +632,7 @@ public class ExceptionDisposalServiceImpl /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */ private List loadingStationAddresses(LoadingManage loading) { List addresses = new ArrayList<>(); - for (Map row : jsonObjectList(loading.getRouteJson())) { + for (Map row : loadingStationRows(loading)) { addAddress(addresses, text(row.get("address"))); } if (!addresses.isEmpty()) { @@ -978,7 +979,7 @@ public class ExceptionDisposalServiceImpl return vo; } - /** 按关联运单补齐详情页路线 / 货物展示字段 */ + /** 按关联运单补齐详情页路线 / 货物 / 配载单展示字段 */ private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { if (vo == null || waybillId == null) { return; @@ -996,6 +997,206 @@ public class ExceptionDisposalServiceImpl cargo.put("name", Func.toStr(waybill.getCargoName(), "")); cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); vo.setCargo(cargo); + + fillRouteNodes(vo, waybill); + } + + /** + * 详情页的可编辑站点与配载单信息。 + *

+ * 配载单是这趟车的执行主体,站点取它的站点行;运单没有配载单号、或配载单已被删除 / 解绑时退回 + * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 + */ + private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { + LoadingManage loading = findLoading(waybill.getLoadingNo()); + if (loading == null) { + vo.setChildWaybillNos(new ArrayList<>()); + vo.setRouteNodes(waybillRouteNodes(waybill)); + return; + } + Map childWaybills = childWaybillsByOrder(loading); + vo.setLoadingNo(loading.getLoadingNo()); + vo.setChildWaybillNos(childWaybillNos(childWaybills)); + List> rows = loadingStationRows(loading); + vo.setRouteNodes(loadingRouteNodes(rows, childWaybills)); + vo.setRoute(loadingRoute(rows, loading, waybill)); + } + + /** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */ + private List> loadingStationRows(LoadingManage loading) { + List> rows = new ArrayList<>(); + for (Map row : jsonObjectList(loading.getRouteJson())) { + if (Func.isNotBlank(text(row.get("address")))) { + rows.add(row); + } + } + return rows; + } + + private List loadingRouteNodes(List> rows, + Map childWaybills) { + List nodes = new ArrayList<>(rows.size()); + for (Map row : rows) { + List tags = stringList(row.get("tags")); + ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode(); + node.setAddress(text(row.get("address"))); + node.setTags(tags); + node.setProjects(stationProjects(tags, childWaybills)); + nodes.add(node); + } + return stationKinds(nodes); + } + + /** + * 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。 + * 地址口径与改路线时的比对一致(地址优先、站名兜底),否则用户什么都没改直接提交也会被判成改过地址。 + */ + private List waybillRouteNodes(Waybill waybill) { + List nodes = new ArrayList<>(); + for (String address : waybillStationAddresses(waybill)) { + if (Func.isBlank(address)) { + continue; + } + ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode(); + node.setAddress(address); + node.setTags(new ArrayList<>()); + node.setProjects(new ArrayList<>()); + nodes.add(node); + } + return stationKinds(nodes); + } + + /** 站序声明:首站 start、末站 end、其余 transit;不足两个站就没有首末可言,不给前端调整入口 */ + private List stationKinds(List nodes) { + if (nodes.size() < 2) { + return new ArrayList<>(); + } + for (int index = 0; index < nodes.size(); index++) { + nodes.get(index).setKind(index == 0 ? NODE_KIND_START + : (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT)); + } + return nodes; + } + + /** + * 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。 + * 标记对不上子运单(越界、子运单已不存在)时跳过,一个坏标记不该让整个详情打不开。 + */ + private List stationProjects(List tags, Map childWaybills) { + Set projects = new LinkedHashSet<>(); + for (String tag : tags) { + Waybill child = childWaybills.get(tagOrder(tag) - 1); + if (child == null) { + continue; + } + String projectName = Func.toStr(child.getProjectName(), "").trim(); + if (Func.isNotBlank(projectName)) { + projects.add(projectName); + } + } + return new ArrayList<>(projects); + } + + /** + * 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。 + * 位次不能因为某张子运单查不到而前移,否则标记会指到别的子运单上。 + */ + private Map childWaybillsByOrder(LoadingManage loading) { + Map childWaybills = new LinkedHashMap<>(); + List waybillIds = waybillIds(loading.getWaybillIdsJson()); + List queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList(); + if (queryIds.isEmpty()) { + return childWaybills; + } + Map waybillsById = new HashMap<>(); + List found = waybillService.listByIds(queryIds); + if (found != null) { + for (Waybill child : found) { + if (child != null && !Objects.equals(child.getIsDeleted(), 1)) { + waybillsById.put(child.getId(), child); + } + } + } + for (int order = 0; order < waybillIds.size(); order++) { + Waybill child = waybillsById.get(waybillIds.get(order)); + if (child != null) { + childWaybills.put(order, child); + } + } + return childWaybills; + } + + /** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */ + private List waybillIds(String waybillIdsJson) { + List waybillIds = new ArrayList<>(); + if (Func.isBlank(waybillIdsJson)) { + return waybillIds; + } + try { + Object parsed = JsonUtil.parse(waybillIdsJson, List.class); + if (parsed instanceof List list) { + for (Object item : list) { + long waybillId = Func.toLong(text(item), 0L); + waybillIds.add(waybillId > 0 ? waybillId : null); + } + } + } catch (Exception ignored) { + // 运单集合坏了就没有子运单可言,详情退回运单自己的路线,不影响打开 + return new ArrayList<>(); + } + return waybillIds; + } + + /** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */ + private List childWaybillNos(Map childWaybills) { + List childWaybillNos = new ArrayList<>(childWaybills.size()); + for (Waybill child : childWaybills.values()) { + String waybillNo = Func.toStr(child.getWaybillNo(), "").trim(); + if (Func.isNotBlank(waybillNo)) { + childWaybillNos.add(waybillNo); + } + } + return childWaybillNos; + } + + /** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */ + private Map loadingRoute(List> rows, LoadingManage loading, Waybill waybill) { + String start; + String end; + if (rows.isEmpty()) { + start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), ""); + end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), ""); + } else { + start = stationDisplayAddress(rows.get(0)); + end = stationDisplayAddress(rows.get(rows.size() - 1)); + } + Map route = new HashMap<>(2); + route.put("start", start); + route.put("end", end); + return route; + } + + private String stationDisplayAddress(Map row) { + return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), ""); + } + + /** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */ + private int tagOrder(String tag) { + String digits = Func.toStr(tag, "").replaceAll("\\D+", ""); + return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + } + + private List stringList(Object value) { + List texts = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + String text = text(item); + if (Func.isNotBlank(text)) { + texts.add(text); + } + } + } + return texts; } private String formatWeight(BigDecimal quantity, String unit) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java new file mode 100644 index 00000000..c28898c5 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -0,0 +1,325 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +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.MockedStatic; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; +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.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常详情下发的配载单、子运单与可编辑站点。 + *

+ * 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段, + * 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。 + */ +class ExceptionDisposalDetailPayloadTest { + + private static final Long DISPOSAL_ID = 9101L; + private static final Long WAYBILL_ID = 9201L; + private static final String LOADING_NO = "PZ20261023009"; + private static final String WAYBILL_NO = "YD202610230009"; + private static final Long CHILD_ID_FIRST = 9301L; + private static final Long CHILD_ID_SECOND = 9302L; + private static final String CHILD_NO_FIRST = "YD202610230011"; + private static final String CHILD_NO_SECOND = "YD202610230012"; + private static final String PROJECT_FIRST = "冷链项目"; + private static final String PROJECT_SECOND = "普货项目"; + private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园"; + private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园"; + private static final String END = "成都市/双流区/口岸路成藏宝物流园"; + private static final String WAYBILL_START_NAME = "南宁"; + private static final String WAYBILL_END_NAME = "成都"; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalFollowRecordMapper followRecordMapper; + private IWaybillService waybillService; + private LoadingManageMapper loadingManageMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposalFollowRecord.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); + waybillService = mock(IWaybillService.class); + loadingManageMapper = mock(LoadingManageMapper.class); + service = new ExceptionDisposalServiceImpl( + followRecordMapper, waybillService, mock(IDriverWaybillService.class), loadingManageMapper); + inject("baseMapper", disposalMapper); + } + + // ------------------------------------------------------------ 有配载单 + + @Test + @DisplayName("有配载单:下发配载单号、按运单集合顺序的子运单号,站点与项目取自站点行") + void loadingDetailPublishesChildWaybillsAndStationNodes() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, null, List.of("装1", "卸1")), + stationRow(TRANSIT, null, List.of("卸1", "装2")), + stationRow(END, null, List.of("卸2")))); + // 数据库不保证按主键集合的顺序返回:倒序回来,下发仍要按配载单运单集合的顺序 + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND), + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(LOADING_NO, vo.getLoadingNo()); + assertEquals(List.of(CHILD_NO_FIRST, CHILD_NO_SECOND), vo.getChildWaybillNos(), + "子运单号顺序就是站点标记里数字的顺序"); + + List nodes = vo.getRouteNodes(); + assertEquals(3, nodes.size(), "站点数与配载单路线一致"); + assertEquals(List.of("start", "transit", "end"), kinds(nodes)); + assertEquals(START, nodes.get(0).getAddress()); + assertEquals(TRANSIT, nodes.get(1).getAddress()); + assertEquals(END, nodes.get(2).getAddress()); + assertEquals(List.of("装1", "卸1"), nodes.get(0).getTags()); + assertEquals(List.of(PROJECT_FIRST), nodes.get(0).getProjects(), "同一站的同一项目只出现一次"); + assertEquals(List.of(PROJECT_FIRST, PROJECT_SECOND), nodes.get(1).getProjects(), + "标记按顺序映射到子运单"); + assertEquals(List.of(PROJECT_SECOND), nodes.get(2).getProjects()); + assertEquals(START, vo.getRoute().get("start")); + assertEquals(END, vo.getRoute().get("end")); + + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + verify(loadingManageMapper, never()).updateById(any(LoadingManage.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("路线摘要用站点行的展示地址,站点地址仍取地址列") + void routeSummaryPrefersDisplayAddress() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, START + "(展示)", List.of("装1")), + stationRow(END, null, List.of("卸2")))); + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST), + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(START + "(展示)", vo.getRoute().get("start"), "摘要展示用站点行的展示地址"); + assertEquals(END, vo.getRoute().get("end")); + assertEquals(START, vo.getRouteNodes().get(0).getAddress(), + "站点地址是改路线时比对用的地址列,不能换成展示地址"); + } + + @Test + @DisplayName("只有一个站时不给出可编辑站点") + void singleStationGetsNoRouteNodes() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of(stationRow(START, null, List.of("装1")))); + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST))); + + ExceptionDisposalVO vo = detailResponse(); + + assertTrue(vo.getRouteNodes().isEmpty(), "没有首末站就没有可调整的站序"); + } + + @Test + @DisplayName("子运单查不到时跳过,不打乱其余子运单的顺序") + void missingChildWaybillIsSkipped() { + stubDisposal(); + stubWaybill(LOADING_NO, null); + stubLoading(List.of( + stationRow(START, null, List.of("装1")), + stationRow(TRANSIT, null, List.of("卸1", "装2")), + stationRow(END, null, List.of("卸2")))); + // 第一张子运单已不在(被删或运单集合与运单实际不一致),只剩第二张 + when(waybillService.listByIds(any())).thenReturn(List.of( + child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND))); + + ExceptionDisposalVO vo = detailResponse(); + + assertEquals(List.of(CHILD_NO_SECOND), vo.getChildWaybillNos()); + assertEquals(3, vo.getRouteNodes().size(), "站点不因子运单缺失而消失"); + assertTrue(vo.getRouteNodes().get(0).getProjects().isEmpty(), "找不到子运单的标记给空项目"); + assertEquals(List.of(PROJECT_SECOND), vo.getRouteNodes().get(1).getProjects()); + } + + // ------------------------------------------------------------ 无配载单 + + @Test + @DisplayName("配载单已不存在时退回运单路线段,历史异常照常打开") + void missingLoadingFallsBackToWaybillRouteSegments() { + stubDisposal(); + // 运单还挂着配载单号,配载单已被删除或解绑:按没有配载单处理 + stubWaybill("PZ202610230404", segmentRouteJson()); + + ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse); + + assertNull(vo.getLoadingNo()); + assertTrue(vo.getChildWaybillNos().isEmpty()); + List nodes = vo.getRouteNodes(); + assertEquals(3, nodes.size(), "首段出发地是首站,其后每段到达地各一站"); + assertEquals(List.of("start", "transit", "end"), kinds(nodes)); + assertEquals(START, nodes.get(0).getAddress()); + assertEquals(List.of(), nodes.get(0).getTags(), "运单路线段没有装卸归属"); + assertEquals(List.of(), nodes.get(0).getProjects()); + assertEquals(WAYBILL_START_NAME, vo.getRoute().get("start"), "无配载单时摘要维持运单起讫名"); + assertEquals(WAYBILL_END_NAME, vo.getRoute().get("end")); + } + + @Test + @DisplayName("无配载单的站点地址地址优先、站名兜底,与改路线时的比对口径一致") + void waybillNodesPreferAddressOverName() { + stubDisposal(); + stubWaybill(null, JsonUtil.toJson(List.of( + segment(WAYBILL_START_NAME, START, "钦州", TRANSIT), + segment("钦州", TRANSIT, "贵阳", null)))); + + ExceptionDisposalVO vo = detailResponse(); + + List nodes = vo.getRouteNodes(); + assertEquals(List.of(START, TRANSIT, "贵阳"), + nodes.stream().map(ExceptionDisposalVO.RouteNode::getAddress).toList(), + "原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底"); + } + + // ------------------------------------------------------------ 夹具 + + private void stubDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("pending"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + } + + private void stubWaybill(String loadingNo, String routeJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setLoadingNo(loadingNo); + waybill.setRouteJson(routeJson); + waybill.setDepartureName(WAYBILL_START_NAME); + waybill.setArrivalName(WAYBILL_END_NAME); + waybill.setIsDeleted(0); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + } + + private void stubLoading(List> rows) { + LoadingManage loading = new LoadingManage(); + loading.setId(9401L); + loading.setLoadingNo(LOADING_NO); + loading.setWaybillIdsJson("[" + CHILD_ID_FIRST + "," + CHILD_ID_SECOND + "]"); + loading.setRouteJson(JsonUtil.toJson(rows)); + loading.setIsDeleted(0); + when(loadingManageMapper.selectOne(any())).thenReturn(loading); + } + + private Waybill child(Long id, String waybillNo, String projectName) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(waybillNo); + waybill.setProjectName(projectName); + waybill.setIsDeleted(0); + return waybill; + } + + private Map stationRow(String address, String displayAddress, List tags) { + Map row = new LinkedHashMap<>(); + row.put("address", address); + if (displayAddress != null) { + row.put("displayAddress", displayAddress); + } + row.put("tags", tags); + row.put("locked", false); + return row; + } + + /** 运单的路线段:两段三站,带地址的段 */ + private String segmentRouteJson() { + return JsonUtil.toJson(List.of( + segment(WAYBILL_START_NAME, START, TRANSIT, TRANSIT), + segment(TRANSIT, TRANSIT, WAYBILL_END_NAME, END))); + } + + private Map segment(String departureName, String departureAddress, + String arrivalName, String arrivalAddress) { + Map segment = new LinkedHashMap<>(); + segment.put("departureName", departureName); + segment.put("departureAddress", departureAddress); + segment.put("arrivalName", arrivalName); + if (arrivalAddress != null) { + segment.put("arrivalAddress", arrivalAddress); + } + return segment; + } + + private List kinds(List nodes) { + return nodes.stream().map(ExceptionDisposalVO.RouteNode::getKind).toList(); + } + + private ExceptionDisposalVO detailResponse() { + // 详情里的创建人 / 更新人姓名走缓存,单测不装配 Spring 上下文 + try (MockedStatic ignored = mockStatic(UserDisplayCache.class)) { + return service.detail(DISPOSAL_ID); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +}