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); + } + +}