From 7f4dc9493488c8bddc9154e1e2a9a45c2dcdf011 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 00:37:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E6=8B=92=E7=BB=9D=E4=B8=8E=E6=92=A4=E9=94=80=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/ExceptionDisposalRejectRequest.java | 55 +++ .../ExceptionDisposalController.java | 19 +- .../service/IExceptionDisposalService.java | 12 + .../impl/ExceptionDisposalServiceImpl.java | 58 ++++ .../ExceptionDisposalRejectRevokeTest.java | 317 ++++++++++++++++++ 5 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java new file mode 100644 index 00000000..2e16aac2 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 异常处置拒绝请求 + *

+ * 只有拒绝原因一个业务字段:拒绝复用了结终态,不留独立列, + * 原因随跟进记录落进 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; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 855f37ec..01ae89e1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -33,6 +33,7 @@ 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.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -50,7 +51,7 @@ import org.springframework.web.bind.annotation.RestController; *

* 对外路径:{@code /api/blade-transport/exception-disposal/**} * 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。 - * 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。 + * 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。 */ @RestController @AllArgsConstructor @@ -114,4 +115,20 @@ 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("已撤销"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index d23ca19c..ed63c310 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ 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.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -56,4 +57,15 @@ public interface IExceptionDisposalService extends BaseService REJECT_REASON_MAX_LENGTH) { + throw new ServiceException("拒绝原因不能超过200字"); + } + + String rejectContent = "拒绝:" + rejectReason; + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(rejectContent); + // 拒绝弹窗只有原因、没有附件,attachments_json 不写 + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + + // 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的 + 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("只能撤销本人上报的异常"); + } + if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { + throw new ServiceException("该异常已处理"); + } + // 不写跟进记录,也不动 latest_follow_content:处置时间线里不该出现一条由上报人「处置」的记录 + disposal.setDisposalStatus(STATUS_REVOKED); + updateById(disposal); + } + private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) @@ -488,6 +545,7 @@ public class ExceptionDisposalServiceImpl case STATUS_PENDING -> "待处理"; case STATUS_PROCESSING -> "处理中"; case STATUS_COMPLETED -> "已完成"; + case STATUS_REVOKED -> "已撤销"; default -> ""; }; } 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 new file mode 100644 index 00000000..5b9384f6 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -0,0 +1,317 @@ +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.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; + +/** + * 拒绝与撤销:两个动作都了结异常,但只有拒绝留痕。 + *

+ * 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句; + * 撤销是上报人收回,不是处置,因此不留跟进记录。 + */ +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)); + 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()); + } + + // ------------------------------------------------------------ 夹具 + + 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 ExceptionDisposalFollowRecord insertedRecord() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); + verify(followRecordMapper).insert(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal updatedDisposal() { + ArgumentCaptor 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 asReporter(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic 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); + } + +} From b56da11a6c653875fdda7d2be2c63a1675ad9e9a Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 00:58:47 +0800 Subject: [PATCH 2/5] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=BD=AE=E5=90=8C=E6=84=8F=E5=B9=B6=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=B7=AF=E7=BA=BF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExceptionDisposalRouteAdjustRequest.java | 78 +++ .../ExceptionDisposalController.java | 9 + .../service/IExceptionDisposalService.java | 7 + .../impl/ExceptionDisposalServiceImpl.java | 501 +++++++++++++++- .../impl/ExceptionDisposalListQueryTest.java | 4 +- .../ExceptionDisposalRejectRevokeTest.java | 4 +- .../impl/ExceptionDisposalReportGateTest.java | 4 +- .../ExceptionDisposalRouteAdjustTest.java | 551 ++++++++++++++++++ 8 files changed, 1147 insertions(+), 11 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java new file mode 100644 index 00000000..b8a78404 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java @@ -0,0 +1,78 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 异常处置「同意并调整路线」请求 + *

