🐛 修正分段改址的出发地同步与站数校验,详情字段移出列表

运单分支中间站改址后逐段同步 departure;无配载单的站数变化显式拒绝;已撤销异常不得再跟进或完成;配载单号、子运单号与可调整站点只在详情组装,列表路径不新增查询。
This commit is contained in:
2026-10-23 01:29:00 +08:00
parent 4658bbf8ca
commit 87b473594a
5 changed files with 173 additions and 44 deletions
@@ -135,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;
}
@@ -375,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("已完成的异常不允许继续跟进");
}
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
@@ -401,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);
@@ -423,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);
@@ -697,11 +699,14 @@ public class ExceptionDisposalServiceImpl
.collect(Collectors.joining(TRANSIT_ADDRESS_JOINER));
sourceTaskInfoJson = loading.getTaskInfoJson();
} else {
// 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,绝不整份换成站点行。
// 没变就一个键都不动,避免把历史数据里缺省的字段顺手补齐成另一份结构。
routeJson = sourceAddresses.equals(targetAddresses)
? null
: rewriteWaybillRouteSegments(waybill.getRouteJson(), targetAddresses);
// 运单的 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);
@@ -773,22 +778,20 @@ public class ExceptionDisposalServiceImpl
}
/**
* 按路线段改写运单路线:第 i 段的到达地对应第 i+1 站,首段出发地对应首站。
* 其它键(segmentNo、transportType、未知键)原样保留。
* 按路线段改写运单路线:每段的出发地对应第 i 站、到达地对应第 i+1 站,其它键(segmentNo、
* transportType、未知键)原样保留。
* <p>
* 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写:
* 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程;
* 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。
* 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写;
* 站数与段数不匹配由调用方拒绝。
*/
private String rewriteWaybillRouteSegments(String routeJson, List<String> targetAddresses) {
List<Map<String, Object>> segments = routeSegments(routeJson);
if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) {
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", index == 0 ? targetAddresses.get(0) : null);
rewriteSegmentPoint(segment, "departure", targetAddresses.get(index));
rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
rewritten.add(segment);
}
@@ -796,15 +799,11 @@ public class ExceptionDisposalServiceImpl
}
private void rewriteSegmentPoint(Map<String, Object> segment, String point, String targetAddress) {
if (targetAddress == null) {
return;
}
String currentAddress = firstNonBlank(text(segment.get(point + "Address")),
text(segment.get(point + "Name")), "");
if (Objects.equals(currentAddress, targetAddress)) {
return;
}
// 站名与地址是同一份文本:调整页只有一个地址输入框,改了地址站名也得跟着变
segment.put(point + "Address", targetAddress);
segment.put(point + "Name", targetAddress);
}
@@ -966,7 +965,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;
@@ -975,18 +981,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(), ""));
@@ -997,8 +1002,7 @@ public class ExceptionDisposalServiceImpl
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
vo.setCargo(cargo);
fillRouteNodes(vo, waybill);
return waybill;
}
/**
@@ -1008,6 +1012,14 @@ public class ExceptionDisposalServiceImpl
* 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。
*/
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<>());
@@ -40,8 +40,8 @@ import static org.mockito.Mockito.when;
/**
* 异常详情下发的配载单、子运单与可编辑站点。
* <p>
* 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段,
* 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。
* 有配载单时站点取配载单的站点行;没有配载单才退回运单的路线段。
* 站点地址与改路线时的比对口径一致(地址优先、站名兜底)。
*/
class ExceptionDisposalDetailPayloadTest {
@@ -224,6 +224,22 @@ class ExceptionDisposalDetailPayloadTest {
"原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底");
}
@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() {
@@ -11,21 +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;
@@ -40,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
@@ -48,16 +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),
waybillService,
mock(IDriverWaybillService.class),
mock(LoadingManageMapper.class));
loadingManageMapper);
inject("baseMapper", disposalMapper);
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
}
@@ -88,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) {
@@ -15,6 +15,7 @@ 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;
@@ -38,10 +39,10 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 拒绝与撤销:两个动作都了结异常,但只有拒绝留痕。
* 拒绝与撤销:两个动作都了结异常,只有拒绝留痕。
* <p>
* 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句;
* 撤销是上报人收回,不是处置,因此不留跟进记录。
* 拒绝复用已完成这个终态(台账仍显示「已处理」),并写一条「拒绝:…」的跟进记录;
* 撤销置为已撤销,不写跟进记录。终态(已完成 / 已撤销)不允许再跟进、再完成。
*/
class ExceptionDisposalRejectRevokeTest {
@@ -253,6 +254,33 @@ class ExceptionDisposalRejectRevokeTest {
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) {
@@ -271,6 +299,13 @@ class ExceptionDisposalRejectRevokeTest {
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);
@@ -49,8 +49,8 @@ import static org.mockito.Mockito.when;
/**
* 同意并调整路线:先写跟进记录、再改载体路线、最后了结。
* <p>
* 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单,
* 而运单的 route_json 是司机端按段解析的形状,改成站点行就读不出路线了。
* 载体跟配载单走:配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单,
* 运单的 route_json 是路线段形状。
*/
class ExceptionDisposalRouteAdjustTest {
@@ -360,22 +360,45 @@ class ExceptionDisposalRouteAdjustTest {
}
@Test
@DisplayName("运单站数变了时不动路线:段形状说不出哪一站被删,硬写等于重写行程")
void adjustRouteSkipsWaybillRouteWhenStationCountChanges() {
@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("end", NEW_END, null))));
node("transit", TRANSIT_SECOND, null),
node("end", END, null))));
Waybill updated = updatedWaybill();
assertEquals(segmentRouteJson(), updated.getRouteJson(), "站数对不上时保留原路线");
assertEquals(NEW_END, updated.getArrivalAddress());
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() {