✨ 异常处置新增编辑功能
运管员可订正已上报的异常内容:异常类型、异常原因、上报说明、现场照片。
车牌、项目、承运商、配载单号由上报时从运单回填,不开放编辑,避免与运单脱钩。
编辑不改动处置状态。状态只能经跟进与完成流转,因为运单详情、运单列表、
首页计数与待办四处都以 disposal_status IN (pending, processing) 判定未完成,
允许编辑改状态会让已结案的异常重新长出来,而那四处判定无从知晓。
每次编辑按字段逐项比对生成变更说明,写入一条跟进记录留痕。
变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超过跟进内容的
varchar(500) 上限,故按 500 截断。
Permission 表达式用 hasMenu('exception_disposal_edit'):
@PreAuth 的值由 SpEL 求值,root object 是 AuthFun,
写成裸字符串会被当作属性访问而在请求时抛错。
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 异常处置编辑请求
|
||||
* <p>
|
||||
* 只承载上报四件套。车牌、项目、承运商、配载单号等字段由上报时从运单回填,
|
||||
* 不在此列,避免编辑后与运单脱钩。
|
||||
* <p>
|
||||
* 不含处置状态:状态只能经跟进与完成流转,编辑不得改变它。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "异常处置编辑请求")
|
||||
public class ExceptionDisposalUpdateRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "异常处置ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "异常类型")
|
||||
private String exceptionType;
|
||||
|
||||
@Schema(description = "异常原因")
|
||||
private String exceptionReason;
|
||||
|
||||
@Schema(description = "上报说明")
|
||||
private String reportDescription;
|
||||
|
||||
@Schema(description = "现场照片,逗号分隔")
|
||||
private String scenePhotos;
|
||||
|
||||
}
|
||||
+12
@@ -30,11 +30,14 @@ import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
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.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.support.ExceptionDisposalPermissions;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController {
|
||||
return R.success("批量完成成功");
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
@PreAuth(ExceptionDisposalPermissions.EDIT)
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态")
|
||||
public R update(@RequestBody ExceptionDisposalUpdateRequest request) {
|
||||
exceptionDisposalService.update(request);
|
||||
return R.success("编辑成功");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -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.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
|
||||
@@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
|
||||
*/
|
||||
ExceptionDisposalVO submitReport(ExceptionDisposal request);
|
||||
|
||||
/**
|
||||
* 编辑异常处置的上报内容。不改动处置状态,并按字段变更追加一条跟进记录
|
||||
*/
|
||||
void update(ExceptionDisposalUpdateRequest request);
|
||||
|
||||
void follow(ExceptionDisposalFollowRequest request);
|
||||
|
||||
void complete(Long id);
|
||||
|
||||
+96
@@ -35,6 +35,7 @@ import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
@@ -47,6 +48,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -68,6 +70,9 @@ public class ExceptionDisposalServiceImpl
|
||||
private static final String STATUS_PROCESSING = "processing";
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
|
||||
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
|
||||
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
|
||||
|
||||
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private final IWaybillService waybillService;
|
||||
|
||||
@@ -165,6 +170,97 @@ public class ExceptionDisposalServiceImpl
|
||||
disposal.setLoadingOrMasterNo(loadingOrMaster);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(ExceptionDisposalUpdateRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要编辑的异常");
|
||||
}
|
||||
if (Func.isBlank(request.getExceptionType())) {
|
||||
throw new ServiceException("请选择异常类型");
|
||||
}
|
||||
if (Func.isBlank(request.getReportDescription())) {
|
||||
throw new ServiceException("请填写上报说明");
|
||||
}
|
||||
if (request.getReportDescription().length() > 500) {
|
||||
throw new ServiceException("上报说明不能超过500字");
|
||||
}
|
||||
if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) {
|
||||
throw new ServiceException("异常原因不能超过200字");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
|
||||
String exceptionType = request.getExceptionType().trim();
|
||||
String exceptionReason = Func.isBlank(request.getExceptionReason())
|
||||
? null
|
||||
: request.getExceptionReason().trim();
|
||||
String reportDescription = request.getReportDescription().trim();
|
||||
String scenePhotos = Func.isBlank(request.getScenePhotos())
|
||||
? null
|
||||
: request.getScenePhotos().trim();
|
||||
|
||||
String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos);
|
||||
if (Func.isBlank(changeLog)) {
|
||||
throw new ServiceException("未检测到任何修改");
|
||||
}
|
||||
|
||||
disposal.setExceptionType(exceptionType);
|
||||
disposal.setExceptionReason(exceptionReason);
|
||||
disposal.setReportDescription(reportDescription);
|
||||
disposal.setScenePhotos(scenePhotos);
|
||||
disposal.setLatestFollowContent(changeLog);
|
||||
updateById(disposal);
|
||||
|
||||
ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
|
||||
record.setDisposalId(disposal.getId());
|
||||
record.setFollowContent(changeLog);
|
||||
record.setFollowUser(AuthUtil.getUserId());
|
||||
record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.setFollowTime(LocalDateTime.now());
|
||||
followRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐字段比对生成变更说明,用于写入跟进记录。
|
||||
* 未发生变更时返回 null,调用方据此拒绝这次编辑。
|
||||
* <p>
|
||||
* 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制,
|
||||
* 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。
|
||||
*/
|
||||
private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason,
|
||||
String reportDescription, String scenePhotos) {
|
||||
List<String> changes = new ArrayList<>();
|
||||
appendChange(changes, "异常类型", before.getExceptionType(), exceptionType);
|
||||
appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason);
|
||||
appendChange(changes, "上报说明", before.getReportDescription(), reportDescription);
|
||||
if (!Objects.equals(before.getScenePhotos(), scenePhotos)) {
|
||||
changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张");
|
||||
}
|
||||
if (changes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return truncate("编辑:" + String.join(";", changes));
|
||||
}
|
||||
|
||||
private void appendChange(List<String> changes, String label, String before, String after) {
|
||||
String oldValue = Func.isBlank(before) ? "空" : before;
|
||||
String newValue = Func.isBlank(after) ? "空" : after;
|
||||
if (!Objects.equals(before, after)) {
|
||||
changes.add(label + "由 " + oldValue + " 改为 " + newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 截断到字段上限内,留出省略号位置 */
|
||||
private String truncate(String text) {
|
||||
return text.length() <= FOLLOW_CONTENT_MAX_LENGTH
|
||||
? text
|
||||
: text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
private int photoCount(String scenePhotos) {
|
||||
return splitPhotos(scenePhotos).size();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void follow(ExceptionDisposalFollowRequest request) {
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
/**
|
||||
* 异常处置的权限表达式
|
||||
* <p>
|
||||
* 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。
|
||||
* <p>
|
||||
* 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun},
|
||||
* 故函数名必须与它的公开方法一致({@code hasMenu})。
|
||||
* 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作
|
||||
* 属性访问而非权限判断,请求时抛 500 而不是 403。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class ExceptionDisposalPermissions {
|
||||
|
||||
/**
|
||||
* 编辑异常处置的上报内容
|
||||
*/
|
||||
public static final String EDIT = "hasMenu('exception_disposal_edit')";
|
||||
|
||||
private ExceptionDisposalPermissions() {
|
||||
}
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.core.secure.auth.AuthFun;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常处置权限表达式的可求值性测试。
|
||||
* <p>
|
||||
* 表达式写在 {@code @PreAuth} 注解里,由框架在请求时交给 SpEL 求值,
|
||||
* root object 是 {@code AuthFun}。写成裸字符串(如 {@code "exception_disposal_edit"})
|
||||
* 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
class ExceptionDisposalPermissionsTest {
|
||||
|
||||
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:拥有编辑菜单即通过")
|
||||
void editWithMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun),
|
||||
"授予编辑菜单的用户应能编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:没有编辑菜单则拒绝")
|
||||
void editWithoutMenu() {
|
||||
assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)),
|
||||
"未授予编辑菜单的用户不得编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:只有查看/跟进菜单不能编辑")
|
||||
void editRequiresDedicatedMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("exception_disposal_view")).thenReturn(true);
|
||||
when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true);
|
||||
when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true);
|
||||
|
||||
assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun),
|
||||
"能跟进、能完成不等于能编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("表达式必须引用真实存在的菜单码")
|
||||
void expressionReferencesRealMenuCode() {
|
||||
assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit"));
|
||||
assertFalse(ExceptionDisposalPermissions.EDIT.contains("hasAnyRole"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用与框架相同的方式求值:AuthFun 作为 root object
|
||||
*/
|
||||
private boolean evaluate(String expression, AuthFun authFun) {
|
||||
Expression parsed = parser.parseExpression(expression);
|
||||
StandardEvaluationContext context = new StandardEvaluationContext(authFun);
|
||||
return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 异常处置新增编辑权限码
|
||||
-- 日期:2026-10-10
|
||||
|
||||
INSERT INTO `blade_menu`
|
||||
(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`)
|
||||
VALUES
|
||||
(2090000000000002006,2090000000000002001,'exception_disposal_edit','编辑','exception_disposal_edit','','',5,2,0,1,NULL,'',0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`parent_id`=VALUES(`parent_id`),
|
||||
`name`=VALUES(`name`),
|
||||
`path`=VALUES(`path`),
|
||||
`sort`=VALUES(`sort`),
|
||||
`is_deleted`=0;
|
||||
|
||||
-- 授权给超级管理员角色。
|
||||
-- 不能按「持有 exception_disposal 菜单的角色」批量授权:司机角色同样持有该菜单,
|
||||
-- 会被一并授予编辑权——而编辑是运管员才有的操作。
|
||||
-- 注意:运管角色当前并未持有 exception_disposal 菜单,故此处不涉及它;
|
||||
-- 若日后把异常处置开放给运管,需另行授予本编辑码。
|
||||
INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`)
|
||||
SELECT 2090000000000002006 + (r.`id` % 1000), 2090000000000002006, r.`id`
|
||||
FROM `blade_role` r
|
||||
WHERE r.`role_alias` = 'administrator'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `blade_role_menu` x
|
||||
WHERE x.`menu_id` = 2090000000000002006 AND x.`role_id` = r.`id`
|
||||
);
|
||||
|
||||
-- 幂等收口:本编辑码只应挂在超级管理员上,其余角色一律收回。
|
||||
-- 上面的 SELECT 是按授权规则下发,这条是按同一规则回收,二者合起来可重复执行。
|
||||
DELETE FROM `blade_role_menu`
|
||||
WHERE `menu_id` = 2090000000000002006
|
||||
AND `role_id` NOT IN (SELECT `id` FROM `blade_role` WHERE `role_alias` = 'administrator');
|
||||
Reference in New Issue
Block a user