+ * 站点按数组顺序即途经顺序回传,kind 是站序的声明:首个 start、末个 end、其余 transit。 + * 每个站点上的 tags 指向配载单里第 N 张子运单(形如「装1」「卸2」),后端据此落成站点行; + * 没有配载单的运单没有子运单归属,tags 不参与落库。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置调整路线请求") +public class ExceptionDisposalRouteAdjustRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "调整后的站点,按途经顺序排列") + private List routeNodes; + + /** + * 一个站点:地址 + 该站装卸的子运单标记 + */ + @Data + @Schema(description = "路线站点") + public static class RouteNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站点类型:start 首站、transit 中间站、end 末站") + private String kind; + + @Schema(description = "站点地址") + private String address; + + @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号") + private List tags; + + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 01ae89e1..2b42643a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -34,6 +34,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -131,4 +132,12 @@ public class ExceptionDisposalController extends BladeController { return R.success("已撤销"); } + @PostMapping("/route-adjust") + @ApiOperationSupport(order = 10) + @Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态") + public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) { + exceptionDisposalService.adjustRoute(request); + return R.success("已调整路线"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index ed63c310..de58cb2a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -26,6 +26,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -68,4 +69,10 @@ public interface IExceptionDisposalService extends BaseService stations = validateRouteNodes(request.getRouteNodes(), loading); + List sourceAddresses = loading != null + ? loadingStationAddresses(loading) + : waybillStationAddresses(waybill); + List targetAddresses = stations.stream().map(Station::address).toList(); + // 首站地址只读;原数据里首站地址缺失时无从比对,放行而不是拒绝整次调整 + if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0)) + && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) { + throw new ServiceException("起始地址不允许修改"); + } + String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus(); + if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) { + throw new ServiceException("当前状态不允许变更运输路线"); + } + RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses); + + // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态。 + // 少了任何一步都读不出「谁在什么时候把路线改成了什么」,所以任一步失败都整体回滚 + insertFollowRecord(disposal.getId(), plan.followContent()); + if (loading != null) { + applyLoadingRoute(loading, plan); + } else { + applyWaybillRoute(waybill, plan); + } + disposal.setDisposalStatus(STATUS_COMPLETED); + disposal.setLatestFollowContent(plan.followContent()); + updateById(disposal); + } + + /** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */ + private void insertFollowRecord(Long disposalId, String content) { + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposalId); + record.setFollowContent(content); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** 配载单按配载单号取,逻辑删除的视为没有 */ + private LoadingManage findLoading(String loadingNo) { + if (Func.isBlank(loadingNo)) { + return null; + } + return loadingManageMapper.selectOne(Wrappers.lambdaQuery() + .eq(LoadingManage::getLoadingNo, loadingNo.trim()) + .eq(LoadingManage::getIsDeleted, 0) + .last("LIMIT 1")); + } + + /** + * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。 + *

+ * kind 不额外接受「按位置猜」的写法:站序就是落库顺序,kind 与位置不一致时写下去的首末站地址会是错的。 + */ + private List validateRouteNodes(List routeNodes, + LoadingManage loading) { + if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) { + throw new ServiceException("路线节点至少需要两个"); + } + List stations = new ArrayList<>(routeNodes.size()); + for (int index = 0; index < routeNodes.size(); index++) { + ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index); + if (node == null) { + throw new ServiceException("请完善路线节点"); + } + String address = Func.toStr(node.getAddress(), "").trim(); + if (Func.isBlank(address)) { + throw new ServiceException("站点地址不能为空"); + } + String expectedKind = index == 0 + ? NODE_KIND_START + : (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT); + if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) { + throw new ServiceException("路线节点类型不正确"); + } + stations.add(new Station(address, normalizedTags(node.getTags()))); + } + assertTagsMatchChildWaybills(stations, loading); + return stations; + } + + private List normalizedTags(List tags) { + List normalized = new ArrayList<>(); + if (tags == null) { + return normalized; + } + for (String tag : tags) { + String text = Func.toStr(tag, "").trim(); + if (Func.isNotBlank(text)) { + normalized.add(text); + } + } + return normalized; + } + + /** + * 站点标记里的数字是配载单运单集合里的位次(1 起),越界的标记会让司机端找不到对应子运单, + * 那样的路线读不出装卸与货物,宁可报错也不落库。 + *

+ * 没有配载单就没有子运单序号可言:运单的路线段没有装卸归属,站点标记不参与落库。 + */ + private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) { + if (loading == null) { + return; + } + int childCount = childWaybillCount(loading.getWaybillIdsJson()); + for (Station station : stations) { + for (String tag : station.tags()) { + String digits = tag.replaceAll("\\D+", ""); + int order = Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + if (order < 1 || order > childCount) { + throw new ServiceException("路线节点未关联子运单"); + } + } + } + } + + /** 配载单运单集合的元素个数,即站点标记可用的位次上限 */ + private int childWaybillCount(String waybillIdsJson) { + if (Func.isBlank(waybillIdsJson)) { + return 0; + } + try { + List waybillIds = JsonUtil.parse(waybillIdsJson, List.class); + return waybillIds == null ? 0 : waybillIds.size(); + } catch (Exception ignored) { + // 运单集合不是 JSON 数组时按没有子运单处理,站点标记一律对不上 + return 0; + } + } + + /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */ + private List loadingStationAddresses(LoadingManage loading) { + List addresses = new ArrayList<>(); + for (Map row : jsonObjectList(loading.getRouteJson())) { + addAddress(addresses, text(row.get("address"))); + } + if (!addresses.isEmpty()) { + return addresses; + } + return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress()); + } + + /** + * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。 + * 段里缺地址时退化用段名——运单详情与调整页用的也是这两列。 + */ + private List waybillStationAddresses(Waybill waybill) { + List> segments = routeSegments(waybill.getRouteJson()); + if (segments.isEmpty()) { + return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress()); + } + List addresses = new ArrayList<>(segments.size() + 1); + Map firstSegment = segments.get(0); + addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")), + text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), ""))); + for (Map segment : segments) { + addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), "")); + } + return addresses; + } + + /** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */ + private List addressColumns(String departureAddress, String transitAddress, String arrivalAddress) { + List addresses = new ArrayList<>(3); + addAddress(addresses, departureAddress); + if (Func.isNotBlank(transitAddress)) { + for (String transit : transitAddress.split("[,,]")) { + addAddress(addresses, transit.trim()); + } + } + addAddress(addresses, arrivalAddress); + return addresses; + } + + private void addAddress(List addresses, String address) { + if (Func.isNotBlank(address)) { + addresses.add(address.trim()); + } + } + + /** + * 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。 + * 长度口径与 PC 改路线一致。 + */ + private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List stations, + List sourceAddresses, List targetAddresses) { + String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses)); + String departureAddress = targetAddresses.get(0); + String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1); + String routeJson; + String transitAddress = null; + String sourceTaskInfoJson; + if (loading != null) { + routeJson = stationRowsJson(stations); + transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream() + .collect(Collectors.joining(TRANSIT_ADDRESS_JOINER)); + sourceTaskInfoJson = loading.getTaskInfoJson(); + } else { + // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,绝不整份换成站点行。 + // 没变就一个键都不动,避免把历史数据里缺省的字段顺手补齐成另一份结构。 + routeJson = sourceAddresses.equals(targetAddresses) + ? null + : rewriteWaybillRouteSegments(waybill.getRouteJson(), targetAddresses); + sourceTaskInfoJson = waybill.getTaskInfoJson(); + } + String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent); + + TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字"); + TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字"); + TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字"); + TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字"); + return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress, + arrivalAddress, taskInfoJson); + } + + /** + * 同一份结论既进跟进记录,也进 task_info_json 的变更记录: + * 首末站地址变了逐条写「出发地 / 到货地 旧 → 新」,否则说明途经站变化,完全没变给固定的一句。 + */ + private String routeChangeContent(List sourceAddresses, List targetAddresses) { + if (sourceAddresses.equals(targetAddresses)) { + return ROUTE_CHANGE_UNCHANGED; + } + List parts = new ArrayList<>(2); + String sourceStart = firstOf(sourceAddresses); + String targetStart = firstOf(targetAddresses); + if (!Objects.equals(sourceStart, targetStart)) { + parts.add("出发地 " + displayAddress(sourceStart) + " → " + displayAddress(targetStart)); + } + String sourceEnd = lastOf(sourceAddresses); + String targetEnd = lastOf(targetAddresses); + if (!Objects.equals(sourceEnd, targetEnd)) { + parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd)); + } + if (parts.isEmpty()) { + // 首末站都没动,变化只能落在途经站:地址细节塞不进 500 字的跟进记录,按站数说明 + parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个"); + } + return ROUTE_CHANGE_PREFIX + String.join(";", parts); + } + + private String firstOf(List addresses) { + return addresses.isEmpty() ? "" : addresses.get(0); + } + + private String lastOf(List addresses) { + return addresses.isEmpty() ? "" : addresses.get(addresses.size() - 1); + } + + private int transitCount(List addresses) { + return Math.max(addresses.size() - 2, 0); + } + + private String displayAddress(String address) { + return Func.isBlank(address) ? "-" : address; + } + + /** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */ + private String stationRowsJson(List stations) { + List> rows = new ArrayList<>(stations.size()); + for (Station station : stations) { + Map row = new LinkedHashMap<>(4); + row.put("address", station.address()); + // 站点行没有独立的站名,展示地址与地址取同一份文本 + row.put("displayAddress", station.address()); + row.put("tags", station.tags()); + // locked 只影响 PC 编辑弹窗能否拖动,司机端不读;这里写入的路线始终可继续调整 + row.put("locked", false); + rows.add(row); + } + return JsonUtil.toJson(rows); + } + + /** + * 按路线段改写运单路线:第 i 段的到达地对应第 i+1 站,首段出发地对应首站。 + * 其它键(segmentNo、transportType、未知键)原样保留。 + *

+ * 站数变了或现有 route_json 不是可识别的段数组时返回 null,调用方跳过路线改写: + * 段形状没有装卸归属,说不出哪一段被删掉或被挪动,硬写等于重写这趟车的行程; + * 历史节点行(PC 改运单路线写下的 address + tags)同理不动,改写会让司机端读不出路线。 + */ + private String rewriteWaybillRouteSegments(String routeJson, List targetAddresses) { + List> segments = routeSegments(routeJson); + if (segments.isEmpty() || segments.size() + 1 != targetAddresses.size()) { + return null; + } + List> rewritten = new ArrayList<>(segments.size()); + for (int index = 0; index < segments.size(); index++) { + Map segment = new LinkedHashMap<>(segments.get(index)); + rewriteSegmentPoint(segment, "departure", index == 0 ? targetAddresses.get(0) : null); + rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1)); + rewritten.add(segment); + } + return JsonUtil.toJson(rewritten); + } + + private void rewriteSegmentPoint(Map segment, String point, String targetAddress) { + if (targetAddress == null) { + return; + } + String currentAddress = firstNonBlank(text(segment.get(point + "Address")), + text(segment.get(point + "Name")), ""); + if (Objects.equals(currentAddress, targetAddress)) { + return; + } + // 站名与地址是同一份文本:调整页只有一个地址输入框,改了地址站名也得跟着变 + segment.put(point + "Address", targetAddress); + segment.put(point + "Name", targetAddress); + } + + /** + * 识别路线段形状的 route_json。运单历史上存在两种形状,另一种是 PC 写下的节点行 + * (address + tags + 运单号),司机端按段解析读不出来,因此这里只认段:元素带段字段、 + * 且不带 address / tags。 + */ + private List> routeSegments(String routeJson) { + List> elements = jsonObjectList(routeJson); + for (Map element : elements) { + if (element.containsKey("address") || element.containsKey("tags")) { + return new ArrayList<>(); + } + if (!element.containsKey("departureAddress") && !element.containsKey("departureName") + && !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) { + return new ArrayList<>(); + } + } + return elements; + } + + /** + * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。 + * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建,只丢这一列的无法解析部分, + * 不能让一次路线调整因为附件信息列而失败。 + */ + private String appendRouteChangeRecord(String taskInfoJson, String content) { + Map taskInfo = parseTaskInfo(taskInfoJson); + List records = new ArrayList<>(); + Map record = new LinkedHashMap<>(4); + record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now())); + record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.put("content", content); + record.put("remark", ""); + records.add(record); + Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS); + if (existing instanceof List existingRecords) { + records.addAll(existingRecords); + } + taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records); + return JsonUtil.toJson(taskInfo); + } + + /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建,这一列不能挡住整次路线调整 */ + private Map parseTaskInfo(String taskInfoJson) { + Map taskInfo = new LinkedHashMap<>(); + if (Func.isBlank(taskInfoJson)) { + return taskInfo; + } + try { + Object parsed = JsonUtil.parse(taskInfoJson, Object.class); + if (parsed instanceof Map map) { + map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value)); + } + } catch (Exception ignored) { + taskInfo.clear(); + } + return taskInfo; + } + + private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) { + loading.setRouteJson(plan.routeJson()); + loading.setDepartureAddress(plan.departureAddress()); + loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress())); + loading.setArrivalAddress(plan.arrivalAddress()); + loading.setTaskInfoJson(plan.taskInfoJson()); + if (loadingManageMapper.updateById(loading) <= 0) { + throw new ServiceException("变更运输路线失败"); + } + } + + private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) { + if (Func.isNotBlank(plan.routeJson())) { + waybill.setRouteJson(plan.routeJson()); + } + waybill.setDepartureAddress(plan.departureAddress()); + waybill.setArrivalAddress(plan.arrivalAddress()); + waybill.setTaskInfoJson(plan.taskInfoJson()); + if (!waybillService.updateById(waybill)) { + throw new ServiceException("变更运输路线失败"); + } + } + + /** 解析成对象数组;坏 JSON 与空值都按空数组处理,路线坏了不该让整次操作崩掉 */ + private List> jsonObjectList(String json) { + List> result = new ArrayList<>(); + if (Func.isBlank(json)) { + return result; + } + try { + Object parsed = JsonUtil.parse(json, List.class); + if (parsed instanceof List list) { + for (Object item : list) { + if (item instanceof Map map) { + Map element = new LinkedHashMap<>(); + map.forEach((key, value) -> element.put(String.valueOf(key), value)); + result.add(element); + } + } + } + } catch (Exception ignored) { + return new ArrayList<>(); + } + return result; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String firstNonBlank(String first, String second, String fallback) { + if (Func.isNotBlank(first)) { + return first; + } + return Func.isNotBlank(second) ? second : fallback; + } + + /** 规范化后的一个站点:地址 + 该站的装卸标记 */ + private record Station(String address, List tags) { + } + + /** 一次路线调整要落库的全部内容,写入阶段只负责 set 与 update */ + private record RouteChangePlan(String followContent, + String routeJson, + String departureAddress, + String transitAddress, + String arrivalAddress, + String taskInfoJson) { + } + private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java index 9d0dd4a1..a00ec202 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IDriverWaybillService; @@ -55,7 +56,8 @@ class ExceptionDisposalListQueryTest { service = new ExceptionDisposalServiceImpl( mock(ExceptionDisposalFollowRecordMapper.class), mock(IWaybillService.class), - mock(IDriverWaybillService.class)); + mock(IDriverWaybillService.class), + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java index 5b9384f6..cdd36344 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRejectRevokeTest.java @@ -14,6 +14,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; @@ -65,7 +66,8 @@ class ExceptionDisposalRejectRevokeTest { disposalMapper = mock(ExceptionDisposalMapper.class); followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); service = new ExceptionDisposalServiceImpl( - followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class)); + followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class), + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index 4a64ae2a..1b13ec03 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -15,6 +15,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IDriverWaybillService; @@ -71,7 +72,8 @@ class ExceptionDisposalReportGateTest { waybillService = mock(IWaybillService.class); driverWaybillService = mock(IDriverWaybillService.class); service = new ExceptionDisposalServiceImpl( - mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService); + mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService, + mock(LoadingManageMapper.class)); inject("baseMapper", disposalMapper); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java new file mode 100644 index 00000000..62780eaa --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -0,0 +1,551 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 同意并调整路线:先写跟进记录、再改载体路线、最后了结。 + *

+ * 载体跟配载单走——配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单, + * 而运单的 route_json 是司机端按段解析的形状,改成站点行就读不出路线了。 + */ +class ExceptionDisposalRouteAdjustTest { + + private static final Long DISPOSAL_ID = 8201L; + private static final Long WAYBILL_ID = 8301L; + private static final Long USER_ID = 8401L; + private static final String LOADING_NO = "PZ20261023001"; + private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园"; + private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园"; + private static final String TRANSIT_SECOND = "贵阳市/白云区/都拉营物流园"; + private static final String END = "成都市/双流区/口岸路成藏宝物流园"; + private static final String NEW_END = "成都市/新都区/物流大道"; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalFollowRecordMapper followRecordMapper; + private IWaybillService waybillService; + private LoadingManageMapper loadingManageMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class); + waybillService = mock(IWaybillService.class); + loadingManageMapper = mock(LoadingManageMapper.class); + service = new ExceptionDisposalServiceImpl( + followRecordMapper, waybillService, mock(IDriverWaybillService.class), loadingManageMapper); + inject("baseMapper", disposalMapper); + when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1); + when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1); + when(loadingManageMapper.updateById(any(LoadingManage.class))).thenReturn(1); + when(waybillService.updateById(any(Waybill.class))).thenReturn(true); + } + + // ------------------------------------------------------------ 有配载单 + + @Test + @DisplayName("有配载单:路线整份写成站点行、地址列同步、变更记录前插、异常了结") + void adjustRouteOnLoadingWritesStationRows() { + stubPendingDisposal(); + LoadingManage loading = loading("in_progress", + stationRowsJson(List.of( + station(START, List.of("装1")), + station(TRANSIT, List.of("卸1")), + station(END, List.of("卸2"))))); + loading.setTaskInfoJson("{\"planName\":\"干线一\",\"routeChangeRecords\":[{\"content\":\"旧记录\"}]}"); + stubLoading(loading); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("transit", TRANSIT_SECOND, List.of("卸1")), + node("transit", TRANSIT, List.of("装2")), + node("end", END, List.of("卸2"))))); + + LoadingManage updated = updatedLoading(); + List> rows = JsonUtil.readListMap(updated.getRouteJson()); + assertEquals(4, rows.size(), "路线整份替换成调整后的站点"); + assertEquals(START, rows.get(0).get("address")); + assertEquals(START, rows.get(0).get("displayAddress"), "站点行没有独立站名,展示地址就是地址"); + assertEquals(List.of("装1"), rows.get(0).get("tags")); + assertEquals(List.of("卸1"), rows.get(1).get("tags"), "站点上的子运单标记必须原样保留"); + assertEquals(List.of("装2"), rows.get(2).get("tags")); + assertEquals(Boolean.FALSE, rows.get(0).get("locked")); + + assertEquals(START, updated.getDepartureAddress()); + assertEquals(END, updated.getArrivalAddress()); + assertEquals(TRANSIT_SECOND + "," + TRANSIT, updated.getTransitAddress(), + "中间站地址用中文逗号连接"); + + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + assertEquals("干线一", taskInfo.get("planName"), "任务信息里其它键不能丢"); + List records = (List) taskInfo.get("routeChangeRecords"); + assertEquals(2, records.size()); + Map latest = (Map) records.get(0); + assertEquals("韦调度", latest.get("changeUser"), "变更记录的操作人是真实姓名"); + assertEquals("", latest.get("remark")); + String changeTime = String.valueOf(latest.get("changeTime")); + assertTrue(changeTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), changeTime); + assertEquals("同意调整路线:途经站由 1 个改为 2 个", latest.get("content")); + assertEquals("旧记录", ((Map) records.get(1)).get("content"), "已有记录排在新的后面"); + + ExceptionDisposalFollowRecord record = insertedRecord(); + assertEquals("同意调整路线:途经站由 1 个改为 2 个", record.getFollowContent()); + assertEquals(USER_ID, record.getFollowUser()); + assertEquals("韦调度", record.getFollowUserName()); + assertNotNull(record.getFollowTime()); + + ExceptionDisposal settled = updatedDisposal(); + assertEquals("completed", settled.getDisposalStatus()); + assertEquals(record.getFollowContent(), settled.getLatestFollowContent()); + + InOrder order = inOrder(followRecordMapper, loadingManageMapper, disposalMapper); + order.verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class)); + order.verify(loadingManageMapper).updateById(any(LoadingManage.class)); + order.verify(disposalMapper).updateById(any(ExceptionDisposal.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("地址一个都没改也允许了结,记录写「同意调整路线,未修改路线」") + void adjustRouteWithoutAddressChangeStillSettles() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", END, List.of("卸1"))))); + + assertEquals("同意调整路线,未修改路线", insertedRecord().getFollowContent()); + LoadingManage updated = updatedLoading(); + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + Map latest = (Map) ((List) taskInfo.get("routeChangeRecords")).get(0); + assertEquals("同意调整路线,未修改路线", latest.get("content"), "变更记录与跟进记录同一句摘要"); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("首站地址被改动时拒绝,且不写任何记录、不改状态") + void adjustRouteRejectsStartAddressChange() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START + "(改)", List.of("装1")), + node("end", END, List.of("卸1")))))); + + assertEquals("起始地址不允许修改", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("站点标记的数字超出子运单序号时拒绝,不写一份司机端读不出装卸的路线") + void adjustRouteRejectsTagOutOfChildRange() { + stubPendingDisposal(); + LoadingManage loading = loading("in_progress", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))); + loading.setWaybillIdsJson("[9001,9002]"); + stubLoading(loading); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", END, List.of("卸3")))))); + + assertEquals("路线节点未关联子运单", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("已完成的配载单不允许再改路线") + void adjustRouteRejectsCompletedLoading() { + stubPendingDisposal(); + stubLoading(loading("completed", + stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", NEW_END, List.of("卸1")))))); + + assertEquals("当前状态不允许变更运输路线", exception.getMessage()); + verifyNoWrite(); + } + + // ------------------------------------------------------------ 节点校验 + + @Test + @DisplayName("少于两个站点的路线不是一条路线") + void adjustRouteRejectsSingleStation() { + stubPendingDisposal(); + stubLoading(loading("in_progress", null)); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request(node("start", START, List.of("装1")))))); + + assertEquals("路线节点至少需要两个", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("站点类型与站序不一致时拒绝:首站必须 start、末站必须 end") + void adjustRouteRejectsWrongNodeKind() { + stubPendingDisposal(); + stubLoading(loading("in_progress", null)); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("transit", START, List.of("装1")), + node("end", END, List.of("卸1")))))); + + assertEquals("路线节点类型不正确", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("已了结的异常再调整路线报「该异常已处理」") + void adjustRouteRejectsSettledDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("completed"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("end", NEW_END, List.of("卸1")))))); + + assertEquals("该异常已处理", exception.getMessage()); + verifyNoWrite(); + } + + // ------------------------------------------------------------ 未配载运单 + + @Test + @DisplayName("未配载的运单只按段改到达地,route_json 仍是司机端能解析的段数组") + void adjustRouteOnWaybillKeepsSegmentShape() { + stubPendingDisposal(); + Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("transit", TRANSIT, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + List> segments = JsonUtil.readListMap(updated.getRouteJson()); + assertEquals(2, segments.size(), "段数不变,不能改成站点行"); + assertFalse(segments.get(0).containsKey("address"), "运单路线不得出现站点行的 address 键"); + assertFalse(segments.get(0).containsKey("tags"), "运单路线不得出现站点行的 tags 键"); + assertEquals(TRANSIT, segments.get(0).get("arrivalAddress"), "没改的段原样保留"); + assertEquals(1, ((Number) segments.get(0).get("segmentNo")).intValue()); + assertEquals("road", segments.get(0).get("transportType"), "未知键与业务键都要原样保留"); + assertEquals(NEW_END, segments.get(1).get("arrivalAddress")); + assertEquals(NEW_END, segments.get(1).get("arrivalName"), "站名跟着地址一起改"); + assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改"); + + assertEquals(START, updated.getDepartureAddress()); + assertEquals(NEW_END, updated.getArrivalAddress()); + Map taskInfo = JsonUtil.toMap(updated.getTaskInfoJson()); + Map latest = (Map) ((List) taskInfo.get("routeChangeRecords")).get(0); + assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, latest.get("content")); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + verify(loadingManageMapper, never()).selectOne(any()); + } + + @Test + @DisplayName("运单已关联配载单但配载单查不到时拒绝,不越权改运单") + void adjustRouteRejectsWaybillBranchWhenLoadingNoPresent() { + stubPendingDisposal(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", segmentRouteJson())); + when(loadingManageMapper.selectOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler( + () -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null))))); + + assertEquals("运单已关联配载单,请在配载单中修改", exception.getMessage()); + verifyNoWrite(); + } + + @Test + @DisplayName("运单路线是 PC 写下的节点行形状时跳过路线改写,仍同步地址列并照常留痕") + void adjustRouteKeepsLegacyNodeRowWaybillRoute() { + stubPendingDisposal(); + String legacyRouteJson = JsonUtil.toJson(List.of( + Map.of("address", START, "tags", List.of("发", "YD001")), + Map.of("address", END, "tags", List.of("收", "YD001")))); + Waybill waybill = waybill(null, "in_progress", legacyRouteJson); + waybill.setDepartureAddress(START); + waybill.setArrivalAddress(END); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + assertEquals(legacyRouteJson, updated.getRouteJson(), "认不出的路线形状不能瞎写"); + assertEquals(START, updated.getDepartureAddress()); + assertEquals(NEW_END, updated.getArrivalAddress()); + assertEquals("同意调整路线:到货地 " + END + " → " + NEW_END, insertedRecord().getFollowContent()); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("运单站数变了时不动路线:段形状说不出哪一站被删,硬写等于重写行程") + void adjustRouteSkipsWaybillRouteWhenStationCountChanges() { + stubPendingDisposal(); + Waybill waybill = waybill(null, "in_progress", segmentRouteJson()); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, null), + node("end", NEW_END, null)))); + + Waybill updated = updatedWaybill(); + assertEquals(segmentRouteJson(), updated.getRouteJson(), "站数对不上时保留原路线"); + assertEquals(NEW_END, updated.getArrivalAddress()); + assertEquals("completed", updatedDisposal().getDisposalStatus()); + } + + @Test + @DisplayName("目的地未变而只见中间站变化时,按途经站数量说明") + void adjustRouteReportsTransitCountChange() { + stubPendingDisposal(); + stubLoading(loading("in_progress", + stationRowsJson(List.of( + station(START, List.of("装1")), + station(TRANSIT, List.of("卸1")), + station(END, List.of("卸2")))))); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null)); + + asScheduler(() -> service.adjustRoute(request( + node("start", START, List.of("装1")), + node("transit", TRANSIT_SECOND, List.of("卸1")), + node("transit", TRANSIT, List.of("装2")), + node("end", END, List.of("卸2"))))); + + assertEquals("同意调整路线:途经站由 1 个改为 2 个", insertedRecord().getFollowContent()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubPendingDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(DISPOSAL_ID); + disposal.setDisposalStatus("pending"); + disposal.setWaybillId(WAYBILL_ID); + disposal.setIsDeleted(0); + when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal); + } + + private void stubLoading(LoadingManage loading) { + when(loadingManageMapper.selectOne(any())).thenReturn(loading); + } + + private Waybill waybill(String loadingNo, String businessStatus, String routeJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD20261023001"); + waybill.setLoadingNo(loadingNo); + waybill.setBusinessStatus(businessStatus); + waybill.setRouteJson(routeJson); + waybill.setIsDeleted(0); + return waybill; + } + + private LoadingManage loading(String businessStatus, String routeJson) { + LoadingManage loading = new LoadingManage(); + loading.setId(8501L); + loading.setLoadingNo(LOADING_NO); + loading.setWaybillIdsJson("[9001,9002]"); + loading.setBusinessStatus(businessStatus); + loading.setRouteJson(routeJson); + loading.setIsDeleted(0); + return loading; + } + + /** 配载单的路线段夹具:两段三站,首段出发地是首站 */ + private String segmentRouteJson() { + List> segments = new ArrayList<>(); + Map first = new LinkedHashMap<>(); + first.put("departureName", START); + first.put("departureAddress", START); + first.put("arrivalName", TRANSIT); + first.put("arrivalAddress", TRANSIT); + first.put("segmentNo", 1); + first.put("transportType", "road"); + segments.add(first); + Map second = new LinkedHashMap<>(); + second.put("departureName", TRANSIT); + second.put("departureAddress", TRANSIT); + second.put("arrivalName", END); + second.put("arrivalAddress", END); + second.put("segmentNo", 2); + segments.add(second); + return JsonUtil.toJson(segments); + } + + private Map station(String address, List tags) { + Map row = new LinkedHashMap<>(); + row.put("address", address); + row.put("displayAddress", address); + row.put("tags", tags); + row.put("locked", false); + return row; + } + + private String stationRowsJson(List> rows) { + return JsonUtil.toJson(rows); + } + + private ExceptionDisposalRouteAdjustRequest.RouteNode node(String kind, String address, List tags) { + ExceptionDisposalRouteAdjustRequest.RouteNode node = new ExceptionDisposalRouteAdjustRequest.RouteNode(); + node.setKind(kind); + node.setAddress(address); + node.setTags(tags); + return node; + } + + private ExceptionDisposalRouteAdjustRequest request( + ExceptionDisposalRouteAdjustRequest.RouteNode... nodes) { + ExceptionDisposalRouteAdjustRequest request = new ExceptionDisposalRouteAdjustRequest(); + request.setId(DISPOSAL_ID); + request.setRouteNodes(List.of(nodes)); + return request; + } + + private ExceptionDisposalFollowRecord insertedRecord() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class); + verify(followRecordMapper).insert(captor.capture()); + return captor.getValue(); + } + + private LoadingManage updatedLoading() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingManage.class); + verify(loadingManageMapper).updateById(captor.capture()); + return captor.getValue(); + } + + private Waybill updatedWaybill() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillService).updateById(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal updatedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).updateById(captor.capture()); + return captor.getValue(); + } + + /** 校验不通过时不该留下任何写入 */ + private void verifyNoWrite() { + verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class)); + verify(loadingManageMapper, never()).updateById(any(LoadingManage.class)); + verify(waybillService, never()).updateById(any(Waybill.class)); + verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class)); + } + + /** 以调度员身份执行,登录态与真实姓名都走缓存 */ + private void asScheduler(Runnable action) { + asScheduler(() -> { + action.run(); + return null; + }); + } + + private T asScheduler(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦调度"); + return action.get(); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From ba2b0a3a8b2d13ec6e1b8c780da6a33385dd71de Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:10:25 +0800 Subject: [PATCH 3/5] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8B=E5=8F=91=E9=85=8D=E8=BD=BD=E5=8D=95=E3=80=81?= =?UTF-8?q?=E5=AD=90=E8=BF=90=E5=8D=95=E4=B8=8E=E5=8F=AF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=AB=99=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/vo/ExceptionDisposalVO.java | 37 ++ .../impl/ExceptionDisposalServiceImpl.java | 209 ++++++++++- .../ExceptionDisposalDetailPayloadTest.java | 325 ++++++++++++++++++ 3 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java index 77839038..d120aa54 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java @@ -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 cargo; + @TableField(exist = false) + @Schema(description = "关联运单的配载单号,没有配载单时为空") + private String loadingNo; + + @TableField(exist = false) + @Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列") + private List childWaybillNos; + + @TableField(exist = false) + @Schema(description = "可调整的路线站点,少于两个站时为空") + private List routeNodes; + + /** + * 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名 + */ + @Data + @Schema(description = "路线站点") + public static class RouteNode implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站点类型:start 首站、transit 中间站、end 末站") + private String kind; + + @Schema(description = "站点地址") + private String address; + + @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号") + private List tags; + + @Schema(description = "该站装卸的子运单项目名,按标记顺序去重") + private List projects; + + } + } 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 09bc49e0..14d0166d 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 @@ -61,10 +61,12 @@ 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; /** @@ -605,8 +607,7 @@ public class ExceptionDisposalServiceImpl int childCount = childWaybillCount(loading.getWaybillIdsJson()); for (Station station : stations) { for (String tag : station.tags()) { - String digits = tag.replaceAll("\\D+", ""); - int order = Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0); + int order = tagOrder(tag); if (order < 1 || order > childCount) { throw new ServiceException("路线节点未关联子运单"); } @@ -631,7 +632,7 @@ public class ExceptionDisposalServiceImpl /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */ private List loadingStationAddresses(LoadingManage loading) { List addresses = new ArrayList<>(); - for (Map row : jsonObjectList(loading.getRouteJson())) { + for (Map row : loadingStationRows(loading)) { addAddress(addresses, text(row.get("address"))); } if (!addresses.isEmpty()) { @@ -978,7 +979,7 @@ public class ExceptionDisposalServiceImpl return vo; } - /** 按关联运单补齐详情页路线 / 货物展示字段 */ + /** 按关联运单补齐详情页路线 / 货物 / 配载单展示字段 */ private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { if (vo == null || waybillId == null) { return; @@ -996,6 +997,206 @@ public class ExceptionDisposalServiceImpl cargo.put("name", Func.toStr(waybill.getCargoName(), "")); cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); vo.setCargo(cargo); + + fillRouteNodes(vo, waybill); + } + + /** + * 详情页的可编辑站点与配载单信息。 + *

