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