diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 14d0166d..774e1c7e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -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> 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、未知键)原样保留。 *

- * 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写: - * 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程; - * 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。 + * 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写; + * 站数与段数不匹配由调用方拒绝。 */ - private String rewriteWaybillRouteSegments(String routeJson, List targetAddresses) { - List> segments = routeSegments(routeJson); - if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) { + private String rewriteWaybillRouteSegments(List> segments, List targetAddresses) { + if (segments.isEmpty()) { return null; } List> rewritten = new ArrayList<>(segments.size()); for (int index = 0; index < segments.size(); index++) { Map segment = new LinkedHashMap<>(segments.get(index)); - rewriteSegmentPoint(segment, "departure", index == 0 ? targetAddresses.get(0) : null); + rewriteSegmentPoint(segment, "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 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 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<>()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java index c28898c5..c68b73f0 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -40,8 +40,8 @@ import static org.mockito.Mockito.when; /** * 异常详情下发的配载单、子运单与可编辑站点。 *

- * 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段, - * 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。 + * 有配载单时站点取配载单的站点行;没有配载单才退回运单的路线段。 + * 站点地址与改路线时的比对口径一致(地址优先、站名兜底)。 */ 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() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java index a00ec202..65917477 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -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(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 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 page; + try (MockedStatic 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 capturedQuery(ExceptionDisposalVO query) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java index cdd36344..f4e063dc 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -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; /** - * 拒绝与撤销:两个动作都了结异常,但只有拒绝留痕。 + * 拒绝与撤销:两个动作都了结异常,只有拒绝留痕。 *

- * 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句; - * 撤销是上报人收回,不是处置,因此不留跟进记录。 + * 拒绝复用已完成这个终态(台账仍显示「已处理」),并写一条「拒绝:…」的跟进记录; + * 撤销置为已撤销,不写跟进记录。终态(已完成 / 已撤销)不允许再跟进、再完成。 */ 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 captor = ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java index 62780eaa..8118b928 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -49,8 +49,8 @@ import static org.mockito.Mockito.when; /** * 同意并调整路线:先写跟进记录、再改载体路线、最后了结。 *

- * 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单, - * 而运单的 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> 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() {