+ * 配载单是这趟车的执行主体,站点取它的站点行;运单没有配载单号、或配载单已被删除 / 解绑时退回 + * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 + */ + private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { + LoadingManage loading = findLoading(waybill.getLoadingNo()); + if (loading == null) { + vo.setChildWaybillNos(new ArrayList<>()); + vo.setRouteNodes(waybillRouteNodes(waybill)); + return; + } + Map childWaybills = childWaybillsByOrder(loading); + vo.setLoadingNo(loading.getLoadingNo()); + vo.setChildWaybillNos(childWaybillNos(childWaybills)); + List> rows = loadingStationRows(loading); + vo.setRouteNodes(loadingRouteNodes(rows, childWaybills)); + vo.setRoute(loadingRoute(rows, loading, waybill)); + } + + /** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */ + private List> loadingStationRows(LoadingManage loading) { + List> rows = new ArrayList<>(); + for (Map row : jsonObjectList(loading.getRouteJson())) { + if (Func.isNotBlank(text(row.get("address")))) { + rows.add(row); + } + } + return rows; + } + + private List loadingRouteNodes(List> rows, + Map childWaybills) { + List nodes = new ArrayList<>(rows.size()); + for (Map row : rows) { + List 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 waybillRouteNodes(Waybill waybill) { + List 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 stationKinds(List 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 stationProjects(List tags, Map childWaybills) { + Set 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 childWaybillsByOrder(LoadingManage loading) { + Map childWaybills = new LinkedHashMap<>(); + List waybillIds = waybillIds(loading.getWaybillIdsJson()); + List queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList(); + if (queryIds.isEmpty()) { + return childWaybills; + } + Map waybillsById = new HashMap<>(); + List 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 waybillIds(String waybillIdsJson) { + List 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) { + // 运单集合坏了就没有子运单可言,详情退回运单自己的路线,不影响打开 + return new ArrayList<>(); + } + return waybillIds; + } + + /** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */ + private List childWaybillNos(Map childWaybills) { + List 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 loadingRoute(List> 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 route = new HashMap<>(2); + route.put("start", start); + route.put("end", end); + return route; + } + + private String stationDisplayAddress(Map 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 stringList(Object value) { + List 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) { 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 new file mode 100644 index 00000000..c28898c5 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalDetailPayloadTest.java @@ -0,0 +1,325 @@ +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; + +/** + * 异常详情下发的配载单、子运单与可编辑站点。 + *

+ * 有配载单时站点取配载单的站点行(这趟车的执行主体就是配载单);没有配载单才退回运单的路线段, + * 站点地址必须沿用改路线时的比对口径(地址优先、站名兜底),否则原样提交会被判成改过地址。 + */ +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 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 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 nodes = vo.getRouteNodes(); + assertEquals(List.of(START, TRANSIT, "贵阳"), + nodes.stream().map(ExceptionDisposalVO.RouteNode::getAddress).toList(), + "原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底"); + } + + // ------------------------------------------------------------ 夹具 + + 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> 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 stationRow(String address, String displayAddress, List tags) { + Map 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 segment(String departureName, String departureAddress, + String arrivalName, String arrivalAddress) { + Map 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 kinds(List nodes) { + return nodes.stream().map(ExceptionDisposalVO.RouteNode::getKind).toList(); + } + + private ExceptionDisposalVO detailResponse() { + // 详情里的创建人 / 更新人姓名走缓存,单测不装配 Spring 上下文 + try (MockedStatic 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); + } + +} From 87b473594a3e4a4e9123ea8e2e1691c4f0ed5062 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:29:00 +0800 Subject: [PATCH 4/5] =?UTF-8?q?:bug:=20=E4=BF=AE=E6=AD=A3=E5=88=86?= =?UTF-8?q?=E6=AE=B5=E6=94=B9=E5=9D=80=E7=9A=84=E5=87=BA=E5=8F=91=E5=9C=B0?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E7=AB=99=E6=95=B0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E8=AF=A6=E6=83=85=E5=AD=97=E6=AE=B5=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运单分支中间站改址后逐段同步 departure;无配载单的站数变化显式拒绝;已撤销异常不得再跟进或完成;配载单号、子运单号与可调整站点只在详情组装,列表路径不新增查询。 --- .../impl/ExceptionDisposalServiceImpl.java | 70 +++++++++++-------- .../ExceptionDisposalDetailPayloadTest.java | 20 +++++- .../impl/ExceptionDisposalListQueryTest.java | 47 ++++++++++++- .../ExceptionDisposalRejectRevokeTest.java | 41 ++++++++++- .../ExceptionDisposalRouteAdjustTest.java | 39 ++++++++--- 5 files changed, 173 insertions(+), 44 deletions(-) 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() { From aa7caf5a4ce896b60581f54c06c2f07f360d4756 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 01:29:09 +0800 Subject: [PATCH 5/5] =?UTF-8?q?:recycle:=20=E6=95=B4=E7=90=86=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=BD=AE=E8=B7=AF=E7=BA=BF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E3=80=81=E5=8F=82=E6=95=B0=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E7=BB=88=E6=80=81=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释只留状态与约束,去掉推理与取舍表述;firstNonBlank / rewriteSegmentPoint 参数改为有语义的名字;reject / revoke / adjustRoute 共用 assertNotSettled,文案沿用「该异常已处理」。 --- .../impl/ExceptionDisposalServiceImpl.java | 95 +++++++++---------- 1 file changed, 46 insertions(+), 49 deletions(-) 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 774e1c7e..bf0d38b7 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 @@ -440,10 +440,7 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请选择需要拒绝的异常"); } ExceptionDisposal disposal = getExisting(request.getId()); - // 已完成、已撤销都算已了结:再拒绝一次会覆盖最新处置结论,并往处置记录里多添一条 - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } + assertNotSettled(disposal); String rejectReason = Func.toStr(request.getRejectReason(), "").trim(); if (Func.isBlank(rejectReason)) { throw new ServiceException("请填写拒绝原因"); @@ -467,14 +464,12 @@ public class ExceptionDisposalServiceImpl public void revoke(Long id) { ExceptionDisposal disposal = getExisting(id); Long currentUserId = AuthUtil.getUserId(); - // 撤销是上报人收回自己的上报,不是一次处置:别的账号(包括调度员)要推翻上报只能走拒绝 + // 撤销是上报人收回自己的上报,不是一次处置 if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) { throw new ServiceException("只能撤销本人上报的异常"); } - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } - // 不写跟进记录,也不动 latest_follow_content:处置时间线里不该出现一条由上报人「处置」的记录 + assertNotSettled(disposal); + // 不写跟进记录,也不动 latest_follow_content disposal.setDisposalStatus(STATUS_REVOKED); updateById(disposal); } @@ -486,25 +481,23 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请选择需要调整路线的异常"); } ExceptionDisposal disposal = getExisting(request.getId()); - if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) { - throw new ServiceException("该异常已处理"); - } + 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 stations = validateRouteNodes(request.getRouteNodes(), loading); List sourceAddresses = loading != null ? loadingStationAddresses(loading) : waybillStationAddresses(waybill); List targetAddresses = stations.stream().map(Station::address).toList(); - // 首站地址只读;原数据里首站地址缺失时无从比对,放行而不是拒绝整次调整 + // 首站地址不可修改;原数据首站地址为空时跳过比对 if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0)) && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) { throw new ServiceException("起始地址不允许修改"); @@ -515,8 +508,7 @@ public class ExceptionDisposalServiceImpl } RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses); - // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态。 - // 少了任何一步都读不出「谁在什么时候把路线改成了什么」,所以任一步失败都整体回滚 + // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态 insertFollowRecord(disposal.getId(), plan.followContent()); if (loading != null) { applyLoadingRoute(loading, plan); @@ -528,6 +520,13 @@ public class ExceptionDisposalServiceImpl 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(); @@ -553,7 +552,7 @@ public class ExceptionDisposalServiceImpl /** * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。 *

- * kind 不额外接受「按位置猜」的写法:站序就是落库顺序,kind 与位置不一致时写下去的首末站地址会是错的。 + * kind 由位置决定,必须与站序一致。 */ private List validateRouteNodes(List routeNodes, LoadingManage loading) { @@ -597,10 +596,9 @@ public class ExceptionDisposalServiceImpl } /** - * 站点标记里的数字是配载单运单集合里的位次(1 起),越界的标记会让司机端找不到对应子运单, - * 那样的路线读不出装卸与货物,宁可报错也不落库。 + * 站点标记里的数字是配载单运单集合里的位次(1 起),越界时拒绝。 *

- * 没有配载单就没有子运单序号可言:运单的路线段没有装卸归属,站点标记不参与落库。 + * 没有配载单时没有子运单序号,站点标记不参与落库。 */ private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) { if (loading == null) { @@ -626,7 +624,7 @@ public class ExceptionDisposalServiceImpl List waybillIds = JsonUtil.parse(waybillIdsJson, List.class); return waybillIds == null ? 0 : waybillIds.size(); } catch (Exception ignored) { - // 运单集合不是 JSON 数组时按没有子运单处理,站点标记一律对不上 + // 运单集合不是 JSON 数组时按没有子运单处理 return 0; } } @@ -645,7 +643,7 @@ public class ExceptionDisposalServiceImpl /** * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。 - * 段里缺地址时退化用段名——运单详情与调整页用的也是这两列。 + * 段里缺地址时用站名替代。 */ private List waybillStationAddresses(Waybill waybill) { List> segments = routeSegments(waybill.getRouteJson()); @@ -739,7 +737,7 @@ public class ExceptionDisposalServiceImpl parts.add("到货地 " + displayAddress(sourceEnd) + " → " + displayAddress(targetEnd)); } if (parts.isEmpty()) { - // 首末站都没动,变化只能落在途经站:地址细节塞不进 500 字的跟进记录,按站数说明 + // 首末站未变时按途经站数量说明变化 parts.add("途经站由 " + transitCount(sourceAddresses) + " 个改为 " + transitCount(targetAddresses) + " 个"); } return ROUTE_CHANGE_PREFIX + String.join(";", parts); @@ -770,7 +768,7 @@ public class ExceptionDisposalServiceImpl // 站点行没有独立的站名,展示地址与地址取同一份文本 row.put("displayAddress", station.address()); row.put("tags", station.tags()); - // locked 只影响 PC 编辑弹窗能否拖动,司机端不读;这里写入的路线始终可继续调整 + // locked 只影响 PC 编辑弹窗能否拖动,司机端不读 row.put("locked", false); rows.add(row); } @@ -798,20 +796,20 @@ public class ExceptionDisposalServiceImpl return JsonUtil.toJson(rewritten); } - private void rewriteSegmentPoint(Map segment, String point, String targetAddress) { - String currentAddress = firstNonBlank(text(segment.get(point + "Address")), - text(segment.get(point + "Name")), ""); + /** 站点地址与站名写同一份文本 */ + private void rewriteSegmentPoint(Map 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(point + "Address", targetAddress); - segment.put(point + "Name", targetAddress); + segment.put(pointKey + "Address", targetAddress); + segment.put(pointKey + "Name", targetAddress); } /** - * 识别路线段形状的 route_json。运单历史上存在两种形状,另一种是 PC 写下的节点行 - * (address + tags + 运单号),司机端按段解析读不出来,因此这里只认段:元素带段字段、 - * 且不带 address / tags。 + * 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。 + * 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。 */ private List> routeSegments(String routeJson) { List> elements = jsonObjectList(routeJson); @@ -829,8 +827,7 @@ public class ExceptionDisposalServiceImpl /** * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。 - * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建,只丢这一列的无法解析部分, - * 不能让一次路线调整因为附件信息列而失败。 + * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建。 */ private String appendRouteChangeRecord(String taskInfoJson, String content) { Map taskInfo = parseTaskInfo(taskInfoJson); @@ -849,7 +846,7 @@ public class ExceptionDisposalServiceImpl return JsonUtil.toJson(taskInfo); } - /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建,这一列不能挡住整次路线调整 */ + /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建 */ private Map parseTaskInfo(String taskInfoJson) { Map taskInfo = new LinkedHashMap<>(); if (Func.isBlank(taskInfoJson)) { @@ -889,7 +886,7 @@ public class ExceptionDisposalServiceImpl } } - /** 解析成对象数组;坏 JSON 与空值都按空数组处理,路线坏了不该让整次操作崩掉 */ + /** 解析成对象数组;坏 JSON 与空值都按空数组处理 */ private List> jsonObjectList(String json) { List> result = new ArrayList<>(); if (Func.isBlank(json)) { @@ -916,18 +913,18 @@ public class ExceptionDisposalServiceImpl return value == null ? "" : String.valueOf(value).trim(); } - private String firstNonBlank(String first, String second, String fallback) { - if (Func.isNotBlank(first)) { - return first; + private String firstNonBlank(String addressValue, String nameValue, String fallbackValue) { + if (Func.isNotBlank(addressValue)) { + return addressValue; } - return Func.isNotBlank(second) ? second : fallback; + return Func.isNotBlank(nameValue) ? nameValue : fallbackValue; } /** 规范化后的一个站点:地址 + 该站的装卸标记 */ private record Station(String address, List tags) { } - /** 一次路线调整要落库的全部内容,写入阶段只负责 set 与 update */ + /** 一次路线调整要落库的全部内容 */ private record RouteChangePlan(String followContent, String routeJson, String departureAddress, @@ -1008,8 +1005,8 @@ public class ExceptionDisposalServiceImpl /** * 详情页的可编辑站点与配载单信息。 *

- * 配载单是这趟车的执行主体,站点取它的站点行;运单没有配载单号、或配载单已被删除 / 解绑时退回 - * 运单自己的路线段——历史异常仍要能打开,不能因为配载单不在了就打不开详情。 + * 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。 + * 运单不存在时三项各给空值。 */ private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) { if (vo == null) { @@ -1061,7 +1058,7 @@ public class ExceptionDisposalServiceImpl /** * 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。 - * 地址口径与改路线时的比对一致(地址优先、站名兜底),否则用户什么都没改直接提交也会被判成改过地址。 + * 地址口径与改路线时的比对一致(地址优先、站名兜底)。 */ private List waybillRouteNodes(Waybill waybill) { List nodes = new ArrayList<>(); @@ -1078,7 +1075,7 @@ public class ExceptionDisposalServiceImpl return stationKinds(nodes); } - /** 站序声明:首站 start、末站 end、其余 transit;不足两个站就没有首末可言,不给前端调整入口 */ + /** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */ private List stationKinds(List nodes) { if (nodes.size() < 2) { return new ArrayList<>(); @@ -1092,7 +1089,7 @@ public class ExceptionDisposalServiceImpl /** * 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。 - * 标记对不上子运单(越界、子运单已不存在)时跳过,一个坏标记不该让整个详情打不开。 + * 标记对不上子运单(越界、子运单已不存在)时跳过。 */ private List stationProjects(List tags, Map childWaybills) { Set projects = new LinkedHashSet<>(); @@ -1111,7 +1108,7 @@ public class ExceptionDisposalServiceImpl /** * 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。 - * 位次不能因为某张子运单查不到而前移,否则标记会指到别的子运单上。 + * 位次不因缺子运单而前移。 */ private Map childWaybillsByOrder(LoadingManage loading) { Map childWaybills = new LinkedHashMap<>(); @@ -1153,7 +1150,7 @@ public class ExceptionDisposalServiceImpl } } } catch (Exception ignored) { - // 运单集合坏了就没有子运单可言,详情退回运单自己的路线,不影响打开 + // 运单集合不是 JSON 数组时按没有子运单处理 return new ArrayList<>(); } return waybillIds;