diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java
new file mode 100644
index 00000000..b8a78404
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java
@@ -0,0 +1,78 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 异常处置「同意并调整路线」请求
+ *
+ * 站点按数组顺序即途经顺序回传,kind 是站序的声明:首个 start、末个 end、其余 transit。
+ * 每个站点上的 tags 指向配载单里第 N 张子运单(形如「装1」「卸2」),后端据此落成站点行;
+ * 没有配载单的运单没有子运单归属,tags 不参与落库。
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "异常处置调整路线请求")
+public class ExceptionDisposalRouteAdjustRequest implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "异常处置ID")
+ private Long id;
+
+ @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;
+
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
index 01ae89e1..2b42643a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
@@ -34,6 +34,7 @@ import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -131,4 +132,12 @@ public class ExceptionDisposalController extends BladeController {
return R.success("已撤销");
}
+ @PostMapping("/route-adjust")
+ @ApiOperationSupport(order = 10)
+ @Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态")
+ public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) {
+ exceptionDisposalService.adjustRoute(request);
+ return R.success("已调整路线");
+ }
+
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
index ed63c310..de58cb2a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
@@ -26,6 +26,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -68,4 +69,10 @@ public interface IExceptionDisposalService extends BaseService stations = validateRouteNodes(request.getRouteNodes(), loading);
+ List sourceAddresses = loading != null
+ ? loadingStationAddresses(loading)
+ : waybillStationAddresses(waybill);
+ List targetAddresses = stations.stream().map(Station::address).toList();
+ // 首站地址只读;原数据里首站地址缺失时无从比对,放行而不是拒绝整次调整
+ if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0))
+ && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) {
+ throw new ServiceException("起始地址不允许修改");
+ }
+ String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus();
+ if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) {
+ throw new ServiceException("当前状态不允许变更运输路线");
+ }
+ RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses);
+
+ // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态。
+ // 少了任何一步都读不出「谁在什么时候把路线改成了什么」,所以任一步失败都整体回滚
+ insertFollowRecord(disposal.getId(), plan.followContent());
+ if (loading != null) {
+ applyLoadingRoute(loading, plan);
+ } else {
+ applyWaybillRoute(waybill, plan);
+ }
+ disposal.setDisposalStatus(STATUS_COMPLETED);
+ disposal.setLatestFollowContent(plan.followContent());
+ updateById(disposal);
+ }
+
+ /** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */
+ private void insertFollowRecord(Long disposalId, String content) {
+ ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
+ record.setDisposalId(disposalId);
+ record.setFollowContent(content);
+ record.setFollowUser(AuthUtil.getUserId());
+ record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
+ record.setFollowTime(LocalDateTime.now());
+ followRecordMapper.insert(record);
+ }
+
+ /** 配载单按配载单号取,逻辑删除的视为没有 */
+ private LoadingManage findLoading(String loadingNo) {
+ if (Func.isBlank(loadingNo)) {
+ return null;
+ }
+ return loadingManageMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(LoadingManage::getLoadingNo, loadingNo.trim())
+ .eq(LoadingManage::getIsDeleted, 0)
+ .last("LIMIT 1"));
+ }
+
+ /**
+ * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。
+ *
+ * kind 不额外接受「按位置猜」的写法:站序就是落库顺序,kind 与位置不一致时写下去的首末站地址会是错的。
+ */
+ private List validateRouteNodes(List routeNodes,
+ LoadingManage loading) {
+ if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) {
+ throw new ServiceException("路线节点至少需要两个");
+ }
+ List stations = new ArrayList<>(routeNodes.size());
+ for (int index = 0; index < routeNodes.size(); index++) {
+ ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index);
+ if (node == null) {
+ throw new ServiceException("请完善路线节点");
+ }
+ String address = Func.toStr(node.getAddress(), "").trim();
+ if (Func.isBlank(address)) {
+ throw new ServiceException("站点地址不能为空");
+ }
+ String expectedKind = index == 0
+ ? NODE_KIND_START
+ : (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT);
+ if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) {
+ throw new ServiceException("路线节点类型不正确");
+ }
+ stations.add(new Station(address, normalizedTags(node.getTags())));
+ }
+ assertTagsMatchChildWaybills(stations, loading);
+ return stations;
+ }
+
+ private List normalizedTags(List tags) {
+ List normalized = new ArrayList<>();
+ if (tags == null) {
+ return normalized;
+ }
+ for (String tag : tags) {
+ String text = Func.toStr(tag, "").trim();
+ if (Func.isNotBlank(text)) {
+ normalized.add(text);
+ }
+ }
+ return normalized;
+ }
+
+ /**
+ * 站点标记里的数字是配载单运单集合里的位次(1 起),越界的标记会让司机端找不到对应子运单,
+ * 那样的路线读不出装卸与货物,宁可报错也不落库。
+ *
+ * 没有配载单就没有子运单序号可言:运单的路线段没有装卸归属,站点标记不参与落库。
+ */
+ private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) {
+ if (loading == null) {
+ return;
+ }
+ 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);
+ if (order < 1 || order > childCount) {
+ throw new ServiceException("路线节点未关联子运单");
+ }
+ }
+ }
+ }
+
+ /** 配载单运单集合的元素个数,即站点标记可用的位次上限 */
+ private int childWaybillCount(String waybillIdsJson) {
+ if (Func.isBlank(waybillIdsJson)) {
+ return 0;
+ }
+ try {
+ List> waybillIds = JsonUtil.parse(waybillIdsJson, List.class);
+ return waybillIds == null ? 0 : waybillIds.size();
+ } catch (Exception ignored) {
+ // 运单集合不是 JSON 数组时按没有子运单处理,站点标记一律对不上
+ return 0;
+ }
+ }
+
+ /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */
+ private List loadingStationAddresses(LoadingManage loading) {
+ List addresses = new ArrayList<>();
+ for (Map row : jsonObjectList(loading.getRouteJson())) {
+ addAddress(addresses, text(row.get("address")));
+ }
+ if (!addresses.isEmpty()) {
+ return addresses;
+ }
+ return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress());
+ }
+
+ /**
+ * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。
+ * 段里缺地址时退化用段名——运单详情与调整页用的也是这两列。
+ */
+ private List waybillStationAddresses(Waybill waybill) {
+ List> segments = routeSegments(waybill.getRouteJson());
+ if (segments.isEmpty()) {
+ return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress());
+ }
+ List addresses = new ArrayList<>(segments.size() + 1);
+ Map firstSegment = segments.get(0);
+ addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")),
+ text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), "")));
+ for (Map segment : segments) {
+ addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), ""));
+ }
+ return addresses;
+ }
+
+ /** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */
+ private List addressColumns(String departureAddress, String transitAddress, String arrivalAddress) {
+ List addresses = new ArrayList<>(3);
+ addAddress(addresses, departureAddress);
+ if (Func.isNotBlank(transitAddress)) {
+ for (String transit : transitAddress.split("[,,]")) {
+ addAddress(addresses, transit.trim());
+ }
+ }
+ addAddress(addresses, arrivalAddress);
+ return addresses;
+ }
+
+ private void addAddress(List addresses, String address) {
+ if (Func.isNotBlank(address)) {
+ addresses.add(address.trim());
+ }
+ }
+
+ /**
+ * 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。
+ * 长度口径与 PC 改路线一致。
+ */
+ private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List stations,
+ List sourceAddresses, List targetAddresses) {
+ String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses));
+ String departureAddress = targetAddresses.get(0);
+ String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1);
+ String routeJson;
+ String transitAddress = null;
+ String sourceTaskInfoJson;
+ if (loading != null) {
+ routeJson = stationRowsJson(stations);
+ transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream()
+ .collect(Collectors.joining(TRANSIT_ADDRESS_JOINER));
+ sourceTaskInfoJson = loading.getTaskInfoJson();
+ } else {
+ // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,绝不整份换成站点行。
+ // 没变就一个键都不动,避免把历史数据里缺省的字段顺手补齐成另一份结构。
+ routeJson = sourceAddresses.equals(targetAddresses)
+ ? null
+ : rewriteWaybillRouteSegments(waybill.getRouteJson(), targetAddresses);
+ sourceTaskInfoJson = waybill.getTaskInfoJson();
+ }
+ String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent);
+
+ TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字");
+ TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字");
+ TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字");
+ TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字");
+ return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress,
+ arrivalAddress, taskInfoJson);
+ }
+
+ /**
+ * 同一份结论既进跟进记录,也进 task_info_json 的变更记录:
+ * 首末站地址变了逐条写「出发地 / 到货地 旧 → 新」,否则说明途经站变化,完全没变给固定的一句。
+ */
+ private String routeChangeContent(List sourceAddresses, List targetAddresses) {
+ if (sourceAddresses.equals(targetAddresses)) {
+ return ROUTE_CHANGE_UNCHANGED;
+ }
+ List parts = new ArrayList<>(2);
+ String sourceStart = firstOf(sourceAddresses);
+ String targetStart = firstOf(targetAddresses);
+ if (!Objects.equals(sourceStart, targetStart)) {
+ parts.add("出发地 " + displayAddress(sourceStart) + " → " + displayAddress(targetStart));
+ }
+ String sourceEnd = lastOf(sourceAddresses);
+ String targetEnd = lastOf(targetAddresses);
+ if (!Objects.equals(sourceEnd, targetEnd)) {
+ parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd));
+ }
+ if (parts.isEmpty()) {
+ // 首末站都没动,变化只能落在途经站:地址细节塞不进 500 字的跟进记录,按站数说明
+ parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个");
+ }
+ return ROUTE_CHANGE_PREFIX + String.join(";", parts);
+ }
+
+ private String firstOf(List addresses) {
+ return addresses.isEmpty() ? "" : addresses.get(0);
+ }
+
+ private String lastOf(List addresses) {
+ return addresses.isEmpty() ? "" : addresses.get(addresses.size() - 1);
+ }
+
+ private int transitCount(List addresses) {
+ return Math.max(addresses.size() - 2, 0);
+ }
+
+ private String displayAddress(String address) {
+ return Func.isBlank(address) ? "-" : address;
+ }
+
+ /** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */
+ private String stationRowsJson(List stations) {
+ List> rows = new ArrayList<>(stations.size());
+ for (Station station : stations) {
+ Map row = new LinkedHashMap<>(4);
+ row.put("address", station.address());
+ // 站点行没有独立的站名,展示地址与地址取同一份文本
+ row.put("displayAddress", station.address());
+ row.put("tags", station.tags());
+ // locked 只影响 PC 编辑弹窗能否拖动,司机端不读;这里写入的路线始终可继续调整
+ row.put("locked", false);
+ rows.add(row);
+ }
+ return JsonUtil.toJson(rows);
+ }
+
+ /**
+ * 按路线段改写运单路线:第 i 段的到达地对应第 i+1 站,首段出发地对应首站。
+ * 其它键(segmentNo、transportType、未知键)原样保留。
+ *
+ * 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写:
+ * 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程;
+ * 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。
+ */
+ private String rewriteWaybillRouteSegments(String routeJson, List targetAddresses) {
+ List> segments = routeSegments(routeJson);
+ if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) {
+ return null;
+ }
+ List> rewritten = new ArrayList<>(segments.size());
+ for (int index = 0; index < segments.size(); index++) {
+ Map segment = new LinkedHashMap<>(segments.get(index));
+ rewriteSegmentPoint(segment, "departure", index == 0 ? targetAddresses.get(0) : null);
+ rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
+ rewritten.add(segment);
+ }
+ return JsonUtil.toJson(rewritten);
+ }
+
+ private void rewriteSegmentPoint(Map segment, String point, String targetAddress) {
+ if (targetAddress == null) {
+ return;
+ }
+ String currentAddress = firstNonBlank(text(segment.get(point + "Address")),
+ text(segment.get(point + "Name")), "");
+ if (Objects.equals(currentAddress, targetAddress)) {
+ return;
+ }
+ // 站名与地址是同一份文本:调整页只有一个地址输入框,改了地址站名也得跟着变
+ segment.put(point + "Address", targetAddress);
+ segment.put(point + "Name", targetAddress);
+ }
+
+ /**
+ * 识别路线段形状的 route_json。运单历史上存在两种形状,另一种是 PC 写下的节点行
+ * (address + tags + 运单号),司机端按段解析读不出来,因此这里只认段:元素带段字段、
+ * 且不带 address / tags。
+ */
+ private List> routeSegments(String routeJson) {
+ List> elements = jsonObjectList(routeJson);
+ for (Map element : elements) {
+ if (element.containsKey("address") || element.containsKey("tags")) {
+ return new ArrayList<>();
+ }
+ if (!element.containsKey("departureAddress") && !element.containsKey("departureName")
+ && !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) {
+ return new ArrayList<>();
+ }
+ }
+ return elements;
+ }
+
+ /**
+ * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。
+ * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建,只丢这一列的无法解析部分,
+ * 不能让一次路线调整因为附件信息列而失败。
+ */
+ private String appendRouteChangeRecord(String taskInfoJson, String content) {
+ Map taskInfo = parseTaskInfo(taskInfoJson);
+ List records = new ArrayList<>();
+ Map record = new LinkedHashMap<>(4);
+ record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now()));
+ record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
+ record.put("content", content);
+ record.put("remark", "");
+ records.add(record);
+ Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS);
+ if (existing instanceof List> existingRecords) {
+ records.addAll(existingRecords);
+ }
+ taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records);
+ return JsonUtil.toJson(taskInfo);
+ }
+
+ /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建,这一列不能挡住整次路线调整 */
+ private Map parseTaskInfo(String taskInfoJson) {
+ Map taskInfo = new LinkedHashMap<>();
+ if (Func.isBlank(taskInfoJson)) {
+ return taskInfo;
+ }
+ try {
+ Object parsed = JsonUtil.parse(taskInfoJson, Object.class);
+ if (parsed instanceof Map, ?> map) {
+ map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value));
+ }
+ } catch (Exception ignored) {
+ taskInfo.clear();
+ }
+ return taskInfo;
+ }
+
+ private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) {
+ loading.setRouteJson(plan.routeJson());
+ loading.setDepartureAddress(plan.departureAddress());
+ loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress()));
+ loading.setArrivalAddress(plan.arrivalAddress());
+ loading.setTaskInfoJson(plan.taskInfoJson());
+ if (loadingManageMapper.updateById(loading) <= 0) {
+ throw new ServiceException("变更运输路线失败");
+ }
+ }
+
+ private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) {
+ if (Func.isNotBlank(plan.routeJson())) {
+ waybill.setRouteJson(plan.routeJson());
+ }
+ waybill.setDepartureAddress(plan.departureAddress());
+ waybill.setArrivalAddress(plan.arrivalAddress());
+ waybill.setTaskInfoJson(plan.taskInfoJson());
+ if (!waybillService.updateById(waybill)) {
+ throw new ServiceException("变更运输路线失败");
+ }
+ }
+
+ /** 解析成对象数组;坏 JSON 与空值都按空数组处理,路线坏了不该让整次操作崩掉 */
+ private List> jsonObjectList(String json) {
+ List> result = new ArrayList<>();
+ if (Func.isBlank(json)) {
+ return result;
+ }
+ try {
+ Object parsed = JsonUtil.parse(json, List.class);
+ if (parsed instanceof List> list) {
+ for (Object item : list) {
+ if (item instanceof Map, ?> map) {
+ Map element = new LinkedHashMap<>();
+ map.forEach((key, value) -> element.put(String.valueOf(key), value));
+ result.add(element);
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ return new ArrayList<>();
+ }
+ return result;
+ }
+
+ private String text(Object value) {
+ return value == null ? "" : String.valueOf(value).trim();
+ }
+
+ private String firstNonBlank(String first, String second, String fallback) {
+ if (Func.isNotBlank(first)) {
+ return first;
+ }
+ return Func.isNotBlank(second) ? second : fallback;
+ }
+
+ /** 规范化后的一个站点:地址 + 该站的装卸标记 */
+ private record Station(String address, List tags) {
+ }
+
+ /** 一次路线调整要落库的全部内容,写入阶段只负责 set 与 update */
+ private record RouteChangePlan(String followContent,
+ String routeJson,
+ String departureAddress,
+ String transitAddress,
+ String arrivalAddress,
+ String taskInfoJson) {
+ }
+
private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) {
LambdaQueryWrapper wrapper = Wrappers.lambdaQuery()
.eq(ExceptionDisposal::getIsDeleted, 0)
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java
index 9d0dd4a1..a00ec202 100644
--- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java
@@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IDriverWaybillService;
@@ -55,7 +56,8 @@ class ExceptionDisposalListQueryTest {
service = new ExceptionDisposalServiceImpl(
mock(ExceptionDisposalFollowRecordMapper.class),
mock(IWaybillService.class),
- mock(IDriverWaybillService.class));
+ mock(IDriverWaybillService.class),
+ mock(LoadingManageMapper.class));
inject("baseMapper", disposalMapper);
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10));
}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java
index 5b9384f6..cdd36344 100644
--- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java
@@ -14,6 +14,7 @@ import org.springblade.core.secure.utils.AuthUtil;
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.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
@@ -65,7 +66,8 @@ class ExceptionDisposalRejectRevokeTest {
disposalMapper = mock(ExceptionDisposalMapper.class);
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
service = new ExceptionDisposalServiceImpl(
- followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class));
+ followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class),
+ mock(LoadingManageMapper.class));
inject("baseMapper", disposalMapper);
when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1);
when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1);
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java
index 4a64ae2a..1b13ec03 100644
--- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java
@@ -15,6 +15,7 @@ import org.springblade.core.secure.utils.AuthUtil;
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.Waybill;
import org.springblade.transport.service.IDriverWaybillService;
@@ -71,7 +72,8 @@ class ExceptionDisposalReportGateTest {
waybillService = mock(IWaybillService.class);
driverWaybillService = mock(IDriverWaybillService.class);
service = new ExceptionDisposalServiceImpl(
- mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService);
+ mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService,
+ mock(LoadingManageMapper.class));
inject("baseMapper", disposalMapper);
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java
new file mode 100644
index 00000000..62780eaa
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java
@@ -0,0 +1,551 @@
+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.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.secure.utils.AuthUtil;
+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.dto.ExceptionDisposalRouteAdjustRequest;
+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.service.IDriverWaybillService;
+import org.springblade.transport.service.IWaybillService;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+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;
+
+/**
+ * 同意并调整路线:先写跟进记录、再改载体路线、最后了结。
+ *
+ * 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单,
+ * 而运单的 route_json 是司机端按段解析的形状,改成站点行就读不出路线了。
+ */
+class ExceptionDisposalRouteAdjustTest {
+
+ private static final Long DISPOSAL_ID = 8201L;
+ private static final Long WAYBILL_ID = 8301L;
+ private static final Long USER_ID = 8401L;
+ private static final String LOADING_NO = "PZ20261023001";
+ private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园";
+ private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园";
+ private static final String TRANSIT_SECOND = "贵阳市/白云区/都拉营物流园";
+ private static final String END = "成都市/双流区/口岸路成藏宝物流园";
+ private static final String NEW_END = "成都市/新都区/物流大道";
+
+ 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, 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);
+ when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1);
+ when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1);
+ when(loadingManageMapper.updateById(any(LoadingManage.class))).thenReturn(1);
+ when(waybillService.updateById(any(Waybill.class))).thenReturn(true);
+ }
+
+ // ------------------------------------------------------------ 有配载单
+
+ @Test
+ @DisplayName("有配载单:路线整份写成站点行、地址列同步、变更记录前插、异常了结")
+ void adjustRouteOnLoadingWritesStationRows() {
+ stubPendingDisposal();
+ LoadingManage loading = loading("in_progress",
+ stationRowsJson(List.of(
+ station(START, List.of("装1")),
+ station(TRANSIT, List.of("卸1")),
+ station(END, List.of("卸2")))));
+ loading.setTaskInfoJson("{\"planName\":\"干线一\",\"routeChangeRecords\":[{\"content\":\"旧记录\"}]}");
+ stubLoading(loading);
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("transit", TRANSIT_SECOND, List.of("卸1")),
+ node("transit", TRANSIT, List.of("装2")),
+ node("end", END, List.of("卸2")))));
+
+ LoadingManage updated = updatedLoading();
+ List> rows = JsonUtil.readListMap(updated.getRouteJson());
+ assertEquals(4, rows.size(), "路线整份替换成调整后的站点");
+ assertEquals(START, rows.get(0).get("address"));
+ assertEquals(START, rows.get(0).get("displayAddress"), "站点行没有独立站名,展示地址就是地址");
+ assertEquals(List.of("装1"), rows.get(0).get("tags"));
+ assertEquals(List.of("卸1"), rows.get(1).get("tags"), "站点上的子运单标记必须原样保留");
+ assertEquals(List.of("装2"), rows.get(2).get("tags"));
+ assertEquals(Boolean.FALSE, rows.get(0).get("locked"));
+
+ assertEquals(START, updated.getDepartureAddress());
+ assertEquals(END, updated.getArrivalAddress());
+ assertEquals(TRANSIT_SECOND + "," + TRANSIT, updated.getTransitAddress(),
+ "中间站地址用中文逗号连接");
+
+ Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
+ assertEquals("干线一", taskInfo.get("planName"), "任务信息里其它键不能丢");
+ List> records = (List>) taskInfo.get("routeChangeRecords");
+ assertEquals(2, records.size());
+ Map, ?> latest = (Map, ?>) records.get(0);
+ assertEquals("韦调度", latest.get("changeUser"), "变更记录的操作人是真实姓名");
+ assertEquals("", latest.get("remark"));
+ String changeTime = String.valueOf(latest.get("changeTime"));
+ assertTrue(changeTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), changeTime);
+ assertEquals("同意调整路线:途经站由 1 个改为 2 个", latest.get("content"));
+ assertEquals("旧记录", ((Map, ?>) records.get(1)).get("content"), "已有记录排在新的后面");
+
+ ExceptionDisposalFollowRecord record = insertedRecord();
+ assertEquals("同意调整路线:途经站由 1 个改为 2 个", record.getFollowContent());
+ assertEquals(USER_ID, record.getFollowUser());
+ assertEquals("韦调度", record.getFollowUserName());
+ assertNotNull(record.getFollowTime());
+
+ ExceptionDisposal settled = updatedDisposal();
+ assertEquals("completed", settled.getDisposalStatus());
+ assertEquals(record.getFollowContent(), settled.getLatestFollowContent());
+
+ InOrder order = inOrder(followRecordMapper, loadingManageMapper, disposalMapper);
+ order.verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class));
+ order.verify(loadingManageMapper).updateById(any(LoadingManage.class));
+ order.verify(disposalMapper).updateById(any(ExceptionDisposal.class));
+ verify(waybillService, never()).updateById(any(Waybill.class));
+ }
+
+ @Test
+ @DisplayName("地址一个都没改也允许了结,记录写「同意调整路线,未修改路线」")
+ void adjustRouteWithoutAddressChangeStillSettles() {
+ stubPendingDisposal();
+ stubLoading(loading("in_progress",
+ stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("end", END, List.of("卸1")))));
+
+ assertEquals("同意调整路线,未修改路线", insertedRecord().getFollowContent());
+ LoadingManage updated = updatedLoading();
+ Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
+ Map, ?> latest = (Map, ?>) ((List>) taskInfo.get("routeChangeRecords")).get(0);
+ assertEquals("同意调整路线,未修改路线", latest.get("content"), "变更记录与跟进记录同一句摘要");
+ assertEquals("completed", updatedDisposal().getDisposalStatus());
+ }
+
+ @Test
+ @DisplayName("首站地址被改动时拒绝,且不写任何记录、不改状态")
+ void adjustRouteRejectsStartAddressChange() {
+ stubPendingDisposal();
+ stubLoading(loading("in_progress",
+ stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("start", START + "(改)", List.of("装1")),
+ node("end", END, List.of("卸1"))))));
+
+ assertEquals("起始地址不允许修改", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ @Test
+ @DisplayName("站点标记的数字超出子运单序号时拒绝,不写一份司机端读不出装卸的路线")
+ void adjustRouteRejectsTagOutOfChildRange() {
+ stubPendingDisposal();
+ LoadingManage loading = loading("in_progress",
+ stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))));
+ loading.setWaybillIdsJson("[9001,9002]");
+ stubLoading(loading);
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("end", END, List.of("卸3"))))));
+
+ assertEquals("路线节点未关联子运单", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ @Test
+ @DisplayName("已完成的配载单不允许再改路线")
+ void adjustRouteRejectsCompletedLoading() {
+ stubPendingDisposal();
+ stubLoading(loading("completed",
+ stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("end", NEW_END, List.of("卸1"))))));
+
+ assertEquals("当前状态不允许变更运输路线", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ // ------------------------------------------------------------ 节点校验
+
+ @Test
+ @DisplayName("少于两个站点的路线不是一条路线")
+ void adjustRouteRejectsSingleStation() {
+ stubPendingDisposal();
+ stubLoading(loading("in_progress", null));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(node("start", START, List.of("装1"))))));
+
+ assertEquals("路线节点至少需要两个", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ @Test
+ @DisplayName("站点类型与站序不一致时拒绝:首站必须 start、末站必须 end")
+ void adjustRouteRejectsWrongNodeKind() {
+ stubPendingDisposal();
+ stubLoading(loading("in_progress", null));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("transit", START, List.of("装1")),
+ node("end", END, List.of("卸1"))))));
+
+ assertEquals("路线节点类型不正确", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ @Test
+ @DisplayName("已了结的异常再调整路线报「该异常已处理」")
+ void adjustRouteRejectsSettledDisposal() {
+ ExceptionDisposal disposal = new ExceptionDisposal();
+ disposal.setId(DISPOSAL_ID);
+ disposal.setDisposalStatus("completed");
+ disposal.setWaybillId(WAYBILL_ID);
+ disposal.setIsDeleted(0);
+ when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("end", NEW_END, List.of("卸1"))))));
+
+ assertEquals("该异常已处理", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ // ------------------------------------------------------------ 未配载运单
+
+ @Test
+ @DisplayName("未配载的运单只按段改到达地,route_json 仍是司机端能解析的段数组")
+ void adjustRouteOnWaybillKeepsSegmentShape() {
+ stubPendingDisposal();
+ Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, null),
+ node("transit", TRANSIT, null),
+ node("end", NEW_END, null))));
+
+ Waybill updated = updatedWaybill();
+ List> segments = JsonUtil.readListMap(updated.getRouteJson());
+ assertEquals(2, segments.size(), "段数不变,不能改成站点行");
+ assertFalse(segments.get(0).containsKey("address"), "运单路线不得出现站点行的 address 键");
+ assertFalse(segments.get(0).containsKey("tags"), "运单路线不得出现站点行的 tags 键");
+ assertEquals(TRANSIT, segments.get(0).get("arrivalAddress"), "没改的段原样保留");
+ assertEquals(1, ((Number) segments.get(0).get("segmentNo")).intValue());
+ assertEquals("road", segments.get(0).get("transportType"), "未知键与业务键都要原样保留");
+ assertEquals(NEW_END, segments.get(1).get("arrivalAddress"));
+ assertEquals(NEW_END, segments.get(1).get("arrivalName"), "站名跟着地址一起改");
+ assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改");
+
+ assertEquals(START, updated.getDepartureAddress());
+ assertEquals(NEW_END, updated.getArrivalAddress());
+ Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
+ Map, ?> latest = (Map, ?>) ((List>) taskInfo.get("routeChangeRecords")).get(0);
+ assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, latest.get("content"));
+ assertEquals("completed", updatedDisposal().getDisposalStatus());
+ verify(loadingManageMapper, never()).selectOne(any());
+ }
+
+ @Test
+ @DisplayName("运单已关联配载单但配载单查不到时拒绝,不越权改运单")
+ void adjustRouteRejectsWaybillBranchWhenLoadingNoPresent() {
+ stubPendingDisposal();
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", segmentRouteJson()));
+ when(loadingManageMapper.selectOne(any())).thenReturn(null);
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
+ () -> service.adjustRoute(request(
+ node("start", START, null),
+ node("end", NEW_END, null)))));
+
+ assertEquals("运单已关联配载单,请在配载单中修改", exception.getMessage());
+ verifyNoWrite();
+ }
+
+ @Test
+ @DisplayName("运单路线是 PC 写下的节点行形状时跳过路线改写,仍同步地址列并照常留痕")
+ void adjustRouteKeepsLegacyNodeRowWaybillRoute() {
+ stubPendingDisposal();
+ String legacyRouteJson = JsonUtil.toJson(List.of(
+ Map.of("address", START, "tags", List.of("发", "YD001")),
+ Map.of("address", END, "tags", List.of("收", "YD001"))));
+ Waybill waybill = waybill(null, "in_progress", legacyRouteJson);
+ waybill.setDepartureAddress(START);
+ waybill.setArrivalAddress(END);
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, null),
+ node("end", NEW_END, null))));
+
+ Waybill updated = updatedWaybill();
+ assertEquals(legacyRouteJson, updated.getRouteJson(), "认不出的路线形状不能瞎写");
+ assertEquals(START, updated.getDepartureAddress());
+ assertEquals(NEW_END, updated.getArrivalAddress());
+ assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, insertedRecord().getFollowContent());
+ assertEquals("completed", updatedDisposal().getDisposalStatus());
+ }
+
+ @Test
+ @DisplayName("运单站数变了时不动路线:段形状说不出哪一站被删,硬写等于重写行程")
+ void adjustRouteSkipsWaybillRouteWhenStationCountChanges() {
+ stubPendingDisposal();
+ Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, null),
+ node("end", NEW_END, null))));
+
+ Waybill updated = updatedWaybill();
+ assertEquals(segmentRouteJson(), updated.getRouteJson(), "站数对不上时保留原路线");
+ assertEquals(NEW_END, updated.getArrivalAddress());
+ assertEquals("completed", updatedDisposal().getDisposalStatus());
+ }
+
+ @Test
+ @DisplayName("目的地未变而只见中间站变化时,按途经站数量说明")
+ void adjustRouteReportsTransitCountChange() {
+ stubPendingDisposal();
+ stubLoading(loading("in_progress",
+ stationRowsJson(List.of(
+ station(START, List.of("装1")),
+ station(TRANSIT, List.of("卸1")),
+ station(END, List.of("卸2"))))));
+ when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
+
+ asScheduler(() -> service.adjustRoute(request(
+ node("start", START, List.of("装1")),
+ node("transit", TRANSIT_SECOND, List.of("卸1")),
+ node("transit", TRANSIT, List.of("装2")),
+ node("end", END, List.of("卸2")))));
+
+ assertEquals("同意调整路线:途经站由 1 个改为 2 个", insertedRecord().getFollowContent());
+ }
+
+ // ------------------------------------------------------------ 夹具
+
+ private void stubPendingDisposal() {
+ 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 stubLoading(LoadingManage loading) {
+ when(loadingManageMapper.selectOne(any())).thenReturn(loading);
+ }
+
+ private Waybill waybill(String loadingNo, String businessStatus, String routeJson) {
+ Waybill waybill = new Waybill();
+ waybill.setId(WAYBILL_ID);
+ waybill.setWaybillNo("YD20261023001");
+ waybill.setLoadingNo(loadingNo);
+ waybill.setBusinessStatus(businessStatus);
+ waybill.setRouteJson(routeJson);
+ waybill.setIsDeleted(0);
+ return waybill;
+ }
+
+ private LoadingManage loading(String businessStatus, String routeJson) {
+ LoadingManage loading = new LoadingManage();
+ loading.setId(8501L);
+ loading.setLoadingNo(LOADING_NO);
+ loading.setWaybillIdsJson("[9001,9002]");
+ loading.setBusinessStatus(businessStatus);
+ loading.setRouteJson(routeJson);
+ loading.setIsDeleted(0);
+ return loading;
+ }
+
+ /** 配载单的路线段夹具:两段三站,首段出发地是首站 */
+ private String segmentRouteJson() {
+ List> segments = new ArrayList<>();
+ Map first = new LinkedHashMap<>();
+ first.put("departureName", START);
+ first.put("departureAddress", START);
+ first.put("arrivalName", TRANSIT);
+ first.put("arrivalAddress", TRANSIT);
+ first.put("segmentNo", 1);
+ first.put("transportType", "road");
+ segments.add(first);
+ Map second = new LinkedHashMap<>();
+ second.put("departureName", TRANSIT);
+ second.put("departureAddress", TRANSIT);
+ second.put("arrivalName", END);
+ second.put("arrivalAddress", END);
+ second.put("segmentNo", 2);
+ segments.add(second);
+ return JsonUtil.toJson(segments);
+ }
+
+ private Map station(String address, List tags) {
+ Map row = new LinkedHashMap<>();
+ row.put("address", address);
+ row.put("displayAddress", address);
+ row.put("tags", tags);
+ row.put("locked", false);
+ return row;
+ }
+
+ private String stationRowsJson(List> rows) {
+ return JsonUtil.toJson(rows);
+ }
+
+ private ExceptionDisposalRouteAdjustRequest.RouteNode node(String kind, String address, List tags) {
+ ExceptionDisposalRouteAdjustRequest.RouteNode node = new ExceptionDisposalRouteAdjustRequest.RouteNode();
+ node.setKind(kind);
+ node.setAddress(address);
+ node.setTags(tags);
+ return node;
+ }
+
+ private ExceptionDisposalRouteAdjustRequest request(
+ ExceptionDisposalRouteAdjustRequest.RouteNode... nodes) {
+ ExceptionDisposalRouteAdjustRequest request = new ExceptionDisposalRouteAdjustRequest();
+ request.setId(DISPOSAL_ID);
+ request.setRouteNodes(List.of(nodes));
+ return request;
+ }
+
+ private ExceptionDisposalFollowRecord insertedRecord() {
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class);
+ verify(followRecordMapper).insert(captor.capture());
+ return captor.getValue();
+ }
+
+ private LoadingManage updatedLoading() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingManage.class);
+ verify(loadingManageMapper).updateById(captor.capture());
+ return captor.getValue();
+ }
+
+ private Waybill updatedWaybill() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class);
+ verify(waybillService).updateById(captor.capture());
+ return captor.getValue();
+ }
+
+ private ExceptionDisposal updatedDisposal() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
+ verify(disposalMapper).updateById(captor.capture());
+ return captor.getValue();
+ }
+
+ /** 校验不通过时不该留下任何写入 */
+ private void verifyNoWrite() {
+ verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
+ verify(loadingManageMapper, never()).updateById(any(LoadingManage.class));
+ verify(waybillService, never()).updateById(any(Waybill.class));
+ verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
+ }
+
+ /** 以调度员身份执行,登录态与真实姓名都走缓存 */
+ private void asScheduler(Runnable action) {
+ asScheduler(() -> {
+ action.run();
+ return null;
+ });
+ }
+
+ private T asScheduler(Supplier action) {
+ try (MockedStatic authUtil = mockStatic(AuthUtil.class);
+ MockedStatic userCache = mockStatic(UserDisplayCache.class)) {
+ authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
+ userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦调度");
+ return action.get();
+ }
+ }
+
+ 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);
+ }
+
+}