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
+ * 拒绝复用已完成这个终态(台账仍显示「已处理」),所以它与「完成」的区别只落在跟进记录的第一句;
+ * 撤销是上报人收回,不是处置,因此不留跟进记录。
+ */
+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