🔀 合并「修改路线地址」异常审批:同意改路线 / 拒绝 / 撤销
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 异常处置拒绝请求
|
||||
* <p>
|
||||
* 只有拒绝原因一个业务字段:拒绝复用了结终态,不留独立列,
|
||||
* 原因随跟进记录落进 follow_content。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "异常处置拒绝请求")
|
||||
public class ExceptionDisposalRejectRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "异常处置ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "拒绝原因,必填,最多200字")
|
||||
private String rejectReason;
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 异常处置「同意并调整路线」请求
|
||||
* <p>
|
||||
* 站点按数组顺序即途经顺序回传,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<RouteNode> 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<String> tags;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -30,6 +30,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -93,4 +94,40 @@ public class ExceptionDisposalVO extends ExceptionDisposal {
|
||||
@Schema(description = "货物信息:{name, weight}")
|
||||
private Map<String, String> cargo;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "关联运单的配载单号,没有配载单时为空")
|
||||
private String loadingNo;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列")
|
||||
private List<String> childWaybillNos;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "可调整的路线站点,少于两个站时为空")
|
||||
private List<RouteNode> 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<String> tags;
|
||||
|
||||
@Schema(description = "该站装卸的子运单项目名,按标记顺序去重")
|
||||
private List<String> projects;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+27
-1
@@ -33,6 +33,8 @@ import org.springblade.core.mp.support.Query;
|
||||
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;
|
||||
@@ -50,7 +52,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
* <p>
|
||||
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
|
||||
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
|
||||
* 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。
|
||||
* 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@@ -114,4 +116,28 @@ public class ExceptionDisposalController extends BladeController {
|
||||
return R.success("编辑成功");
|
||||
}
|
||||
|
||||
@PostMapping("/reject")
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "拒绝异常", description = "调度端拒绝上报:先写一条拒绝原因的跟进记录,再了结;仅需登录态")
|
||||
public R reject(@RequestBody ExceptionDisposalRejectRequest request) {
|
||||
exceptionDisposalService.reject(request);
|
||||
return R.success("已拒绝");
|
||||
}
|
||||
|
||||
@PostMapping("/revoke")
|
||||
@ApiOperationSupport(order = 9)
|
||||
@Operation(summary = "撤销异常上报", description = "上报人撤销自己未了结的上报,不写跟进记录;仅需登录态")
|
||||
public R revoke(@RequestBody ExceptionDisposalFollowRequest request) {
|
||||
exceptionDisposalService.revoke(request.getId());
|
||||
return R.success("已撤销");
|
||||
}
|
||||
|
||||
@PostMapping("/route-adjust")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态")
|
||||
public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) {
|
||||
exceptionDisposalService.adjustRoute(request);
|
||||
return R.success("已调整路线");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+19
@@ -25,6 +25,8 @@ package org.springblade.transport.service;
|
||||
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;
|
||||
@@ -56,4 +58,21 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
|
||||
|
||||
void batchComplete(String ids);
|
||||
|
||||
/**
|
||||
* 拒绝异常:先写一条「拒绝:原因」的跟进记录,再了结。
|
||||
* 拒绝了结复用已完成终态,不新增状态值。
|
||||
*/
|
||||
void reject(ExceptionDisposalRejectRequest request);
|
||||
|
||||
/**
|
||||
* 撤销异常上报:仅限上报人本人、仅限未了结,置为已撤销,不留跟进记录
|
||||
*/
|
||||
void revoke(Long id);
|
||||
|
||||
/**
|
||||
* 同意并调整路线:先写一条调整说明的跟进记录,再改载体路线,最后了结。
|
||||
* 有配载单时改配载单的站点行路线,没有配载单时只按路线段改运单的地址。
|
||||
*/
|
||||
void adjustRoute(ExceptionDisposalRouteAdjustRequest request);
|
||||
|
||||
}
|
||||
|
||||
+763
-10
@@ -29,21 +29,28 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
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.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.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -53,10 +60,13 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -72,20 +82,47 @@ public class ExceptionDisposalServiceImpl
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
private static final String STATUS_PROCESSING = "processing";
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
private static final String STATUS_REVOKED = "revoked";
|
||||
private static final String STATUS_CANCELLED = "cancelled";
|
||||
|
||||
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
|
||||
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
|
||||
|
||||
/** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
|
||||
private static final int REJECT_REASON_MAX_LENGTH = 200;
|
||||
|
||||
/** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
|
||||
private static final int ROUTE_JSON_MAX_LENGTH = 8000;
|
||||
private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
|
||||
private static final int ADDRESS_MAX_LENGTH = 255;
|
||||
|
||||
private static final String NODE_KIND_START = "start";
|
||||
private static final String NODE_KIND_TRANSIT = "transit";
|
||||
private static final String NODE_KIND_END = "end";
|
||||
|
||||
/** 途经站地址在配载单上的连接符,与 PC 同步地址列时一致 */
|
||||
private static final String TRANSIT_ADDRESS_JOINER = ",";
|
||||
|
||||
/** 跟进记录与变更记录共用的同一句结论 */
|
||||
private static final String ROUTE_CHANGE_UNCHANGED = "同意调整路线,未修改路线";
|
||||
private static final String ROUTE_CHANGE_PREFIX = "同意调整路线:";
|
||||
|
||||
/** task_info_json 里存路线变更记录数组的键,与 PC 同一份结构 */
|
||||
private static final String TASK_INFO_ROUTE_CHANGE_RECORDS = "routeChangeRecords";
|
||||
|
||||
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final LoadingManageMapper loadingManageMapper;
|
||||
|
||||
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
|
||||
IWaybillService waybillService,
|
||||
IDriverWaybillService driverWaybillService) {
|
||||
IDriverWaybillService driverWaybillService,
|
||||
LoadingManageMapper loadingManageMapper) {
|
||||
this.followRecordMapper = followRecordMapper;
|
||||
this.waybillService = waybillService;
|
||||
this.driverWaybillService = driverWaybillService;
|
||||
this.loadingManageMapper = loadingManageMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +135,9 @@ public class ExceptionDisposalServiceImpl
|
||||
|
||||
@Override
|
||||
public ExceptionDisposalVO detail(Long id) {
|
||||
ExceptionDisposalVO vo = toVO(getExisting(id));
|
||||
ExceptionDisposal entity = getExisting(id);
|
||||
ExceptionDisposalVO vo = convertVO(entity);
|
||||
fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
|
||||
vo.setFollowRecords(followRecords(id));
|
||||
return vo;
|
||||
}
|
||||
@@ -338,7 +377,7 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("跟进说明不能超过500字");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("已完成的异常不允许继续跟进");
|
||||
}
|
||||
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
|
||||
@@ -364,7 +403,7 @@ public class ExceptionDisposalServiceImpl
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void complete(Long id) {
|
||||
ExceptionDisposal disposal = getExisting(id);
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("异常已完成,请勿重复操作");
|
||||
}
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
@@ -386,7 +425,7 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("存在无效的异常记录");
|
||||
}
|
||||
for (ExceptionDisposal disposal : disposals) {
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("已完成的异常不允许重复完成");
|
||||
}
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
@@ -394,6 +433,506 @@ public class ExceptionDisposalServiceImpl
|
||||
updateBatchById(disposals);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(ExceptionDisposalRejectRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要拒绝的异常");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
assertNotSettled(disposal);
|
||||
String rejectReason = Func.toStr(request.getRejectReason(), "").trim();
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
throw new ServiceException("请填写拒绝原因");
|
||||
}
|
||||
if (rejectReason.length() > REJECT_REASON_MAX_LENGTH) {
|
||||
throw new ServiceException("拒绝原因不能超过200字");
|
||||
}
|
||||
|
||||
String rejectContent = "拒绝:" + rejectReason;
|
||||
// 拒绝弹窗只有原因、没有附件,attachments_json 不写
|
||||
insertFollowRecord(disposal.getId(), rejectContent);
|
||||
|
||||
// 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
disposal.setLatestFollowContent(rejectContent);
|
||||
updateById(disposal);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revoke(Long id) {
|
||||
ExceptionDisposal disposal = getExisting(id);
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
// 撤销是上报人收回自己的上报,不是一次处置
|
||||
if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) {
|
||||
throw new ServiceException("只能撤销本人上报的异常");
|
||||
}
|
||||
assertNotSettled(disposal);
|
||||
// 不写跟进记录,也不动 latest_follow_content
|
||||
disposal.setDisposalStatus(STATUS_REVOKED);
|
||||
updateById(disposal);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void adjustRoute(ExceptionDisposalRouteAdjustRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要调整路线的异常");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
assertNotSettled(disposal);
|
||||
Waybill waybill = disposal.getWaybillId() == null ? null : waybillService.getById(disposal.getWaybillId());
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("关联运单不存在");
|
||||
}
|
||||
// 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading == null && Func.isNotBlank(waybill.getLoadingNo())) {
|
||||
throw new ServiceException("运单已关联配载单,请在配载单中修改");
|
||||
}
|
||||
// 校验与拼装全部在写入之前完成
|
||||
List<Station> stations = validateRouteNodes(request.getRouteNodes(), loading);
|
||||
List<String> sourceAddresses = loading != null
|
||||
? loadingStationAddresses(loading)
|
||||
: waybillStationAddresses(waybill);
|
||||
List<String> 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 assertNotSettled(ExceptionDisposal disposal) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("该异常已处理");
|
||||
}
|
||||
}
|
||||
|
||||
/** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */
|
||||
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.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo.trim())
|
||||
.eq(LoadingManage::getIsDeleted, 0)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。
|
||||
* <p>
|
||||
* kind 由位置决定,必须与站序一致。
|
||||
*/
|
||||
private List<Station> validateRouteNodes(List<ExceptionDisposalRouteAdjustRequest.RouteNode> routeNodes,
|
||||
LoadingManage loading) {
|
||||
if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) {
|
||||
throw new ServiceException("路线节点至少需要两个");
|
||||
}
|
||||
List<Station> 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<String> normalizedTags(List<String> tags) {
|
||||
List<String> 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 起),越界时拒绝。
|
||||
* <p>
|
||||
* 没有配载单时没有子运单序号,站点标记不参与落库。
|
||||
*/
|
||||
private void assertTagsMatchChildWaybills(List<Station> stations, LoadingManage loading) {
|
||||
if (loading == null) {
|
||||
return;
|
||||
}
|
||||
int childCount = childWaybillCount(loading.getWaybillIdsJson());
|
||||
for (Station station : stations) {
|
||||
for (String tag : station.tags()) {
|
||||
int order = tagOrder(tag);
|
||||
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<String> loadingStationAddresses(LoadingManage loading) {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
for (Map<String, Object> row : loadingStationRows(loading)) {
|
||||
addAddress(addresses, text(row.get("address")));
|
||||
}
|
||||
if (!addresses.isEmpty()) {
|
||||
return addresses;
|
||||
}
|
||||
return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。
|
||||
* 段里缺地址时用站名替代。
|
||||
*/
|
||||
private List<String> waybillStationAddresses(Waybill waybill) {
|
||||
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
|
||||
if (segments.isEmpty()) {
|
||||
return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress());
|
||||
}
|
||||
List<String> addresses = new ArrayList<>(segments.size() + 1);
|
||||
Map<String, Object> firstSegment = segments.get(0);
|
||||
addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")),
|
||||
text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), "")));
|
||||
for (Map<String, Object> segment : segments) {
|
||||
addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), ""));
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */
|
||||
private List<String> addressColumns(String departureAddress, String transitAddress, String arrivalAddress) {
|
||||
List<String> 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<String> addresses, String address) {
|
||||
if (Func.isNotBlank(address)) {
|
||||
addresses.add(address.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。
|
||||
* 长度口径与 PC 改路线一致。
|
||||
*/
|
||||
private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List<Station> stations,
|
||||
List<String> sourceAddresses, List<String> 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 是路线段形状,司机端按段解析:只按段改地址,不整份换成站点行
|
||||
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
|
||||
boolean addressChanged = !sourceAddresses.equals(targetAddresses);
|
||||
// 段形状可识别时站数必须等于段数 + 1;认不出段形状时不改 route_json
|
||||
if (addressChanged && !segments.isEmpty() && segments.size() + 1 != targetAddresses.size()) {
|
||||
throw new ServiceException("无配载单的运单不支持增减途经站");
|
||||
}
|
||||
routeJson = addressChanged ? rewriteWaybillRouteSegments(segments, targetAddresses) : null;
|
||||
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<String> sourceAddresses, List<String> targetAddresses) {
|
||||
if (sourceAddresses.equals(targetAddresses)) {
|
||||
return ROUTE_CHANGE_UNCHANGED;
|
||||
}
|
||||
List<String> 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()) {
|
||||
// 首末站未变时按途经站数量说明变化
|
||||
parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个");
|
||||
}
|
||||
return ROUTE_CHANGE_PREFIX + String.join(";", parts);
|
||||
}
|
||||
|
||||
private String firstOf(List<String> addresses) {
|
||||
return addresses.isEmpty() ? "" : addresses.get(0);
|
||||
}
|
||||
|
||||
private String lastOf(List<String> addresses) {
|
||||
return addresses.isEmpty() ? "" : addresses.get(addresses.size() - 1);
|
||||
}
|
||||
|
||||
private int transitCount(List<String> addresses) {
|
||||
return Math.max(addresses.size() - 2, 0);
|
||||
}
|
||||
|
||||
private String displayAddress(String address) {
|
||||
return Func.isBlank(address) ? "-" : address;
|
||||
}
|
||||
|
||||
/** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */
|
||||
private String stationRowsJson(List<Station> stations) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>(stations.size());
|
||||
for (Station station : stations) {
|
||||
Map<String, Object> 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、未知键)原样保留。
|
||||
* <p>
|
||||
* 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写;
|
||||
* 站数与段数不匹配由调用方拒绝。
|
||||
*/
|
||||
private String rewriteWaybillRouteSegments(List<Map<String, Object>> segments, List<String> targetAddresses) {
|
||||
if (segments.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, Object>> rewritten = new ArrayList<>(segments.size());
|
||||
for (int index = 0; index < segments.size(); index++) {
|
||||
Map<String, Object> segment = new LinkedHashMap<>(segments.get(index));
|
||||
rewriteSegmentPoint(segment, "departure", targetAddresses.get(index));
|
||||
rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
|
||||
rewritten.add(segment);
|
||||
}
|
||||
return JsonUtil.toJson(rewritten);
|
||||
}
|
||||
|
||||
/** 站点地址与站名写同一份文本 */
|
||||
private void rewriteSegmentPoint(Map<String, Object> segment, String pointKey, String targetAddress) {
|
||||
String currentAddress = firstNonBlank(text(segment.get(pointKey + "Address")),
|
||||
text(segment.get(pointKey + "Name")), "");
|
||||
if (Objects.equals(currentAddress, targetAddress)) {
|
||||
return;
|
||||
}
|
||||
segment.put(pointKey + "Address", targetAddress);
|
||||
segment.put(pointKey + "Name", targetAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。
|
||||
* 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。
|
||||
*/
|
||||
private List<Map<String, Object>> routeSegments(String routeJson) {
|
||||
List<Map<String, Object>> elements = jsonObjectList(routeJson);
|
||||
for (Map<String, Object> 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<String, Object> taskInfo = parseTaskInfo(taskInfoJson);
|
||||
List<Object> records = new ArrayList<>();
|
||||
Map<String, Object> 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<String, Object> parseTaskInfo(String taskInfoJson) {
|
||||
Map<String, Object> 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<Map<String, Object>> jsonObjectList(String json) {
|
||||
List<Map<String, Object>> 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<String, Object> 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 addressValue, String nameValue, String fallbackValue) {
|
||||
if (Func.isNotBlank(addressValue)) {
|
||||
return addressValue;
|
||||
}
|
||||
return Func.isNotBlank(nameValue) ? nameValue : fallbackValue;
|
||||
}
|
||||
|
||||
/** 规范化后的一个站点:地址 + 该站的装卸标记 */
|
||||
private record Station(String address, List<String> tags) {
|
||||
}
|
||||
|
||||
/** 一次路线调整要落库的全部内容 */
|
||||
private record RouteChangePlan(String followContent,
|
||||
String routeJson,
|
||||
String departureAddress,
|
||||
String transitAddress,
|
||||
String arrivalAddress,
|
||||
String taskInfoJson) {
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ExceptionDisposal> buildQuery(ExceptionDisposalVO query) {
|
||||
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
@@ -423,7 +962,14 @@ public class ExceptionDisposalServiceImpl
|
||||
return disposal;
|
||||
}
|
||||
|
||||
/** 列表与上报结果共用的转换:不含详情页的配载单号、子运单号与可调整站点 */
|
||||
private ExceptionDisposalVO toVO(ExceptionDisposal entity) {
|
||||
ExceptionDisposalVO vo = convertVO(entity);
|
||||
fillRouteAndCargo(vo, entity.getWaybillId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private ExceptionDisposalVO convertVO(ExceptionDisposal entity) {
|
||||
ExceptionDisposalVO vo = BeanUtil.copyProperties(entity, ExceptionDisposalVO.class);
|
||||
if (vo == null) {
|
||||
return null;
|
||||
@@ -432,18 +978,17 @@ public class ExceptionDisposalServiceImpl
|
||||
vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser()));
|
||||
vo.setDisposalStatusName(statusName(entity.getDisposalStatus()));
|
||||
vo.setScenePhotoList(splitPhotos(entity.getScenePhotos()));
|
||||
fillRouteAndCargo(vo, entity.getWaybillId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 按关联运单补齐详情页路线 / 货物展示字段 */
|
||||
private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
|
||||
/** 按关联运单补齐路线与货物展示字段,返回本次取到的运单;运单不存在时返回 null */
|
||||
private Waybill fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
|
||||
if (vo == null || waybillId == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
Waybill waybill = waybillService.getById(waybillId);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
Map<String, String> route = new HashMap<>(2);
|
||||
route.put("start", Func.toStr(waybill.getDepartureName(), ""));
|
||||
@@ -454,6 +999,213 @@ public class ExceptionDisposalServiceImpl
|
||||
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
|
||||
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
vo.setCargo(cargo);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页的可编辑站点与配载单信息。
|
||||
* <p>
|
||||
* 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。
|
||||
* 运单不存在时三项各给空值。
|
||||
*/
|
||||
private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) {
|
||||
if (vo == null) {
|
||||
return;
|
||||
}
|
||||
if (waybill == null) {
|
||||
vo.setChildWaybillNos(List.of());
|
||||
vo.setRouteNodes(List.of());
|
||||
return;
|
||||
}
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading == null) {
|
||||
vo.setChildWaybillNos(new ArrayList<>());
|
||||
vo.setRouteNodes(waybillRouteNodes(waybill));
|
||||
return;
|
||||
}
|
||||
Map<Integer, Waybill> childWaybills = childWaybillsByOrder(loading);
|
||||
vo.setLoadingNo(loading.getLoadingNo());
|
||||
vo.setChildWaybillNos(childWaybillNos(childWaybills));
|
||||
List<Map<String, Object>> rows = loadingStationRows(loading);
|
||||
vo.setRouteNodes(loadingRouteNodes(rows, childWaybills));
|
||||
vo.setRoute(loadingRoute(rows, loading, waybill));
|
||||
}
|
||||
|
||||
/** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */
|
||||
private List<Map<String, Object>> loadingStationRows(LoadingManage loading) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map<String, Object> row : jsonObjectList(loading.getRouteJson())) {
|
||||
if (Func.isNotBlank(text(row.get("address")))) {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<ExceptionDisposalVO.RouteNode> loadingRouteNodes(List<Map<String, Object>> rows,
|
||||
Map<Integer, Waybill> childWaybills) {
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
List<String> tags = stringList(row.get("tags"));
|
||||
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
|
||||
node.setAddress(text(row.get("address")));
|
||||
node.setTags(tags);
|
||||
node.setProjects(stationProjects(tags, childWaybills));
|
||||
nodes.add(node);
|
||||
}
|
||||
return stationKinds(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。
|
||||
* 地址口径与改路线时的比对一致(地址优先、站名兜底)。
|
||||
*/
|
||||
private List<ExceptionDisposalVO.RouteNode> waybillRouteNodes(Waybill waybill) {
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>();
|
||||
for (String address : waybillStationAddresses(waybill)) {
|
||||
if (Func.isBlank(address)) {
|
||||
continue;
|
||||
}
|
||||
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
|
||||
node.setAddress(address);
|
||||
node.setTags(new ArrayList<>());
|
||||
node.setProjects(new ArrayList<>());
|
||||
nodes.add(node);
|
||||
}
|
||||
return stationKinds(nodes);
|
||||
}
|
||||
|
||||
/** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */
|
||||
private List<ExceptionDisposalVO.RouteNode> stationKinds(List<ExceptionDisposalVO.RouteNode> nodes) {
|
||||
if (nodes.size() < 2) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (int index = 0; index < nodes.size(); index++) {
|
||||
nodes.get(index).setKind(index == 0 ? NODE_KIND_START
|
||||
: (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。
|
||||
* 标记对不上子运单(越界、子运单已不存在)时跳过。
|
||||
*/
|
||||
private List<String> stationProjects(List<String> tags, Map<Integer, Waybill> childWaybills) {
|
||||
Set<String> projects = new LinkedHashSet<>();
|
||||
for (String tag : tags) {
|
||||
Waybill child = childWaybills.get(tagOrder(tag) - 1);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
String projectName = Func.toStr(child.getProjectName(), "").trim();
|
||||
if (Func.isNotBlank(projectName)) {
|
||||
projects.add(projectName);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(projects);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。
|
||||
* 位次不因缺子运单而前移。
|
||||
*/
|
||||
private Map<Integer, Waybill> childWaybillsByOrder(LoadingManage loading) {
|
||||
Map<Integer, Waybill> childWaybills = new LinkedHashMap<>();
|
||||
List<Long> waybillIds = waybillIds(loading.getWaybillIdsJson());
|
||||
List<Long> queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (queryIds.isEmpty()) {
|
||||
return childWaybills;
|
||||
}
|
||||
Map<Long, Waybill> waybillsById = new HashMap<>();
|
||||
List<Waybill> found = waybillService.listByIds(queryIds);
|
||||
if (found != null) {
|
||||
for (Waybill child : found) {
|
||||
if (child != null && !Objects.equals(child.getIsDeleted(), 1)) {
|
||||
waybillsById.put(child.getId(), child);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int order = 0; order < waybillIds.size(); order++) {
|
||||
Waybill child = waybillsById.get(waybillIds.get(order));
|
||||
if (child != null) {
|
||||
childWaybills.put(order, child);
|
||||
}
|
||||
}
|
||||
return childWaybills;
|
||||
}
|
||||
|
||||
/** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */
|
||||
private List<Long> waybillIds(String waybillIdsJson) {
|
||||
List<Long> waybillIds = new ArrayList<>();
|
||||
if (Func.isBlank(waybillIdsJson)) {
|
||||
return waybillIds;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
long waybillId = Func.toLong(text(item), 0L);
|
||||
waybillIds.add(waybillId > 0 ? waybillId : null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 运单集合不是 JSON 数组时按没有子运单处理
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return waybillIds;
|
||||
}
|
||||
|
||||
/** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */
|
||||
private List<String> childWaybillNos(Map<Integer, Waybill> childWaybills) {
|
||||
List<String> childWaybillNos = new ArrayList<>(childWaybills.size());
|
||||
for (Waybill child : childWaybills.values()) {
|
||||
String waybillNo = Func.toStr(child.getWaybillNo(), "").trim();
|
||||
if (Func.isNotBlank(waybillNo)) {
|
||||
childWaybillNos.add(waybillNo);
|
||||
}
|
||||
}
|
||||
return childWaybillNos;
|
||||
}
|
||||
|
||||
/** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */
|
||||
private Map<String, String> loadingRoute(List<Map<String, Object>> rows, LoadingManage loading, Waybill waybill) {
|
||||
String start;
|
||||
String end;
|
||||
if (rows.isEmpty()) {
|
||||
start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), "");
|
||||
end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), "");
|
||||
} else {
|
||||
start = stationDisplayAddress(rows.get(0));
|
||||
end = stationDisplayAddress(rows.get(rows.size() - 1));
|
||||
}
|
||||
Map<String, String> route = new HashMap<>(2);
|
||||
route.put("start", start);
|
||||
route.put("end", end);
|
||||
return route;
|
||||
}
|
||||
|
||||
private String stationDisplayAddress(Map<String, Object> row) {
|
||||
return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), "");
|
||||
}
|
||||
|
||||
/** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */
|
||||
private int tagOrder(String tag) {
|
||||
String digits = Func.toStr(tag, "").replaceAll("\\D+", "");
|
||||
return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0);
|
||||
}
|
||||
|
||||
private List<String> stringList(Object value) {
|
||||
List<String> texts = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = text(item);
|
||||
if (Func.isNotBlank(text)) {
|
||||
texts.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
private String formatWeight(BigDecimal quantity, String unit) {
|
||||
@@ -488,6 +1240,7 @@ public class ExceptionDisposalServiceImpl
|
||||
case STATUS_PENDING -> "待处理";
|
||||
case STATUS_PROCESSING -> "处理中";
|
||||
case STATUS_COMPLETED -> "已完成";
|
||||
case STATUS_REVOKED -> "已撤销";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常详情下发的配载单、子运单与可编辑站点。
|
||||
* <p>
|
||||
* 有配载单时站点取配载单的站点行;没有配载单才退回运单的路线段。
|
||||
* 站点地址与改路线时的比对口径一致(地址优先、站名兜底)。
|
||||
*/
|
||||
class ExceptionDisposalDetailPayloadTest {
|
||||
|
||||
private static final Long DISPOSAL_ID = 9101L;
|
||||
private static final Long WAYBILL_ID = 9201L;
|
||||
private static final String LOADING_NO = "PZ20261023009";
|
||||
private static final String WAYBILL_NO = "YD202610230009";
|
||||
private static final Long CHILD_ID_FIRST = 9301L;
|
||||
private static final Long CHILD_ID_SECOND = 9302L;
|
||||
private static final String CHILD_NO_FIRST = "YD202610230011";
|
||||
private static final String CHILD_NO_SECOND = "YD202610230012";
|
||||
private static final String PROJECT_FIRST = "冷链项目";
|
||||
private static final String PROJECT_SECOND = "普货项目";
|
||||
private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园";
|
||||
private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园";
|
||||
private static final String END = "成都市/双流区/口岸路成藏宝物流园";
|
||||
private static final String WAYBILL_START_NAME = "南宁";
|
||||
private static final String WAYBILL_END_NAME = "成都";
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private IWaybillService waybillService;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposalFollowRecord.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
followRecordMapper, waybillService, mock(IDriverWaybillService.class), loadingManageMapper);
|
||||
inject("baseMapper", disposalMapper);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 有配载单
|
||||
|
||||
@Test
|
||||
@DisplayName("有配载单:下发配载单号、按运单集合顺序的子运单号,站点与项目取自站点行")
|
||||
void loadingDetailPublishesChildWaybillsAndStationNodes() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, null, List.of("装1", "卸1")),
|
||||
stationRow(TRANSIT, null, List.of("卸1", "装2")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
// 数据库不保证按主键集合的顺序返回:倒序回来,下发仍要按配载单运单集合的顺序
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND),
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(LOADING_NO, vo.getLoadingNo());
|
||||
assertEquals(List.of(CHILD_NO_FIRST, CHILD_NO_SECOND), vo.getChildWaybillNos(),
|
||||
"子运单号顺序就是站点标记里数字的顺序");
|
||||
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(3, nodes.size(), "站点数与配载单路线一致");
|
||||
assertEquals(List.of("start", "transit", "end"), kinds(nodes));
|
||||
assertEquals(START, nodes.get(0).getAddress());
|
||||
assertEquals(TRANSIT, nodes.get(1).getAddress());
|
||||
assertEquals(END, nodes.get(2).getAddress());
|
||||
assertEquals(List.of("装1", "卸1"), nodes.get(0).getTags());
|
||||
assertEquals(List.of(PROJECT_FIRST), nodes.get(0).getProjects(), "同一站的同一项目只出现一次");
|
||||
assertEquals(List.of(PROJECT_FIRST, PROJECT_SECOND), nodes.get(1).getProjects(),
|
||||
"标记按顺序映射到子运单");
|
||||
assertEquals(List.of(PROJECT_SECOND), nodes.get(2).getProjects());
|
||||
assertEquals(START, vo.getRoute().get("start"));
|
||||
assertEquals(END, vo.getRoute().get("end"));
|
||||
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(loadingManageMapper, never()).updateById(any(LoadingManage.class));
|
||||
verify(waybillService, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("路线摘要用站点行的展示地址,站点地址仍取地址列")
|
||||
void routeSummaryPrefersDisplayAddress() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, START + "(展示)", List.of("装1")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST),
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(START + "(展示)", vo.getRoute().get("start"), "摘要展示用站点行的展示地址");
|
||||
assertEquals(END, vo.getRoute().get("end"));
|
||||
assertEquals(START, vo.getRouteNodes().get(0).getAddress(),
|
||||
"站点地址是改路线时比对用的地址列,不能换成展示地址");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只有一个站时不给出可编辑站点")
|
||||
void singleStationGetsNoRouteNodes() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(stationRow(START, null, List.of("装1"))));
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertTrue(vo.getRouteNodes().isEmpty(), "没有首末站就没有可调整的站序");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子运单查不到时跳过,不打乱其余子运单的顺序")
|
||||
void missingChildWaybillIsSkipped() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, null, List.of("装1")),
|
||||
stationRow(TRANSIT, null, List.of("卸1", "装2")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
// 第一张子运单已不在(被删或运单集合与运单实际不一致),只剩第二张
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(List.of(CHILD_NO_SECOND), vo.getChildWaybillNos());
|
||||
assertEquals(3, vo.getRouteNodes().size(), "站点不因子运单缺失而消失");
|
||||
assertTrue(vo.getRouteNodes().get(0).getProjects().isEmpty(), "找不到子运单的标记给空项目");
|
||||
assertEquals(List.of(PROJECT_SECOND), vo.getRouteNodes().get(1).getProjects());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 无配载单
|
||||
|
||||
@Test
|
||||
@DisplayName("配载单已不存在时退回运单路线段,历史异常照常打开")
|
||||
void missingLoadingFallsBackToWaybillRouteSegments() {
|
||||
stubDisposal();
|
||||
// 运单还挂着配载单号,配载单已被删除或解绑:按没有配载单处理
|
||||
stubWaybill("PZ202610230404", segmentRouteJson());
|
||||
|
||||
ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse);
|
||||
|
||||
assertNull(vo.getLoadingNo());
|
||||
assertTrue(vo.getChildWaybillNos().isEmpty());
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(3, nodes.size(), "首段出发地是首站,其后每段到达地各一站");
|
||||
assertEquals(List.of("start", "transit", "end"), kinds(nodes));
|
||||
assertEquals(START, nodes.get(0).getAddress());
|
||||
assertEquals(List.of(), nodes.get(0).getTags(), "运单路线段没有装卸归属");
|
||||
assertEquals(List.of(), nodes.get(0).getProjects());
|
||||
assertEquals(WAYBILL_START_NAME, vo.getRoute().get("start"), "无配载单时摘要维持运单起讫名");
|
||||
assertEquals(WAYBILL_END_NAME, vo.getRoute().get("end"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无配载单的站点地址地址优先、站名兜底,与改路线时的比对口径一致")
|
||||
void waybillNodesPreferAddressOverName() {
|
||||
stubDisposal();
|
||||
stubWaybill(null, JsonUtil.toJson(List.of(
|
||||
segment(WAYBILL_START_NAME, START, "钦州", TRANSIT),
|
||||
segment("钦州", TRANSIT, "贵阳", null))));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(List.of(START, TRANSIT, "贵阳"),
|
||||
nodes.stream().map(ExceptionDisposalVO.RouteNode::getAddress).toList(),
|
||||
"原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关联运单已删除时详情照常打开,配载单号 / 子运单号 / 站点各给空值")
|
||||
void deletedWaybillGivesEmptyDetailFields() {
|
||||
stubDisposal();
|
||||
Waybill deleted = new Waybill();
|
||||
deleted.setId(WAYBILL_ID);
|
||||
deleted.setIsDeleted(1);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(deleted);
|
||||
|
||||
ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse);
|
||||
|
||||
assertNull(vo.getLoadingNo());
|
||||
assertEquals(List.of(), vo.getChildWaybillNos());
|
||||
assertEquals(List.of(), vo.getRouteNodes());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubDisposal() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
}
|
||||
|
||||
private void stubWaybill(String loadingNo, String routeJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo(WAYBILL_NO);
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setRouteJson(routeJson);
|
||||
waybill.setDepartureName(WAYBILL_START_NAME);
|
||||
waybill.setArrivalName(WAYBILL_END_NAME);
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private void stubLoading(List<Map<String, Object>> rows) {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(9401L);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setWaybillIdsJson("[" + CHILD_ID_FIRST + "," + CHILD_ID_SECOND + "]");
|
||||
loading.setRouteJson(JsonUtil.toJson(rows));
|
||||
loading.setIsDeleted(0);
|
||||
when(loadingManageMapper.selectOne(any())).thenReturn(loading);
|
||||
}
|
||||
|
||||
private Waybill child(Long id, String waybillNo, String projectName) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setProjectName(projectName);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private Map<String, Object> stationRow(String address, String displayAddress, List<String> tags) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("address", address);
|
||||
if (displayAddress != null) {
|
||||
row.put("displayAddress", displayAddress);
|
||||
}
|
||||
row.put("tags", tags);
|
||||
row.put("locked", false);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 运单的路线段:两段三站,带地址的段 */
|
||||
private String segmentRouteJson() {
|
||||
return JsonUtil.toJson(List.of(
|
||||
segment(WAYBILL_START_NAME, START, TRANSIT, TRANSIT),
|
||||
segment(TRANSIT, TRANSIT, WAYBILL_END_NAME, END)));
|
||||
}
|
||||
|
||||
private Map<String, Object> segment(String departureName, String departureAddress,
|
||||
String arrivalName, String arrivalAddress) {
|
||||
Map<String, Object> segment = new LinkedHashMap<>();
|
||||
segment.put("departureName", departureName);
|
||||
segment.put("departureAddress", departureAddress);
|
||||
segment.put("arrivalName", arrivalName);
|
||||
if (arrivalAddress != null) {
|
||||
segment.put("arrivalAddress", arrivalAddress);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
private List<String> kinds(List<ExceptionDisposalVO.RouteNode> nodes) {
|
||||
return nodes.stream().map(ExceptionDisposalVO.RouteNode::getKind).toList();
|
||||
}
|
||||
|
||||
private ExceptionDisposalVO detailResponse() {
|
||||
// 详情里的创建人 / 更新人姓名走缓存,单测不装配 Spring 上下文
|
||||
try (MockedStatic<UserDisplayCache> ignored = mockStatic(UserDisplayCache.class)) {
|
||||
return service.detail(DISPOSAL_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到字段:" + field);
|
||||
}
|
||||
|
||||
}
|
||||
+47
-2
@@ -11,20 +11,29 @@ 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.MockedStatic;
|
||||
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.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -39,6 +48,8 @@ class ExceptionDisposalListQueryTest {
|
||||
private static final Long WAYBILL_ID = 5401L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private IWaybillService waybillService;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
@@ -47,15 +58,19 @@ class ExceptionDisposalListQueryTest {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
mock(ExceptionDisposalFollowRecordMapper.class),
|
||||
mock(IWaybillService.class),
|
||||
mock(IDriverWaybillService.class));
|
||||
waybillService,
|
||||
mock(IDriverWaybillService.class),
|
||||
loadingManageMapper);
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
|
||||
}
|
||||
@@ -86,6 +101,36 @@ class ExceptionDisposalListQueryTest {
|
||||
assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("列表不组装详情字段:每行不查配载单、不查子运单")
|
||||
void listPageDoesNotLoadDetailFields() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(6501L);
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setIsDeleted(0);
|
||||
Page<ExceptionDisposal> entityPage = new Page<>(1, 10);
|
||||
entityPage.setRecords(List.of(disposal));
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(entityPage);
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setLoadingNo("PZ20261023001");
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
IPage<ExceptionDisposalVO> page;
|
||||
try (MockedStatic<UserDisplayCache> ignored = mockStatic(UserDisplayCache.class)) {
|
||||
page = service.selectPage(new Page<>(1, 10), new ExceptionDisposalVO());
|
||||
}
|
||||
|
||||
assertEquals(1, page.getRecords().size());
|
||||
ExceptionDisposalVO vo = page.getRecords().get(0);
|
||||
assertNull(vo.getLoadingNo(), "配载单号只在详情下发");
|
||||
assertNull(vo.getChildWaybillNos(), "子运单号只在详情下发");
|
||||
assertNull(vo.getRouteNodes(), "可调整站点只在详情下发");
|
||||
verify(loadingManageMapper, never()).selectOne(any());
|
||||
verify(waybillService, never()).listByIds(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private LambdaQueryWrapper<ExceptionDisposal> capturedQuery(ExceptionDisposalVO query) {
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
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.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
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.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 拒绝与撤销:两个动作都了结异常,只有拒绝留痕。
|
||||
* <p>
|
||||
* 拒绝复用已完成这个终态(台账仍显示「已处理」),并写一条「拒绝:…」的跟进记录;
|
||||
* 撤销置为已撤销,不写跟进记录。终态(已完成 / 已撤销)不允许再跟进、再完成。
|
||||
*/
|
||||
class ExceptionDisposalRejectRevokeTest {
|
||||
|
||||
private static final Long DISPOSAL_ID = 6101L;
|
||||
private static final Long REPORTER_ID = 7001L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
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);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 拒绝
|
||||
|
||||
@Test
|
||||
@DisplayName("待处理异常被拒绝后了结,并留下一条带「拒绝:」前缀的跟进记录")
|
||||
void rejectSettlesPendingDisposalWithFollowRecord() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest("地址核实无误,按原计划执行")));
|
||||
|
||||
ExceptionDisposalFollowRecord record = insertedRecord();
|
||||
assertEquals("拒绝:地址核实无误,按原计划执行", record.getFollowContent());
|
||||
assertEquals(DISPOSAL_ID, record.getDisposalId());
|
||||
assertEquals(REPORTER_ID, record.getFollowUser());
|
||||
assertEquals("韦调度", record.getFollowUserName());
|
||||
assertNotNull(record.getFollowTime());
|
||||
assertNull(record.getAttachmentsJson(), "拒绝弹窗没有附件,跟进记录不得凭空写一份");
|
||||
|
||||
ExceptionDisposal settled = updatedDisposal();
|
||||
assertEquals("completed", settled.getDisposalStatus(), "拒绝复用已完成终态,不新增状态值");
|
||||
assertEquals("拒绝:地址核实无误,按原计划执行", settled.getLatestFollowContent(),
|
||||
"列表上的最新跟进说明要与跟进记录同一条文案");
|
||||
verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 拒绝门禁与校验
|
||||
|
||||
@Test
|
||||
@DisplayName("处理中的异常同样能被拒绝并了结")
|
||||
void rejectSettlesProcessingDisposal() {
|
||||
stubDisposal("processing", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest("按原计划执行")));
|
||||
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
assertEquals("拒绝:按原计划执行", insertedRecord().getFollowContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的异常再拒绝报「该异常已处理」,且不多写记录、不改状态")
|
||||
void rejectSettledDisposalRejected() {
|
||||
stubDisposal("completed", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误"))));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常同属了结态,再拒绝也是「该异常已处理」")
|
||||
void rejectRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误"))));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因只有空白时不肯了结,要求填写原因")
|
||||
void blankRejectReasonRejected() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest(" "))));
|
||||
|
||||
assertEquals("请填写拒绝原因", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因超过200字被拒,避免撑爆跟进记录列")
|
||||
void tooLongRejectReasonRejected() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("理".repeat(201)))));
|
||||
|
||||
assertEquals("拒绝原因不能超过200字", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因去掉首尾空白后落库")
|
||||
void rejectReasonIsTrimmed() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest(" 地址核实无误 ")));
|
||||
|
||||
assertEquals("拒绝:地址核实无误", insertedRecord().getFollowContent());
|
||||
assertEquals("拒绝:地址核实无误", updatedDisposal().getLatestFollowContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常状态名为「已撤销」,不能是空串")
|
||||
void revokedStatusHasDisplayName() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
when(followRecordMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
ExceptionDisposalVO vo = asReporter(() -> service.detail(DISPOSAL_ID));
|
||||
|
||||
assertEquals("已撤销", vo.getDisposalStatusName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 撤销
|
||||
|
||||
@Test
|
||||
@DisplayName("上报人撤销自己的待处理异常:置为已撤销,不留跟进记录")
|
||||
void revokeOwnPendingDisposal() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.revoke(DISPOSAL_ID));
|
||||
|
||||
assertEquals("revoked", updatedDisposal().getDisposalStatus());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已经有人跟进(处理中)的异常,上报人仍能撤销:撤销取回的是上报本身")
|
||||
void revokeOwnProcessingDisposal() {
|
||||
stubDisposal("processing", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.revoke(DISPOSAL_ID));
|
||||
|
||||
assertEquals("revoked", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤销别人的上报被拒:撤销只能由上报人发起")
|
||||
void revokeOthersDisposalRejected() {
|
||||
stubDisposal("pending", 7002L);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("只能撤销本人上报的异常", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有上报人(历史数据)时不得撤销:份属不明按非本人处理")
|
||||
void revokeDisposalWithoutReporterRejected() {
|
||||
stubDisposal("pending", null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("只能撤销本人上报的异常", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的异常不能再撤销")
|
||||
void revokeSettledDisposalRejected() {
|
||||
stubDisposal("completed", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常再撤销同样是「该异常已处理」")
|
||||
void revokeAlreadyRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 终态门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常不能再跟进:终态不允许回到处理中")
|
||||
void followRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.follow(followRequest("已核实,等待补件"))));
|
||||
|
||||
assertEquals("已完成的异常不允许继续跟进", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常不能再完成:撤销后不能改成已完成")
|
||||
void completeRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.complete(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("异常已完成,请勿重复操作", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubDisposal(String status, Long reporterId) {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus(status);
|
||||
disposal.setReporterId(reporterId);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
}
|
||||
|
||||
private ExceptionDisposalRejectRequest rejectRequest(String reason) {
|
||||
ExceptionDisposalRejectRequest request = new ExceptionDisposalRejectRequest();
|
||||
request.setId(DISPOSAL_ID);
|
||||
request.setRejectReason(reason);
|
||||
return request;
|
||||
}
|
||||
|
||||
private ExceptionDisposalFollowRequest followRequest(String content) {
|
||||
ExceptionDisposalFollowRequest request = new ExceptionDisposalFollowRequest();
|
||||
request.setId(DISPOSAL_ID);
|
||||
request.setFollowContent(content);
|
||||
return request;
|
||||
}
|
||||
|
||||
private ExceptionDisposalFollowRecord insertedRecord() {
|
||||
ArgumentCaptor<ExceptionDisposalFollowRecord> captor =
|
||||
ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class);
|
||||
verify(followRecordMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private ExceptionDisposal updatedDisposal() {
|
||||
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
|
||||
verify(disposalMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/** 以调度员身份执行,登录态与真实姓名都走缓存 */
|
||||
private void runAsReporter(Runnable action) {
|
||||
asReporter(() -> {
|
||||
action.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T asReporter(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
|
||||
MockedStatic<UserDisplayCache> userCache = mockStatic(UserDisplayCache.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(REPORTER_ID);
|
||||
userCache.when(() -> UserDisplayCache.getUserRealName(REPORTER_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);
|
||||
}
|
||||
|
||||
}
|
||||
+3
-1
@@ -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());
|
||||
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 同意并调整路线:先写跟进记录、再改载体路线、最后了结。
|
||||
* <p>
|
||||
* 载体跟配载单走:配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单,
|
||||
* 运单的 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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 adjustRouteSyncsMiddleStationOnBothSegments() {
|
||||
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_SECOND, null),
|
||||
node("end", END, null))));
|
||||
|
||||
List<Map<String, Object>> segments = JsonUtil.readListMap(updatedWaybill().getRouteJson());
|
||||
assertEquals(2, segments.size(), "站数不变,段数也不变");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalAddress"));
|
||||
assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalName"), "站名跟着地址一起改");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(1).get("departureAddress"),
|
||||
"下一段的出发地必须与上一段的到达地一致");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(1).get("departureName"));
|
||||
assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改");
|
||||
assertEquals(END, segments.get(1).get("arrivalAddress"), "没改的到达地原样保留");
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单站数变化时拒绝:无配载单的运单只支持改地址,不支持增减途经站")
|
||||
void adjustRouteRejectsWaybillStationCountChange() {
|
||||
stubPendingDisposal();
|
||||
Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("end", NEW_END, null)))));
|
||||
|
||||
assertEquals("无配载单的运单不支持增减途经站", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@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<Map<String, Object>> segments = new ArrayList<>();
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> station(String address, List<String> tags) {
|
||||
Map<String, Object> 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<Map<String, Object>> rows) {
|
||||
return JsonUtil.toJson(rows);
|
||||
}
|
||||
|
||||
private ExceptionDisposalRouteAdjustRequest.RouteNode node(String kind, String address, List<String> 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<ExceptionDisposalFollowRecord> captor =
|
||||
ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class);
|
||||
verify(followRecordMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private LoadingManage updatedLoading() {
|
||||
ArgumentCaptor<LoadingManage> captor = ArgumentCaptor.forClass(LoadingManage.class);
|
||||
verify(loadingManageMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private Waybill updatedWaybill() {
|
||||
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
|
||||
verify(waybillService).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private ExceptionDisposal updatedDisposal() {
|
||||
ArgumentCaptor<ExceptionDisposal> 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> T asScheduler(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
|
||||
MockedStatic<UserDisplayCache> 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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user