From d44e91d6e67e22b2405c0704f61ce54e52609f3f Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 19:59:41 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=BB=B4=E6=8A=A4=E6=8E=A5=E5=8F=A3=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B8=85=E7=A9=BA=E8=A7=92=E8=89=B2=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/system/role-member 分页/添加/移除,@DS("server"),权限 sys:role:update,写操作记操作日志 - UserServiceImpl.updateUser 在 roles 非空判断下先删后插,空列表清空全部角色;updateUser/pageRel 切 server 库 - OperationRecordServiceImpl 加类级 @DS("server"),操作日志真正写入 gxtyzx_core - OperationLogAspect 兼容无 User-Agent 的客户端,避免写日志 NPE 导致写操作失败 - RoleMemberIT 覆盖添加成员、移除最后一名成员、按角色分页、用户清空角色、403 与操作日志 --- pom.xml | 7 + .../core/aspect/OperationLogAspect.java | 9 +- .../controller/RoleMemberController.java | 60 +++++ .../system/mapper/RoleMemberMapper.java | 29 +++ .../system/mapper/xml/RoleMemberMapper.xml | 26 ++ .../common/system/param/RoleMemberParam.java | 31 +++ .../system/service/RoleMemberService.java | 38 +++ .../impl/OperationRecordServiceImpl.java | 2 + .../service/impl/RoleMemberServiceImpl.java | 102 ++++++++ .../system/service/impl/UserServiceImpl.java | 7 +- .../common/system/vo/RoleMemberVO.java | 40 ++++ .../com/gxwebsoft/approval/RoleMemberIT.java | 225 ++++++++++++++++++ .../com/gxwebsoft/support/CoreTestData.java | 167 +++++++++++++ 13 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java create mode 100644 src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java create mode 100644 src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml create mode 100644 src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java create mode 100644 src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java create mode 100644 src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/RoleMemberIT.java create mode 100644 src/test/java/com/gxwebsoft/support/CoreTestData.java diff --git a/pom.xml b/pom.xml index bc1296e..c5e8ec3 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,13 @@ test + + + org.springframework.security + spring-security-test + test + + org.springframework.boot diff --git a/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java b/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java index fefe1c7..86e6cfb 100644 --- a/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java +++ b/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java @@ -89,9 +89,12 @@ public class OperationLogAspect { record.setUrl(request.getRequestURI()); record.setRequestMethod(request.getMethod()); UserAgent ua = UserAgentUtil.parse(ServletUtil.getHeaderIgnoreCase(request, "User-Agent")); - record.setOs(ua.getPlatform().toString()); - record.setDevice(ua.getOs().toString()); - record.setBrowser(ua.getBrowser().toString()); + // 非浏览器客户端可能不带 User-Agent,此时不解析终端信息 + if (ua != null) { + record.setOs(ua.getPlatform() == null ? null : ua.getPlatform().toString()); + record.setDevice(ua.getOs() == null ? null : ua.getOs().toString()); + record.setBrowser(ua.getBrowser() == null ? null : ua.getBrowser().toString()); + } record.setIp(ServletUtil.getClientIP(request)); } // 记录异常信息 diff --git a/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java b/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java new file mode 100644 index 0000000..8985d1d --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java @@ -0,0 +1,60 @@ +package com.gxwebsoft.common.system.controller; + +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.param.RoleMemberParam; +import com.gxwebsoft.common.system.service.RoleMemberService; +import com.gxwebsoft.common.system.vo.RoleMemberVO; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +/** + * 角色成员控制器 + * + * @author WebSoft + * @since 2026-10-30 + */ +@Api(tags = "角色成员") +@RestController +@RequestMapping("/api/system/role-member") +public class RoleMemberController extends BaseController { + @Resource + private RoleMemberService roleMemberService; + + @PreAuthorize("hasAuthority('sys:role:update')") + @ApiOperation("分页查询角色成员") + @GetMapping("/page") + public ApiResult> page(RoleMemberParam param) { + return success(roleMemberService.pageMembers(param)); + } + + @PreAuthorize("hasAuthority('sys:role:update')") + @OperationLog + @ApiOperation("添加角色成员") + @PostMapping() + public ApiResult save(@RequestBody RoleMemberParam param) { + roleMemberService.addMember(param.getRoleId(), param.getUserId()); + return success("添加成功"); + } + + @PreAuthorize("hasAuthority('sys:role:update')") + @OperationLog + @ApiOperation("移除角色成员") + @DeleteMapping() + public ApiResult remove(@RequestParam Integer roleId, @RequestParam Integer userId) { + roleMemberService.removeMember(roleId, userId); + return success("移除成功"); + } +} diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java b/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java new file mode 100644 index 0000000..2bbc828 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.common.system.mapper; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.common.system.vo.RoleMemberVO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 角色成员Mapper + * + * @author WebSoft + * @since 2026-10-30 + */ +public interface RoleMemberMapper { + + /** + * 分页查询角色下的启用用户 + * + * @param page 分页对象 + * @param roleId 角色id + * @param keywords 账号/姓名关键词 + * @return List + */ + List selectPageByRoleId(@Param("page") IPage page, + @Param("roleId") Integer roleId, + @Param("keywords") String keywords); + +} diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml new file mode 100644 index 0000000..103cacf --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java b/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java new file mode 100644 index 0000000..4f0d757 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.common.system.param; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色成员查询/操作参数 + * + * @author WebSoft + * @since 2026-10-30 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@ApiModel(description = "角色成员参数") +public class RoleMemberParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("角色id") + @TableField(exist = false) + private Integer roleId; + + @ApiModelProperty("用户id") + @TableField(exist = false) + private Integer userId; +} diff --git a/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java b/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java new file mode 100644 index 0000000..166b0bc --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.common.system.service; + +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.param.RoleMemberParam; +import com.gxwebsoft.common.system.vo.RoleMemberVO; + +/** + * 角色成员Service + * + * @author WebSoft + * @since 2026-10-30 + */ +public interface RoleMemberService { + + /** + * 分页查询角色下的启用用户 + * + * @param param 查询参数 + * @return PageResult + */ + PageResult pageMembers(RoleMemberParam param); + + /** + * 添加角色成员 + * + * @param roleId 角色id + * @param userId 用户id + */ + void addMember(Integer roleId, Integer userId); + + /** + * 移除角色成员 + * + * @param roleId 角色id + * @param userId 用户id + */ + void removeMember(Integer roleId, Integer userId); +} diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java index 8095bf4..00884df 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java @@ -1,5 +1,6 @@ package com.gxwebsoft.common.system.service.impl; +import com.baomidou.dynamic.datasource.annotation.DS; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; @@ -19,6 +20,7 @@ import java.util.List; * @since 2018-12-24 16:10:02 */ @Service +@DS("server") public class OperationRecordServiceImpl extends ServiceImpl implements OperationRecordService { diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java new file mode 100644 index 0000000..190bcab --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java @@ -0,0 +1,102 @@ +package com.gxwebsoft.common.system.service.impl; + +import com.baomidou.dynamic.datasource.annotation.DS; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.entity.UserRole; +import com.gxwebsoft.common.system.mapper.RoleMapper; +import com.gxwebsoft.common.system.mapper.RoleMemberMapper; +import com.gxwebsoft.common.system.mapper.UserMapper; +import com.gxwebsoft.common.system.mapper.UserRoleMapper; +import com.gxwebsoft.common.system.param.RoleMemberParam; +import com.gxwebsoft.common.system.service.RoleMemberService; +import com.gxwebsoft.common.system.vo.RoleMemberVO; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.List; + +/** + * 角色成员Service实现 + * + * @author WebSoft + * @since 2026-10-30 + */ +@Service +@DS("server") +public class RoleMemberServiceImpl implements RoleMemberService { + @Resource + private RoleMapper roleMapper; + @Resource + private UserMapper userMapper; + @Resource + private UserRoleMapper userRoleMapper; + @Resource + private RoleMemberMapper roleMemberMapper; + + @Override + public PageResult pageMembers(RoleMemberParam param) { + PageParam page = new PageParam<>(param); + List list = roleMemberMapper.selectPageByRoleId(page, + param.getRoleId(), param.getKeywords()); + return new PageResult<>(list, page.getTotal()); + } + + @Transactional(rollbackFor = Exception.class) + @Override + public void addMember(Integer roleId, Integer userId) { + requireIds(roleId, userId); + checkRoleExists(roleId); + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + if (user.getStatus() == null || user.getStatus() != 0) { + throw new BusinessException("该用户已停用,不能添加为角色成员"); + } + if (userRoleMapper.selectCount(new QueryWrapper() + .eq("user_id", userId) + .eq("role_id", roleId)) > 0) { + throw new BusinessException("该用户已是角色成员"); + } + if (userRoleMapper.insertBatch(userId, Collections.singletonList(roleId)) < 1) { + throw new BusinessException("添加成员失败"); + } + } + + @Transactional(rollbackFor = Exception.class) + @Override + public void removeMember(Integer roleId, Integer userId) { + requireIds(roleId, userId); + checkRoleExists(roleId); + int removed = userRoleMapper.delete(new LambdaQueryWrapper() + .eq(UserRole::getUserId, userId) + .eq(UserRole::getRoleId, roleId)); + if (removed < 1) { + throw new BusinessException("该用户不是该角色成员"); + } + } + + private void requireIds(Integer roleId, Integer userId) { + if (roleId == null) { + throw new BusinessException("角色不能为空"); + } + if (userId == null) { + throw new BusinessException("用户不能为空"); + } + } + + private void checkRoleExists(Integer roleId) { + Role role = roleMapper.selectById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java index 660f618..ccaa7d2 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java @@ -49,6 +49,7 @@ public class UserServiceImpl extends ServiceImpl implements Us @Resource private RedisUtil redisUtil; + @DS("server") @Override public PageResult pageRel(UserParam param) { PageParam page = new PageParam<>(param); @@ -142,6 +143,7 @@ public class UserServiceImpl extends ServiceImpl implements Us return result; } + @DS("server") @Transactional(rollbackFor = {Exception.class}) @Override public boolean updateUser(User user) { @@ -161,10 +163,11 @@ public class UserServiceImpl extends ServiceImpl implements Us throw new BusinessException("邮箱已存在"); } boolean result = baseMapper.updateById(user) > 0; - if (result && user.getRoles() != null && user.getRoles().size() > 0) { + // roles 非 null 即代表本次提交了角色数据:先清空旧关联,空列表表示清空全部角色 + if (result && user.getRoles() != null) { userRoleService.remove(new LambdaUpdateWrapper().eq(UserRole::getUserId, user.getUserId())); List roleIds = user.getRoles().stream().map(Role::getRoleId).collect(Collectors.toList()); - if (userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) { + if (!roleIds.isEmpty() && userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) { throw new BusinessException("用户角色添加失败"); } } diff --git a/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java new file mode 100644 index 0000000..68edf9f --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.common.system.vo; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 角色成员 + * + * @author WebSoft + * @since 2026-10-30 + */ +@Data +@ApiModel(description = "角色成员") +public class RoleMemberVO implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("用户id") + private Integer userId; + + @ApiModelProperty("账号") + private String username; + + @ApiModelProperty("真实姓名") + private String realName; + + @ApiModelProperty("昵称") + private String nickname; + + @ApiModelProperty("所属组织id") + private Integer organizationId; + + @ApiModelProperty("所属组织名称") + private String organizationName; + + @ApiModelProperty("状态, 0正常, 1停用") + private Integer status; +} diff --git a/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java new file mode 100644 index 0000000..c62a63b --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java @@ -0,0 +1,225 @@ +package com.gxwebsoft.approval; + +import com.gxwebsoft.support.CoreTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 角色成员维护与用户清空角色的端到端行为。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class RoleMemberIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DataSource dataSource; + + private CoreTestData data; + private String suffix; + + @BeforeEach + void setUp() { + data = new CoreTestData(dataSource); + suffix = String.valueOf(System.nanoTime()); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void addsMemberAndListsRoleMembers() throws Exception { + int roleId = data.createRole("dept_leader_" + suffix, "部门负责人" + suffix); + int organizationId = data.createOrganization("测试部门-" + suffix); + int userId = data.createUser("member_" + suffix, "张三", organizationId, 0); + + addMember(roleId, userId).andExpect(jsonPath("$.code").value(0)); + assertThat(awaitOperationRecord("/api/system/role-member", String.valueOf(roleId))).isTrue(); + + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("page", "1") + .param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].userId").value(userId)) + .andExpect(jsonPath("$.data.list[0].username").value("member_" + suffix)) + .andExpect(jsonPath("$.data.list[0].realName").value("张三")) + .andExpect(jsonPath("$.data.list[0].organizationId").value(organizationId)) + .andExpect(jsonPath("$.data.list[0].organizationName").value("测试部门-" + suffix)) + .andExpect(jsonPath("$.data.list[0].status").value(0)); + } + + @Test + void removingLastMemberLeavesRoleEmpty() throws Exception { + int roleId = data.createRole("approval_archivist_" + suffix, "收文员" + suffix); + int userId = data.createUser("archivist_" + suffix, "李四", null, 0); + addMember(roleId, userId).andExpect(jsonPath("$.code").value(0)); + assertThat(data.roleIdsOf(userId)).containsExactly(roleId); + + mockMvc.perform(delete("/api/system/role-member") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("userId", String.valueOf(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(data.roleIdsOf(userId)).isEmpty(); + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("page", "1") + .param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(0)) + .andExpect(jsonPath("$.data.list.length()").value(0)); + } + + @Test + void clearsAllRolesWhenUserUpdateHasEmptyRoleList() throws Exception { + int roleId = data.createRole("dept_deputy_" + suffix, "部门副职" + suffix); + int userId = data.createUser("deputy_" + suffix, "王五", null, 0); + data.bind(userId, roleId); + assertThat(data.roleIdsOf(userId)).containsExactly(roleId); + + mockMvc.perform(put("/api/system/user") + .with(auth("sys:user:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"userId\":" + userId + ",\"nickname\":\"改名" + suffix + "\",\"roles\":[]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(data.roleIdsOf(userId)).isEmpty(); + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("page", "1") + .param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(0)); + } + + @Test + void pagesRoleMembersAndHidesDisabledUsers() throws Exception { + int roleId = data.createRole("dept_leader_page_" + suffix, "分页部门负责人" + suffix); + int otherRoleId = data.createRole("other_role_" + suffix, "其他角色" + suffix); + int first = data.createUser("page_a_" + suffix, "甲", null, 0); + int second = data.createUser("page_b_" + suffix, "乙", null, 0); + int third = data.createUser("page_c_" + suffix, "丙", null, 0); + int disabled = data.createUser("page_disabled_" + suffix, "停用", null, 1); + int outsider = data.createUser("page_other_" + suffix, "外角色", null, 0); + for (int userId : new int[]{first, second, third, disabled}) { + data.bind(userId, roleId); + } + data.bind(outsider, otherRoleId); + + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("page", "1") + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(3)) + .andExpect(jsonPath("$.data.list.length()").value(2)); + + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("page", "2") + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(3)) + .andExpect(jsonPath("$.data.list.length()").value(1)); + + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", String.valueOf(roleId)) + .param("keywords", "page_a_")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].userId").value(first)); + } + + @Test + void rejectsDuplicateMemberWithChineseMessage() throws Exception { + int roleId = data.createRole("dept_leader_dup_" + suffix, "重复部门负责人" + suffix); + int userId = data.createUser("dup_" + suffix, "赵六", null, 0); + addMember(roleId, userId).andExpect(jsonPath("$.code").value(0)); + + addMember(roleId, userId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已是角色成员"))); + } + + @Test + void rejectsWithoutRoleUpdateAuthority() throws Exception { + mockMvc.perform(get("/api/system/role-member/page") + .with(auth("sys:role:list")) + .header("tenantId", TestAuthentication.TENANT_ID) + .param("roleId", "586")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + } + + private org.springframework.test.web.servlet.ResultActions addMember(int roleId, int userId) throws Exception { + return mockMvc.perform(post("/api/system/role-member") + .with(auth("sys:role:update")) + .header("tenantId", TestAuthentication.TENANT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"roleId\":" + roleId + ",\"userId\":" + userId + "}")) + .andExpect(status().isOk()); + } + + private RequestPostProcessor auth(String... authorities) { + return authentication(TestAuthentication.admin(authorities)); + } + + /* 操作日志异步写入,最多等待 2 秒 */ + private boolean awaitOperationRecord(String url, String paramsMark) throws InterruptedException { + long deadline = System.currentTimeMillis() + 2000; + while (System.currentTimeMillis() < deadline) { + if (data.countOperationRecords(url, paramsMark) > 0) { + return true; + } + Thread.sleep(100); + } + return data.countOperationRecords(url, paramsMark) > 0; + } +} diff --git a/src/test/java/com/gxwebsoft/support/CoreTestData.java b/src/test/java/com/gxwebsoft/support/CoreTestData.java new file mode 100644 index 0000000..4f19c0f --- /dev/null +++ b/src/test/java/com/gxwebsoft/support/CoreTestData.java @@ -0,0 +1,167 @@ +package com.gxwebsoft.support; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * gxtyzx_core 测试夹具:直接写 sys_user / sys_role / sys_user_role / sys_organization。 + * + *

共享测试库不会被测试事务回滚,调用方必须在用例结束后执行 {@link #cleanup()}。

+ */ +public final class CoreTestData { + + private final JdbcTemplate core; + private final List userIds = new ArrayList<>(); + private final List roleIds = new ArrayList<>(); + private final List organizationIds = new ArrayList<>(); + private final List operationParamMarks = new ArrayList<>(); + + public CoreTestData(DataSource dataSource) { + DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server"); + this.core = new JdbcTemplate(server); + } + + /** + * 创建启用/停用用户,返回用户id。 + */ + public int createUser(String username, String realName, Integer organizationId, int status) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + core.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) " + + "VALUES (?, ?, ?, ?, ?, ?, 0, ?)", Statement.RETURN_GENERATED_KEYS); + ps.setString(1, username); + ps.setString(2, "test-password"); + ps.setString(3, realName); + ps.setString(4, realName); + if (organizationId == null) { + ps.setNull(5, java.sql.Types.INTEGER); + } else { + ps.setInt(5, organizationId); + } + ps.setInt(6, status); + ps.setInt(7, TestAuthentication.TENANT_ID); + return ps; + }, keyHolder); + int userId = keyHolder.getKey().intValue(); + userIds.add(userId); + return userId; + } + + /** + * 创建角色,返回角色id。 + */ + public int createRole(String roleCode, String roleName) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + core.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO sys_role (role_name, role_code, deleted, tenant_id) VALUES (?, ?, 0, ?)", + Statement.RETURN_GENERATED_KEYS); + ps.setString(1, roleName); + ps.setString(2, roleCode); + ps.setInt(3, TestAuthentication.TENANT_ID); + return ps; + }, keyHolder); + int roleId = keyHolder.getKey().intValue(); + roleIds.add(roleId); + // 角色成员写操作的操作日志参数里含 roleId,结束时可据此清理 + operationParamMarks.add(String.valueOf(roleId)); + return roleId; + } + + /** + * 创建组织,返回组织id。 + */ + public int createOrganization(String organizationName) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + core.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO sys_organization (parent_id, organization_name, organization_type, deleted, tenant_id) " + + "VALUES (0, ?, '', 0, ?)", Statement.RETURN_GENERATED_KEYS); + ps.setString(1, organizationName); + ps.setInt(2, TestAuthentication.TENANT_ID); + return ps; + }, keyHolder); + int organizationId = keyHolder.getKey().intValue(); + organizationIds.add(organizationId); + return organizationId; + } + + /** + * 绑定用户与角色。 + */ + public void bind(int userId, int roleId) { + core.update("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)", + userId, roleId, TestAuthentication.TENANT_ID); + } + + /** + * 查询用户当前的角色id。 + */ + public List roleIdsOf(int userId) { + return core.queryForList("SELECT role_id FROM sys_user_role WHERE user_id = ?", Integer.class, userId); + } + + /** + * 统计包含指定参数标记的操作日志条数。 + */ + public int countOperationRecords(String url, String paramsMark) { + Integer count = core.queryForObject( + "SELECT COUNT(*) FROM sys_operation_record WHERE url = ? AND params LIKE ?", + Integer.class, url, "%" + paramsMark + "%"); + return count == null ? 0 : count; + } + + /** + * 清理本夹具创建的全部行。 + */ + public void cleanup() { + if (!userIds.isEmpty() || !roleIds.isEmpty()) { + List args = new ArrayList<>(); + String userIn = placeholders(userIds.size()); + String roleIn = placeholders(roleIds.size()); + StringBuilder sql = new StringBuilder("DELETE FROM sys_user_role WHERE 1 = 0"); + if (!userIds.isEmpty()) { + sql.append(" OR user_id IN (").append(userIn).append(')'); + args.addAll(userIds); + } + if (!roleIds.isEmpty()) { + sql.append(" OR role_id IN (").append(roleIn).append(')'); + args.addAll(roleIds); + } + core.update(sql.toString(), args.toArray()); + } + if (!userIds.isEmpty()) { + core.update("DELETE FROM sys_user WHERE user_id IN (" + placeholders(userIds.size()) + ")", userIds.toArray()); + } + if (!roleIds.isEmpty()) { + core.update("DELETE FROM sys_role WHERE role_id IN (" + placeholders(roleIds.size()) + ")", roleIds.toArray()); + } + if (!organizationIds.isEmpty()) { + core.update("DELETE FROM sys_organization WHERE organization_id IN (" + placeholders(organizationIds.size()) + ")", + organizationIds.toArray()); + } + if (!operationParamMarks.isEmpty()) { + for (String mark : operationParamMarks) { + core.update("DELETE FROM sys_operation_record WHERE params LIKE ?", "%" + mark + "%"); + } + operationParamMarks.clear(); + } + userIds.clear(); + roleIds.clear(); + organizationIds.clear(); + } + + private static String placeholders(int size) { + return String.join(",", Collections.nCopies(size, "?")); + } +} From 4a1d02f040a03f01920a33cc24d705e9677c4138 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:18:47 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat(approval):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=A1=AE=E8=AE=A4=E5=8D=95=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E3=80=81=E9=99=84=E4=BB=B6=E3=80=81=E6=8F=90=E4=BA=A4=E4=B8=8E?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E8=B5=B7=E6=AD=A5=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 + sql/approval.sql | 140 +++++ sql/approval_menu.sql | 77 +++ .../approval/constant/ApprovalConstants.java | 169 ++++++ .../ApprovalAttachmentController.java | 47 ++ .../ApprovalDirectoryController.java | 45 ++ .../ApprovalInstanceController.java | 65 +++ .../approval/entity/ApprovalAttachment.java | 41 ++ .../entity/ApprovalCompletionForm.java | 40 ++ .../entity/ApprovalCoordinationForm.java | 41 ++ .../entity/ApprovalFlowDefinition.java | 41 ++ .../approval/entity/ApprovalInstance.java | 80 +++ .../approval/entity/ApprovalNoSequence.java | 31 + .../approval/entity/ApprovalRecord.java | 43 ++ .../approval/entity/ApprovalTask.java | 53 ++ .../mapper/ApprovalAttachmentMapper.java | 10 + .../mapper/ApprovalCompletionFormMapper.java | 10 + .../ApprovalCoordinationFormMapper.java | 10 + .../mapper/ApprovalFlowDefinitionMapper.java | 10 + .../mapper/ApprovalInstanceMapper.java | 35 ++ .../mapper/ApprovalNoSequenceMapper.java | 33 ++ .../approval/mapper/ApprovalRecordMapper.java | 10 + .../approval/mapper/ApprovalTaskMapper.java | 10 + .../mapper/xml/ApprovalInstanceMapper.xml | 59 ++ .../param/ApprovalCompletionParam.java | 30 + .../approval/param/ApprovalMineParam.java | 20 + .../service/ApprovalAttachmentService.java | 243 ++++++++ .../service/ApprovalDirectoryService.java | 177 ++++++ .../service/ApprovalEngineService.java | 159 ++++++ .../approval/service/ApprovalFileService.java | 94 +++ .../approval/service/ApprovalFlowService.java | 121 ++++ .../service/ApprovalInstanceService.java | 540 ++++++++++++++++++ .../approval/service/ApprovalNoService.java | 37 ++ .../approval/support/ApprovalApprover.java | 16 + .../approval/support/ApprovalItemRow.java | 28 + .../approval/support/ApprovalNode.java | 24 + .../support/ApprovalTenantContext.java | 48 ++ .../approval/vo/ApprovalAttachmentVO.java | 36 ++ .../approval/vo/ApprovalConfirmVO.java | 26 + .../approval/vo/ApprovalInstanceVO.java | 92 +++ .../gxwebsoft/approval/vo/ApprovalItemVO.java | 46 ++ .../approval/vo/ApprovalMyTaskVO.java | 23 + .../approval/vo/ApprovalOrganizationVO.java | 27 + .../approval/vo/ApprovalPermissionsVO.java | 33 ++ .../approval/vo/ApprovalRecordVO.java | 38 ++ .../gxwebsoft/approval/vo/ApprovalUserVO.java | 27 + .../approval/ApprovalCompletionDraftIT.java | 519 +++++++++++++++++ .../gxwebsoft/support/ApprovalTestData.java | 203 +++++++ .../gxwebsoft/support/TestAuthentication.java | 23 +- 49 files changed, 3732 insertions(+), 3 deletions(-) create mode 100644 sql/approval.sql create mode 100644 sql/approval_menu.sql create mode 100644 src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java create mode 100644 src/test/java/com/gxwebsoft/support/ApprovalTestData.java diff --git a/pom.xml b/pom.xml index bc1296e..30b2cca 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + diff --git a/sql/approval.sql b/sql/approval.sql new file mode 100644 index 0000000..c87f202 --- /dev/null +++ b/sql/approval.sql @@ -0,0 +1,140 @@ +-- 一期线下审批公共表与业务表(可重复执行) +-- 目标库:db_gxtyzx(master) + +CREATE TABLE IF NOT EXISTS approval_flow_definition ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + flow_code VARCHAR(40) NOT NULL, + version INT NOT NULL, + name VARCHAR(100) NOT NULL, + nodes_json JSON NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_flow_ver (tenant_id, flow_code, version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义版本'; + +CREATE TABLE IF NOT EXISTS approval_instance ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + biz_no VARCHAR(32) DEFAULT NULL, + flow_code VARCHAR(40) NOT NULL, + definition_id BIGINT NOT NULL, + definition_version INT NOT NULL, + doc_type VARCHAR(40) NOT NULL, + biz_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + round INT NOT NULL DEFAULT 0, + reporter_id INT NOT NULL, + reporter_name VARCHAR(100) DEFAULT NULL, + report_org_id INT DEFAULT NULL, + report_org_name VARCHAR(200) DEFAULT NULL, + accept_org_id INT DEFAULT NULL, + accept_org_name VARCHAR(200) DEFAULT NULL, + current_node_key VARCHAR(40) DEFAULT NULL, + current_node_type VARCHAR(20) DEFAULT NULL, + dispatch_target_id INT DEFAULT NULL, + dispatch_target_name VARCHAR(100) DEFAULT NULL, + submit_time DATETIME DEFAULT NULL, + approve_time DATETIME DEFAULT NULL, + archive_time DATETIME DEFAULT NULL, + archivist_id INT DEFAULT NULL, + archivist_name VARCHAR(100) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_biz_no (tenant_id, biz_no), + KEY idx_status_submit (tenant_id, status, submit_time), + KEY idx_reporter (tenant_id, reporter_id), + KEY idx_report_org (tenant_id, report_org_id), + KEY idx_accept_org (tenant_id, accept_org_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例'; + +CREATE TABLE IF NOT EXISTS approval_task ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + node_key VARCHAR(40) NOT NULL, + node_type VARCHAR(20) NOT NULL, + round INT NOT NULL, + task_type VARCHAR(20) NOT NULL, + assignee_id INT NOT NULL, + assignee_name VARCHAR(100) DEFAULT NULL, + status VARCHAR(20) NOT NULL, + handle_time DATETIME DEFAULT NULL, + opinion VARCHAR(500) DEFAULT NULL, + dispatch_target_id INT DEFAULT NULL, + dispatch_target_name VARCHAR(100) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_instance (instance_id), + KEY idx_assignee_status (tenant_id, assignee_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务'; + +CREATE TABLE IF NOT EXISTS approval_record ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + task_id BIGINT DEFAULT NULL, + node_key VARCHAR(40) DEFAULT NULL, + operator_id INT NOT NULL, + operator_name VARCHAR(100) DEFAULT NULL, + action VARCHAR(20) NOT NULL, + opinion VARCHAR(500) DEFAULT NULL, + round INT NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_instance_time (instance_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批处理记录'; + +CREATE TABLE IF NOT EXISTS approval_attachment ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + file_id INT NOT NULL, + category VARCHAR(20) NOT NULL, + uploader_id INT NOT NULL, + deleted TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_instance (instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批附件'; + +CREATE TABLE IF NOT EXISTS approval_no_sequence ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + prefix VARCHAR(8) NOT NULL, + seq_date DATE NOT NULL, + current_seq INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_seq (tenant_id, prefix, seq_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批单号序列'; + +CREATE TABLE IF NOT EXISTS approval_completion_form ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + implement_org VARCHAR(100) NOT NULL, + fee DECIMAL(12,2) NOT NULL, + service_content VARCHAR(2000) NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_instance (instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务完成确认单'; + +CREATE TABLE IF NOT EXISTS approval_coordination_form ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + accept_org_id INT NOT NULL, + accept_org_name VARCHAR(200) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(2000) NOT NULL, + remark VARCHAR(500) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_instance (instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协调单'; diff --git a/sql/approval_menu.sql b/sql/approval_menu.sql new file mode 100644 index 0000000..b92066c --- /dev/null +++ b/sql/approval_menu.sql @@ -0,0 +1,77 @@ +-- 一期线下审批菜单与权限种子(可重复执行) +-- 目标库:gxtyzx_core(server) + +-- 顶级菜单:审批管理 +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT 0, '审批管理', '/approval', '', 18, 'approval:instance:list', 'AuditOutlined', 0, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 +); + +SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1); + +-- 子菜单:待办审批 +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @parent, '待办审批', '/approval/todo', '/approval/todo', 1, 'approval:instance:list', '', 0, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/todo' AND menu_type = 0 AND deleted = 0 +); + +-- 子菜单:单据查询 +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @parent, '单据查询', '/approval/instance', '/approval/instance', 2, 'approval:instance:list', '', 0, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 +); + +SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1); + +-- 按钮权限(挂在单据查询下) +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @instanceMenu, '提交单据', '', '', 1, 'approval:instance:submit', '', 1, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:submit' AND menu_type = 1 AND deleted = 0 +); + +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @instanceMenu, '审批通过', '', '', 2, 'approval:instance:approve', '', 1, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:approve' AND menu_type = 1 AND deleted = 0 +); + +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @instanceMenu, '归档', '', '', 3, 'approval:instance:archive', '', 1, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:archive' AND menu_type = 1 AND deleted = 0 +); + +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @instanceMenu, '改派', '', '', 4, 'approval:instance:assign', '', 1, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:assign' AND menu_type = 1 AND deleted = 0 +); + +INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted) +SELECT @instanceMenu, '导出', '', '', 5, 'approval:instance:export', '', 1, 10150, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:export' AND menu_type = 1 AND deleted = 0 +); + +-- 授权 superAdmin(role_id = 586, tenant 10150) +INSERT INTO sys_role_menu (role_id, menu_id, tenant_id) +SELECT 586, m.menu_id, 10150 +FROM sys_menu m +WHERE m.tenant_id = 10150 + AND m.deleted = 0 + AND (m.path IN ('/approval', '/approval/todo', '/approval/instance') OR m.authority LIKE 'approval:instance:%') + AND NOT EXISTS ( + SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150 + ); diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java new file mode 100644 index 0000000..3ea09af --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -0,0 +1,169 @@ +package com.gxwebsoft.approval.constant; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 审批域常量:状态、节点、动作、单据类型、单号前缀与附件规则。 + */ +public final class ApprovalConstants { + + private ApprovalConstants() { + } + + // 实例状态 + public static final String STATUS_DRAFT = "DRAFT"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_REJECTED = "REJECTED"; + public static final String STATUS_WITHDRAWN = "WITHDRAWN"; + public static final String STATUS_APPROVED = "APPROVED"; + public static final String STATUS_ARCHIVED = "ARCHIVED"; + + /** 发起人可编辑、可提交的状态。 */ + public static final Set EDITABLE_STATUSES = + Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList( + STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN))); + + // 任务状态 + public static final String TASK_PENDING = "PENDING"; + public static final String TASK_DONE = "DONE"; + public static final String TASK_CANCELLED = "CANCELLED"; + + // 节点类型 / 任务类型 + public static final String NODE_APPROVE = "APPROVE"; + public static final String NODE_COUNTERSIGN = "COUNTERSIGN"; + public static final String NODE_DISPATCH = "DISPATCH"; + public static final String NODE_CONFIRM = "CONFIRM"; + public static final String NODE_ARCHIVE = "ARCHIVE"; + + // 动作 + public static final String ACTION_SUBMIT = "SUBMIT"; + public static final String ACTION_RESUBMIT = "RESUBMIT"; + public static final String ACTION_APPROVE = "APPROVE"; + public static final String ACTION_REJECT = "REJECT"; + public static final String ACTION_WITHDRAW = "WITHDRAW"; + public static final String ACTION_DISPATCH = "DISPATCH"; + public static final String ACTION_CONFIRM = "CONFIRM"; + public static final String ACTION_ARCHIVE = "ARCHIVE"; + public static final String ACTION_ASSIGN = "ASSIGN"; + + // 单据类型 / 流程编码 + public static final String DOC_COMPLETION = "completion"; + public static final String DOC_COORDINATION = "coordination"; + public static final String FLOW_COMPLETION = "completion"; + public static final String FLOW_COORDINATION_INTERNAL = "coordination_internal"; + + // 单号前缀 + public static final String NO_PREFIX_COMPLETION = "WG"; + public static final String NO_PREFIX_COORDINATION = "XT"; + + // 附件分类 + public static final String CATEGORY_SIGNED = "SIGNED"; + public static final String CATEGORY_OTHER = "OTHER"; + + // 角色编码 + public static final String ROLE_ARCHIVIST = "approval_archivist"; + public static final String ROLE_SUPER_ADMIN = "superAdmin"; + + // 附件规则 + public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; + public static final int MAX_ATTACHMENT_COUNT = 9; + public static final List ATTACHMENT_EXTENSIONS = + Collections.unmodifiableList(Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx")); + + /** 扩展名与允许的 content-type 对应关系(服务端双校验)。 */ + public static final Map> ATTACHMENT_CONTENT_TYPES; + + static { + Map> types = new LinkedHashMap<>(); + types.put("jpg", Arrays.asList("image/jpeg", "image/jpg")); + types.put("jpeg", Arrays.asList("image/jpeg", "image/jpg")); + types.put("png", Collections.singletonList("image/png")); + types.put("pdf", Collections.singletonList("application/pdf")); + types.put("doc", Collections.singletonList("application/msword")); + types.put("docx", Collections.singletonList( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document")); + types.put("xls", Collections.singletonList("application/vnd.ms-excel")); + types.put("xlsx", Collections.singletonList( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + ATTACHMENT_CONTENT_TYPES = Collections.unmodifiableMap(types); + } + + private static final Map STATUS_NAMES; + private static final Map DOC_TYPE_NAMES; + private static final Map ACTION_NAMES; + private static final Map CATEGORY_NAMES; + + static { + Map status = new LinkedHashMap<>(); + status.put(STATUS_DRAFT, "未提交"); + status.put(STATUS_PENDING, "审批中"); + status.put(STATUS_REJECTED, "已驳回"); + status.put(STATUS_WITHDRAWN, "已撤回"); + status.put(STATUS_APPROVED, "审批完成"); + status.put(STATUS_ARCHIVED, "已归档"); + STATUS_NAMES = Collections.unmodifiableMap(status); + + Map docType = new LinkedHashMap<>(); + docType.put(DOC_COMPLETION, "业务完成确认单"); + docType.put(DOC_COORDINATION, "内部协调单"); + DOC_TYPE_NAMES = Collections.unmodifiableMap(docType); + + Map action = new LinkedHashMap<>(); + action.put(ACTION_SUBMIT, "提交"); + action.put(ACTION_RESUBMIT, "重新提交"); + action.put(ACTION_APPROVE, "通过"); + action.put(ACTION_REJECT, "驳回"); + action.put(ACTION_WITHDRAW, "撤回"); + action.put(ACTION_DISPATCH, "派单"); + action.put(ACTION_CONFIRM, "存档确认"); + action.put(ACTION_ARCHIVE, "归档"); + action.put(ACTION_ASSIGN, "改派"); + ACTION_NAMES = Collections.unmodifiableMap(action); + + Map category = new LinkedHashMap<>(); + category.put(CATEGORY_SIGNED, "签字件"); + category.put(CATEGORY_OTHER, "其他"); + CATEGORY_NAMES = Collections.unmodifiableMap(category); + } + + /** 业务完成确认单流程定义(version=1)。 */ + public static final String COMPLETION_NODES_JSON = "[" + + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + + "]"; + + /** 内部协调单流程定义(version=1,表单与派单由后续迭代启用)。 */ + public static final String COORDINATION_NODES_JSON = "[" + + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"}," + + "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"}," + + "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgMembers\",\"name\":\"派单\"}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + + "]"; + + public static String statusName(String status) { + return STATUS_NAMES.getOrDefault(status, status); + } + + public static String docTypeName(String docType) { + return DOC_TYPE_NAMES.getOrDefault(docType, docType); + } + + public static String actionName(String action) { + return ACTION_NAMES.getOrDefault(action, action); + } + + public static String categoryName(String category) { + return CATEGORY_NAMES.getOrDefault(category, category); + } + + public static String departmentLeaderRoleCode(Integer organizationId) { + return organizationId == null ? null : "dept_leader_" + organizationId; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java new file mode 100644 index 0000000..8893eeb --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java @@ -0,0 +1,47 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.service.ApprovalAttachmentService; +import com.gxwebsoft.approval.vo.ApprovalAttachmentVO; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; + +/** + * 审批附件接口:走审批域端点,不要求文件管理权限。 + */ +@Api(tags = "审批附件") +@RestController +@RequestMapping("/api/approval/attachment") +public class ApprovalAttachmentController extends BaseController { + + @Resource + private ApprovalAttachmentService attachmentService; + + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("上传附件") + @PostMapping("/upload") + public ApiResult upload(@RequestParam("instanceId") Long instanceId, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "category", required = false) String category) { + return success(attachmentService.upload(instanceId, file, category, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("逻辑删除附件") + @DeleteMapping("/{id}") + public ApiResult delete(@PathVariable("id") Long id) { + attachmentService.delete(id, getLoginUser()); + return success("删除成功"); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java new file mode 100644 index 0000000..192eb34 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.service.ApprovalDirectoryService; +import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 审批目录接口:组织与启用用户。 + */ +@Api(tags = "审批目录") +@RestController +@RequestMapping("/api/approval/directory") +public class ApprovalDirectoryController extends BaseController { + + @Resource + private ApprovalDirectoryService directoryService; + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("启用组织") + @GetMapping("/organizations") + public ApiResult> organizations() { + return success(directoryService.listOrganizations()); + } + + @PreAuthorize("hasAuthority('approval:instance:assign')") + @ApiOperation("启用用户搜索") + @GetMapping("/users") + public ApiResult> users( + @RequestParam(value = "keywords", required = false) String keywords, + @RequestParam(value = "organizationId", required = false) Integer organizationId) { + return success(directoryService.searchUsers(keywords, organizationId)); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java new file mode 100644 index 0000000..586a594 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalMineParam; +import com.gxwebsoft.approval.service.ApprovalInstanceService; +import com.gxwebsoft.approval.vo.ApprovalInstanceVO; +import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +/** + * 审批实例接口(员工端与 PC 共用)。 + */ +@Api(tags = "审批实例") +@RestController +@RequestMapping("/api/approval") +public class ApprovalInstanceController extends BaseController { + + @Resource + private ApprovalInstanceService instanceService; + + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("新建或保存业务完成确认单草稿") + @PostMapping("/instance/completion") + public ApiResult saveCompletion(@RequestBody ApprovalCompletionParam param) { + User user = getLoginUser(); + return success("保存成功", instanceService.saveCompletion(param, user)); + } + + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("提交或重提") + @PostMapping("/instance/{id}/submit") + public ApiResult submit(@PathVariable("id") Long id) { + User user = getLoginUser(); + String warning = instanceService.submit(id, user); + return success(warning == null ? "提交成功" : warning, instanceService.detail(id, user)); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("单据详情") + @GetMapping("/instance/{id}") + public ApiResult detail(@PathVariable("id") Long id) { + return success(instanceService.detail(id, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("我发起的") + @GetMapping("/mine/page") + public ApiResult> mine(ApprovalMineParam param) { + return success(instanceService.mine(param, getLoginUser())); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java new file mode 100644 index 0000000..3aa1985 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java @@ -0,0 +1,41 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批附件绑定(逻辑删除)。 + */ +@Data +@ApiModel(description = "审批附件") +@TableName("approval_attachment") +public class ApprovalAttachment implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private Integer fileId; + + @ApiModelProperty("分类:SIGNED 签字件 / OTHER 其他") + private String category; + + private Integer uploaderId; + + @TableLogic + private Integer deleted; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java new file mode 100644 index 0000000..456873b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 业务完成确认单。 + */ +@Data +@ApiModel(description = "业务完成确认单") +@TableName("approval_completion_form") +public class ApprovalCompletionForm implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private String name; + + private String implementOrg; + + private BigDecimal fee; + + private String serviceContent; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java new file mode 100644 index 0000000..121d497 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java @@ -0,0 +1,41 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 内部协调单(一期仅内部流程)。 + */ +@Data +@ApiModel(description = "内部协调单") +@TableName("approval_coordination_form") +public class ApprovalCoordinationForm implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private Integer acceptOrgId; + + private String acceptOrgName; + + private String title; + + private String content; + + private String remark; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java new file mode 100644 index 0000000..fce235c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java @@ -0,0 +1,41 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批流程定义版本。 + */ +@Data +@ApiModel(description = "审批流程定义版本") +@TableName("approval_flow_definition") +public class ApprovalFlowDefinition implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + @ApiModelProperty("租户id") + private Integer tenantId; + + @ApiModelProperty("流程编码") + private String flowCode; + + @ApiModelProperty("流程版本") + private Integer version; + + @ApiModelProperty("流程名称") + private String name; + + @ApiModelProperty("节点JSON") + private String nodesJson; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java new file mode 100644 index 0000000..ee9ff55 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java @@ -0,0 +1,80 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批实例。 + */ +@Data +@ApiModel(description = "审批实例") +@TableName("approval_instance") +public class ApprovalInstance implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + @ApiModelProperty("单号,草稿为空") + private String bizNo; + + private String flowCode; + + private Long definitionId; + + private Integer definitionVersion; + + @ApiModelProperty("单据类型:completion/coordination") + private String docType; + + @ApiModelProperty("业务表主键") + private Long bizId; + + private String status; + + @ApiModelProperty("重提轮次") + private Integer round; + + private Integer reporterId; + + private String reporterName; + + private Integer reportOrgId; + + private String reportOrgName; + + private Integer acceptOrgId; + + private String acceptOrgName; + + private String currentNodeKey; + + private String currentNodeType; + + private Integer dispatchTargetId; + + private String dispatchTargetName; + + private LocalDateTime submitTime; + + private LocalDateTime approveTime; + + private LocalDateTime archiveTime; + + private Integer archivistId; + + private String archivistName; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java new file mode 100644 index 0000000..149b313 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDate; + +/** + * 审批单号序列(租户 + 前缀 + 日期)。 + */ +@Data +@ApiModel(description = "审批单号序列") +@TableName("approval_no_sequence") +public class ApprovalNoSequence implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private String prefix; + + private LocalDate seqDate; + + private Integer currentSeq; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java new file mode 100644 index 0000000..22d99a2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java @@ -0,0 +1,43 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批处理记录(只增不改)。 + */ +@Data +@ApiModel(description = "审批处理记录") +@TableName("approval_record") +public class ApprovalRecord implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private Long taskId; + + private String nodeKey; + + private Integer operatorId; + + private String operatorName; + + private String action; + + private String opinion; + + private Integer round; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java new file mode 100644 index 0000000..81a34b8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java @@ -0,0 +1,53 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批任务。 + */ +@Data +@ApiModel(description = "审批任务") +@TableName("approval_task") +public class ApprovalTask implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private String nodeKey; + + private String nodeType; + + private Integer round; + + private String taskType; + + private Integer assigneeId; + + private String assigneeName; + + private String status; + + private LocalDateTime handleTime; + + private String opinion; + + private Integer dispatchTargetId; + + private String dispatchTargetName; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java new file mode 100644 index 0000000..eeb5962 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalAttachment; + +/** + * 审批附件Mapper。 + */ +public interface ApprovalAttachmentMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java new file mode 100644 index 0000000..0019df0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalCompletionForm; + +/** + * 业务完成确认单Mapper。 + */ +public interface ApprovalCompletionFormMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java new file mode 100644 index 0000000..9c4c13f --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalCoordinationForm; + +/** + * 内部协调单Mapper。 + */ +public interface ApprovalCoordinationFormMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java new file mode 100644 index 0000000..2ee3873 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalFlowDefinition; + +/** + * 审批流程定义Mapper。 + */ +public interface ApprovalFlowDefinitionMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java new file mode 100644 index 0000000..0212f77 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java @@ -0,0 +1,35 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.param.ApprovalMineParam; +import com.gxwebsoft.approval.support.ApprovalItemRow; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 审批实例Mapper。 + */ +public interface ApprovalInstanceMapper extends BaseMapper { + + /** + * 对实例行加锁(必须在事务内调用)。 + */ + ApprovalInstance selectByIdForUpdate(@Param("id") Long id); + + /** + * 判断当前用户是否可查看实例。 + */ + int countVisible(@Param("id") Long id, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); + + /** + * 我发起的列表。 + */ + List selectMinePage(@Param("page") IPage page, + @Param("param") ApprovalMineParam param, + @Param("userId") Integer userId); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java new file mode 100644 index 0000000..999e44d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.approval.mapper; + +import com.gxwebsoft.approval.entity.ApprovalNoSequence; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** + * 审批单号序列Mapper:事务内行锁取号。 + */ +public interface ApprovalNoSequenceMapper { + + /** + * 确保当天序列行存在;唯一键冲突时静默忽略。 + */ + @Insert("INSERT IGNORE INTO approval_no_sequence (prefix, seq_date, current_seq) " + + "VALUES (#{prefix}, CURDATE(), 0)") + int insertTodayIfAbsent(@Param("prefix") String prefix); + + /** + * 锁定当天序列行。 + */ + @Select("SELECT id, prefix, seq_date, current_seq FROM approval_no_sequence " + + "WHERE prefix = #{prefix} AND seq_date = CURDATE() FOR UPDATE") + ApprovalNoSequence selectTodayForUpdate(@Param("prefix") String prefix); + + /** + * 序号自增。 + */ + @Update("UPDATE approval_no_sequence SET current_seq = current_seq + 1 WHERE id = #{id}") + int increment(@Param("id") Long id); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java new file mode 100644 index 0000000..34f58ed --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalRecord; + +/** + * 审批处理记录Mapper。 + */ +public interface ApprovalRecordMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java new file mode 100644 index 0000000..9326221 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalTask; + +/** + * 审批任务Mapper。 + */ +public interface ApprovalTaskMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml new file mode 100644 index 0000000..61cdb83 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java new file mode 100644 index 0000000..e8151d4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业务完成确认单草稿参数;id 为审批实例id,更新草稿时传入。 + */ +@Data +public class ApprovalCompletionParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("审批实例id(更新草稿时传入)") + private Long id; + + @ApiModelProperty("名称") + private String name; + + @ApiModelProperty("实施单位") + private String implementOrg; + + @ApiModelProperty("费用(元)") + private BigDecimal fee; + + @ApiModelProperty("服务内容") + private String serviceContent; +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java new file mode 100644 index 0000000..05b67f5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.param; + +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 我发起的查询参数。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalMineParam extends BaseParam { + + @ApiModelProperty("实例状态") + private String status; + + @ApiModelProperty("单据类型") + private String docType; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java new file mode 100644 index 0000000..7a3dbfa --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -0,0 +1,243 @@ +package com.gxwebsoft.approval.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalAttachment; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.vo.ApprovalAttachmentVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.utils.FileServerUtil; +import com.gxwebsoft.common.system.entity.FileRecord; +import com.gxwebsoft.common.system.entity.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.io.File; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 审批附件服务:上传、逻辑删除、详情拼装。 + * + *

文件落盘与 sys_file_record 复用既有文件基础设施,附件行写 master 库。 + * 上传不开启跨库事务:先落文件记录再绑定附件,失败时最多留下孤儿文件(允许)。

+ */ +@Service +public class ApprovalAttachmentService { + + private static final Logger log = LoggerFactory.getLogger(ApprovalAttachmentService.class); + + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalAttachmentMapper attachmentMapper; + @Resource + private ApprovalFileService fileService; + @Resource + private ApprovalDirectoryService directoryService; + @Resource + private ConfigProperties config; + + /** + * 详情附件列表:过滤逻辑删除,并从文件记录补齐名称/地址/大小。 + */ + public List listVos(Long instanceId) { + List attachments = attachmentMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalAttachment::getInstanceId, instanceId) + .orderByAsc(ApprovalAttachment::getId)); + if (attachments.isEmpty()) { + return new ArrayList<>(); + } + List fileIds = attachments.stream().map(ApprovalAttachment::getFileId).collect(Collectors.toList()); + Map files = fileService.listByIds(fileIds).stream() + .collect(Collectors.toMap(FileRecord::getId, Function.identity(), (a, b) -> a)); + List uploaderIds = attachments.stream().map(ApprovalAttachment::getUploaderId) + .distinct().collect(Collectors.toList()); + Map uploaders = directoryService.listUsersByIds(uploaderIds).stream() + .collect(Collectors.toMap(ApprovalUserVO::getUserId, this::displayName, (a, b) -> a)); + List result = new ArrayList<>(); + for (ApprovalAttachment attachment : attachments) { + FileRecord file = files.get(attachment.getFileId()); + if (file == null) { + // 文件记录缺失视为不可用附件,不向前端暴露悬空链接 + continue; + } + result.add(toVO(attachment, file, uploaders.get(attachment.getUploaderId()))); + } + return result; + } + + public int count(Long instanceId) { + Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalAttachment::getInstanceId, instanceId)); + return count == null ? 0 : count; + } + + /** + * 上传附件:仅发起人且实例可编辑时可上传。 + */ + public ApprovalAttachmentVO upload(Long instanceId, MultipartFile file, String category, User user) { + ApprovalInstance instance = instanceMapper.selectById(instanceId); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + checkEditable(instance, user); + + String normalizedCategory = StrUtil.isBlank(category) ? ApprovalConstants.CATEGORY_OTHER : category.trim(); + if (!ApprovalConstants.CATEGORY_SIGNED.equals(normalizedCategory) + && !ApprovalConstants.CATEGORY_OTHER.equals(normalizedCategory)) { + throw new BusinessException("附件分类不正确"); + } + if (file == null || file.isEmpty()) { + throw new BusinessException("附件内容为空"); + } + if (count(instanceId) >= ApprovalConstants.MAX_ATTACHMENT_COUNT) { + throw new BusinessException("每单最多上传" + ApprovalConstants.MAX_ATTACHMENT_COUNT + "张附件"); + } + if (file.getSize() > ApprovalConstants.MAX_ATTACHMENT_SIZE) { + throw new BusinessException("单个附件不能超过20MB"); + } + String extension = extensionOf(file.getOriginalFilename()); + checkExtensionAndContentType(extension, file.getContentType()); + + File stored; + String path; + try { + String base = config.getUploadPath(); + String dir = (base.endsWith("/") ? base : base + "/") + "approval/"; + stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName())); + path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); + } catch (Exception e) { + log.error("审批附件落盘失败", e); + throw new BusinessException("附件上传失败"); + } + + FileRecord fileRecord = new FileRecord(); + fileRecord.setCreateUserId(user.getUserId()); + fileRecord.setName(StrUtil.isBlank(file.getOriginalFilename()) ? stored.getName() : file.getOriginalFilename()); + fileRecord.setLength(stored.length()); + fileRecord.setPath(path); + fileRecord.setContentType(normalizeContentType(file.getContentType())); + fileService.save(fileRecord); + + ApprovalAttachment attachment = new ApprovalAttachment(); + attachment.setInstanceId(instanceId); + attachment.setFileId(fileRecord.getId()); + attachment.setCategory(normalizedCategory); + attachment.setUploaderId(user.getUserId()); + attachment.setDeleted(0); + attachment.setCreateTime(LocalDateTime.now()); + attachmentMapper.insert(attachment); + + return toVO(attachment, fileRecord, displayName(user)); + } + + /** + * 逻辑删除附件,底层文件记录不删除。 + */ + public void delete(Long attachmentId, User user) { + ApprovalAttachment attachment = attachmentMapper.selectById(attachmentId); + if (attachment == null) { + throw new BusinessException("附件不存在"); + } + ApprovalInstance instance = instanceMapper.selectById(attachment.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + checkEditable(instance, user); + attachmentMapper.deleteById(attachmentId); + } + + private void checkEditable(ApprovalInstance instance, User user) { + if (user == null || !instance.getReporterId().equals(user.getUserId()) + || !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException("当前状态不可修改"); + } + } + + private void checkExtensionAndContentType(String extension, String contentType) { + if (extension == null || !ApprovalConstants.ATTACHMENT_EXTENSIONS.contains(extension)) { + throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx"); + } + List allowed = ApprovalConstants.ATTACHMENT_CONTENT_TYPES.get(extension); + String normalized = normalizeContentType(contentType); + if (normalized == null || allowed == null || !allowed.contains(normalized)) { + throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx"); + } + } + + private String extensionOf(String filename) { + if (filename == null) { + return null; + } + int index = filename.lastIndexOf('.'); + if (index < 0 || index == filename.length() - 1) { + return null; + } + return filename.substring(index + 1).toLowerCase(); + } + + private String normalizeContentType(String contentType) { + if (contentType == null) { + return null; + } + int index = contentType.indexOf(';'); + String value = index >= 0 ? contentType.substring(0, index) : contentType; + return value.trim().toLowerCase(); + } + + private ApprovalAttachmentVO toVO(ApprovalAttachment attachment, FileRecord file, String uploaderName) { + ApprovalAttachmentVO vo = new ApprovalAttachmentVO(); + vo.setId(attachment.getId()); + vo.setFileId(attachment.getFileId()); + vo.setName(file.getName()); + vo.setUrl(urlOf(file)); + vo.setLength(file.getLength()); + vo.setCategory(attachment.getCategory()); + vo.setCategoryName(ApprovalConstants.categoryName(attachment.getCategory())); + vo.setUploaderId(attachment.getUploaderId()); + vo.setUploaderName(uploaderName); + vo.setCreateTime(attachment.getCreateTime()); + return vo; + } + + private String urlOf(FileRecord file) { + if (StrUtil.isNotBlank(file.getUrl())) { + return file.getUrl(); + } + return config.getServerUrl() + "/file" + (file.getPath() == null ? "" : file.getPath()); + } + + private String displayName(User user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + + private String displayName(ApprovalUserVO user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java new file mode 100644 index 0000000..0005653 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -0,0 +1,177 @@ +package com.gxwebsoft.approval.service; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.gxwebsoft.approval.support.ApprovalTenantContext; +import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.util.Collections; +import java.util.List; + +/** + * 审批目录服务:组织、部门负责人/收文员解析、启用用户查询。 + * + *

直接使用 gxtyzx_core 的专用连接,而不是 MyBatis 动态数据源代理: + * 审批请求可能处在外层事务中,动态数据源在事务内无法切换库。 + * 所有 SQL 显式带 tenant_id。

+ */ +@Service +public class ApprovalDirectoryService { + + private static final RowMapper ORGANIZATION_MAPPER = (resultSet, rowNum) -> { + ApprovalOrganizationVO vo = new ApprovalOrganizationVO(); + vo.setOrganizationId(resultSet.getInt("organization_id")); + vo.setParentId(resultSet.getInt("parent_id")); + vo.setOrganizationName(resultSet.getString("organization_name")); + vo.setOrganizationFullName(resultSet.getString("organization_full_name")); + vo.setOrganizationCode(resultSet.getString("organization_code")); + int leaderId = resultSet.getInt("leader_id"); + vo.setLeaderId(resultSet.wasNull() ? null : leaderId); + return vo; + }; + + private static final RowMapper USER_MAPPER = (resultSet, rowNum) -> { + ApprovalUserVO vo = new ApprovalUserVO(); + vo.setUserId(resultSet.getInt("user_id")); + vo.setUsername(resultSet.getString("username")); + vo.setNickname(resultSet.getString("nickname")); + vo.setRealName(resultSet.getString("real_name")); + int organizationId = resultSet.getInt("organization_id"); + vo.setOrganizationId(resultSet.wasNull() ? null : organizationId); + vo.setOrganizationName(resultSet.getString("organization_name")); + return vo; + }; + + private final JdbcTemplate jdbcTemplate; + + public ApprovalDirectoryService(DataSource dataSource) { + DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server"); + this.jdbcTemplate = new JdbcTemplate(server); + } + + /** 启用组织。 */ + public List listOrganizations() { + return jdbcTemplate.query( + "SELECT organization_id, parent_id, organization_name, organization_full_name, " + + "organization_code, leader_id FROM sys_organization " + + "WHERE tenant_id = ? AND deleted = 0 ORDER BY sort_number, organization_id", + ORGANIZATION_MAPPER, ApprovalTenantContext.currentTenantId()); + } + + /** 按id取启用组织;不存在返回 null。 */ + public ApprovalOrganizationVO getOrganization(Integer organizationId) { + if (organizationId == null || organizationId <= 0) { + return null; + } + List list = jdbcTemplate.query( + "SELECT organization_id, parent_id, organization_name, organization_full_name, " + + "organization_code, leader_id FROM sys_organization " + + "WHERE organization_id = ? AND tenant_id = ? AND deleted = 0", + ORGANIZATION_MAPPER, organizationId, ApprovalTenantContext.currentTenantId()); + return list.isEmpty() ? null : list.get(0); + } + + /** 角色下全部启用成员;角色不存在或成员为空时返回空列表。 */ + public List listRoleMembers(String roleCode) { + if (roleCode == null) { + return Collections.emptyList(); + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + return jdbcTemplate.query( + "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "JOIN sys_user_role ur ON ur.user_id = u.user_id AND ur.tenant_id = ? " + + "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE r.role_code = ? AND r.deleted = 0 AND u.status = 0 AND u.deleted = 0 " + + "AND u.tenant_id = ? ORDER BY u.user_id", + USER_MAPPER, tenantId, tenantId, tenantId, roleCode, tenantId); + } + + /** 部门负责人角色是否已配置至少一名启用成员。 */ + public boolean hasDepartmentLeader(Integer organizationId) { + return organizationId != null && !listRoleMembers("dept_leader_" + organizationId).isEmpty(); + } + + /** 启用用户搜索,最多 50 条。 */ + public List searchUsers(String keywords, Integer organizationId) { + Integer tenantId = ApprovalTenantContext.currentTenantId(); + StringBuilder sql = new StringBuilder( + "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0"); + List args = new java.util.ArrayList<>(); + args.add(tenantId); + args.add(tenantId); + if (organizationId != null) { + sql.append(" AND u.organization_id = ?"); + args.add(organizationId); + } + if (keywords != null && !keywords.isEmpty()) { + sql.append(" AND (u.username LIKE ? OR u.real_name LIKE ? OR u.nickname LIKE ?)"); + String like = "%" + keywords + "%"; + args.add(like); + args.add(like); + args.add(like); + } + sql.append(" ORDER BY u.user_id LIMIT 50"); + return jdbcTemplate.query(sql.toString(), USER_MAPPER, args.toArray()); + } + + /** 部门启用成员。 */ + public List listOrganizationMembers(Integer organizationId) { + if (organizationId == null || organizationId <= 0) { + return Collections.emptyList(); + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + return jdbcTemplate.query( + "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE u.organization_id = ? AND u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 " + + "ORDER BY u.user_id", + USER_MAPPER, tenantId, organizationId, tenantId); + } + + /** 按id批量取启用用户。 */ + public List listUsersByIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?")); + String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE u.tenant_id = ? AND u.user_id IN (" + placeholders + ")"; + List args = new java.util.ArrayList<>(); + args.add(tenantId); + args.add(tenantId); + args.addAll(userIds); + return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); + } + + /** 是否持有任一角色(用于 allVisible 判断)。 */ + public boolean hasAnyRole(Integer userId, String... roleCodes) { + if (userId == null || roleCodes == null || roleCodes.length == 0) { + return false; + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + String placeholders = String.join(",", Collections.nCopies(roleCodes.length, "?")); + String sql = "SELECT COUNT(1) FROM sys_user_role ur " + + "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? " + + "WHERE ur.user_id = ? AND ur.tenant_id = ? AND r.deleted = 0 AND r.role_code IN (" + placeholders + ")"; + List args = new java.util.ArrayList<>(); + args.add(tenantId); + args.add(userId); + args.add(tenantId); + Collections.addAll(args, roleCodes); + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args.toArray()); + return count != null && count > 0; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java new file mode 100644 index 0000000..4711d23 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -0,0 +1,159 @@ +package com.gxwebsoft.approval.service; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalRecord; +import com.gxwebsoft.approval.entity.ApprovalTask; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; +import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; +import com.gxwebsoft.approval.support.ApprovalApprover; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。 + * + *

本类只写 master 库,必须在调用方的事务内执行;后续迭代在此扩展 + * 通过/驳回/撤回/存档/归档/派单的节点推进,不再改动已发布的方法签名。

+ */ +@Service +public class ApprovalEngineService { + + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalTaskMapper taskMapper; + @Resource + private ApprovalRecordMapper recordMapper; + @Resource + private ApprovalFlowService flowService; + + /** + * 提交/重提:激活流程首节点,为解析出的审批人生成或签任务。 + * + * @return 生成的任务数;0 表示节点缺配,实例停留在当前节点等待管理员补配置 + */ + public int startFlow(ApprovalInstance instance, List approvers, + User operator, String action) { + List nodes = flowService.parseNodes(instance.getDefinitionId()); + if (nodes.isEmpty()) { + throw new BusinessException("审批流程未配置节点"); + } + return activateNode(instance, nodes.get(0), approvers, operator, action, null); + } + + /** + * 激活指定节点:快照审批人并写处理记录。 + */ + public int activateNode(ApprovalInstance instance, ApprovalNode node, + List approvers, User operator, + String action, String opinion) { + instance.setCurrentNodeKey(node.getKey()); + instance.setCurrentNodeType(node.getType()); + instanceMapper.updateById(instance); + addRecord(instance, null, node.getKey(), operator, action, opinion); + return createTasks(instance, node, instance.getRound(), approvers); + } + + /** + * 对实例行加锁,串行化节点推进。 + */ + public ApprovalInstance lockInstance(Long instanceId) { + ApprovalInstance instance = instanceMapper.selectByIdForUpdate(instanceId); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + return instance; + } + + /** + * 取消同一轮同一节点的其余待办(或签处理完成后调用)。 + */ + public int cancelPendingTasks(Long instanceId, Integer round, Long exceptTaskId) { + UpdateWrapper wrapper = new UpdateWrapper() + .eq("instance_id", instanceId) + .eq("round", round) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("status", ApprovalConstants.TASK_CANCELLED); + if (exceptTaskId != null) { + wrapper.ne("id", exceptTaskId); + } + return taskMapper.update(null, wrapper); + } + + /** + * 追加处理记录。 + */ + public void addRecord(ApprovalInstance instance, Long taskId, String nodeKey, + User operator, String action, String opinion) { + ApprovalRecord record = new ApprovalRecord(); + record.setInstanceId(instance.getId()); + record.setTaskId(taskId); + record.setNodeKey(nodeKey); + record.setOperatorId(operator.getUserId()); + record.setOperatorName(displayName(operator)); + record.setAction(action); + record.setOpinion(opinion); + record.setRound(instance.getRound()); + record.setCreateTime(LocalDateTime.now()); + recordMapper.insert(record); + } + + /** + * 当前节点之后的下一个节点;已是末节点返回 null。 + */ + public ApprovalNode nextNode(ApprovalInstance instance, String currentNodeKey) { + List nodes = flowService.parseNodes(instance.getDefinitionId()); + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i).getKey().equals(currentNodeKey)) { + return i + 1 < nodes.size() ? nodes.get(i + 1) : null; + } + } + return null; + } + + public String nodeName(ApprovalInstance instance, String nodeKey) { + return flowService.nodeName(instance.getDefinitionId(), nodeKey); + } + + private int createTasks(ApprovalInstance instance, ApprovalNode node, Integer round, + List approvers) { + if (approvers == null || approvers.isEmpty()) { + return 0; + } + int created = 0; + for (ApprovalApprover approver : approvers) { + ApprovalTask task = new ApprovalTask(); + task.setInstanceId(instance.getId()); + task.setNodeKey(node.getKey()); + task.setNodeType(node.getType()); + task.setRound(round); + task.setTaskType(node.getType()); + task.setAssigneeId(approver.getUserId()); + task.setAssigneeName(approver.getUserName()); + task.setStatus(ApprovalConstants.TASK_PENDING); + task.setCreateTime(LocalDateTime.now()); + taskMapper.insert(task); + created++; + } + return created; + } + + private String displayName(User user) { + if (user.getRealName() != null && !user.getRealName().isEmpty()) { + return user.getRealName(); + } + if (user.getNickname() != null && !user.getNickname().isEmpty()) { + return user.getNickname(); + } + return user.getUsername(); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java new file mode 100644 index 0000000..c987404 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java @@ -0,0 +1,94 @@ +package com.gxwebsoft.approval.service; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.gxwebsoft.approval.support.ApprovalTenantContext; +import com.gxwebsoft.common.system.entity.FileRecord; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * 审批域文件记录读写:直接操作 gxtyzx_core.sys_file_record。 + * + *

使用 server 库专用连接,保证在 master 事务内仍能读写核心库。 + * 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。

+ */ +@Service +public class ApprovalFileService { + + private static final RowMapper FILE_MAPPER = (resultSet, rowNum) -> { + FileRecord record = new FileRecord(); + record.setId(resultSet.getInt("id")); + record.setName(resultSet.getString("name")); + record.setPath(resultSet.getString("path")); + record.setLength(resultSet.getLong("length")); + record.setContentType(resultSet.getString("content_type")); + record.setCreateUserId(resultSet.getInt("create_user_id")); + record.setDeleted(resultSet.getInt("deleted")); + return record; + }; + + private final JdbcTemplate jdbcTemplate; + + public ApprovalFileService(DataSource dataSource) { + DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server"); + this.jdbcTemplate = new JdbcTemplate(server); + } + + public FileRecord save(FileRecord fileRecord) { + Integer tenantId = ApprovalTenantContext.currentTenantId(); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO sys_file_record " + + "(name, path, length, content_type, create_user_id, deleted, tenant_id) " + + "VALUES (?, ?, ?, ?, ?, 0, ?)", + Statement.RETURN_GENERATED_KEYS); + statement.setString(1, fileRecord.getName()); + statement.setString(2, fileRecord.getPath()); + statement.setLong(3, fileRecord.getLength() == null ? 0L : fileRecord.getLength()); + statement.setString(4, fileRecord.getContentType()); + statement.setInt(5, fileRecord.getCreateUserId()); + statement.setInt(6, tenantId); + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + fileRecord.setId(key == null ? null : key.intValue()); + return fileRecord; + } + + public FileRecord getById(Integer id) { + if (id == null) { + return null; + } + List records = jdbcTemplate.query( + "SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record " + + "WHERE id = ? AND tenant_id = ? AND deleted = 0", + FILE_MAPPER, id, ApprovalTenantContext.currentTenantId()); + return records.isEmpty() ? null : records.get(0); + } + + public List listByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + List args = new ArrayList<>(); + args.add(ApprovalTenantContext.currentTenantId()); + args.addAll(ids); + return jdbcTemplate.query( + "SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record " + + "WHERE tenant_id = ? AND deleted = 0 AND id IN (" + placeholders + ")", + FILE_MAPPER, args.toArray()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java new file mode 100644 index 0000000..d618338 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java @@ -0,0 +1,121 @@ +package com.gxwebsoft.approval.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalFlowDefinition; +import com.gxwebsoft.approval.mapper.ApprovalFlowDefinitionMapper; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.common.core.exception.BusinessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.List; + +/** + * 流程定义服务:按租户幂等发布 version=1,并解析节点 JSON。 + */ +@Service +public class ApprovalFlowService { + + private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class); + private static final int INITIAL_VERSION = 1; + + @Resource + private ApprovalFlowDefinitionMapper flowDefinitionMapper; + @Resource + private ObjectMapper objectMapper; + + /** + * 获取租户下的流程定义;不存在时创建 version=1。 + * + *

并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新查询。

+ */ + public ApprovalFlowDefinition getOrCreate(String flowCode) { + ApprovalFlowDefinition definition = find(flowCode); + if (definition != null) { + return definition; + } + ApprovalFlowDefinition created = new ApprovalFlowDefinition(); + created.setFlowCode(flowCode); + created.setVersion(INITIAL_VERSION); + created.setName(flowName(flowCode)); + created.setNodesJson(flowNodesJson(flowCode)); + try { + flowDefinitionMapper.insert(created); + return created; + } catch (DuplicateKeyException e) { + log.info("审批流程定义已被并发创建,重新读取: {}", flowCode); + definition = find(flowCode); + if (definition == null) { + throw new BusinessException("审批流程定义初始化失败"); + } + return definition; + } + } + + public ApprovalFlowDefinition getById(Long definitionId) { + ApprovalFlowDefinition definition = flowDefinitionMapper.selectById(definitionId); + if (definition == null) { + throw new BusinessException("审批流程定义不存在"); + } + return definition; + } + + public List parseNodes(Long definitionId) { + ApprovalFlowDefinition definition = getById(definitionId); + try { + List nodes = objectMapper.readValue(definition.getNodesJson(), + objectMapper.getTypeFactory().constructCollectionType(List.class, ApprovalNode.class)); + return nodes == null ? Collections.emptyList() : nodes; + } catch (Exception e) { + throw new BusinessException("审批流程定义格式错误"); + } + } + + public ApprovalNode findNode(Long definitionId, String nodeKey) { + if (nodeKey == null) { + return null; + } + return parseNodes(definitionId).stream() + .filter(node -> nodeKey.equals(node.getKey())) + .findFirst() + .orElse(null); + } + + public String nodeName(Long definitionId, String nodeKey) { + ApprovalNode node = findNode(definitionId, nodeKey); + return node == null ? null : node.getName(); + } + + private ApprovalFlowDefinition find(String flowCode) { + return flowDefinitionMapper.selectOne(new LambdaQueryWrapper() + .eq(ApprovalFlowDefinition::getFlowCode, flowCode) + .eq(ApprovalFlowDefinition::getVersion, INITIAL_VERSION) + .last("LIMIT 1")); + } + + private String flowName(String flowCode) { + if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) { + return "业务完成确认单"; + } + if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) { + return "内部协调单"; + } + throw new BusinessException("未知的审批流程: " + flowCode); + } + + private String flowNodesJson(String flowCode) { + if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) { + return ApprovalConstants.COMPLETION_NODES_JSON; + } + if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) { + return ApprovalConstants.COORDINATION_NODES_JSON; + } + throw new BusinessException("未知的审批流程: " + flowCode); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java new file mode 100644 index 0000000..7142dc9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -0,0 +1,540 @@ +package com.gxwebsoft.approval.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalCompletionForm; +import com.gxwebsoft.approval.entity.ApprovalCoordinationForm; +import com.gxwebsoft.approval.entity.ApprovalFlowDefinition; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalRecord; +import com.gxwebsoft.approval.entity.ApprovalTask; +import com.gxwebsoft.approval.mapper.ApprovalCompletionFormMapper; +import com.gxwebsoft.approval.mapper.ApprovalCoordinationFormMapper; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; +import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; +import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalMineParam; +import com.gxwebsoft.approval.support.ApprovalApprover; +import com.gxwebsoft.approval.support.ApprovalItemRow; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalConfirmVO; +import com.gxwebsoft.approval.vo.ApprovalInstanceVO; +import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.approval.vo.ApprovalMyTaskVO; +import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; +import com.gxwebsoft.approval.vo.ApprovalPermissionsVO; +import com.gxwebsoft.approval.vo.ApprovalRecordVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.Menu; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 业务完成确认单草稿、提交与详情服务。 + * + *

跨库约束:服务方法本身不开启事务;需要写 master 的片段放进 + * TransactionTemplate,任何 @DS("server") 的目录查询都在事务外完成, + * 避免动态数据源在事务内无法切换。

+ */ +@Service +public class ApprovalInstanceService { + + private static final BigDecimal MAX_FEE = new BigDecimal("99999999.99"); + private static final String PERMISSION_ASSIGN = "approval:instance:assign"; + + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalCompletionFormMapper completionFormMapper; + @Resource + private ApprovalCoordinationFormMapper coordinationFormMapper; + @Resource + private ApprovalRecordMapper recordMapper; + @Resource + private ApprovalTaskMapper taskMapper; + @Resource + private ApprovalFlowService flowService; + @Resource + private ApprovalNoService noService; + @Resource + private ApprovalEngineService engineService; + @Resource + private ApprovalAttachmentService attachmentService; + @Resource + private ApprovalDirectoryService directoryService; + @Resource + private PlatformTransactionManager transactionManager; + + /** + * 新建或更新业务完成确认单草稿,返回实例id。 + */ + public Long saveCompletion(ApprovalCompletionParam param, User user) { + validateCompletion(param); + Integer organizationId = user.getOrganizationId(); + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + String organizationName = organization == null ? null : organization.getOrganizationName(); + + if (param.getId() != null) { + return inTransaction(() -> updateDraft(param, user, organizationId, organizationName)); + } + ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COMPLETION); + return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName)); + } + + /** + * 提交或重提,返回节点缺配提示;正常提交返回 null。 + */ + public String submit(Long instanceId, User user) { + ApprovalInstance instance = instanceMapper.selectById(instanceId); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException(submitBlockedMessage(instance.getStatus())); + } + if (completionForm(instanceId) == null) { + throw new BusinessException("单据内容不存在"); + } + + Integer organizationId = user.getOrganizationId(); + if (organizationId == null || organizationId <= 0) { + throw new BusinessException("未归属部门,无法提交"); + } + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + if (organization == null) { + throw new BusinessException("未归属部门,无法提交"); + } + List leaders = directoryService.listRoleMembers( + ApprovalConstants.departmentLeaderRoleCode(organizationId)); + if (leaders.isEmpty()) { + throw new BusinessException("报送部门负责人角色缺配"); + } + if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) { + throw new BusinessException("收文员角色缺配"); + } + if (attachmentService.count(instanceId) == 0) { + throw new BusinessException("请至少上传一张附件"); + } + + List approvers = leaders.stream() + .map(this::toApprover) + .collect(Collectors.toList()); + + return inTransaction(() -> doSubmit(instanceId, user, organization, approvers)); + } + + private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization, + List approvers) { + ApprovalInstance locked = engineService.lockInstance(instanceId); + if (!locked.getReporterId().equals(user.getUserId()) + || !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + boolean firstSubmit = StrUtil.isBlank(locked.getBizNo()); + locked.setReportOrgId(organization.getOrganizationId()); + locked.setReportOrgName(organization.getOrganizationName()); + if (firstSubmit) { + locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION)); + } + locked.setStatus(ApprovalConstants.STATUS_PENDING); + locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1); + if (locked.getSubmitTime() == null) { + locked.setSubmitTime(LocalDateTime.now()); + } + int tasks = engineService.startFlow(locked, approvers, user, + firstSubmit ? ApprovalConstants.ACTION_SUBMIT : ApprovalConstants.ACTION_RESUBMIT); + if (tasks == 0) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + return null; + } + + /** + * 详情;草稿仅发起人可见,其余按任务/记录/派单目标或 allVisible 角色判断。 + */ + public ApprovalInstanceVO detail(Long instanceId, User user) { + ApprovalInstance instance = instanceMapper.selectById(instanceId); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + int allVisible = directoryService.hasAnyRole(user.getUserId(), + ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0; + if (instanceMapper.countVisible(instanceId, user.getUserId(), allVisible) == 0) { + throw new BusinessException("无权查看该单据"); + } + + ApprovalInstanceVO vo = baseVO(instance); + ApprovalCompletionForm completion = completionForm(instanceId); + if (completion != null) { + vo.setName(completion.getName()); + vo.setImplementOrg(completion.getImplementOrg()); + vo.setFee(completion.getFee()); + vo.setServiceContent(completion.getServiceContent()); + } + ApprovalCoordinationForm coordination = coordinationForm(instanceId); + if (coordination != null) { + vo.setAcceptOrgId(coordination.getAcceptOrgId()); + vo.setAcceptOrgName(coordination.getAcceptOrgName()); + vo.setTitle(coordination.getTitle()); + vo.setContent(coordination.getContent()); + vo.setRemark(coordination.getRemark()); + } + vo.setAttachments(attachmentService.listVos(instanceId)); + vo.setRecords(records(instance)); + vo.setConfirms(confirms(instance)); + ApprovalMyTaskVO myTask = myTask(instance, user.getUserId()); + vo.setMyTask(myTask); + vo.setPermissions(permissions(instance, user, myTask)); + return vo; + } + + /** + * 我发起的列表:按提交时间倒序,草稿排最后。 + */ + public PageResult mine(ApprovalMineParam param, User user) { + long current = param.getPage() == null ? 1 : Math.max(1, param.getPage()); + long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit()); + Page page = new Page<>(current, size); + List rows = instanceMapper.selectMinePage(page, param, user.getUserId()); + Map> nodeCache = new HashMap<>(); + List list = new ArrayList<>(); + for (ApprovalItemRow row : rows) { + ApprovalItemVO item = new ApprovalItemVO(); + item.setId(row.getId()); + item.setBizNo(row.getBizNo()); + item.setDocType(row.getDocType()); + item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType())); + item.setStatus(row.getStatus()); + item.setStatusName(ApprovalConstants.statusName(row.getStatus())); + item.setTitle(row.getTitle()); + item.setSubmitTime(row.getSubmitTime()); + item.setReporterId(row.getReporterId()); + item.setReporterName(row.getReporterName()); + item.setReportOrgName(row.getReportOrgName()); + item.setAcceptOrgName(row.getAcceptOrgName()); + item.setArchiveTime(row.getArchiveTime()); + item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())); + list.add(item); + } + return new PageResult<>(list, page.getTotal()); + } + + private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition, + Integer organizationId, String organizationName) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setFlowCode(ApprovalConstants.FLOW_COMPLETION); + instance.setDefinitionId(definition.getId()); + instance.setDefinitionVersion(definition.getVersion()); + instance.setDocType(ApprovalConstants.DOC_COMPLETION); + instance.setBizId(0L); + instance.setStatus(ApprovalConstants.STATUS_DRAFT); + instance.setRound(0); + instance.setReporterId(user.getUserId()); + instance.setReporterName(displayName(user)); + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.insert(instance); + + ApprovalCompletionForm form = new ApprovalCompletionForm(); + form.setInstanceId(instance.getId()); + form.setName(param.getName().trim()); + form.setImplementOrg(param.getImplementOrg().trim()); + form.setFee(param.getFee()); + form.setServiceContent(param.getServiceContent().trim()); + completionFormMapper.insert(form); + + instance.setBizId(form.getId()); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private Long updateDraft(ApprovalCompletionParam param, User user, + Integer organizationId, String organizationName) { + ApprovalInstance instance = instanceMapper.selectById(param.getId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException("当前状态不可修改"); + } + ApprovalCompletionForm form = completionForm(instance.getId()); + if (form == null) { + throw new BusinessException("单据内容不存在"); + } + form.setName(param.getName().trim()); + form.setImplementOrg(param.getImplementOrg().trim()); + form.setFee(param.getFee()); + form.setServiceContent(param.getServiceContent().trim()); + completionFormMapper.updateById(form); + + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private ApprovalInstanceVO baseVO(ApprovalInstance instance) { + ApprovalInstanceVO vo = new ApprovalInstanceVO(); + vo.setId(instance.getId()); + vo.setBizNo(instance.getBizNo()); + vo.setDocType(instance.getDocType()); + vo.setDocTypeName(ApprovalConstants.docTypeName(instance.getDocType())); + vo.setFlowCode(instance.getFlowCode()); + vo.setStatus(instance.getStatus()); + vo.setStatusName(ApprovalConstants.statusName(instance.getStatus())); + vo.setRound(instance.getRound()); + vo.setReporterId(instance.getReporterId()); + vo.setReporterName(instance.getReporterName()); + vo.setReportOrgId(instance.getReportOrgId()); + vo.setReportOrgName(instance.getReportOrgName()); + vo.setAcceptOrgId(instance.getAcceptOrgId()); + vo.setAcceptOrgName(instance.getAcceptOrgName()); + vo.setCurrentNodeKey(instance.getCurrentNodeKey()); + vo.setCurrentNodeType(instance.getCurrentNodeType()); + vo.setCurrentNodeName(flowService.nodeName(instance.getDefinitionId(), instance.getCurrentNodeKey())); + vo.setSubmitTime(instance.getSubmitTime()); + vo.setApproveTime(instance.getApproveTime()); + vo.setArchiveTime(instance.getArchiveTime()); + vo.setArchivistId(instance.getArchivistId()); + vo.setArchivistName(instance.getArchivistName()); + vo.setDispatchTargetId(instance.getDispatchTargetId()); + vo.setDispatchTargetName(instance.getDispatchTargetName()); + return vo; + } + + private List records(ApprovalInstance instance) { + List records = recordMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalRecord::getInstanceId, instance.getId()) + .orderByAsc(ApprovalRecord::getCreateTime) + .orderByAsc(ApprovalRecord::getId)); + List result = new ArrayList<>(); + for (ApprovalRecord record : records) { + ApprovalRecordVO vo = new ApprovalRecordVO(); + vo.setId(record.getId()); + vo.setTaskId(record.getTaskId()); + vo.setNodeKey(record.getNodeKey()); + vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), record.getNodeKey())); + vo.setOperatorId(record.getOperatorId()); + vo.setOperatorName(record.getOperatorName()); + vo.setAction(record.getAction()); + vo.setActionName(ApprovalConstants.actionName(record.getAction())); + vo.setOpinion(record.getOpinion()); + vo.setRound(record.getRound()); + vo.setCreateTime(record.getCreateTime()); + result.add(vo); + } + return result; + } + + private List confirms(ApprovalInstance instance) { + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getNodeType, ApprovalConstants.NODE_CONFIRM) + .eq(ApprovalTask::getRound, instance.getRound()) + .orderByAsc(ApprovalTask::getId)); + List result = new ArrayList<>(); + for (ApprovalTask task : tasks) { + ApprovalConfirmVO vo = new ApprovalConfirmVO(); + vo.setUserId(task.getAssigneeId()); + vo.setUserName(task.getAssigneeName()); + vo.setStatus(task.getStatus()); + vo.setStatusName(taskStatusName(task.getStatus())); + vo.setHandleTime(task.getHandleTime()); + result.add(vo); + } + return result; + } + + private ApprovalMyTaskVO myTask(ApprovalInstance instance, Integer userId) { + if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) { + return null; + } + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getAssigneeId, userId) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING) + .orderByDesc(ApprovalTask::getId) + .last("LIMIT 1")); + if (tasks.isEmpty()) { + return null; + } + ApprovalTask task = tasks.get(0); + ApprovalMyTaskVO vo = new ApprovalMyTaskVO(); + vo.setId(task.getId()); + vo.setNodeKey(task.getNodeKey()); + vo.setNodeType(task.getNodeType()); + vo.setStatus(task.getStatus()); + return vo; + } + + private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask) { + ApprovalPermissionsVO permissions = new ApprovalPermissionsVO(); + boolean reporter = instance.getReporterId().equals(user.getUserId()); + boolean editable = reporter && ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus()); + permissions.setCanEdit(editable); + permissions.setCanSubmit(editable); + if (reporter && ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { + Integer done = taskMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getRound, instance.getRound()) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE)); + permissions.setCanWithdraw(done == null || done == 0); + } + if (myTask != null) { + boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType()) + || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()); + permissions.setCanApprove(approveNode); + permissions.setCanReject(approveNode); + permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType())); + permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType())); + permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType())); + permissions.setCanAssign(hasAuthority(user, PERMISSION_ASSIGN)); + } + return permissions; + } + + private boolean hasAuthority(User user, String authority) { + if (user.getAuthorities() == null) { + return false; + } + return user.getAuthorities().stream() + .map(Menu::getAuthority) + .anyMatch(authority::equals); + } + + private ApprovalCompletionForm completionForm(Long instanceId) { + List forms = completionFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalCompletionForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private ApprovalCoordinationForm coordinationForm(Long instanceId) { + List forms = coordinationFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalCoordinationForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private void validateCompletion(ApprovalCompletionParam param) { + if (param == null) { + throw new BusinessException("参数不能为空"); + } + String name = param.getName() == null ? "" : param.getName().trim(); + if (name.isEmpty()) { + throw new BusinessException("名称不能为空"); + } + if (name.length() > 100) { + throw new BusinessException("名称不能超过100字"); + } + String implementOrg = param.getImplementOrg() == null ? "" : param.getImplementOrg().trim(); + if (implementOrg.isEmpty()) { + throw new BusinessException("实施单位不能为空"); + } + if (implementOrg.length() > 100) { + throw new BusinessException("实施单位不能超过100字"); + } + BigDecimal fee = param.getFee(); + if (fee == null) { + throw new BusinessException("费用不能为空"); + } + if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 || fee.compareTo(MAX_FEE) > 0) { + throw new BusinessException("费用必须为0到99999999.99之间的两位小数"); + } + String serviceContent = param.getServiceContent() == null ? "" : param.getServiceContent().trim(); + if (serviceContent.isEmpty()) { + throw new BusinessException("服务内容不能为空"); + } + if (serviceContent.length() > 2000) { + throw new BusinessException("服务内容不能超过2000字"); + } + } + + private String submitBlockedMessage(String status) { + if (ApprovalConstants.STATUS_PENDING.equals(status)) { + return "单据正在审批中,不能重复提交"; + } + if (ApprovalConstants.STATUS_ARCHIVED.equals(status)) { + return "单据已归档,不能修改"; + } + return "当前状态不可提交"; + } + + private ApprovalApprover toApprover(ApprovalUserVO user) { + return new ApprovalApprover(user.getUserId(), userDisplayName(user)); + } + + private String userDisplayName(ApprovalUserVO user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + + private String displayName(User user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + + private String nodeName(Map> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes); + return nodes.stream() + .filter(node -> nodeKey.equals(node.getKey())) + .map(ApprovalNode::getName) + .findFirst() + .orElse(null); + } + + private String taskStatusName(String status) { + if (ApprovalConstants.TASK_PENDING.equals(status)) { + return "待确认"; + } + if (ApprovalConstants.TASK_DONE.equals(status)) { + return "已确认"; + } + if (ApprovalConstants.TASK_CANCELLED.equals(status)) { + return "已取消"; + } + return status; + } + + private T inTransaction(Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java new file mode 100644 index 0000000..955954c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.approval.service; + +import com.gxwebsoft.approval.entity.ApprovalNoSequence; +import com.gxwebsoft.approval.mapper.ApprovalNoSequenceMapper; +import com.gxwebsoft.common.core.exception.BusinessException; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.format.DateTimeFormatter; + +/** + * 审批单号服务:事务内行锁取号,格式 前缀 + yyyyMMdd + - + 至少 4 位流水。 + * + *

调用方必须开启事务,锁会在事务提交/回滚时释放。

+ */ +@Service +public class ApprovalNoService { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + @Resource + private ApprovalNoSequenceMapper noSequenceMapper; + + /** + * 生成当天单号;同租户+前缀+日期每日重置,超过 9999 自然进位。 + */ + public String nextNo(String prefix) { + noSequenceMapper.insertTodayIfAbsent(prefix); + ApprovalNoSequence sequence = noSequenceMapper.selectTodayForUpdate(prefix); + if (sequence == null) { + throw new BusinessException("单号序列初始化失败"); + } + noSequenceMapper.increment(sequence.getId()); + long current = sequence.getCurrentSeq() == null ? 1 : sequence.getCurrentSeq() + 1L; + return prefix + sequence.getSeqDate().format(DATE_FORMAT) + "-" + String.format("%04d", current); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java new file mode 100644 index 0000000..40e7537 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java @@ -0,0 +1,16 @@ +package com.gxwebsoft.approval.support; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 节点激活时快照的审批人。 + */ +@Data +@AllArgsConstructor +public class ApprovalApprover { + + private Integer userId; + + private String userName; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java new file mode 100644 index 0000000..c5a8bc0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.approval.support; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 列表查询数据库行,额外携带当前节点key与流程定义id供服务端翻译。 + */ +@Data +public class ApprovalItemRow implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String bizNo; + private String docType; + private String status; + private String title; + private LocalDateTime submitTime; + private Integer reporterId; + private String reporterName; + private String reportOrgName; + private String acceptOrgName; + private LocalDateTime archiveTime; + private String currentNodeKey; + private Long definitionId; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java new file mode 100644 index 0000000..968fac5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java @@ -0,0 +1,24 @@ +package com.gxwebsoft.approval.support; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 流程定义中的节点描述(nodes_json 数组元素)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ApprovalNode { + + /** 节点标识,仅在同一流程内唯一。 */ + private String key; + + /** 节点类型:APPROVE/COUNTERSIGN/DISPATCH/CONFIRM/ARCHIVE。 */ + private String type; + + /** 审批人解析器标识,由 ApprovalDirectoryService 负责解析。 */ + private String resolver; + + /** 节点显示名称。 */ + private String name; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java new file mode 100644 index 0000000..6ce3fdc --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java @@ -0,0 +1,48 @@ +package com.gxwebsoft.approval.support; + +import com.gxwebsoft.common.system.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * 审批域租户上下文。 + * + *

gxtyzx_core 的查询使用独立连接(不受 master 事务绑定影响), + * 因此需要显式拼 tenant_id;解析顺序与登录用户一致: + * 登录用户 → tenantId 请求头 → 兜底 10150。

+ */ +public final class ApprovalTenantContext { + + public static final int DEFAULT_TENANT_ID = 10150; + + private ApprovalTenantContext() { + } + + public static Integer currentTenantId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof User) { + Integer tenantId = ((User) authentication.getPrincipal()).getTenantId(); + if (tenantId != null) { + return tenantId; + } + } + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes) { + HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest(); + String header = request.getHeader("tenantId"); + if (header != null && !header.isEmpty()) { + try { + return Integer.valueOf(header); + } catch (NumberFormatException ignored) { + // 非法租户头按兜底租户处理 + } + } + } + return DEFAULT_TENANT_ID; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java new file mode 100644 index 0000000..3034537 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 详情中的附件。 + */ +@Data +@ApiModel(description = "审批附件") +public class ApprovalAttachmentVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private Integer fileId; + + private String name; + + private String url; + + private Long length; + + private String category; + + private String categoryName; + + private Integer uploaderId; + + private String uploaderName; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java new file mode 100644 index 0000000..4409f5b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 当前轮的存档确认状态。 + */ +@Data +@ApiModel(description = "存档确认状态") +public class ApprovalConfirmVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer userId; + + private String userName; + + private String status; + + private String statusName; + + private LocalDateTime handleTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java new file mode 100644 index 0000000..55235d2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java @@ -0,0 +1,92 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 审批实例详情。 + */ +@Data +@ApiModel(description = "审批实例详情") +public class ApprovalInstanceVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String bizNo; + + private String docType; + + private String docTypeName; + + private String flowCode; + + private String status; + + private String statusName; + + private Integer round; + + private Integer reporterId; + + private String reporterName; + + private Integer reportOrgId; + + private String reportOrgName; + + private Integer acceptOrgId; + + private String acceptOrgName; + + private String currentNodeKey; + + private String currentNodeType; + + private String currentNodeName; + + private LocalDateTime submitTime; + + private LocalDateTime approveTime; + + private LocalDateTime archiveTime; + + private Integer archivistId; + + private String archivistName; + + // 业务完成确认单字段 + private String name; + + private String implementOrg; + + private BigDecimal fee; + + private String serviceContent; + + // 内部协调单字段 + private String title; + + private String content; + + private String remark; + + private Integer dispatchTargetId; + + private String dispatchTargetName; + + private List attachments; + + private List records; + + private List confirms; + + private ApprovalMyTaskVO myTask; + + private ApprovalPermissionsVO permissions; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java new file mode 100644 index 0000000..4a58e90 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 我发起的列表行。 + */ +@Data +@ApiModel(description = "审批单据列表行") +public class ApprovalItemVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String bizNo; + + private String docType; + + private String docTypeName; + + private String status; + + private String statusName; + + @ApiModelProperty("名称或标题") + private String title; + + private LocalDateTime submitTime; + + private String currentNodeName; + + private Integer reporterId; + + private String reporterName; + + private String reportOrgName; + + private String acceptOrgName; + + private LocalDateTime archiveTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java new file mode 100644 index 0000000..0ca7eef --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java @@ -0,0 +1,23 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 当前登录人在该实例上的待办任务。 + */ +@Data +@ApiModel(description = "当前用户待办任务") +public class ApprovalMyTaskVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String nodeKey; + + private String nodeType; + + private String status; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java new file mode 100644 index 0000000..a24b3a0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 启用组织。 + */ +@Data +@ApiModel(description = "审批目录组织") +public class ApprovalOrganizationVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer organizationId; + + private Integer parentId; + + private String organizationName; + + private String organizationFullName; + + private String organizationCode; + + private Integer leaderId; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java new file mode 100644 index 0000000..a37dfc8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 详情动作权限,前端据此显示按钮。 + */ +@Data +@ApiModel(description = "单据动作权限") +public class ApprovalPermissionsVO implements Serializable { + private static final long serialVersionUID = 1L; + + private boolean canEdit; + + private boolean canSubmit; + + private boolean canWithdraw; + + private boolean canApprove; + + private boolean canReject; + + private boolean canConfirm; + + private boolean canArchive; + + private boolean canDispatch; + + private boolean canAssign; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java new file mode 100644 index 0000000..99cf750 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 详情时间线记录。 + */ +@Data +@ApiModel(description = "审批处理记录") +public class ApprovalRecordVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private Long taskId; + + private String nodeKey; + + private String nodeName; + + private Integer operatorId; + + private String operatorName; + + private String action; + + private String actionName; + + private String opinion; + + private Integer round; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java new file mode 100644 index 0000000..3046425 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 审批目录用户。 + */ +@Data +@ApiModel(description = "审批目录用户") +public class ApprovalUserVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer userId; + + private String username; + + private String nickname; + + private String realName; + + private Integer organizationId; + + private String organizationName; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java new file mode 100644 index 0000000..953aa0e --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java @@ -0,0 +1,519 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 业务完成确认单:草稿、附件、提交预检与引擎起步的集成测试。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +@Transactional +class ApprovalCompletionDraftIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer organizationId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("测试部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("报送人" + data.uniqueSuffix(), organizationId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void createsDraftWithoutBizNoAndListsAsUnsubmitted() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "场馆维修"); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(organizationId); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.statusName").value("未提交")) + .andExpect(jsonPath("$.data.name").value("场馆维修")) + .andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.bizNo").doesNotExist()); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].statusName").value("未提交")) + .andExpect(jsonPath("$.data.list[0].title").value("场馆维修")) + .andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist()); + } + + @Test + void validatesCompletionFields() throws Exception { + expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空"); + expectSaveFailure(body(repeat("名", 101), "实施单位", "10.00", "服务内容"), "名称不能超过100字"); + expectSaveFailure(body("名称", "", "10.00", "服务内容"), "实施单位不能为空"); + expectSaveFailure(body("名称", "实施单位", "-1", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "100000000", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "1.234", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "10.00", ""), "服务内容不能为空"); + expectSaveFailure(body("名称", "实施单位", "10.00", repeat("内", 2001)), "服务内容不能超过2000字"); + } + + @Test + void rejectsSubmitWhenReporterHasNoOrganization() throws Exception { + ApprovalTestData.TestUser noOrgReporter = data.createUser("无部门" + data.uniqueSuffix(), null); + long instanceId = createCompletion(noOrgReporter, null, TENANT, "无部门单据"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + noOrgReporter, null, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("未归属部门"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenDepartmentLeaderRoleMissing() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "缺负责人"); + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void rejectsSubmitWhenArchivistRoleMissing() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "缺收文员"); + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收文员角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void rejectsSubmitWhenAttachmentMissing() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "无附件"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void submitsAndStartsDepartmentLeaderCountersign() throws Exception { + List leaders = grantDepartmentLeader(organizationId, TENANT, 2); + grantArchivist(TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "完工确认"); + long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.statusName").value("审批中")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT")) + .andExpect(jsonPath("$.data.records[0].actionName").value("提交")); + + assertThat(instanceStatus(instanceId)).isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)).isEqualTo(1); + assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)).isNotNull(); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("deptLeader"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("APPROVE"); + String bizNo = bizNo(instanceId); + assertThat(bizNo).matches("WG\\d{8}-\\d{4,}"); + assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase()); + + List assignees = data.master().queryForList( + "SELECT assignee_id FROM approval_task WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", + Integer.class, instanceId); + assertThat(assignees).containsExactlyInAnyOrder(leaders.get(0).userId, leaders.get(1).userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'SUBMIT'", + instanceId)).isEqualTo(1); + + // 提交后字段与附件均只读 + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body("改名", "实施单位", "10.00", "服务内容", instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + + MockMultipartFile file = new MockMultipartFile("file", "locked.png", "image/png", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "OTHER") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + + // 重复提交保持审批中 + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)); + assertThat(instanceStatus(instanceId)).isEqualTo("PENDING"); + assertThat(attachmentId).isPositive(); + } + + @Test + void allocatesUniqueDailySequenceByTenant() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + + long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号一")); + long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号二")); + + String firstNo = bizNo(first); + String secondNo = bizNo(second); + String date = todayFromDatabase(); + assertThat(firstNo).matches("WG" + date + "-\\d{4,}"); + assertThat(secondNo).matches("WG" + date + "-\\d{4,}"); + int firstSeq = Integer.parseInt(firstNo.substring(firstNo.indexOf('-') + 1)); + int secondSeq = Integer.parseInt(secondNo.substring(secondNo.indexOf('-') + 1)); + assertThat(secondSeq).isEqualTo(firstSeq + 1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + TENANT, firstNo)).isEqualTo(1); + + // 另一租户使用同一天同一前缀独立取号,互不冲突 + int otherTenant = 10023; + Integer otherOrg = data.createOrganization("外租户部门" + data.uniqueSuffix(), 136, otherTenant); + ApprovalTestData.TestUser otherReporter = data.createUser("外租户报送人" + data.uniqueSuffix(), otherOrg, otherTenant); + ApprovalTestData.TestRole otherLeaderRole = data.createRole("dept_leader_" + otherOrg, "外租户负责人", otherTenant); + ApprovalTestData.TestUser otherLeader = data.createUser("外租户负责人" + data.uniqueSuffix(), otherOrg, otherTenant); + data.bindTenant(otherLeader.userId, otherLeaderRole.roleId, otherTenant); + ApprovalTestData.TestRole otherArchivistRole = data.createRole("approval_archivist", "外租户收文员", otherTenant); + ApprovalTestData.TestUser otherArchivist = data.createUser("外租户收文员" + data.uniqueSuffix(), otherOrg, otherTenant); + data.bindTenant(otherArchivist.userId, otherArchivistRole.roleId, otherTenant); + + long otherInstance = submitCompletionFor(createCompletion(otherReporter, otherOrg, otherTenant, "外租户单据"), + otherReporter, otherOrg, otherTenant); + String otherNo = bizNo(otherInstance); + assertThat(otherNo).matches("WG" + date + "-\\d{4,}"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + otherTenant, otherNo)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + TENANT, firstNo)).isEqualTo(1); + } + + @Test + void enforcesAttachmentRules() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "附件规则"); + + MockMultipartFile text = new MockMultipartFile("file", "evil.txt", "text/plain", + "x".getBytes(StandardCharsets.UTF_8)); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(text) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持"))); + + MockMultipartFile mismatch = new MockMultipartFile("file", "fake.jpg", "application/pdf", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(mismatch) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持"))); + + MockMultipartFile tooLarge = new MockMultipartFile("file", "big.png", "image/png", + new byte[20 * 1024 * 1024 + 1]); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(tooLarge) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("20MB"))); + + for (int i = 0; i < 9; i++) { + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + } + MockMultipartFile tenth = new MockMultipartFile("file", "tenth.png", "image/png", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(tenth) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("9张"))); + } + + @Test + void deletesAttachmentLogicallyAndKeepsFileRecord() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "删除附件"); + long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png"); + Integer fileId = data.queryMaster("SELECT file_id FROM approval_attachment WHERE id = ?", Integer.class, attachmentId); + + mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(data.queryMaster("SELECT deleted FROM approval_attachment WHERE id = ?", Integer.class, attachmentId)) + .isEqualTo(1); + assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND deleted = 0", fileId)) + .isEqualTo(1); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.data.attachments.length()").value(0)); + } + + @Test + void mineListIsOrderedAndScoped() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "先提交")); + long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "后提交")); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(2)) + .andExpect(jsonPath("$.data.list[0].id").value((int) second)) + .andExpect(jsonPath("$.data.list[1].id").value((int) first)) + .andExpect(jsonPath("$.data.list[0].statusName").value("审批中")) + .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty()) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + ApprovalTestData.TestUser outsider = data.createUser("路人" + data.uniqueSuffix(), organizationId); + mockMvc.perform(authed(get("/api/approval/mine/page"), outsider, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + + mockMvc.perform(authed(get("/api/approval/instance/" + first), outsider, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + + ApprovalTestData.TestUser archivist = grantArchivist(TENANT); + mockMvc.perform(authed(get("/api/approval/instance/" + first), archivist, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) first)); + } + + @Test + void draftStaysInvisibleEvenToAllVisibleArchivist() throws Exception { + ApprovalTestData.TestUser archivist = grantArchivist(TENANT); + long draft = createCompletion(reporter, organizationId, TENANT, "草稿保密"); + + mockMvc.perform(authed(get("/api/approval/instance/" + draft), archivist, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + } + + // ---------- helpers ---------- + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage))); + } + + private long createCompletion(ApprovalTestData.TestUser user, Integer org, int tenant, String name) throws Exception { + String body = body(name, "广西体育中心实施部", "123.45", "完成场馆设施维修与验收"); + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), user, org, tenant, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response); + long id = node.path("data").asLong(); + data.trackInstance(id); + return id; + } + + private long submitCompletion(long instanceId) throws Exception { + return submitCompletionFor(instanceId, reporterFor(instanceId), organizationIdFor(instanceId), TENANT); + } + + private long submitCompletionFor(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant) + throws Exception { + uploadAttachment(instanceId, user, org, tenant, "image/png"); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, tenant, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, + String contentType) throws Exception { + return uploadAttachment(instanceId, user, org, TENANT, contentType); + } + + private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant, + String contentType) throws Exception { + String filename = "sign." + extensionOf(contentType); + MockMultipartFile file = new MockMultipartFile("file", filename, contentType, pngBytes()); + String response = mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", tenant) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return objectMapper.readTree(response).path("data").path("id").asLong(); + } + + private String extensionOf(String contentType) { + switch (contentType) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + default: + return "bin"; + } + } + + private byte[] pngBytes() { + return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, ApprovalTestData.TestUser user, + Integer org, int tenant, String... authorities) { + return builder.header("tenantId", tenant) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, authorities))); + } + + private ApprovalTestData.TestUser reporterFor(long instanceId) { + Integer reporterId = data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId); + return new ApprovalTestData.TestUser(reporterId, "reporter-" + reporterId, "报送人"); + } + + private Integer organizationIdFor(long instanceId) { + return data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId); + } + + private List grantDepartmentLeader(int orgId, int tenant) { + return grantDepartmentLeader(orgId, tenant, 1); + } + + private List grantDepartmentLeader(int orgId, int tenant, int memberCount) { + ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人", tenant); + List members = new ArrayList<>(); + for (int i = 0; i < memberCount; i++) { + ApprovalTestData.TestUser member = data.createUser("负责人" + data.uniqueSuffix(), orgId, tenant); + data.bindTenant(member.userId, role.roleId, tenant); + members.add(member); + } + return members; + } + + private ApprovalTestData.TestUser grantArchivist(int tenant) { + ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员", tenant); + ApprovalTestData.TestUser member = data.createUser("收文员" + data.uniqueSuffix(), organizationId, tenant); + data.bindTenant(member.userId, role.roleId, tenant); + return member; + } + + private String body(String name, String implementOrg, String fee, String serviceContent) { + return body(name, implementOrg, fee, serviceContent, null); + } + + private String body(String name, String implementOrg, String fee, String serviceContent, Long id) { + StringBuilder sb = new StringBuilder("{"); + if (id != null) { + sb.append("\"id\":").append(id).append(','); + } + sb.append("\"name\":").append(quote(name)).append(',') + .append("\"implementOrg\":").append(quote(implementOrg)).append(',') + .append("\"fee\":").append(fee).append(',') + .append("\"serviceContent\":").append(quote(serviceContent)) + .append('}'); + return sb.toString(); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String value, int count) { + return value.repeat(count); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String bizNo(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String todayFromDatabase() { + return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class); + } +} diff --git a/src/test/java/com/gxwebsoft/support/ApprovalTestData.java b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java new file mode 100644 index 0000000..913b843 --- /dev/null +++ b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java @@ -0,0 +1,203 @@ +package com.gxwebsoft.support; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。 + * + *

sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate, + * 避免测试事务与动态数据源切换互相影响。

+ */ +public class ApprovalTestData { + + private static final AtomicInteger SEQ = new AtomicInteger(); + + private final JdbcTemplate master; + private final JdbcTemplate server; + + private final List userIds = new ArrayList<>(); + private final List roleIds = new ArrayList<>(); + private final List organizationIds = new ArrayList<>(); + private final List instanceIds = new ArrayList<>(); + + public ApprovalTestData(DataSource dataSource) { + this.master = new JdbcTemplate(dataSource); + DataSource serverDataSource = ((DynamicRoutingDataSource) dataSource).getDataSource("server"); + this.server = new JdbcTemplate(serverDataSource); + } + + /** 唯一后缀,避免共享库中的账号/角色/组织重名。 */ + public String uniqueSuffix() { + return System.nanoTime() + "-" + SEQ.incrementAndGet(); + } + + public TestUser createUser(String realName, Integer organizationId, int tenantId) { + String suffix = uniqueSuffix(); + String username = "it_" + suffix; + Integer id = insert(server, + "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) " + + "VALUES (?, 'it', ?, ?, ?, 0, 0, ?)", + username, realName, realName, organizationId, tenantId); + userIds.add(id); + return new TestUser(id, username, realName); + } + + public TestUser createUser(String realName, Integer organizationId) { + return createUser(realName, organizationId, TestAuthentication.TENANT_ID); + } + + public TestRole createRole(String roleCode, String roleName, int tenantId) { + Integer id = insert(server, + "INSERT INTO sys_role (role_name, role_code, sort_number, deleted, tenant_id) VALUES (?, ?, 1, 0, ?)", + roleName, roleCode, tenantId); + roleIds.add(id); + return new TestRole(id, roleCode); + } + + public TestRole createRole(String roleCode, String roleName) { + return createRole(roleCode, roleName, TestAuthentication.TENANT_ID); + } + + public void bind(int userId, int roleId) { + insert(server, + "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)", + userId, roleId, TestAuthentication.TENANT_ID); + } + + public void bindTenant(int userId, int roleId, int tenantId) { + insert(server, + "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)", + userId, roleId, tenantId); + } + + public Integer createOrganization(String name, int parentId, int tenantId) { + Integer id = insert(server, + "INSERT INTO sys_organization (organization_name, parent_id, organization_type, deleted, tenant_id) " + + "VALUES (?, ?, 'dept', 0, ?)", + name, parentId, tenantId); + organizationIds.add(id); + return id; + } + + public Integer createOrganization(String name, int parentId) { + return createOrganization(name, parentId, TestAuthentication.TENANT_ID); + } + + public void disableOrganization(int organizationId) { + server.update("UPDATE sys_organization SET deleted = 1 WHERE organization_id = ?", organizationId); + } + + public void setUserOrganization(int userId, Integer organizationId) { + server.update("UPDATE sys_user SET organization_id = ? WHERE user_id = ?", organizationId, userId); + } + + public Integer countServer(String sql, Object... args) { + return server.queryForObject(sql, Integer.class, args); + } + + public Integer countMaster(String sql, Object... args) { + return master.queryForObject(sql, Integer.class, args); + } + + public T queryMaster(String sql, Class type, Object... args) { + return master.queryForObject(sql, type, args); + } + + public JdbcTemplate master() { + return master; + } + + public JdbcTemplate server() { + return server; + } + + /** 记录实例 id,便于非事务用例结束后清理。 */ + public void trackInstance(Long instanceId) { + if (instanceId != null) { + instanceIds.add(instanceId); + } + } + + /** 清理本次用例创建的核心库数据与审批数据。 */ + public void cleanup() { + try { + for (Long instanceId : instanceIds) { + deleteInstance(instanceId); + } + instanceIds.clear(); + for (Integer userId : userIds) { + server.update("DELETE FROM sys_user_role WHERE user_id = ?", userId); + server.update("DELETE FROM sys_file_record WHERE create_user_id = ?", userId); + server.update("DELETE FROM sys_user WHERE user_id = ?", userId); + } + userIds.clear(); + for (Integer roleId : roleIds) { + server.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId); + server.update("DELETE FROM sys_role WHERE role_id = ?", roleId); + } + roleIds.clear(); + for (Integer organizationId : organizationIds) { + server.update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId); + } + organizationIds.clear(); + } catch (Exception ignored) { + // 清理失败不阻断测试报告;残留行均由唯一后缀标识。 + } + } + + private void deleteInstance(Long instanceId) { + master.update("DELETE FROM approval_attachment WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_record WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_task WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_completion_form WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_coordination_form WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_instance WHERE id = ?", instanceId); + } + + private Integer insert(JdbcTemplate jdbcTemplate, String sql, Object... args) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + for (int i = 0; i < args.length; i++) { + statement.setObject(i + 1, args[i]); + } + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("未取得自增主键: " + sql); + } + return key.intValue(); + } + + public static class TestUser { + public final int userId; + public final String username; + public final String realName; + + public TestUser(int userId, String username, String realName) { + this.userId = userId; + this.username = username; + this.realName = realName; + } + } + + public static class TestRole { + public final int roleId; + public final String roleCode; + + TestRole(int roleId, String roleCode) { + this.roleId = roleId; + this.roleCode = roleCode; + } + } +} diff --git a/src/test/java/com/gxwebsoft/support/TestAuthentication.java b/src/test/java/com/gxwebsoft/support/TestAuthentication.java index 21a2051..cb7f05f 100644 --- a/src/test/java/com/gxwebsoft/support/TestAuthentication.java +++ b/src/test/java/com/gxwebsoft/support/TestAuthentication.java @@ -23,6 +23,20 @@ public final class TestAuthentication { } public static Authentication admin(String... authorities) { + return user(USER_ID, "admin", null, TENANT_ID, authorities); + } + + /** + * 构造指定用户/组织/租户的登录态,供审批流集成测试使用。 + * + * @param userId 用户id + * @param username 账号(同时作为姓名) + * @param organizationId 所属部门id,可为 null + * @param tenantId 租户id + * @param authorities 权限串 + */ + public static Authentication user(int userId, String username, Integer organizationId, + int tenantId, String... authorities) { List menus = Arrays.stream(authorities) .map(authority -> { Menu menu = new Menu(); @@ -32,9 +46,12 @@ public final class TestAuthentication { .collect(Collectors.toList()); User user = new User(); - user.setUserId(USER_ID); - user.setUsername("admin"); - user.setTenantId(TENANT_ID); + user.setUserId(userId); + user.setUsername(username); + user.setNickname(username); + user.setRealName(username); + user.setOrganizationId(organizationId); + user.setTenantId(tenantId); user.setStatus(0); user.setAuthorities(menus); From 370d8a6d36947796cd6cda507df368706872e431 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:21:03 +0800 Subject: [PATCH 03/18] =?UTF-8?q?chore:=20=E5=90=88=E5=B9=B6=E5=90=8E?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=87=8D=E5=A4=8D=E7=9A=84=20spring-security?= =?UTF-8?q?-test=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pom.xml b/pom.xml index 6a66176..c5e8ec3 100644 --- a/pom.xml +++ b/pom.xml @@ -39,11 +39,6 @@ spring-boot-starter-test test - - org.springframework.security - spring-security-test - test - From 3b599efb3b5466f6e28bc975b41c35b805b58c3e Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:27:11 +0800 Subject: [PATCH 04/18] =?UTF-8?q?test(approval):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=AE=9A=E4=B9=89=E5=B9=82=E7=AD=89=EF=BC=9B?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=BA=93=E6=9C=8D=E5=8A=A1=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=20server=20=E6=95=B0=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/service/ApprovalDirectoryService.java | 2 ++ .../approval/service/ApprovalFileService.java | 2 ++ .../approval/ApprovalCompletionDraftIT.java | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index 0005653..c032296 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -1,6 +1,7 @@ package com.gxwebsoft.approval.service; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.annotation.DS; import com.gxwebsoft.approval.support.ApprovalTenantContext; import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; import com.gxwebsoft.approval.vo.ApprovalUserVO; @@ -20,6 +21,7 @@ import java.util.List; * 所有 SQL 显式带 tenant_id。

*/ @Service +@DS("server") public class ApprovalDirectoryService { private static final RowMapper ORGANIZATION_MAPPER = (resultSet, rowNum) -> { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java index c987404..102a855 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java @@ -1,6 +1,7 @@ package com.gxwebsoft.approval.service; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.annotation.DS; import com.gxwebsoft.approval.support.ApprovalTenantContext; import com.gxwebsoft.common.system.entity.FileRecord; import org.springframework.jdbc.core.JdbcTemplate; @@ -24,6 +25,7 @@ import java.util.List; * 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。

*/ @Service +@DS("server") public class ApprovalFileService { private static final RowMapper FILE_MAPPER = (resultSet, rowNum) -> { diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java index 953aa0e..509b4c0 100644 --- a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java @@ -96,6 +96,19 @@ class ApprovalCompletionDraftIT { .andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist()); } + @Test + void publishesSingleFlowDefinitionPerTenant() throws Exception { + createCompletion(reporter, organizationId, TENANT, "流程一"); + createCompletion(reporter, organizationId, TENANT, "流程二"); + + Integer count = data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", TENANT); + assertThat(count).isEqualTo(1); + assertThat(data.queryMaster("SELECT nodes_json FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", String.class, TENANT)) + .contains("deptLeader").contains("confirm").contains("archive"); + } + @Test void validatesCompletionFields() throws Exception { expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空"); From a85895583eb609d2eb5add68d426ad725a98c441 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:45:26 +0800 Subject: [PATCH 05/18] =?UTF-8?q?feat(approval):=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=BE=85=E5=8A=9E/=E5=B7=B2=E5=8A=9E?= =?UTF-8?q?=E4=B8=8E=E9=80=9A=E8=BF=87=E9=A9=B3=E5=9B=9E=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApprovalTaskService/Controller:通过、驳回、撤回、待办数量与节点推进 - 通过后同节点同轮其余待办取消,下一节点审批人事务外解析、事务内生成任务 - 任务条件更新(WHERE status='PENDING')保证并发抢办仅成功一次 - 新增 todo/done 列表(状态+关键词)与实例去重的待办计数 - ApprovalTaskIT 覆盖通过、驳回重提、或签、并发、自审、撤回、列表筛选 --- .../controller/ApprovalTaskController.java | 88 ++++ .../approval/mapper/ApprovalTaskMapper.java | 19 + .../mapper/xml/ApprovalTaskMapper.xml | 50 +++ .../param/ApprovalTaskActionParam.java | 17 + .../approval/param/ApprovalTaskListParam.java | 20 + .../service/ApprovalDirectoryService.java | 18 + .../service/ApprovalEngineService.java | 16 + .../service/ApprovalInstanceService.java | 29 +- .../approval/service/ApprovalTaskService.java | 344 +++++++++++++++ .../gxwebsoft/approval/ApprovalTaskIT.java | 406 ++++++++++++++++++ 10 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java new file mode 100644 index 0000000..58af23e --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -0,0 +1,88 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.param.ApprovalTaskActionParam; +import com.gxwebsoft.approval.param.ApprovalTaskListParam; +import com.gxwebsoft.approval.service.ApprovalInstanceService; +import com.gxwebsoft.approval.service.ApprovalTaskService; +import com.gxwebsoft.approval.vo.ApprovalInstanceVO; +import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +/** + * 审批待办与任务动作接口(员工端与 PC 共用)。 + */ +@Api(tags = "审批待办") +@RestController +@RequestMapping("/api/approval") +public class ApprovalTaskController extends BaseController { + + @Resource + private ApprovalTaskService taskService; + @Resource + private ApprovalInstanceService instanceService; + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("我的待办") + @GetMapping("/todo/page") + public ApiResult> todo(ApprovalTaskListParam param) { + return success(instanceService.todo(param, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("我的已办") + @GetMapping("/done/page") + public ApiResult> done(ApprovalTaskListParam param) { + return success(instanceService.done(param, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("待办数量") + @GetMapping("/todo/count") + public ApiResult todoCount() { + return success(taskService.todoCount(getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("通过") + @PostMapping("/task/{id}/approve") + public ApiResult approve(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskActionParam param) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.approve(id, param == null ? null : param.getOpinion(), user); + return success(result.getWarning() == null ? "审批成功" : result.getWarning(), + instanceService.detail(result.getInstanceId(), user)); + } + + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("驳回") + @PostMapping("/task/{id}/reject") + public ApiResult reject(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskActionParam param) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.reject(id, param == null ? null : param.getOpinion(), user); + return success("驳回成功", instanceService.detail(result.getInstanceId(), user)); + } + + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("撤回") + @PostMapping("/instance/{id}/withdraw") + public ApiResult withdraw(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.withdraw(id, user); + return success("撤回成功", instanceService.detail(result.getInstanceId(), user)); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java index 9326221..ece5152 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java @@ -1,10 +1,29 @@ package com.gxwebsoft.approval.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.gxwebsoft.approval.entity.ApprovalTask; +import com.gxwebsoft.approval.param.ApprovalTaskListParam; +import com.gxwebsoft.approval.support.ApprovalItemRow; +import org.apache.ibatis.annotations.Param; + +import java.util.List; /** * 审批任务Mapper。 */ public interface ApprovalTaskMapper extends BaseMapper { + + /** + * 待办/已办列表:按本人任务状态取实例,天然按实例去重。 + */ + List selectTaskInstancePage(@Param("page") IPage page, + @Param("param") ApprovalTaskListParam param, + @Param("userId") Integer userId, + @Param("taskStatus") String taskStatus); + + /** + * 本人待办实例数量(同一实例多任务只算一条)。 + */ + Integer countPendingInstances(@Param("userId") Integer userId); } diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml new file mode 100644 index 0000000..cedbc0b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java new file mode 100644 index 0000000..5f64d3e --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 任务动作参数(通过/驳回)。 + */ +@Data +public class ApprovalTaskActionParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("处理意见;驳回必填") + private String opinion; +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java new file mode 100644 index 0000000..8bb757b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.param; + +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 待办/已办列表查询参数。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalTaskListParam extends BaseParam { + + @ApiModelProperty("实例状态") + private String status; + + @ApiModelProperty("单据类型") + private String docType; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index c032296..132d6f9 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -158,6 +158,24 @@ public class ApprovalDirectoryService { return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); } + /** 按id批量取启用用户;停用或已删除的用户不返回(节点缺配时不生成任务)。 */ + public List listActiveUsersByIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?")); + String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 AND u.user_id IN (" + placeholders + ")"; + List args = new java.util.ArrayList<>(); + args.add(tenantId); + args.add(tenantId); + args.addAll(userIds); + return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); + } + /** 是否持有任一角色(用于 allVisible 判断)。 */ public boolean hasAnyRole(Integer userId, String... roleCodes) { if (userId == null || roleCodes == null || roleCodes.length == 0) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java index 4711d23..7d9b789 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -63,6 +63,22 @@ public class ApprovalEngineService { return createTasks(instance, node, instance.getRound(), approvers); } + /** + * 当前节点处理完成后推进到下一节点:更新当前节点并快照生成任务。 + * + *

与 {@link #activateNode} 的区别:处理记录已由调用方按任务补充 taskId, + * 因此这里只更新节点与任务,不重复写处理记录。

+ * + * @return 生成的任务数;0 表示节点缺配 + */ + public int moveToNode(ApprovalInstance instance, ApprovalNode node, Integer round, + List approvers) { + instance.setCurrentNodeKey(node.getKey()); + instance.setCurrentNodeType(node.getType()); + instanceMapper.updateById(instance); + return createTasks(instance, node, round, approvers); + } + /** * 对实例行加锁,串行化节点推进。 */ diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index 7142dc9..d2b6af0 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -17,6 +17,7 @@ import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.param.ApprovalCompletionParam; import com.gxwebsoft.approval.param.ApprovalMineParam; +import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalItemRow; import com.gxwebsoft.approval.support.ApprovalNode; @@ -216,6 +217,32 @@ public class ApprovalInstanceService { long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit()); Page page = new Page<>(current, size); List rows = instanceMapper.selectMinePage(page, param, user.getUserId()); + return new PageResult<>(toItems(rows), page.getTotal()); + } + + /** + * 我的待办:本人 PENDING 任务对应的实例,按实例去重。 + */ + public PageResult todo(ApprovalTaskListParam param, User user) { + return taskPage(param, user, ApprovalConstants.TASK_PENDING); + } + + /** + * 我的已办:本人 DONE 任务对应的实例,按实例去重。 + */ + public PageResult done(ApprovalTaskListParam param, User user) { + return taskPage(param, user, ApprovalConstants.TASK_DONE); + } + + private PageResult taskPage(ApprovalTaskListParam param, User user, String taskStatus) { + long current = param.getPage() == null ? 1 : Math.max(1, param.getPage()); + long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit()); + Page page = new Page<>(current, size); + List rows = taskMapper.selectTaskInstancePage(page, param, user.getUserId(), taskStatus); + return new PageResult<>(toItems(rows), page.getTotal()); + } + + private List toItems(List rows) { Map> nodeCache = new HashMap<>(); List list = new ArrayList<>(); for (ApprovalItemRow row : rows) { @@ -236,7 +263,7 @@ public class ApprovalInstanceService { item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())); list.add(item); } - return new PageResult<>(list, page.getTotal()); + return list; } private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition, diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java new file mode 100644 index 0000000..0cf213d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -0,0 +1,344 @@ +package com.gxwebsoft.approval.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalTask; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; +import com.gxwebsoft.approval.support.ApprovalApprover; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 审批任务动作:通过、驳回、撤回与节点推进。 + * + *

跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接), + * 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用 + * WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。

+ */ +@Service +public class ApprovalTaskService { + + private static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader"; + private static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader"; + private static final String RESOLVER_ARCHIVIST = "archivist"; + private static final String RESOLVER_REPORTER = "reporter"; + private static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget"; + private static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers"; + private static final int MAX_OPINION_LENGTH = 500; + + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalTaskMapper taskMapper; + @Resource + private ApprovalEngineService engineService; + @Resource + private ApprovalDirectoryService directoryService; + @Resource + private PlatformTransactionManager transactionManager; + + /** + * 通过:APPROVE 节点或签,处理后取消同节点同轮其余待办并推进下一节点。 + */ + public Result approve(Long taskId, String opinion, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持通过"); + } + String normalized = normalizeOpinion(opinion); + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ApprovalNode next = engineService.nextNode(instance, task.getNodeKey()); + List approvers = + next == null ? Collections.emptyList() : resolveApprovers(instance, next); + + String warning = inTransaction(() -> doApprove(taskId, task, normalized, user, next, approvers)); + return new Result(instance.getId(), warning); + } + + /** + * 驳回:意见必填,同轮待办全部取消,实例回到报送人。 + */ + public Result reject(Long taskId, String opinion, User user) { + ApprovalTask task = requireTask(taskId, user); + boolean rejectable = ApprovalConstants.NODE_APPROVE.equals(task.getNodeType()) + || ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType()); + if (!rejectable) { + throw new BusinessException("当前任务不支持驳回"); + } + String required = requireOpinion(opinion); + inTransaction(() -> doReject(taskId, task, required, user)); + return new Result(task.getInstanceId(), null); + } + + /** + * 撤回:仅报送人,且本轮没有任何任务被处理。 + */ + public Result withdraw(Long instanceId, User user) { + ApprovalInstance instance = instanceMapper.selectById(instanceId); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (user == null || !instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { + throw new BusinessException("当前状态不可撤回"); + } + ensureNoHandledTask(instance); + inTransaction(() -> doWithdraw(instanceId, user)); + return new Result(instanceId, null); + } + + public Integer todoCount(User user) { + return taskMapper.countPendingInstances(user.getUserId()); + } + + private String doApprove(Long taskId, ApprovalTask task, String opinion, User user, + ApprovalNode next, List approvers) { + // 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁 + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureTaskHandleable(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId); + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_APPROVE, opinion); + if (next == null) { + return null; + } + int created = engineService.moveToNode(locked, next, locked.getRound(), approvers); + return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null; + } + + private void doReject(Long taskId, ApprovalTask task, String opinion, User user) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureTaskHandleable(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) { + throw new BusinessException("任务已处理"); + } + Integer round = locked.getRound(); + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_REJECT, opinion); + engineService.cancelPendingTasks(locked.getId(), round, null); + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_PENDING) + .set("status", ApprovalConstants.STATUS_REJECTED) + .set("round", round + 1)); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + } + + private void doWithdraw(Long instanceId, User user) { + ApprovalInstance locked = engineService.lockInstance(instanceId); + if (!ApprovalConstants.STATUS_PENDING.equals(locked.getStatus())) { + throw new BusinessException("当前状态不可撤回"); + } + ensureNoHandledTask(locked); + Integer round = locked.getRound(); + engineService.addRecord(locked, null, locked.getCurrentNodeKey(), user, + ApprovalConstants.ACTION_WITHDRAW, null); + engineService.cancelPendingTasks(locked.getId(), round, null); + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", instanceId) + .eq("status", ApprovalConstants.STATUS_PENDING) + .set("status", ApprovalConstants.STATUS_WITHDRAWN) + .set("round", round + 1)); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + } + + /** + * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。 + */ + private void ensureTaskHandleable(ApprovalTask task, ApprovalInstance instance) { + if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + if (!task.getRound().equals(instance.getRound()) + || !task.getNodeKey().equals(instance.getCurrentNodeKey())) { + throw new BusinessException("任务已处理"); + } + } + + private void ensureNoHandledTask(ApprovalInstance instance) { + Integer handled = taskMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getRound, instance.getRound()) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE)); + if (handled != null && handled > 0) { + throw new BusinessException("已有审批人处理,无法撤回"); + } + } + + private int updatePendingTask(Long taskId, String status, String opinion) { + UpdateWrapper wrapper = new UpdateWrapper() + .eq("id", taskId) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("status", status) + .set("handle_time", LocalDateTime.now()); + if (opinion != null) { + wrapper.set("opinion", opinion); + } + return taskMapper.update(null, wrapper); + } + + private ApprovalTask requireTask(Long taskId, User user) { + if (taskId == null) { + throw new BusinessException("任务不存在"); + } + ApprovalTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + if (user == null || !user.getUserId().equals(task.getAssigneeId())) { + throw new BusinessException("无权处理该任务"); + } + return task; + } + + /** + * 事务外按节点解析器解析审批人;缺配时返回空列表,由调用方提示并停留当前节点。 + */ + private List resolveApprovers(ApprovalInstance instance, ApprovalNode node) { + String resolver = node.getResolver(); + if (StrUtil.isBlank(resolver)) { + return Collections.emptyList(); + } + switch (resolver) { + case RESOLVER_REPORTER_ORG_LEADER: + return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getReportOrgId())); + case RESOLVER_ACCEPT_ORG_LEADER: + return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getAcceptOrgId())); + case RESOLVER_ARCHIVIST: + return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST); + case RESOLVER_REPORTER: + return userApprovers(Collections.singletonList(instance.getReporterId())); + case RESOLVER_REPORTER_AND_DISPATCH_TARGET: + return userApprovers(confirmCandidateIds(instance)); + case RESOLVER_ACCEPT_ORG_MEMBERS: + return organizationApprovers(instance.getAcceptOrgId()); + default: + return Collections.emptyList(); + } + } + + private List confirmCandidateIds(ApprovalInstance instance) { + List ids = new ArrayList<>(); + if (instance.getReporterId() != null) { + ids.add(instance.getReporterId()); + } + if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) { + ids.add(instance.getDispatchTargetId()); + } + return ids; + } + + private List roleApprovers(String roleCode) { + if (StrUtil.isBlank(roleCode)) { + return Collections.emptyList(); + } + return toApprovers(directoryService.listRoleMembers(roleCode)); + } + + private List userApprovers(List userIds) { + return toApprovers(directoryService.listActiveUsersByIds(userIds)); + } + + private List organizationApprovers(Integer organizationId) { + return toApprovers(directoryService.listOrganizationMembers(organizationId)); + } + + private List toApprovers(List users) { + return users.stream() + .map(user -> new ApprovalApprover(user.getUserId(), displayName(user))) + .collect(Collectors.toList()); + } + + private String displayName(ApprovalUserVO user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + + private String normalizeOpinion(String opinion) { + if (opinion == null) { + return null; + } + String trimmed = opinion.trim(); + if (trimmed.isEmpty()) { + return null; + } + if (trimmed.length() > MAX_OPINION_LENGTH) { + throw new BusinessException("意见不能超过500字"); + } + return trimmed; + } + + private String requireOpinion(String opinion) { + String normalized = normalizeOpinion(opinion); + if (normalized == null) { + throw new BusinessException("请填写驳回意见"); + } + return normalized; + } + + private T inTransaction(Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } + + private void inTransaction(Runnable action) { + new TransactionTemplate(transactionManager).execute(status -> { + action.run(); + return null; + }); + } + + /** + * 动作结果:实例id + 需要透出的节点缺配提示。 + */ + public static class Result { + private final Long instanceId; + private final String warning; + + public Result(Long instanceId, String warning) { + this.instanceId = instanceId; + this.warning = warning; + } + + public Long getInstanceId() { + return instanceId; + } + + public String getWarning() { + return warning; + } + } +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java new file mode 100644 index 0000000..4dfc907 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java @@ -0,0 +1,406 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。 + * + *

本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新; + * 数据由 {@link ApprovalTestData#cleanup()} 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalTaskIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser leader; + private Integer organizationId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("任务部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("任务报送人" + data.uniqueSuffix(), organizationId); + ApprovalTestData.TestRole leaderRole = + data.createRole("dept_leader_" + organizationId, "部门负责人"); + leader = data.createUser("任务负责人" + data.uniqueSuffix(), organizationId); + data.bind(leader.userId, leaderRole.roleId); + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员"); + ApprovalTestData.TestUser archivist = data.createUser("任务收文员" + data.uniqueSuffix(), organizationId); + data.bind(archivist.userId, archivistRole.roleId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void approvesTaskAndActivatesNextConfirmNode() throws Exception { + long instanceId = createAndSubmit("通过测试"); + long taskId = pendingTaskId(instanceId, leader.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意办理\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) instanceId)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")) + .andExpect(jsonPath("$.data.confirms.length()").value(1)) + .andExpect(jsonPath("$.data.confirms[0].userId").value(reporter.userId)) + .andExpect(jsonPath("$.data.confirms[0].status").value("PENDING")); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo("DONE"); + assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo("同意办理"); + assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, taskId)) + .isNotNull(); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("confirm"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'CONFIRM' AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId)) + .isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE' AND operator_id = ? AND opinion = '同意办理'", instanceId, leader.userId)) + .isEqualTo(1); + + // 存档人能看到待确认任务与对应权限 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST)) + .andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM")) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(true)) + .andExpect(jsonPath("$.data.permissions.canApprove").value(false)); + } + + @Test + void requiresRejectOpinionAndResubmitsWithSameBizNo() throws Exception { + long instanceId = createAndSubmit("驳回重提"); + long taskId = pendingTaskId(instanceId, leader.userId); + String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\" \"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见"))); + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo("PENDING"); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"材料不完整\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("REJECTED")) + .andExpect(jsonPath("$.data.statusName").value("已驳回")) + .andExpect(jsonPath("$.data.round").value(2)); + + // 单据回到报送人:可修改、可重新提交 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST)) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(true)); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'", + instanceId)).isZero(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'REJECT' AND opinion = '材料不完整'", instanceId)).isEqualTo(1); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + assertThat(pendingTaskId(instanceId, leader.userId)).isPositive(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1); + } + + @Test + void approvingOneCountersignTaskCancelsTheRest() throws Exception { + ApprovalTestData.TestUser second = + data.createUser("第二负责人" + data.uniqueSuffix(), organizationId); + Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?", + Integer.class, "dept_leader_" + organizationId, TENANT); + data.bind(second.userId, leaderRoleId); + + long instanceId = createAndSubmit("或签取消"); + long firstTask = pendingTaskId(instanceId, leader.userId); + long secondTask = pendingTaskId(instanceId, second.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondTask)) + .isEqualTo("CANCELLED"); + + mockMvc.perform(authed(get("/api/approval/todo/page"), second, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(0)); + + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), second, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'", + instanceId)).isEqualTo(1); + } + + @Test + void concurrentApprovalOnlyHandlesTaskOnce() throws Exception { + long instanceId = createAndSubmit("并发处理"); + long taskId = pendingTaskId(instanceId, leader.userId); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + AtomicInteger success = new AtomicInteger(); + AtomicInteger conflict = new AtomicInteger(); + try { + for (int i = 0; i < 2; i++) { + executor.submit(() -> { + try { + start.await(); + int code = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), + leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"并发\"}")) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8) + .contains("\"code\":0") ? 0 : 1; + if (code == 0) { + success.incrementAndGet(); + } else { + conflict.incrementAndGet(); + } + } catch (Exception e) { + conflict.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } finally { + executor.shutdownNow(); + } + + assertThat(success.get()).isEqualTo(1); + assertThat(conflict.get()).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'", + instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'DONE'", + instanceId)).isEqualTo(1); + } + + @Test + void allowsReporterToApproveOwnTask() throws Exception { + Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?", + Integer.class, "dept_leader_" + organizationId, TENANT); + data.bind(reporter.userId, leaderRoleId); + + long instanceId = createAndSubmit("自审单据"); + long taskId = pendingTaskId(instanceId, reporter.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), + reporter, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE' AND operator_id = ?", instanceId, reporter.userId)).isEqualTo(1); + } + + @Test + void withdrawsOnlyBeforeAnyTaskHandled() throws Exception { + long withdrawn = createAndSubmit("撤回单据"); + String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, withdrawn); + + mockMvc.perform(authed(post("/api/approval/instance/" + withdrawn + "/withdraw"), + reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("WITHDRAWN")) + .andExpect(jsonPath("$.data.round").value(2)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(true)); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'", + withdrawn)).isZero(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'WITHDRAW'", + withdrawn)).isEqualTo(1); + + // 已有处理记录的实例不能撤回 + long handled = createAndSubmit("已审批撤回"); + long taskId = pendingTaskId(handled, leader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/instance/" + handled + "/withdraw"), + reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已有审批人处理,无法撤回"))); + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, handled)) + .isEqualTo("PENDING"); + + // 非报送人不能撤回 + long other = createAndSubmit("他人撤回"); + mockMvc.perform(authed(post("/api/approval/instance/" + other + "/withdraw"), + leader, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据"))); + } + + @Test + void listsTodoDoneAndTodoCountWithFilters() throws Exception { + long first = createAndSubmit("场馆维修A"); + long second = createAndSubmit("场馆维修B"); + + mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(2)); + + mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(2)) + .andExpect(jsonPath("$.data.list[0].id").value((int) second)) + .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty()) + .andExpect(jsonPath("$.data.list[0].status").value("PENDING")) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.list[0].submitTime").isNotEmpty()); + + mockMvc.perform(authed(get("/api/approval/todo/page").param("keywords", "B"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) second)); + mockMvc.perform(authed(get("/api/approval/todo/page").param("status", "REJECTED"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + + mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", "A"), reporter, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) first)); + + long taskId = pendingTaskId(first, leader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.data").value(1)); + mockMvc.perform(authed(get("/api/approval/done/page"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) first)) + .andExpect(jsonPath("$.data.list[0].status").value("PENDING")) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("存档")); + mockMvc.perform(authed(get("/api/approval/done/page").param("keywords", "B"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + + ApprovalTestData.TestUser outsider = data.createUser("任务路人" + data.uniqueSuffix(), organizationId); + mockMvc.perform(authed(get("/api/approval/todo/page"), outsider, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + mockMvc.perform(authed(get("/api/approval/done/page"), outsider, organizationId, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + } + + // ---------- helpers ---------- + + private long createAndSubmit(String name) throws Exception { + String body = "{\"name\":" + quote(name) + + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45," + + "\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), + reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From da5b5cfde7223cd95a6ba2725e148c3bab5ca3c2 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:50:53 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat(approval):=20PC=20=E5=8D=95=E6=8D=AE?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=88=86=E9=A1=B5=E4=B8=8E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApprovalDirectoryController.java | 2 +- .../controller/ApprovalQueryController.java | 35 ++ .../approval/mapper/ApprovalQueryMapper.java | 22 ++ .../mapper/xml/ApprovalQueryMapper.xml | 71 ++++ .../approval/param/ApprovalQueryParam.java | 50 +++ .../service/ApprovalQueryService.java | 87 +++++ .../gxwebsoft/approval/ApprovalQueryIT.java | 343 ++++++++++++++++++ 7 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java index 192eb34..8819b12 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java @@ -34,7 +34,7 @@ public class ApprovalDirectoryController extends BaseController { return success(directoryService.listOrganizations()); } - @PreAuthorize("hasAuthority('approval:instance:assign')") + @PreAuthorize("hasAnyAuthority('approval:instance:list', 'approval:instance:assign')") @ApiOperation("启用用户搜索") @GetMapping("/users") public ApiResult> users( diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java new file mode 100644 index 0000000..80b26b6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java @@ -0,0 +1,35 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.service.ApprovalQueryService; +import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +/** + * PC 单据查询接口。 + */ +@Api(tags = "审批单据查询") +@RestController +@RequestMapping("/api/approval") +public class ApprovalQueryController extends BaseController { + + @Resource + private ApprovalQueryService queryService; + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("PC 单据查询分页") + @GetMapping("/instance/page") + public ApiResult> page(ApprovalQueryParam param) { + return success(queryService.page(param, getLoginUser())); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java new file mode 100644 index 0000000..e3ae64c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalItemRow; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * PC 单据查询Mapper:两类单据联表单,数据权限条件与详情一致。 + */ +public interface ApprovalQueryMapper { + + /** + * 单据查询分页,默认提交时间倒序、同秒 id 倒序。 + */ + List selectInstancePage(@Param("page") IPage page, + @Param("param") ApprovalQueryParam param, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml new file mode 100644 index 0000000..1b13427 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java new file mode 100644 index 0000000..3e87857 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.approval.param; + +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * PC 单据查询参数:两类单据统一检索,含归档单据。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@ApiModel(description = "审批单据查询参数") +public class ApprovalQueryParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("单据类型 completion/coordination") + private String docType; + + @ApiModelProperty("单号") + private String bizNo; + + @ApiModelProperty("关键词:单号、名称或标题、实施单位") + private String keyword; + + @ApiModelProperty("报送部门id") + private Integer reportOrgId; + + @ApiModelProperty("受理部门id") + private Integer acceptOrgId; + + @ApiModelProperty("发起人id") + private Integer reporterId; + + @ApiModelProperty("单据状态") + private String status; + + @ApiModelProperty("提交时间起,yyyy-MM-dd HH:mm:ss") + private String submitTimeStart; + + @ApiModelProperty("提交时间止,yyyy-MM-dd HH:mm:ss") + private String submitTimeEnd; + + @ApiModelProperty("归档时间起,yyyy-MM-dd HH:mm:ss") + private String archiveTimeStart; + + @ApiModelProperty("归档时间止,yyyy-MM-dd HH:mm:ss") + private String archiveTimeEnd; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java new file mode 100644 index 0000000..1ab4609 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java @@ -0,0 +1,87 @@ +package com.gxwebsoft.approval.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.mapper.ApprovalQueryMapper; +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalItemRow; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * PC 单据查询服务:两类单据统一分页检索,列表与详情共用同一套数据权限条件。 + * + *

跨库约束:allVisible 角色判定走 core 专用连接,本方法不开启事务。

+ */ +@Service +public class ApprovalQueryService { + + private static final long DEFAULT_LIMIT = 10L; + + @Resource + private ApprovalQueryMapper queryMapper; + + @Resource + private ApprovalFlowService flowService; + + @Resource + private ApprovalDirectoryService directoryService; + + /** + * 单据查询分页,默认提交时间倒序、同秒按 id 倒序。 + */ + public PageResult page(ApprovalQueryParam param, User user) { + long current = param.getPage() == null ? 1 : Math.max(1, param.getPage()); + long size = param.getLimit() == null ? DEFAULT_LIMIT : Math.max(1, param.getLimit()); + int allVisible = directoryService.hasAnyRole(user.getUserId(), + ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0; + Page page = new Page<>(current, size); + List rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible); + Map> nodeCache = new HashMap<>(); + List list = new ArrayList<>(rows.size()); + for (ApprovalItemRow row : rows) { + list.add(toItem(row, nodeCache)); + } + return new PageResult<>(list, page.getTotal()); + } + + private ApprovalItemVO toItem(ApprovalItemRow row, Map> nodeCache) { + ApprovalItemVO item = new ApprovalItemVO(); + item.setId(row.getId()); + item.setBizNo(row.getBizNo()); + item.setDocType(row.getDocType()); + item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType())); + item.setStatus(row.getStatus()); + item.setStatusName(ApprovalConstants.statusName(row.getStatus())); + item.setTitle(row.getTitle()); + item.setSubmitTime(row.getSubmitTime()); + item.setReporterId(row.getReporterId()); + item.setReporterName(row.getReporterName()); + item.setReportOrgName(row.getReportOrgName()); + item.setAcceptOrgName(row.getAcceptOrgName()); + item.setArchiveTime(row.getArchiveTime()); + item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())); + return item; + } + + private String nodeName(Map> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes); + return nodes.stream() + .filter(node -> nodeKey.equals(node.getKey())) + .map(ApprovalNode::getName) + .findFirst() + .orElse(null); + } +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java new file mode 100644 index 0000000..37c38ef --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java @@ -0,0 +1,343 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * PC 单据查询:筛选、关键词、排序、归档检索与数据权限的集成测试。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +@Transactional +class ApprovalQueryIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private Integer reportOrgId; + private Integer acceptOrgId; + private ApprovalTestData.TestUser reporter; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + String suffix = data.uniqueSuffix(); + reportOrgId = data.createOrganization("报送部门" + suffix, 136); + acceptOrgId = data.createOrganization("受理部门" + suffix, 136); + reporter = data.createUser("报送人" + suffix, reportOrgId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void filtersByTypeKeywordOrganizationReporterAndStatus() throws Exception { + long submitted = submitCompletion("场馆维修实施"); + long coordination = seedCoordination("场馆维修协调", "PENDING", LocalDateTime.now().minusHours(1), null); + long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部"); + + // 单据类型 + 关键词命中实施单位 + assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted); + // 关键词命中协调单标题 + assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination); + // 关键词命中单号 + String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted); + assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted); + // 报送部门:两类单据都按报送部门过滤,其它部门不命中 + assertThat(queryIds("?reportOrgId=" + reportOrgId)) + .containsExactlyInAnyOrder(submitted, coordination, draft); + assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty(); + // 受理部门 + assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination); + // 发起人 + assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft); + // 状态 + assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination); + assertThat(queryIds("?status=DRAFT")).containsExactly(draft); + // 总数与分页总数一致 + assertThat(queryCount("?keyword=实施部")).isEqualTo(1); + // 单号精确筛选 + assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted); + // 提交时间范围 + assertThat(queryIds("?submitTimeStart=" + format(LocalDateTime.now().minusHours(2)) + + "&submitTimeEnd=" + format(LocalDateTime.now().plusMinutes(1)))) + .containsExactlyInAnyOrder(submitted, coordination); + assertThat(queryIds("?submitTimeEnd=" + format(LocalDateTime.now().minusDays(1)))).isEmpty(); + } + + @Test + void ordersBySubmitTimeDescThenIdDescAndKeepsPaginationStable() throws Exception { + LocalDateTime sameSecond = LocalDateTime.now().withNano(0).minusMinutes(10); + long first = seedCoordination("同秒一", "PENDING", sameSecond, null); + long second = seedCoordination("同秒二", "PENDING", sameSecond, null); + long third = seedCoordination("同秒三", "PENDING", sameSecond, null); + long older = seedCoordination("更早", "PENDING", sameSecond.minusDays(1), null); + long draft = createCompletionDraft("无提交时间草稿"); + + List page1 = queryIds("?limit=2&page=1"); + List page2 = queryIds("?limit=2&page=2"); + List page3 = queryIds("?limit=2&page=3"); + + assertThat(page1).containsExactly(third, second); + assertThat(page2).containsExactly(first, older); + assertThat(page3).containsExactly(draft); + } + + @Test + void includesArchivedInstancesAndFiltersByArchiveTime() throws Exception { + LocalDateTime archivedAt = LocalDateTime.now().withNano(0).minusDays(3); + long archived = seedCoordination("已归档协调单", "ARCHIVED", archivedAt.minusDays(1), archivedAt); + long pending = seedCoordination("在途协调单", "PENDING", archivedAt.minusDays(1), null); + + assertThat(queryIds("?status=ARCHIVED")).containsExactly(archived); + assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.minusHours(1)) + + "&archiveTimeEnd=" + format(archivedAt.plusHours(1)))).containsExactly(archived); + assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.plusDays(1)))).isEmpty(); + assertThat(queryIds("")).containsExactlyInAnyOrder(archived, pending); + } + + @Test + void limitsListByDataPermission() throws Exception { + long draft = createCompletionDraft("仅发起人可见草稿"); + long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null); + + ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser assignee = data.createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser operator = data.createUser("记录操作人" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser dispatched = data.createUser("被派单人" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), acceptOrgId); + + data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, " + + "task_type, assignee_id, status) VALUES (?, ?, 'reportLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')", + TENANT, pending, assignee.userId); + data.master().update("INSERT INTO approval_record (tenant_id, instance_id, operator_id, action, round) " + + "VALUES (?, ?, ?, 'SUBMIT', 1)", TENANT, pending, operator.userId); + data.master().update("UPDATE approval_instance SET dispatch_target_id = ?, dispatch_target_name = ? WHERE id = ?", + dispatched.userId, dispatched.realName, pending); + ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员"); + data.bind(archivist.userId, archivistRole.roleId); + + // 发起人可见自己的草稿与在途单据 + assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending); + // 参与人:有任务、有处理记录、被派单 + assertThat(queryIdsAs(assignee, "")).containsExactly(pending); + assertThat(queryIdsAs(operator, "")).containsExactly(pending); + assertThat(queryIdsAs(dispatched, "")).containsExactly(pending); + // 无关人员看不到他人单据 + assertThat(queryIdsAs(outsider, "")).isEmpty(); + assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty(); + // 收文员看全租户,但草稿仍只属于发起人 + assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft); + assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty(); + } + + @Test + void rejectsDetailOfInstanceOutsideDataPermission() throws Exception { + long draft = createCompletionDraft("他人草稿"); + ApprovalTestData.TestUser outsider = data.createUser("越权人员" + data.uniqueSuffix(), acceptOrgId); + + mockMvc.perform(authed(get("/api/approval/instance/" + draft), outsider, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + + mockMvc.perform(authed(get("/api/approval/instance/" + draft), reporter, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) draft)) + .andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单")); + } + + @Test + void allowsInstanceQueryUsersToSearchDirectoryUsers() throws Exception { + mockMvc.perform(authed(get("/api/approval/directory/users?keywords=" + reporter.username), reporter, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value(reporter.userId)); + } + + // ---------- helpers ---------- + + private List queryIds(String query) throws Exception { + return queryIdsAs(reporter, query); + } + + private List queryIdsAs(ApprovalTestData.TestUser user, String query) throws Exception { + String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), user, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response).path("data"); + List ids = new ArrayList<>(); + for (JsonNode item : node.path("list")) { + ids.add(item.path("id").asLong()); + } + return ids; + } + + private long queryCount(String query) throws Exception { + String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), reporter, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return objectMapper.readTree(response).path("data").path("count").asLong(); + } + + private long createCompletionDraft(String name) throws Exception { + return createCompletionDraft(name, "广西体育中心实施部"); + } + + private long createCompletionDraft(String name, String implementOrg) throws Exception { + String body = "{\"name\":" + quote(name) + ",\"implementOrg\":" + quote(implementOrg) + "," + + "\"fee\":100.00,\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long id = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(id); + return id; + } + + private long submitCompletion(String name) throws Exception { + grantDepartmentLeader(); + grantArchivist(); + long instanceId = createCompletionDraft(name); + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("PENDING"); + return instanceId; + } + + /** + * 直接落库一条协调单实例:T23 的表单/提交流程尚未合入,查询侧只需完整数据行。 + */ + private long seedCoordination(String title, String status, LocalDateTime submitTime, LocalDateTime archiveTime) { + String bizNo = "XT" + System.nanoTime(); + data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, " + + "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, " + + "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time, " + + "archive_time, archivist_id, archivist_name) " + + "VALUES (?, ?, 'coordination_internal', ?, 1, 'coordination', 0, ?, 1, ?, ?, ?, ?, ?, ?, " + + "'reportLeader', 'APPROVE', ?, ?, ?, ?)", + TENANT, bizNo, coordinationDefinitionId(), status, reporter.userId, reporter.realName, + reportOrgId, orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId), + Timestamp.valueOf(submitTime), + archiveTime == null ? null : Timestamp.valueOf(archiveTime), + archiveTime == null ? null : reporter.userId, + archiveTime == null ? null : reporter.realName); + Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + Long.class, TENANT, bizNo); + data.trackInstance(instanceId); + data.master().update("INSERT INTO approval_coordination_form (tenant_id, instance_id, accept_org_id, " + + "accept_org_name, title, content) VALUES (?, ?, ?, ?, ?, '协调内容')", + TENANT, instanceId, acceptOrgId, orgName(acceptOrgId), title); + Long formId = data.queryMaster("SELECT id FROM approval_coordination_form WHERE instance_id = ?", + Long.class, instanceId); + data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId); + return instanceId; + } + + private long coordinationDefinitionId() { + List ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? " + + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT); + if (ids.isEmpty()) { + data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) " + + "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)", + TENANT, ApprovalConstants.COORDINATION_NODES_JSON); + ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? " + + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT); + } + return ids.isEmpty() ? 0L : ids.get(0); + } + + private String format(LocalDateTime time) { + return TIME_FORMAT.format(time.withNano(0)); + } + + private String orgName(Integer organizationId) { + return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?", + String.class, organizationId); + } + + private void grantDepartmentLeader() { + ApprovalTestData.TestRole role = data.createRole("dept_leader_" + reportOrgId, "部门负责人"); + ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId); + data.bind(leader.userId, role.roleId); + } + + private void grantArchivist() { + ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员"); + ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId); + data.bind(archivist.userId, role.roleId); + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, + reportOrgId, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From 7db8097c8445179127296f212730cb7c0e947852 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:03:07 +0800 Subject: [PATCH 07/18] =?UTF-8?q?feat(approval):=20PC=20=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E6=94=B9=E6=B4=BE=E6=8E=A5=E5=8F=A3=E4=B8=8E=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/approval/task/{id}/assign:权限 approval:instance:assign, 任务须 PENDING、目标同租户启用用户、原因必填;事务内锁实例后条件更新 处理人并写 ASSIGN 记录(opinion=原因),@OperationLog 记录原/新处理人与原因 - 新增 ApprovalTaskAssignParam / ApprovalAssignVO - 新增 ApprovalPcTodoIT:改派成功与归属变化、无效目标、非待办任务、原因必填、 无权限 403、ASSIGN 记录、操作日志、改派候选用户 --- .../controller/ApprovalTaskController.java | 12 + .../param/ApprovalTaskAssignParam.java | 20 + .../approval/service/ApprovalTaskService.java | 84 +++++ .../approval/vo/ApprovalAssignVO.java | 29 ++ .../gxwebsoft/approval/ApprovalPcTodoIT.java | 349 ++++++++++++++++++ 5 files changed, 494 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index 58af23e..6d75442 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -1,11 +1,14 @@ package com.gxwebsoft.approval.controller; import com.gxwebsoft.approval.param.ApprovalTaskActionParam; +import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.service.ApprovalInstanceService; import com.gxwebsoft.approval.service.ApprovalTaskService; +import com.gxwebsoft.approval.vo.ApprovalAssignVO; import com.gxwebsoft.approval.vo.ApprovalInstanceVO; import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.PageResult; @@ -85,4 +88,13 @@ public class ApprovalTaskController extends BaseController { ApprovalTaskService.Result result = taskService.withdraw(id, user); return success("撤回成功", instanceService.detail(result.getInstanceId(), user)); } + + @PreAuthorize("hasAuthority('approval:instance:assign')") + @OperationLog(value = "改派审批待办", module = "审批管理") + @ApiOperation("改派") + @PostMapping("/task/{id}/assign") + public ApiResult assign(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskAssignParam param) { + return success("改派成功", taskService.assign(id, param, getLoginUser())); + } } diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java new file mode 100644 index 0000000..a826e08 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 待办改派参数。 + */ +@Data +public class ApprovalTaskAssignParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("改派对象用户id") + private Integer targetUserId; + + @ApiModelProperty("改派原因;必填") + private String reason; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 0cf213d..a6a1a2f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -8,8 +8,10 @@ import com.gxwebsoft.approval.entity.ApprovalInstance; import com.gxwebsoft.approval.entity.ApprovalTask; import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; +import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalAssignVO; import com.gxwebsoft.approval.vo.ApprovalUserVO; import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.system.entity.User; @@ -109,6 +111,28 @@ public class ApprovalTaskService { return new Result(instanceId, null); } + /** + * 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。 + * + *

改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。

+ */ + public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) { + if (param == null || param.getTargetUserId() == null) { + throw new BusinessException("请选择改派对象"); + } + String reason = requireAssignReason(param.getReason()); + ApprovalTask task = requireAssignableTask(taskId); + // 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户) + List targets = + directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId())); + if (targets.isEmpty()) { + throw new BusinessException("改派对象不存在或已停用"); + } + ApprovalUserVO target = targets.get(0); + String targetName = displayName(target); + return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user)); + } + public Integer todoCount(User user) { return taskMapper.countPendingInstances(user.getUserId()); } @@ -171,6 +195,58 @@ public class ApprovalTaskService { } } + /** + * 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。 + */ + private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId, + String targetName, String reason, User user) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureTaskHandleable(task, locked); + int rows = taskMapper.update(null, new UpdateWrapper() + .eq("id", taskId) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("assignee_id", targetUserId) + .set("assignee_name", targetName)); + if (rows == 0) { + throw new BusinessException("任务已处理"); + } + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_ASSIGN, reason); + ApprovalAssignVO vo = new ApprovalAssignVO(); + vo.setInstanceId(locked.getId()); + vo.setTaskId(taskId); + vo.setOriginalAssigneeId(task.getAssigneeId()); + vo.setOriginalAssigneeName(task.getAssigneeName()); + vo.setAssigneeId(targetUserId); + vo.setAssigneeName(targetName); + vo.setReason(reason); + return vo; + } + + /** + * 改派前的基本校验:任务存在、待处理且单据仍在审批中。 + */ + private ApprovalTask requireAssignableTask(Long taskId) { + if (taskId == null) { + throw new BusinessException("任务不存在"); + } + ApprovalTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) { + throw new BusinessException("任务已处理"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { + throw new BusinessException("单据不在审批中,无法改派"); + } + return task; + } + /** * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。 */ @@ -310,6 +386,14 @@ public class ApprovalTaskService { return normalized; } + private String requireAssignReason(String reason) { + String normalized = normalizeOpinion(reason); + if (normalized == null) { + throw new BusinessException("请填写改派原因"); + } + return normalized; + } + private T inTransaction(Supplier action) { return new TransactionTemplate(transactionManager).execute(status -> action.get()); } diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java new file mode 100644 index 0000000..718c9d3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。 + */ +@Data +@ApiModel(description = "改派结果") +public class ApprovalAssignVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long instanceId; + + private Long taskId; + + private Integer originalAssigneeId; + + private String originalAssigneeName; + + private Integer assigneeId; + + private String assigneeName; + + private String reason; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java new file mode 100644 index 0000000..26e43fd --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java @@ -0,0 +1,349 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * PC 待办审批与改派的端到端行为。 + * + *

覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、 + * ASSIGN 处理记录、操作日志以及改派候选用户查询。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalPcTodoIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + private static final String ASSIGN = "approval:instance:assign"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser leader; + private ApprovalTestData.TestUser admin; + private Integer organizationId; + private final List assignUrls = new ArrayList<>(); + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId); + ApprovalTestData.TestRole leaderRole = + data.createRole("dept_leader_" + organizationId, "部门负责人"); + leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId); + data.bind(leader.userId, leaderRole.roleId); + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员"); + ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId); + data.bind(archivist.userId, archivistRole.roleId); + admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId); + } + + @AfterEach + void tearDown() { + for (String url : assignUrls) { + data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?", + admin.userId, url); + } + assignUrls.clear(); + data.cleanup(); + } + + @Test + void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception { + long instanceId = createAndSubmit("改派单据"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + String reason = "原处理人休假" + data.uniqueSuffix(); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.instanceId").value((int) instanceId)) + .andExpect(jsonPath("$.data.taskId").value((int) taskId)) + .andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId)) + .andExpect(jsonPath("$.data.assigneeId").value(target.userId)) + .andExpect(jsonPath("$.data.assigneeName").value(target.realName)) + .andExpect(jsonPath("$.data.reason").value(reason)); + + // 任务仍为待处理,归属已换人 + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(target.userId); + assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo(target.realName); + + // 处理记录留痕:改派人与原因 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason)) + .isEqualTo(1); + + // 原处理人待办消失、新处理人待办出现 + mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(0)); + mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + // 新处理人可正常处理改派后的任务 + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + + // 原处理人已无权处理该任务 + long second = createAndSubmit("改派后原处理人"); + long secondTask = pendingTaskId(second, leader.userId); + mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix())) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务"))); + } + + @Test + void rejectsAssignToUnknownOrInactiveUser() throws Exception { + long instanceId = createAndSubmit("改派无效目标"); + long taskId = pendingTaskId(instanceId, leader.userId); + + ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId); + data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId); + + mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用"))); + + mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'", + instanceId)).isZero(); + } + + @Test + void requiresAssignReason() throws Exception { + long instanceId = createAndSubmit("改派无原因"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + } + + @Test + void rejectsAssignForHandledTask() throws Exception { + long instanceId = createAndSubmit("改派已处理任务"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + } + + @Test + void rejectsAssignWithoutAuthority() throws Exception { + long instanceId = createAndSubmit("无权限改派"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + + mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"), + admin, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void writesOperationLogWithOriginalAndNewAssignee() throws Exception { + long instanceId = createAndSubmit("改派操作日志"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + String reason = "操作日志原因" + data.uniqueSuffix(); + String url = "/api/approval/task/" + taskId + "/assign"; + assignUrls.add(url); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason)) + .andExpect(jsonPath("$.code").value(0)); + + JsonNode record = awaitOperationRecord(url, reason); + assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull(); + assertThat(record.path("userId").asInt()).isEqualTo(admin.userId); + assertThat(record.path("description").asText()).contains("改派"); + assertThat(record.path("params").asText()).contains(reason); + assertThat(record.path("result").asText()) + .contains(leader.realName) + .contains(target.realName) + .contains(reason); + } + + @Test + void listsOnlyActiveUsersForAssignCandidates() throws Exception { + ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId); + ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId); + data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId); + + String body = mockMvc.perform(authed(get("/api/approval/directory/users") + .param("keywords", active.realName), admin, organizationId, ASSIGN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode users = objectMapper.readTree(body).path("data"); + assertThat(users.isArray()).isTrue(); + assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId)); + assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId)); + JsonNode matched = null; + for (JsonNode user : users) { + if (user.path("userId").asInt() == active.userId) { + matched = user; + } + } + assertThat(matched).isNotNull(); + assertThat(matched.path("realName").asText()).isEqualTo(active.realName); + } + + // ---------- helpers ---------- + + private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator, + String authority, int targetUserId, String reason) + throws Exception { + return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}"); + } + + private long createAndSubmit(String name) throws Exception { + String body = "{\"name\":" + quote(name) + + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45," + + "\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), + reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + /** 操作日志异步写入,最多等待 2 秒。 */ + private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception { + long deadline = System.currentTimeMillis() + 2000; + while (System.currentTimeMillis() < deadline) { + List> rows = data.server().queryForList( + "SELECT user_id AS userId, description, params, result FROM sys_operation_record " + + "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1", + url, "%" + paramsMark + "%"); + if (!rows.isEmpty()) { + return objectMapper.valueToTree(rows.get(0)); + } + Thread.sleep(100); + } + return null; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From 7f26fafba3d399167ca0434ff2f87eb244720ef7 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:07:35 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat(approval):=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E8=A1=A8=E5=8D=95=E3=80=81=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=A2=84=E6=A3=80=E4=B8=8E=E5=8F=8C=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E8=B4=9F=E8=B4=A3=E4=BA=BA=E5=AE=A1=E6=89=B9=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApprovalInstanceController.java | 9 + .../mapper/xml/ApprovalInstanceMapper.xml | 2 +- .../param/ApprovalCoordinationParam.java | 31 + .../service/ApprovalInstanceService.java | 170 +++++- .../approval/ApprovalCoordinationIT.java | 568 ++++++++++++++++++ 5 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java index 586a594..cf3b993 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java @@ -1,6 +1,7 @@ package com.gxwebsoft.approval.controller; import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalCoordinationParam; import com.gxwebsoft.approval.param.ApprovalMineParam; import com.gxwebsoft.approval.service.ApprovalInstanceService; import com.gxwebsoft.approval.vo.ApprovalInstanceVO; @@ -40,6 +41,14 @@ public class ApprovalInstanceController extends BaseController { return success("保存成功", instanceService.saveCompletion(param, user)); } + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("新建或保存内部协调单草稿") + @PostMapping("/instance/coordination") + public ApiResult saveCoordination(@RequestBody ApprovalCoordinationParam param) { + User user = getLoginUser(); + return success("保存成功", instanceService.saveCoordination(param, user)); + } + @PreAuthorize("hasAuthority('approval:instance:submit')") @ApiOperation("提交或重提") @PostMapping("/instance/{id}/submit") diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml index 61cdb83..e934317 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -32,7 +32,7 @@ i.reporter_id, i.reporter_name, i.report_org_name, - i.accept_org_name, + COALESCE(i.accept_org_name, co.accept_org_name) AS accept_org_name, i.archive_time, i.current_node_key, i.definition_id, diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java new file mode 100644 index 0000000..bb9ab53 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。 + * + *

报送部门与经办人由服务端按登录态带出,不在参数中接收。

+ */ +@Data +public class ApprovalCoordinationParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("审批实例id(更新草稿时传入)") + private Long id; + + @ApiModelProperty("受理部门id,必须是启用组织") + private Integer acceptOrgId; + + @ApiModelProperty("标题") + private String title; + + @ApiModelProperty("内容") + private String content; + + @ApiModelProperty("备注") + private String remark; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index d2b6af0..fc709b0 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -16,6 +16,7 @@ import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalCoordinationParam; import com.gxwebsoft.approval.param.ApprovalMineParam; import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.support.ApprovalApprover; @@ -99,6 +100,30 @@ public class ApprovalInstanceService { return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName)); } + /** + * 新建或更新内部协调单草稿,返回实例id。 + * + *

报送部门与经办人取登录态;受理部门必须是启用组织,保存时即校验。

+ */ + public Long saveCoordination(ApprovalCoordinationParam param, User user) { + validateCoordination(param); + ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId()); + if (acceptOrganization == null) { + throw new BusinessException("受理部门不存在或已停用"); + } + Integer organizationId = user.getOrganizationId(); + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + String organizationName = organization == null ? null : organization.getOrganizationName(); + + if (param.getId() != null) { + return inTransaction(() -> + updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization)); + } + ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL); + return inTransaction(() -> + createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization)); + } + /** * 提交或重提,返回节点缺配提示;正常提交返回 null。 */ @@ -113,7 +138,14 @@ public class ApprovalInstanceService { if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { throw new BusinessException(submitBlockedMessage(instance.getStatus())); } - if (completionForm(instanceId) == null) { + boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType()); + ApprovalCoordinationForm coordinationForm = null; + if (coordination) { + coordinationForm = coordinationForm(instanceId); + if (coordinationForm == null) { + throw new BusinessException("单据内容不存在"); + } + } else if (completionForm(instanceId) == null) { throw new BusinessException("单据内容不存在"); } @@ -130,6 +162,10 @@ public class ApprovalInstanceService { if (leaders.isEmpty()) { throw new BusinessException("报送部门负责人角色缺配"); } + ApprovalOrganizationVO acceptOrganization = null; + if (coordination) { + acceptOrganization = preflightAcceptOrganization(coordinationForm); + } if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) { throw new BusinessException("收文员角色缺配"); } @@ -141,11 +177,30 @@ public class ApprovalInstanceService { .map(this::toApprover) .collect(Collectors.toList()); - return inTransaction(() -> doSubmit(instanceId, user, organization, approvers)); + ApprovalOrganizationVO acceptOrg = acceptOrganization; + return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers)); + } + + /** + * 协调单提交预检:受理部门负责人角色、受理部门停用。 + */ + private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) { + Integer acceptOrgId = form.getAcceptOrgId(); + if (acceptOrgId == null || acceptOrgId <= 0) { + throw new BusinessException("请选择受理部门"); + } + if (directoryService.listRoleMembers(ApprovalConstants.departmentLeaderRoleCode(acceptOrgId)).isEmpty()) { + throw new BusinessException("受理部门负责人角色缺配"); + } + ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId); + if (acceptOrganization == null) { + throw new BusinessException("受理部门已停用"); + } + return acceptOrganization; } private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization, - List approvers) { + ApprovalOrganizationVO acceptOrganization, List approvers) { ApprovalInstance locked = engineService.lockInstance(instanceId); if (!locked.getReporterId().equals(user.getUserId()) || !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) { @@ -154,8 +209,15 @@ public class ApprovalInstanceService { boolean firstSubmit = StrUtil.isBlank(locked.getBizNo()); locked.setReportOrgId(organization.getOrganizationId()); locked.setReportOrgName(organization.getOrganizationName()); + if (acceptOrganization != null) { + locked.setAcceptOrgId(acceptOrganization.getOrganizationId()); + locked.setAcceptOrgName(acceptOrganization.getOrganizationName()); + } if (firstSubmit) { - locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION)); + boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType()); + locked.setBizNo(noService.nextNo(coordination + ? ApprovalConstants.NO_PREFIX_COORDINATION + : ApprovalConstants.NO_PREFIX_COMPLETION)); } locked.setStatus(ApprovalConstants.STATUS_PENDING); locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1); @@ -323,6 +385,79 @@ public class ApprovalInstanceService { return instance.getId(); } + private Long createCoordinationDraft(ApprovalCoordinationParam param, User user, + ApprovalFlowDefinition definition, + Integer organizationId, String organizationName, + ApprovalOrganizationVO acceptOrganization) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setFlowCode(ApprovalConstants.FLOW_COORDINATION_INTERNAL); + instance.setDefinitionId(definition.getId()); + instance.setDefinitionVersion(definition.getVersion()); + instance.setDocType(ApprovalConstants.DOC_COORDINATION); + instance.setBizId(0L); + instance.setStatus(ApprovalConstants.STATUS_DRAFT); + instance.setRound(0); + instance.setReporterId(user.getUserId()); + instance.setReporterName(displayName(user)); + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.insert(instance); + + ApprovalCoordinationForm form = new ApprovalCoordinationForm(); + form.setInstanceId(instance.getId()); + form.setAcceptOrgId(acceptOrganization.getOrganizationId()); + form.setAcceptOrgName(acceptOrganization.getOrganizationName()); + form.setTitle(param.getTitle().trim()); + form.setContent(param.getContent().trim()); + form.setRemark(normalizeRemark(param.getRemark())); + coordinationFormMapper.insert(form); + + instance.setBizId(form.getId()); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private Long updateCoordinationDraft(ApprovalCoordinationParam param, User user, + Integer organizationId, String organizationName, + ApprovalOrganizationVO acceptOrganization) { + ApprovalInstance instance = instanceMapper.selectById(param.getId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType())) { + throw new BusinessException("单据类型不匹配"); + } + if (!instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException("当前状态不可修改"); + } + ApprovalCoordinationForm form = coordinationForm(instance.getId()); + if (form == null) { + throw new BusinessException("单据内容不存在"); + } + form.setAcceptOrgId(acceptOrganization.getOrganizationId()); + form.setAcceptOrgName(acceptOrganization.getOrganizationName()); + form.setTitle(param.getTitle().trim()); + form.setContent(param.getContent().trim()); + form.setRemark(normalizeRemark(param.getRemark())); + coordinationFormMapper.updateById(form); + + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private String normalizeRemark(String remark) { + if (remark == null) { + return null; + } + String trimmed = remark.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private ApprovalInstanceVO baseVO(ApprovalInstance instance) { ApprovalInstanceVO vo = new ApprovalInstanceVO(); vo.setId(instance.getId()); @@ -502,6 +637,33 @@ public class ApprovalInstanceService { } } + private void validateCoordination(ApprovalCoordinationParam param) { + if (param == null) { + throw new BusinessException("参数不能为空"); + } + if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) { + throw new BusinessException("请选择受理部门"); + } + String title = param.getTitle() == null ? "" : param.getTitle().trim(); + if (title.isEmpty()) { + throw new BusinessException("标题不能为空"); + } + if (title.length() > 100) { + throw new BusinessException("标题不能超过100字"); + } + String content = param.getContent() == null ? "" : param.getContent().trim(); + if (content.isEmpty()) { + throw new BusinessException("内容不能为空"); + } + if (content.length() > 2000) { + throw new BusinessException("内容不能超过2000字"); + } + String remark = normalizeRemark(param.getRemark()); + if (remark != null && remark.length() > 500) { + throw new BusinessException("备注不能超过500字"); + } + } + private String submitBlockedMessage(String status) { if (ApprovalConstants.STATUS_PENDING.equals(status)) { return "单据正在审批中,不能重复提交"; diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java new file mode 100644 index 0000000..4ffdf2a --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java @@ -0,0 +1,568 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 内部协调单:草稿、字段校验、提交预检、部门快照与双节点审批的集成测试。 + * + *

不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进; + * 数据由 {@link ApprovalTestData#cleanup()} 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalCoordinationIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer reportOrgId; + private Integer acceptOrgId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + reportOrgId = data.createOrganization("协调报送部门" + data.uniqueSuffix(), 136); + acceptOrgId = data.createOrganization("协调受理部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("协调报送人" + data.uniqueSuffix(), reportOrgId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void createsDraftWithServerSideReportFields() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "设备借用协调", + "借用体育馆音响设备一天", "活动结束后归还"); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("coordination"); + assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("coordination_internal"); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo(reporter.username); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reportOrgId); + assertThat(data.queryMaster("SELECT report_org_name FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isNotBlank(); + // 受理部门在提交时才写入实例快照 + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isNull(); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(acceptOrgId); + assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("设备借用协调"); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("活动结束后归还"); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.docType").value("coordination")) + .andExpect(jsonPath("$.data.docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.statusName").value("未提交")) + .andExpect(jsonPath("$.data.title").value("设备借用协调")) + .andExpect(jsonPath("$.data.content").value("借用体育馆音响设备一天")) + .andExpect(jsonPath("$.data.remark").value("活动结束后归还")) + .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.bizNo").doesNotExist()); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.list[0].title").value("设备借用协调")) + .andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.list[0].statusName").value("未提交")); + + // 流程定义按租户幂等,节点顺序固定;一期不产生外部协调流程 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", TENANT)).isEqualTo(1); + String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", String.class, TENANT); + assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch") + .contains("confirm").contains("archive"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero(); + } + + @Test + void updatesDraftOnlyForOwnerInEditableState() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "原标题", "原内容", null); + Integer otherAcceptOrg = data.createOrganization("备用受理部门" + data.uniqueSuffix(), 136); + + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(otherAcceptOrg, "新标题", "新内容", "补充说明", instanceId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value((int) instanceId)); + + assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("新标题"); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(otherAcceptOrg); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("补充说明"); + + ApprovalTestData.TestUser outsider = data.createUser("协调路人" + data.uniqueSuffix(), reportOrgId); + mockMvc.perform(authed(post("/api/approval/instance/coordination"), outsider, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(otherAcceptOrg, "越权标题", "内容", null, instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据"))); + + // 提交后不可再修改 + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(otherAcceptOrg, 1); + grantArchivist(); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(acceptOrgId, "提交后改标题", "内容", null, instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + } + + @Test + void validatesCoordinationFields() throws Exception { + expectSaveFailure(body(null, "标题", "内容", null, null), "请选择受理部门"); + expectSaveFailure(body(acceptOrgId, "", "内容", null, null), "标题不能为空"); + expectSaveFailure(body(acceptOrgId, repeat("标", 101), "内容", null, null), "标题不能超过100字"); + expectSaveFailure(body(acceptOrgId, "标题", "", null, null), "内容不能为空"); + expectSaveFailure(body(acceptOrgId, "标题", repeat("内", 2001), null, null), "内容不能超过2000字"); + expectSaveFailure(body(acceptOrgId, "标题", "内容", repeat("备", 501), null), "备注不能超过500字"); + + Integer disabledOrg = data.createOrganization("停用受理部门" + data.uniqueSuffix(), 136); + data.disableOrganization(disabledOrg); + expectSaveFailure(body(disabledOrg, "标题", "内容", null, null), "受理部门不存在或已停用"); + + // 备注为可选字段,留空保存成功 + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "无备注", "内容", " "); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenReportLeaderRoleMissing() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺报送负责人", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenAcceptLeaderRoleMissing() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺受理负责人", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenAcceptOrganizationDisabled() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "受理部门停用", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + data.disableOrganization(acceptOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门已停用"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isNull(); + } + + @Test + void submitsWithXtBizNoAndSnapshotsBothOrganizations() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "提交协调单", "协调内容", "备注内容"); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.statusName").value("审批中")) + .andExpect(jsonPath("$.data.docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)); + + String bizNo = bizNo(instanceId); + assertThat(bizNo).matches("XT\\d{8}-\\d{4}"); + assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase()); + + assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(1); + assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)) + .isNotNull(); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("reportLeader"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("APPROVE"); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isEqualTo(reportOrgId); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isEqualTo(acceptOrgId); + assertThat(data.queryMaster("SELECT accept_org_name FROM approval_instance WHERE id = ?", + String.class, instanceId)) + .isEqualTo(data.server().queryForObject( + "SELECT organization_name FROM sys_organization WHERE organization_id = ?", + String.class, acceptOrgId)); + + assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId); + } + + @Test + void approvesReportThenAcceptLeaderAndReachesDispatch() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + ApprovalTestData.TestUser acceptLeader = grantDepartmentLeader(acceptOrgId, 1).get(0); + grantArchivist(); + ApprovalTestData.TestUser acceptMember = data.createUser("受理部门成员" + data.uniqueSuffix(), acceptOrgId); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "双节点审批", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(get("/api/approval/todo/page"), reportLeader, reportOrgId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + long reportTask = pendingTaskId(instanceId, reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + reportTask + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意协调\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")) + .andExpect(jsonPath("$.data.myTask").doesNotExist()); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("acceptLeader"); + assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId); + + mockMvc.perform(authed(get("/api/approval/todo/page"), acceptLeader, acceptOrgId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)); + + long acceptTask = pendingTaskId(instanceId, acceptLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + acceptTask + "/approve"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"可以协调\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("dispatch"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("DISPATCH"); + assertThat(pendingTaskIds(instanceId)).contains(acceptMember.userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE'", instanceId)).isEqualTo(2); + assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask)) + .isEqualTo("同意协调"); + } + + @Test + void allowsSamePersonToHandleBothApprovalNodes() throws Exception { + ApprovalTestData.TestUser both = data.createUser("双部门负责人" + data.uniqueSuffix(), reportOrgId); + ApprovalTestData.TestRole reportRole = data.createRole("dept_leader_" + reportOrgId, "部门负责人"); + ApprovalTestData.TestRole acceptRole = data.createRole("dept_leader_" + acceptOrgId, "部门负责人"); + data.bind(both.userId, reportRole.roleId); + data.bind(both.userId, acceptRole.roleId); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "同人连做", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + long firstTask = pendingTaskId(instanceId, both.userId); + mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), both, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")); + + long secondTask = pendingTaskId(instanceId, both.userId); + assertThat(secondTask).isNotEqualTo(firstTask); + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), both, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("dispatch"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE' AND operator_id = ?", instanceId, both.userId)).isEqualTo(2); + } + + @Test + void rejectsThenResubmitsKeepingBizNo() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "驳回重提协调", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + String bizNo = bizNo(instanceId); + + long taskId = pendingTaskId(instanceId, reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\" \"}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见"))); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"协调范围不清\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("REJECTED")) + .andExpect(jsonPath("$.data.statusName").value("已驳回")) + .andExpect(jsonPath("$.data.round").value(2)); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST)) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(true)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + + assertThat(pendingTaskId(instanceId, reportLeader.userId)).isPositive(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'REJECT' AND opinion = '协调范围不清'", instanceId)).isEqualTo(1); + } + + @Test + void withdrawsAndResubmitsBeforeAnyApproval() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "撤回协调", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + String bizNo = bizNo(instanceId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("WITHDRAWN")) + .andExpect(jsonPath("$.data.round").value(2)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + } + + @Test + void hidesDraftFromOutsider() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "草稿保密", "内容", null); + ApprovalTestData.TestUser outsider = data.createUser("协调外人" + data.uniqueSuffix(), reportOrgId); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, reportOrgId, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + } + + // ---------- helpers ---------- + + private long saveCoordination(ApprovalTestData.TestUser user, Integer reportOrg, Integer acceptOrg, + String title, String content, String remark) throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + user, reportOrg, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(acceptOrg, title, content, remark, null))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response); + long id = node.path("data").asLong(); + data.trackInstance(id); + return id; + } + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage))); + } + + private void submitCoordination(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private List grantDepartmentLeader(int orgId, int memberCount) { + ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人"); + List members = new ArrayList<>(); + for (int i = 0; i < memberCount; i++) { + ApprovalTestData.TestUser member = data.createUser("协调负责人" + data.uniqueSuffix(), orgId); + data.bind(member.userId, role.roleId); + members.add(member); + } + return members; + } + + private void grantArchivist() { + ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员"); + ApprovalTestData.TestUser member = data.createUser("协调收文员" + data.uniqueSuffix(), reportOrgId); + data.bind(member.userId, role.roleId); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private List pendingTaskIds(long instanceId) { + return data.master().queryForList("SELECT assignee_id FROM approval_task " + + "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId); + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String body(Integer acceptOrgId, String title, String content, String remark, Long id) { + StringBuilder sb = new StringBuilder("{"); + if (id != null) { + sb.append("\"id\":").append(id).append(','); + } + sb.append("\"acceptOrgId\":").append(acceptOrgId == null ? "null" : acceptOrgId).append(',') + .append("\"title\":").append(quote(title)).append(',') + .append("\"content\":").append(quote(content)).append(',') + .append("\"remark\":").append(remark == null ? "null" : quote(remark)) + .append('}'); + return sb.toString(); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String value, int count) { + return value.repeat(count); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String bizNo(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String todayFromDatabase() { + return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class); + } +} From a336e207476d9b076cccd6a65a8be7cd960ebacf Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:09:05 +0800 Subject: [PATCH 09/18] =?UTF-8?q?feat(approval):=20=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8E=E5=BD=92=E6=A1=A3=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /task/{id}/confirm(存档确认)与 /task/{id}/archive(归档)端点 - CONFIRM 全员完成后实例进入 APPROVED 并激活归档节点;缺配时停留审批中并提示 - ARCHIVE 或签,实例进入 ARCHIVED,记录归档人与时间,其余待办取消 - 归档后提交/撤回/编辑/附件/存档动作统一中文拒绝 - ApprovalConfirmArchiveIT 覆盖全员/部分存档、或签、只读、缺配阻断与全租户可见 --- .../approval/constant/ApprovalConstants.java | 7 + .../controller/ApprovalTaskController.java | 19 + .../service/ApprovalAttachmentService.java | 8 +- .../service/ApprovalInstanceService.java | 2 +- .../approval/service/ApprovalTaskService.java | 123 ++++++ .../approval/ApprovalConfirmArchiveIT.java | 367 ++++++++++++++++++ 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index 3ea09af..e27ff0e 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -163,6 +163,13 @@ public final class ApprovalConstants { return CATEGORY_NAMES.getOrDefault(category, category); } + /** + * 归档后单据为只读终态:任何写动作统一返回该提示。 + */ + public static String writeBlockedMessage(String status) { + return STATUS_ARCHIVED.equals(status) ? "单据已归档,不能修改" : "当前状态不可修改"; + } + public static String departmentLeaderRoleCode(Integer organizationId) { return organizationId == null ? null : "dept_leader_" + organizationId; } diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index 58af23e..c062d8c 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -77,6 +77,25 @@ public class ApprovalTaskController extends BaseController { return success("驳回成功", instanceService.detail(result.getInstanceId(), user)); } + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("存档确认") + @PostMapping("/task/{id}/confirm") + public ApiResult confirm(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.confirm(id, user); + return success(result.getWarning() == null ? "存档成功" : result.getWarning(), + instanceService.detail(result.getInstanceId(), user)); + } + + @PreAuthorize("hasAuthority('approval:instance:archive')") + @ApiOperation("归档") + @PostMapping("/task/{id}/archive") + public ApiResult archive(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.archive(id, user); + return success("归档成功", instanceService.detail(result.getInstanceId(), user)); + } + @PreAuthorize("hasAuthority('approval:instance:submit')") @ApiOperation("撤回") @PostMapping("/instance/{id}/withdraw") diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java index 7a3dbfa..a48089f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -162,9 +162,11 @@ public class ApprovalAttachmentService { } private void checkEditable(ApprovalInstance instance, User user) { - if (user == null || !instance.getReporterId().equals(user.getUserId()) - || !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { - throw new BusinessException("当前状态不可修改"); + if (user == null || !instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); } } diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index d2b6af0..325a5be 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -305,7 +305,7 @@ public class ApprovalInstanceService { throw new BusinessException("无权操作该单据"); } if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { - throw new BusinessException("当前状态不可修改"); + throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); } ApprovalCompletionForm form = completionForm(instance.getId()); if (form == null) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 0cf213d..6bc98d5 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -75,6 +75,45 @@ public class ApprovalTaskService { return new Result(instance.getId(), warning); } + /** + * 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成, + * 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。 + */ + public Result confirm(Long taskId, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持存档确认"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureNotArchived(instance); + ApprovalNode next = engineService.nextNode(instance, task.getNodeKey()); + List approvers = + next == null ? Collections.emptyList() : resolveApprovers(instance, next); + + String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers)); + return new Result(instance.getId(), warning); + } + + /** + * 归档:ARCHIVE 任务或签,处理后取消同节点其余待办,实例进入只读终态。 + */ + public Result archive(Long taskId, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_ARCHIVE.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持归档"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureNotArchived(instance); + inTransaction(() -> doArchive(taskId, task, user)); + return new Result(instance.getId(), null); + } + /** * 驳回:意见必填,同轮待办全部取消,实例回到报送人。 */ @@ -101,6 +140,9 @@ public class ApprovalTaskService { if (user == null || !instance.getReporterId().equals(user.getUserId())) { throw new BusinessException("无权操作该单据"); } + if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) { + throw new BusinessException("单据已归档,不能修改"); + } if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { throw new BusinessException("当前状态不可撤回"); } @@ -131,6 +173,67 @@ public class ApprovalTaskService { return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null; } + private String doConfirm(Long taskId, ApprovalTask task, User user, + ApprovalNode next, List approvers) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureNotArchived(locked); + ensureTaskHandleable(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_CONFIRM, null); + Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, locked.getId()) + .eq(ApprovalTask::getRound, locked.getRound()) + .eq(ApprovalTask::getNodeKey, task.getNodeKey()) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)); + if (remaining != null && remaining > 0) { + return null; + } + if (next == null) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + int created = engineService.moveToNode(locked, next, locked.getRound(), approvers); + if (created == 0) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_PENDING) + .set("status", ApprovalConstants.STATUS_APPROVED) + .set("approve_time", LocalDateTime.now())); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + return null; + } + + private void doArchive(Long taskId, ApprovalTask task, User user) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureNotArchived(locked); + if (!ApprovalConstants.STATUS_APPROVED.equals(locked.getStatus())) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + ensureTaskCurrent(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId); + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_ARCHIVE, null); + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_APPROVED) + .set("status", ApprovalConstants.STATUS_ARCHIVED) + .set("archive_time", LocalDateTime.now()) + .set("archivist_id", user.getUserId()) + .set("archivist_name", displayName(user))); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + } + private void doReject(Long taskId, ApprovalTask task, String opinion, User user) { ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); ensureTaskHandleable(task, locked); @@ -178,12 +281,22 @@ public class ApprovalTaskService { if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { throw new BusinessException("单据状态已变化,请刷新后重试"); } + ensureTaskCurrent(task, instance); + } + + private void ensureTaskCurrent(ApprovalTask task, ApprovalInstance instance) { if (!task.getRound().equals(instance.getRound()) || !task.getNodeKey().equals(instance.getCurrentNodeKey())) { throw new BusinessException("任务已处理"); } } + private void ensureNotArchived(ApprovalInstance instance) { + if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) { + throw new BusinessException("单据已归档,不能修改"); + } + } + private void ensureNoHandledTask(ApprovalInstance instance) { Integer handled = taskMapper.selectCount(new LambdaQueryWrapper() .eq(ApprovalTask::getInstanceId, instance.getId()) @@ -288,6 +401,16 @@ public class ApprovalTaskService { return user.getUsername(); } + private String displayName(User user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + private String normalizeOpinion(String opinion) { if (opinion == null) { return null; diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java new file mode 100644 index 0000000..c89dc95 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java @@ -0,0 +1,367 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 存档确认与归档闭环集成测试:全员存档、部分存档、归档或签、归档后只读、 + * 归档缺配阻断、收文员与 superAdmin 全租户可见。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalConfirmArchiveIT { + + /** + * 独立租户:收文员解析按 (tenant_id, role_code) 全局匹配,使用专属租户可避免 + * 与并行执行的其它审批测试类互相污染角色成员。 + */ + private static final int TENANT = 10024; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + private static final String ARCHIVE = "approval:instance:archive"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser leader; + private ApprovalTestData.TestUser archivist; + private ApprovalTestData.TestUser secondArchivist; + private ApprovalTestData.TestUser superAdmin; + private ApprovalTestData.TestUser outsider; + private Integer organizationId; + private Integer archivistRoleId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("归档部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("归档报送人" + data.uniqueSuffix(), organizationId, TENANT); + ApprovalTestData.TestRole leaderRole = + data.createRole("dept_leader_" + organizationId, "部门负责人", TENANT); + leader = data.createUser("归档负责人" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(leader.userId, leaderRole.roleId, TENANT); + + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT); + archivistRoleId = archivistRole.roleId; + archivist = data.createUser("归档收文员" + data.uniqueSuffix(), organizationId, TENANT); + secondArchivist = data.createUser("归档收文员B" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(archivist.userId, archivistRoleId, TENANT); + data.bindTenant(secondArchivist.userId, archivistRoleId, TENANT); + + ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT); + superAdmin = data.createUser("归档超管" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT); + + outsider = data.createUser("归档路人" + data.uniqueSuffix(), organizationId, TENANT); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void confirmsOnlyAfterAllConfirmTasksDone() throws Exception { + long instanceId = createAndSubmit("全员存档"); + approveByLeader(instanceId); + long reporterTask = pendingTaskId(instanceId, reporter.userId); + + // 存档节点缺配前先验证:CONFIRM 任务不能驳回,也不需要意见 + mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/reject"), + reporter, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"不同意\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持驳回"))); + + // 追加一名并存档人(协调单场景:报送人 + 派单对象) + ApprovalTestData.TestUser secondConfirmer = data.createUser("归档存档人B" + data.uniqueSuffix(), organizationId); + long secondTask = insertConfirmTask(instanceId, secondConfirmer); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), secondConfirmer, organizationId, LIST)) + .andExpect(jsonPath("$.data.confirms.length()").value(2)) + .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING")) + .andExpect(jsonPath("$.data.confirms[1].statusName").value("待确认")) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(true)); + + // 部分存档:仍等待其他人 + mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"), reporter, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")) + .andExpect(jsonPath("$.data.confirms[0].status").value("DONE")) + .andExpect(jsonPath("$.data.confirms[0].statusName").value("已确认")) + .andExpect(jsonPath("$.data.confirms[0].handleTime").isNotEmpty()) + .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING")); + assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?", + Object.class, instanceId)).isNull(); + + // 全员存档完成 → APPROVED,并为全部收文员生成或签归档任务 + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/confirm"), + secondConfirmer, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.statusName").value("审批完成")) + .andExpect(jsonPath("$.data.approveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")) + .andExpect(jsonPath("$.data.confirms[1].status").value("DONE")); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'ARCHIVE' AND status = 'PENDING'", instanceId)).isEqualTo(2); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'", + instanceId)).isEqualTo(2); + // 存档记录不带意见 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero(); + } + + @Test + void archivesOnceAndCancelsOtherArchiveTasks() throws Exception { + long instanceId = createAndSubmit("归档或签"); + approveByLeader(instanceId); + confirmReporterTask(instanceId); + + long firstArchive = pendingTaskId(instanceId, archivist.userId); + long secondArchive = pendingTaskId(instanceId, secondArchivist.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + firstArchive + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.statusName").value("已归档")) + .andExpect(jsonPath("$.data.archiveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.archivistId").value(archivist.userId)) + .andExpect(jsonPath("$.data.archivistName").value(archivist.username)); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondArchive)) + .isEqualTo("CANCELLED"); + assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, firstArchive)) + .isNotNull(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'", + instanceId)).isEqualTo(1); + + mockMvc.perform(authed(post("/api/approval/task/" + secondArchive + "/archive"), + secondArchivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已归档"))); + } + + @Test + void rejectsEveryWriteAfterArchive() throws Exception { + long instanceId = createAndSubmit("归档只读"); + long attachmentId = data.queryMaster("SELECT id FROM approval_attachment WHERE instance_id = ? LIMIT 1", + Long.class, instanceId); + approveByLeader(instanceId); + long confirmTask = pendingTaskId(instanceId, reporter.userId); + confirmReporterTask(instanceId); + long archiveTask = pendingTaskId(instanceId, archivist.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)) + .andExpect(jsonPath("$.data.permissions.canWithdraw").value(false)) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(false)) + .andExpect(jsonPath("$.data.permissions.canArchive").value(false)) + .andExpect(jsonPath("$.data.myTask").doesNotExist()); + + String archived = "单据已归档,不能修改"; + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":" + instanceId + ",\"name\":\"改归档单据\"," + + "\"implementOrg\":\"广西体育中心实施部\",\"fee\":1.00," + + "\"serviceContent\":\"尝试修改已归档单据\"}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("ARCHIVED"); + } + + @Test + void blocksWhenArchiveNodeHasNoApprover() throws Exception { + long instanceId = createAndSubmit("归档缺配"); + approveByLeader(instanceId); + + // 提交后收文员角色被清空,存档完成时归档节点缺配 + data.server().update("DELETE FROM sys_user_role WHERE role_id = ?", archivistRoleId); + + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"), + reporter, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("缺少审批人"))) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")) + .andExpect(jsonPath("$.data.approveTime").doesNotExist()); + + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("archive"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'ARCHIVE'", instanceId)).isZero(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'", + instanceId)).isEqualTo(1); + } + + @Test + void letsArchivistAndSuperAdminViewAllTenantInstances() throws Exception { + long instanceId = createAndSubmit("全租户可见"); + approveByLeader(instanceId); + confirmReporterTask(instanceId); + long archiveTask = pendingTaskId(instanceId, archivist.userId); + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)); + + // 收文员与 superAdmin 非参与人也可见完整详情(含归档人/时间与附件) + ApprovalTestData.TestUser otherArchivist = + data.createUser("归档外聘收文员" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(otherArchivist.userId, archivistRoleId, TENANT); + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), otherArchivist, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.archivistName").value(archivist.username)) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records.length()").isNotEmpty()); + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), superAdmin, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) instanceId)); + + // 普通参与人之外的用户仍被拒绝 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + // 参与人(报送人)可见 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)); + } + + // ---------- helpers ---------- + + private long createAndSubmit(String name) throws Exception { + String body = "{\"name\":" + quote(name) + + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45," + + "\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), + reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private void approveByLeader(long instanceId) throws Exception { + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + "/approve"), + leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意办理\"}")) + .andExpect(jsonPath("$.code").value(0)); + } + + private void confirmReporterTask(long instanceId) throws Exception { + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"), + reporter, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")); + } + + private long insertConfirmTask(long instanceId, ApprovalTestData.TestUser assignee) { + Integer round = data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId); + data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, " + + "task_type, assignee_id, assignee_name, status) VALUES (?, ?, 'confirm', 'CONFIRM', ?, 'CONFIRM', ?, ?, 'PENDING')", + TENANT, instanceId, round, assignee.userId, assignee.realName); + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? AND assignee_id = ? " + + "AND status = 'PENDING'", Long.class, instanceId, assignee.userId); + assertThat(taskId).as("并存档任务应存在").isNotNull(); + return taskId; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From 8224e6380a5e0b4b12c1880d052db96deaeb6a8f Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:11:51 +0800 Subject: [PATCH 10/18] =?UTF-8?q?test(approval):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E4=BC=AA=E9=80=A0=E6=8A=A5=E9=80=81?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E5=AD=97=E6=AE=B5=E8=A2=AB=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/ApprovalCoordinationIT.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java index 4ffdf2a..867c788 100644 --- a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java @@ -129,6 +129,23 @@ class ApprovalCoordinationIT { .contains("confirm").contains("archive"); assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero(); + + // 报送部门与经办人只取登录态,客户端伪造字段被忽略 + ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId); + String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\"," + + "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}"; + String forgedResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(forged)) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long forgedId = objectMapper.readTree(forgedResponse).path("data").asLong(); + data.trackInstance(forgedId); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", + Integer.class, forgedId)).isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", + Integer.class, forgedId)).isEqualTo(reportOrgId); } @Test From 2c1dc9b43c038b6e6a0be17d105d28dd92bf0d3a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:21:10 +0800 Subject: [PATCH 11/18] =?UTF-8?q?feat(approval):=20=E5=8D=95=E6=8D=AE?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=AF=BC=E5=87=BA=20xlsx=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ApprovalExportController.java | 40 ++ .../approval/mapper/ApprovalExportMapper.java | 22 + .../mapper/xml/ApprovalExportMapper.xml | 79 +++ .../service/ApprovalExportService.java | 186 +++++++ .../support/ApprovalExportResult.java | 20 + .../approval/support/ApprovalExportRow.java | 45 ++ .../gxwebsoft/approval/ApprovalExportIT.java | 479 ++++++++++++++++++ 7 files changed, 871 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java new file mode 100644 index 0000000..89c94a4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.service.ApprovalExportService; +import com.gxwebsoft.approval.support.ApprovalExportResult; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.FileRecord; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.io.IOException; + +/** + * PC 单据导出接口:与单据查询共用筛选参数与数据权限。 + */ +@Api(tags = "审批单据导出") +@RestController +@RequestMapping("/api/approval") +public class ApprovalExportController extends BaseController { + + @Resource + private ApprovalExportService exportService; + + @PreAuthorize("hasAuthority('approval:instance:export')") + @OperationLog(module = "审批管理", comments = "导出单据查询结果") + @ApiOperation("导出单据") + @PostMapping("/export") + public ApiResult export(@RequestBody ApprovalQueryParam param) throws IOException { + ApprovalExportResult result = exportService.export(param, getLoginUser()); + return success("导出成功,共" + result.getRowCount() + "条", result.getFile()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java new file mode 100644 index 0000000..2663ca4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.approval.mapper; + +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalExportRow; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 导出查询Mapper:独立的非分页 SQL,绕过分页插件上限,最多取 5001 行。 + * + *

筛选条件与数据权限必须与 {@code ApprovalQueryMapper.selectInstancePage} 保持一致。

+ */ +public interface ApprovalExportMapper { + + /** + * 按筛选条件取导出数据,最多 5001 行(超出即代表需要拒绝导出)。 + */ + List selectExportRows(@Param("param") ApprovalQueryParam param, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml new file mode 100644 index 0000000..837c511 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml @@ -0,0 +1,79 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java new file mode 100644 index 0000000..4cbc29b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java @@ -0,0 +1,186 @@ +package com.gxwebsoft.approval.service; + +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.mapper.ApprovalExportMapper; +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalExportResult; +import com.gxwebsoft.approval.support.ApprovalExportRow; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.FileRecord; +import com.gxwebsoft.common.system.entity.User; +import org.apache.poi.xssf.streaming.SXSSFRow; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 单据导出:按列表同一套筛选与数据权限同步生成 xlsx,并写文件记录。 + * + *

独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限; + * 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。

+ */ +@Service +public class ApprovalExportService { + + /** 单次导出上限,超过即要求缩小筛选范围。 */ + public static final int MAX_EXPORT_ROWS = 5000; + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final String[] HEADERS = { + "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)", + "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人" + }; + + /** 列宽(字符数),长文本列加宽但不截断内容。 */ + private static final int[] COLUMN_WIDTHS = { + 20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12 + }; + + @Resource + private ApprovalExportMapper exportMapper; + + @Resource + private ApprovalFlowService flowService; + + @Resource + private ApprovalDirectoryService directoryService; + + @Resource + private ApprovalFileService fileService; + + @Resource + private ConfigProperties config; + + /** + * 导出当前筛选结果。 + * + * @throws BusinessException 结果超过 {@link #MAX_EXPORT_ROWS} 行时拒绝,不生成文件 + */ + public ApprovalExportResult export(ApprovalQueryParam param, User user) throws IOException { + int allVisible = directoryService.hasAnyRole(user.getUserId(), + ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0; + List rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible); + if (rows.size() > MAX_EXPORT_ROWS) { + throw new BusinessException("请缩小筛选范围"); + } + String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx"; + String path = config.getUploadPath() + filename; + File target = new File(path); + try { + File parent = target.getParentFile(); + if (parent != null) { + Files.createDirectories(parent.toPath()); + } + writeWorkbook(rows, target); + } catch (IOException | RuntimeException e) { + // 生成失败时不留下半成品文件 + Files.deleteIfExists(target.toPath()); + throw e; + } + + FileRecord record = new FileRecord(); + record.setName(filename); + record.setPath(path); + record.setLength(target.length()); + record.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + record.setCreateUserId(user.getUserId()); + record.setUrl(config.getServerUrl() + "/file/" + filename); + fileService.save(record); + return new ApprovalExportResult(record, rows.size()); + } + + private void writeWorkbook(List rows, File target) throws IOException { + SXSSFWorkbook workbook = new SXSSFWorkbook(100); + try { + SXSSFSheet sheet = workbook.createSheet("审批单据"); + for (int i = 0; i < COLUMN_WIDTHS.length; i++) { + sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256); + } + SXSSFRow header = sheet.createRow(0); + for (int i = 0; i < HEADERS.length; i++) { + header.createCell(i).setCellValue(HEADERS[i]); + } + Map> nodeCache = new HashMap<>(); + int rowNum = 1; + for (ApprovalExportRow row : rows) { + SXSSFRow dataRow = sheet.createRow(rowNum++); + String[] values = toValues(row, nodeCache); + for (int i = 0; i < values.length; i++) { + dataRow.createCell(i).setCellValue(values[i]); + } + } + try (OutputStream output = Files.newOutputStream(target.toPath())) { + workbook.write(output); + } + } finally { + workbook.dispose(); + workbook.close(); + } + } + + /** + * 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、 + * 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。 + */ + private String[] toValues(ApprovalExportRow row, Map> nodeCache) { + boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType()); + return new String[]{ + text(row.getBizNo()), + text(ApprovalConstants.docTypeName(row.getDocType())), + completion ? text(row.getName()) : text(row.getTitle()), + text(row.getReportOrgName()), + text(row.getReporterName()), + text(row.getAcceptOrgName()), + completion ? text(row.getImplementOrg()) : "", + completion ? fee(row.getFee()) : "", + completion ? text(row.getServiceContent()) : text(row.getContent()), + text(row.getRemark()), + text(ApprovalConstants.statusName(row.getStatus())), + text(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())), + time(row.getSubmitTime()), + time(row.getApproveTime()), + time(row.getArchiveTime()), + text(row.getArchivistName()) + }; + } + + private String nodeName(Map> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes); + return nodes.stream() + .filter(node -> nodeKey.equals(node.getKey())) + .map(ApprovalNode::getName) + .findFirst() + .orElse(null); + } + + private String fee(BigDecimal fee) { + return fee == null ? "" : fee.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private String time(LocalDateTime time) { + return time == null ? "" : TIME_FORMAT.format(time); + } + + private String text(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java new file mode 100644 index 0000000..a63bccf --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.support; + +import com.gxwebsoft.common.system.entity.FileRecord; +import lombok.Data; + +/** + * 导出结果:返回给前端的文件记录 + 实际导出行数(用于提示与操作日志)。 + */ +@Data +public class ApprovalExportResult { + + private final FileRecord file; + + private final int rowCount; + + public ApprovalExportResult(FileRecord file, int rowCount) { + this.file = file; + this.rowCount = rowCount; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java new file mode 100644 index 0000000..0ad1409 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.approval.support; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 导出查询数据库行:两类单据合并列,未命中的表单字段为空。 + */ +@Data +public class ApprovalExportRow implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String bizNo; + private String docType; + private String status; + private String reporterName; + private String reportOrgName; + private String acceptOrgName; + private String currentNodeKey; + private Long definitionId; + private LocalDateTime submitTime; + private LocalDateTime approveTime; + private LocalDateTime archiveTime; + private String archivistName; + + /** 业务完成确认单:名称。 */ + private String name; + /** 业务完成确认单:实施单位。 */ + private String implementOrg; + /** 业务完成确认单:费用(元)。 */ + private BigDecimal fee; + /** 业务完成确认单:服务内容。 */ + private String serviceContent; + + /** 内部协调单:标题。 */ + private String title; + /** 内部协调单:协调内容。 */ + private String content; + /** 内部协调单:备注。 */ + private String remark; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java new file mode 100644 index 0000000..d29ddcc --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java @@ -0,0 +1,479 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 单据导出:权限、5000 行上限、列头与内容、数据权限与操作日志。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalExportIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String EXPORT = "approval:instance:export"; + private static final String URL = "/api/approval/export"; + private static final int EXPORT_LIMIT = 5000; + /** 多值 INSERT 每批行数,避免超出占位符上限。 */ + private static final int SEED_CHUNK = 200; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final String[] HEADERS = { + "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)", + "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人" + }; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + @Autowired + private ConfigProperties config; + + private ApprovalTestData data; + private Integer reportOrgId; + private Integer acceptOrgId; + private ApprovalTestData.TestUser reporter; + /** 本次用例创建的导出文件,结束时删除。 */ + private final List exportedFiles = new ArrayList<>(); + /** 本次用例批量种子的关键字,结束时按单号前缀清理。 */ + private final List batchMarks = new ArrayList<>(); + /** 本次用例创建的用户id,结束时清理其操作日志。 */ + private final List testUserIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + String suffix = data.uniqueSuffix(); + reportOrgId = data.createOrganization("报送部门" + suffix, 136); + acceptOrgId = data.createOrganization("受理部门" + suffix, 136); + reporter = createUser("报送人" + suffix, reportOrgId); + } + + @AfterEach + void tearDown() { + for (String mark : batchMarks) { + cleanBatch(mark); + } + batchMarks.clear(); + for (Path path : exportedFiles) { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // 文件清理失败不影响断言结果 + } + } + exportedFiles.clear(); + try { + // 操作日志异步写入,稍等后再按测试用户清理 + Thread.sleep(300); + for (Integer userId : testUserIds) { + data.server().update("DELETE FROM sys_operation_record WHERE url = ? AND user_id = ?", URL, userId); + } + } catch (Exception ignored) { + // 共享库清理尽力而为 + } + testUserIds.clear(); + data.cleanup(); + } + + @Test + void rejectsWithoutExportAuthority() throws Exception { + mockMvc.perform(authed(post(URL), reporter, LIST) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void rejectsWhenResultExceedsLimitAndWritesNoFile() throws Exception { + String mark = "超限" + data.uniqueSuffix(); + seedCompletionBatch(mark, EXPORT_LIMIT + 1); + List before = listExportFiles(); + + mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"keyword\":" + quote(mark) + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围"))); + + assertThat(listExportFiles()).isEqualTo(before); + } + + @Test + void exportsExactlyLimitRowsWithFixedColumnsAndFullText() throws Exception { + String mark = "上限" + data.uniqueSuffix(); + String specialName = mark + "特殊名称"; + String longContent = repeat("场馆设施维修与验收", 200); + Map special = new LinkedHashMap<>(); + special.put("name", specialName); + special.put("implementOrg", mark + "实施单位"); + special.put("fee", "1234.56"); + special.put("serviceContent", longContent); + LocalDateTime submitTime = LocalDateTime.now().withNano(0).minusDays(1); + LocalDateTime approveTime = submitTime.plusHours(2); + LocalDateTime archiveTime = submitTime.plusDays(1); + seedCompletionBatch(mark, EXPORT_LIMIT, special, submitTime, approveTime, archiveTime); + + String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"keyword\":" + quote(mark) + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response).path("data"); + assertThat(node.path("id").asInt()).isPositive(); + String name = node.path("name").asText(); + assertThat(name).startsWith("excel/审批单据-").endsWith(".xlsx"); + assertThat(node.path("url").asText()).contains("/file/excel/审批单据-"); + File file = new File(node.path("path").asText()); + assertThat(file).exists(); + exportedFiles.add(file.toPath()); + assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND name = ?", + node.path("id").asInt(), name)).isEqualTo(1); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum() + 1).isEqualTo(EXPORT_LIMIT + 1); + Row header = sheet.getRow(0); + List headerValues = new ArrayList<>(); + for (int i = 0; i < HEADERS.length; i++) { + headerValues.add(cellText(header.getCell(i))); + } + assertThat(headerValues).containsExactly(HEADERS); + + Row matched = null; + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (specialName.equals(cellText(row.getCell(2)))) { + matched = row; + break; + } + } + assertThat(matched).as("应导出特殊名称行").isNotNull(); + assertThat(cellText(matched.getCell(0))).isEqualTo(mark + "-0"); + assertThat(cellText(matched.getCell(1))).isEqualTo("业务完成确认单"); + assertThat(cellText(matched.getCell(3))).isEqualTo(orgName(reportOrgId)); + assertThat(cellText(matched.getCell(4))).isEqualTo(reporter.realName); + assertThat(cellText(matched.getCell(5))).isEqualTo(orgName(acceptOrgId)); + assertThat(cellText(matched.getCell(6))).isEqualTo(mark + "实施单位"); + assertThat(cellText(matched.getCell(7))).isEqualTo("1234.56"); + assertThat(cellText(matched.getCell(8))).isEqualTo(longContent); + assertThat(cellText(matched.getCell(9))).isEmpty(); + assertThat(cellText(matched.getCell(10))).isEqualTo("已归档"); + assertThat(cellText(matched.getCell(11))).isEqualTo("报送部门负责人"); + assertThat(cellText(matched.getCell(12))).isEqualTo(TIME_FORMAT.format(submitTime)); + assertThat(cellText(matched.getCell(13))).isEqualTo(TIME_FORMAT.format(approveTime)); + assertThat(cellText(matched.getCell(14))).isEqualTo(TIME_FORMAT.format(archiveTime)); + assertThat(cellText(matched.getCell(15))).isEqualTo("收文人" + mark); + } + } + + @Test + void appliesSameDataPermissionAsList() throws Exception { + String mark = "权限" + data.uniqueSuffix(); + long draft = seedCompletion(mark + "草稿", "DRAFT", null); + long pending = seedCompletion(mark + "在途", "PENDING", LocalDateTime.now().minusHours(1)); + ApprovalTestData.TestUser outsider = createUser("无关人员" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser assignee = createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId); + ApprovalTestData.TestUser archivist = createUser("收文员" + data.uniqueSuffix(), acceptOrgId); + data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, " + + "task_type, assignee_id, status) VALUES (?, ?, 'deptLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')", + TENANT, pending, assignee.userId); + ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员"); + data.bind(archivist.userId, archivistRole.roleId); + + assertThat(exportBizNos(reporter, mark)).containsExactlyInAnyOrder(bizNoOf(draft), bizNoOf(pending)); + assertThat(exportBizNos(assignee, mark)).containsExactly(bizNoOf(pending)); + assertThat(exportBizNos(outsider, mark)).isEmpty(); + assertThat(exportBizNos(archivist, mark)).containsExactly(bizNoOf(pending)); + } + + @Test + void writesOperationLogWithFilterAndRowCount() throws Exception { + String mark = "日志" + data.uniqueSuffix(); + seedCompletion(mark + "一", "PENDING", LocalDateTime.now().minusHours(2)); + seedCompletion(mark + "二", "PENDING", LocalDateTime.now().minusHours(1)); + + mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"keyword\":" + quote(mark) + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条"))); + + Map record = awaitOperationRecord(mark); + assertThat(record).as("应记录导出操作日志").isNotNull(); + assertThat(record.get("user_id")).isEqualTo(reporter.userId); + assertThat((String) record.get("params")).contains(mark); + assertThat((String) record.get("result")).contains("共2条"); + assertThat(record.get("create_time")).isNotNull(); + assertThat(((Number) record.get("spend_time")).longValue()).isNotNegative(); + } + + // ---------- helpers ---------- + + private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) { + ApprovalTestData.TestUser user = data.createUser(realName, organizationId); + testUserIds.add(user.userId); + return user; + } + + private Map awaitOperationRecord(String paramsMark) throws InterruptedException { + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + List> rows = data.server().queryForList( + "SELECT user_id, params, result, spend_time, create_time FROM sys_operation_record " + + "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1", + URL, "%" + paramsMark + "%"); + if (!rows.isEmpty()) { + return rows.get(0); + } + Thread.sleep(100); + } + return null; + } + + private List exportBizNos(ApprovalTestData.TestUser user, String keyword) throws Exception { + String response = mockMvc.perform(authed(post(URL), user, LIST, EXPORT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"keyword\":" + quote(keyword) + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response).path("data"); + File file = new File(node.path("path").asText()); + exportedFiles.add(file.toPath()); + List bizNos = new ArrayList<>(); + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) { + Sheet sheet = workbook.getSheetAt(0); + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + bizNos.add(cellText(sheet.getRow(i).getCell(0))); + } + } + return bizNos; + } + + private long seedCompletion(String name, String status, LocalDateTime submitTime) { + long definitionId = completionDefinitionId(); + String bizNo = "WG" + data.uniqueSuffix(); + data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, " + + "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, " + + "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time) " + + "VALUES (?, ?, 'completion', ?, 1, 'completion', 0, ?, 1, ?, ?, ?, ?, ?, ?, 'deptLeader', 'APPROVE', ?)", + TENANT, bizNo, definitionId, status, reporter.userId, reporter.realName, reportOrgId, + orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId), + submitTime == null ? null : Timestamp.valueOf(submitTime)); + Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + Long.class, TENANT, bizNo); + data.trackInstance(instanceId); + data.master().update("INSERT INTO approval_completion_form (tenant_id, instance_id, name, implement_org, " + + "fee, service_content) VALUES (?, ?, ?, '广西体育中心实施部', 100.00, '完成场馆设施维修与验收')", + TENANT, instanceId, name); + Long formId = data.queryMaster("SELECT id FROM approval_completion_form WHERE instance_id = ?", + Long.class, instanceId); + data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId); + return instanceId; + } + + private String bizNoOf(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + /** + * 批量种子:count 条已归档确认单,单号前缀与名称都用 mark 标识,便于按前缀清理。 + * + *

master 库连接未开启 rewriteBatchedStatements,逐条 batchUpdate 会产生上千次往返; + * 这里按 200 行一条多值 INSERT 落库,再一次性回填 biz_id。

+ */ + private void seedCompletionBatch(String mark, int count) { + seedCompletionBatch(mark, count, null, null, null, null); + } + + private void seedCompletionBatch(String mark, int count, Map special, + LocalDateTime submitTime, LocalDateTime approveTime, + LocalDateTime archiveTime) { + batchMarks.add(mark); + long definitionId = completionDefinitionId(); + String reportOrgName = orgName(reportOrgId); + String acceptOrgName = orgName(acceptOrgId); + Timestamp submit = Timestamp.valueOf(submitTime == null + ? LocalDateTime.now().withNano(0).minusHours(1) : submitTime); + Timestamp approve = approveTime == null ? null : Timestamp.valueOf(approveTime); + Timestamp archive = archiveTime == null ? null : Timestamp.valueOf(archiveTime); + Integer archivistId = archive == null ? null : reporter.userId; + String archivistName = archive == null ? null : "收文人" + mark; + + String instanceRow = "(?, ?, 'completion', ?, 1, 'completion', 0, 'ARCHIVED', 1, ?, ?, ?, ?, ?, ?, " + + "'deptLeader', 'APPROVE', ?, ?, ?, ?, ?)"; + for (int start = 0; start < count; start += SEED_CHUNK) { + int end = Math.min(start + SEED_CHUNK, count); + StringBuilder sql = new StringBuilder("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, " + + "definition_id, definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, " + + "report_org_id, report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, " + + "submit_time, approve_time, archive_time, archivist_id, archivist_name) VALUES "); + List args = new ArrayList<>(); + for (int i = start; i < end; i++) { + if (i > start) { + sql.append(','); + } + sql.append(instanceRow); + java.util.Collections.addAll(args, TENANT, mark + "-" + i, definitionId, reporter.userId, + reporter.realName, reportOrgId, reportOrgName, acceptOrgId, acceptOrgName, submit, + approve, archive, archivistId, archivistName); + } + data.master().update(sql.toString(), args.toArray()); + } + + List ids = data.master().queryForList( + "SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no LIKE ? ORDER BY id", + Long.class, TENANT, mark + "-%"); + assertThat(ids).hasSize(count); + + for (int start = 0; start < ids.size(); start += SEED_CHUNK) { + int end = Math.min(start + SEED_CHUNK, ids.size()); + StringBuilder sql = new StringBuilder("INSERT INTO approval_completion_form (tenant_id, instance_id, " + + "name, implement_org, fee, service_content) VALUES "); + List args = new ArrayList<>(); + for (int i = start; i < end; i++) { + if (i > start) { + sql.append(','); + } + sql.append("(?, ?, ?, ?, ?, ?)"); + boolean isSpecial = special != null && i == 0; + java.util.Collections.addAll(args, TENANT, ids.get(i), + isSpecial ? special.get("name") : mark + "-" + i, + isSpecial ? special.get("implementOrg") : "广西体育中心实施部", + new java.math.BigDecimal(isSpecial ? (String) special.get("fee") : "100.00"), + isSpecial ? special.get("serviceContent") : "完成场馆设施维修与验收"); + } + data.master().update(sql.toString(), args.toArray()); + } + + data.master().update("UPDATE approval_instance i JOIN approval_completion_form f " + + "ON f.instance_id = i.id SET i.biz_id = f.id WHERE i.biz_no LIKE ?", mark + "-%"); + } + + private void cleanBatch(String mark) { + try { + String[] tables = {"approval_attachment", "approval_record", "approval_task", + "approval_completion_form", "approval_coordination_form"}; + for (String table : tables) { + data.master().update("DELETE t FROM " + table + " t JOIN approval_instance i " + + "ON t.instance_id = i.id WHERE i.biz_no LIKE ?", mark + "-%"); + } + data.master().update("DELETE FROM approval_instance WHERE biz_no LIKE ?", mark + "-%"); + } catch (Exception ignored) { + // 清理失败不阻断测试报告 + } + } + + private long completionDefinitionId() { + List ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? " + + "AND flow_code = 'completion' AND version = 1 LIMIT 1", Long.class, TENANT); + if (ids.isEmpty()) { + data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) " + + "VALUES (?, 'completion', 1, '业务完成确认单', ?)", + TENANT, ApprovalConstants.COMPLETION_NODES_JSON); + ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? " + + "AND flow_code = 'completion' AND version = 1 LIMIT 1", Long.class, TENANT); + } + return ids.isEmpty() ? 0L : ids.get(0); + } + + private List listExportFiles() throws Exception { + Path dir = Paths.get(config.getUploadPath(), "excel"); + if (!Files.isDirectory(dir)) { + return new ArrayList<>(); + } + List files = new ArrayList<>(); + try (java.util.stream.Stream stream = Files.list(dir)) { + stream.filter(path -> path.getFileName().toString().startsWith("审批单据-")) + .forEach(files::add); + } + files.sort(Comparator.comparing(Path::toString)); + return files; + } + + private String cellText(Cell cell) { + if (cell == null) { + return ""; + } + if (cell.getCellType() == CellType.NUMERIC) { + return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros() + .toPlainString(); + } + return cell.getStringCellValue(); + } + + private String orgName(Integer organizationId) { + return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?", + String.class, organizationId); + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, + reportOrgId, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String unit, int times) { + StringBuilder builder = new StringBuilder(unit.length() * times); + for (int i = 0; i < times; i++) { + builder.append(unit); + } + return builder.toString(); + } +} From 92a982054b01eddba51afe5b5dcaafec9bf9dd8b Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:42:04 +0800 Subject: [PATCH 12/18] =?UTF-8?q?feat(approval):=20=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E5=80=99=E9=80=89=E6=B1=A0=E3=80=81=E6=B4=BE=E5=8D=95=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=E5=8F=8C=E6=96=B9=E5=AD=98=E6=A1=A3=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/constant/ApprovalConstants.java | 3 + .../controller/ApprovalTaskController.java | 22 + .../param/ApprovalTaskDispatchParam.java | 17 + .../service/ApprovalDirectoryService.java | 28 + .../service/ApprovalInstanceService.java | 6 +- .../approval/service/ApprovalTaskService.java | 126 +++++ .../approval/ApprovalDispatchIT.java | 486 ++++++++++++++++++ 7 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index e27ff0e..ed5bbaf 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -68,6 +68,9 @@ public final class ApprovalConstants { // 角色编码 public static final String ROLE_ARCHIVIST = "approval_archivist"; public static final String ROLE_SUPER_ADMIN = "superAdmin"; + /** 普通消费用户角色:无部门且仅有这些角色时不算派单候选。 */ + public static final String ROLE_USER = "user"; + public static final String ROLE_GUEST = "guest"; // 附件规则 public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index f6c4fa5..8301b34 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -2,12 +2,14 @@ package com.gxwebsoft.approval.controller; import com.gxwebsoft.approval.param.ApprovalTaskActionParam; import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; +import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam; import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.service.ApprovalInstanceService; import com.gxwebsoft.approval.service.ApprovalTaskService; import com.gxwebsoft.approval.vo.ApprovalAssignVO; import com.gxwebsoft.approval.vo.ApprovalInstanceVO; import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; @@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; +import java.util.List; /** * 审批待办与任务动作接口(员工端与 PC 共用)。 @@ -108,6 +111,25 @@ public class ApprovalTaskController extends BaseController { return success("撤回成功", instanceService.detail(result.getInstanceId(), user)); } + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("派单候选池") + @GetMapping("/task/{id}/candidates") + public ApiResult> candidates(@PathVariable("id") Long id) { + return success(taskService.candidates(id, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:approve')") + @OperationLog(value = "派单审批待办", module = "审批管理") + @ApiOperation("派单") + @PostMapping("/task/{id}/dispatch") + public ApiResult dispatch(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskDispatchParam param) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.dispatch(id, param, user); + return success(result.getWarning() == null ? "派单成功" : result.getWarning(), + instanceService.detail(result.getInstanceId(), user)); + } + @PreAuthorize("hasAuthority('approval:instance:assign')") @OperationLog(value = "改派审批待办", module = "审批管理") @ApiOperation("改派") diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java new file mode 100644 index 0000000..3b062f4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 派单参数。 + */ +@Data +public class ApprovalTaskDispatchParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("被派单人用户id;必须来自候选池") + private Integer targetUserId; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index 132d6f9..ce9e400 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -2,6 +2,7 @@ package com.gxwebsoft.approval.service; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; import com.baomidou.dynamic.datasource.annotation.DS; +import com.gxwebsoft.approval.constant.ApprovalConstants; import com.gxwebsoft.approval.support.ApprovalTenantContext; import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; import com.gxwebsoft.approval.vo.ApprovalUserVO; @@ -176,6 +177,33 @@ public class ApprovalDirectoryService { return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); } + /** + * 派单候选池:优先受理部门启用成员;部门无成员时回退为当前租户的非普通消费用户。 + * + *

回退排除「未归属组织且角色仅为 user/guest 或无角色」的账号; + * 部门有成员时不混入回退候选。

+ */ + public List listDispatchCandidates(Integer acceptOrganizationId) { + List members = listOrganizationMembers(acceptOrganizationId); + if (!members.isEmpty()) { + return members; + } + Integer tenantId = ApprovalTenantContext.currentTenantId(); + return jdbcTemplate.query( + "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name " + + "FROM sys_user u " + + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? " + + "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 " + + "AND NOT ((u.organization_id IS NULL OR u.organization_id = 0) AND NOT EXISTS (" + + "SELECT 1 FROM sys_user_role ur " + + "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? " + + "WHERE ur.user_id = u.user_id AND ur.tenant_id = ? AND r.deleted = 0 " + + "AND r.role_code NOT IN (?, ?))) " + + "ORDER BY u.user_id", + USER_MAPPER, tenantId, tenantId, tenantId, tenantId, + ApprovalConstants.ROLE_USER, ApprovalConstants.ROLE_GUEST); + } + /** 是否持有任一角色(用于 allVisible 判断)。 */ public boolean hasAnyRole(Integer userId, String... roleCodes) { if (userId == null || roleCodes == null || roleCodes.length == 0) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index da03752..7868be4 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -566,10 +566,10 @@ public class ApprovalInstanceService { permissions.setCanWithdraw(done == null || done == 0); } if (myTask != null) { - boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType()) - || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()); + boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType()); + boolean rejectableNode = approveNode || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()); permissions.setCanApprove(approveNode); - permissions.setCanReject(approveNode); + permissions.setCanReject(rejectableNode); permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType())); permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType())); permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType())); diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 504d349..381dd26 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -9,6 +9,7 @@ import com.gxwebsoft.approval.entity.ApprovalTask; import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; +import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam; import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalNode; import com.gxwebsoft.approval.vo.ApprovalAssignVO; @@ -175,6 +176,43 @@ public class ApprovalTaskService { return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user)); } + /** + * 派单候选池:仅派单节点、本人待办的任务可查询;优先受理部门启用成员, + * 部门无成员时回退为租户非普通消费用户。 + */ + public List candidates(Long taskId, User user) { + ApprovalTask task = requireDispatchTask(taskId, user); + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureTaskHandleable(task, instance); + return directoryService.listDispatchCandidates(instance.getAcceptOrgId()); + } + + /** + * 派单:选定候选池中的 1 人即完成派单节点,激活存档节点并为报送人与被派单人生成两条 + * 存档任务。目标校验与审批人解析在事务外完成(core 库),写 master 的部分在事务内。 + */ + public Result dispatch(Long taskId, ApprovalTaskDispatchParam param, User user) { + if (param == null || param.getTargetUserId() == null) { + throw new BusinessException("请选择派单对象"); + } + ApprovalTask task = requireDispatchTask(taskId, user); + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureNotArchived(instance); + ApprovalUserVO target = requireDispatchTarget(instance, param.getTargetUserId()); + String targetName = displayName(target); + ApprovalNode next = engineService.nextNode(instance, task.getNodeKey()); + List approvers = dispatchNextApprovers(instance, next, target); + String warning = inTransaction(() -> + doDispatch(taskId, task, target, targetName, user, next, approvers)); + return new Result(instance.getId(), warning); + } + public Integer todoCount(User user) { return taskMapper.countPendingInstances(user.getUserId()); } @@ -326,6 +364,94 @@ public class ApprovalTaskService { return vo; } + /** + * 事务内派单:锁实例后条件更新任务、快照派单对象、写派单记录并激活存档节点。 + */ + private String doDispatch(Long taskId, ApprovalTask task, ApprovalUserVO target, String targetName, + User user, ApprovalNode next, List approvers) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureNotArchived(locked); + ensureTaskHandleable(task, locked); + if (updateDispatchTask(taskId, target.getUserId(), targetName) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId); + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_PENDING) + .set("dispatch_target_id", target.getUserId()) + .set("dispatch_target_name", targetName)); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + locked.setDispatchTargetId(target.getUserId()); + locked.setDispatchTargetName(targetName); + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_DISPATCH, null); + if (next == null) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + int created = engineService.moveToNode(locked, next, locked.getRound(), approvers); + return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null; + } + + /** + * 派单前校验:任务存在、属于本人、是派单节点且仍待处理。 + */ + private ApprovalTask requireDispatchTask(Long taskId, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持派单"); + } + return task; + } + + /** + * 派单目标必须来自候选池(受理部门启用成员,或部门无成员时的租户回退候选)。 + */ + private ApprovalUserVO requireDispatchTarget(ApprovalInstance instance, Integer targetUserId) { + for (ApprovalUserVO candidate : directoryService.listDispatchCandidates(instance.getAcceptOrgId())) { + if (targetUserId.equals(candidate.getUserId())) { + return candidate; + } + } + throw new BusinessException("派单对象不在候选范围内"); + } + + /** + * 存档节点审批人:报送人 + 本次派单对象(实例快照尚未写入,需显式解析)。 + */ + private List dispatchNextApprovers(ApprovalInstance instance, ApprovalNode next, + ApprovalUserVO target) { + if (next == null) { + return Collections.emptyList(); + } + if (!RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) { + return resolveApprovers(instance, next); + } + List userIds = new ArrayList<>(); + if (instance.getReporterId() != null) { + userIds.add(instance.getReporterId()); + } + if (!userIds.contains(target.getUserId())) { + userIds.add(target.getUserId()); + } + return userApprovers(userIds); + } + + /** + * 派单任务条件更新:完成并快照派单对象。 + */ + private int updateDispatchTask(Long taskId, Integer targetUserId, String targetName) { + return taskMapper.update(null, new UpdateWrapper() + .eq("id", taskId) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("status", ApprovalConstants.TASK_DONE) + .set("handle_time", LocalDateTime.now()) + .set("dispatch_target_id", targetUserId) + .set("dispatch_target_name", targetName)); + } + /** * 改派前的基本校验:任务存在、待处理且单据仍在审批中。 */ diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java new file mode 100644 index 0000000..139a9ec --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java @@ -0,0 +1,486 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 派单闭环集成测试:候选池、回退过滤、派单并发、被派单人可见性、双方存档缺一不可、 + * 归档与 PC 派单权限。 + * + *

独立租户隔离角色与用户,避免与并行执行的其它审批 IT 互相污染。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalDispatchIT { + + private static final int TENANT = 10025; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + private static final String ARCHIVE = "approval:instance:archive"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private Integer reportOrgId; + private Integer acceptOrgId; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser reportLeader; + private ApprovalTestData.TestUser acceptLeader; + private ApprovalTestData.TestUser acceptMember; + private ApprovalTestData.TestUser archivist; + private ApprovalTestData.TestUser outsider; + private ApprovalTestData.TestUser staffNoOrg; + private ApprovalTestData.TestUser consumerUser; + private ApprovalTestData.TestUser guestUser; + private ApprovalTestData.TestUser noRoleUser; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + reportOrgId = data.createOrganization("派单报送部门" + data.uniqueSuffix(), 136, TENANT); + acceptOrgId = data.createOrganization("派单受理部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("派单报送人" + data.uniqueSuffix(), reportOrgId, TENANT); + + ApprovalTestData.TestRole reportLeaderRole = + data.createRole("dept_leader_" + reportOrgId, "报送部门负责人", TENANT); + reportLeader = data.createUser("派单报送负责人" + data.uniqueSuffix(), reportOrgId, TENANT); + data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT); + + ApprovalTestData.TestRole acceptLeaderRole = + data.createRole("dept_leader_" + acceptOrgId, "受理部门负责人", TENANT); + acceptLeader = data.createUser("派单受理负责人" + data.uniqueSuffix(), acceptOrgId, TENANT); + data.bindTenant(acceptLeader.userId, acceptLeaderRole.roleId, TENANT); + acceptMember = data.createUser("派单受理成员" + data.uniqueSuffix(), acceptOrgId, TENANT); + + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT); + archivist = data.createUser("派单收文员" + data.uniqueSuffix(), reportOrgId, TENANT); + data.bindTenant(archivist.userId, archivistRole.roleId, TENANT); + + outsider = data.createUser("派单路人" + data.uniqueSuffix(), reportOrgId, TENANT); + + // 回退候选:无部门但持有业务角色应保留,仅有 user/guest 角色或无角色的普通消费用户被排除 + ApprovalTestData.TestRole staffRole = data.createRole("backoffice_staff", "业务管理员", TENANT); + staffNoOrg = data.createUser("派单无部门业务员" + data.uniqueSuffix(), null, TENANT); + data.bindTenant(staffNoOrg.userId, staffRole.roleId, TENANT); + + ApprovalTestData.TestRole userRole = data.createRole("user", "普通用户", TENANT); + consumerUser = data.createUser("派单消费者" + data.uniqueSuffix(), null, TENANT); + data.bindTenant(consumerUser.userId, userRole.roleId, TENANT); + + ApprovalTestData.TestRole guestRole = data.createRole("guest", "访客", TENANT); + guestUser = data.createUser("派单访客" + data.uniqueSuffix(), null, TENANT); + data.bindTenant(guestUser.userId, guestRole.roleId, TENANT); + + // 无角色且无部门 + noRoleUser = data.createUser("派单无角色" + data.uniqueSuffix(), null, TENANT); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void listsOnlyEnabledAcceptOrgMembers() throws Exception { + long instanceId = reachDispatch("部门候选池"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + + // 部门内停用成员不进入候选池 + ApprovalTestData.TestUser disabled = + data.createUser("派单停用成员" + data.uniqueSuffix(), acceptOrgId, TENANT); + data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", disabled.userId); + + String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), + acceptLeader, acceptOrgId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].userId").value(acceptLeader.userId)) + .andExpect(jsonPath("$.data[0].realName").value(acceptLeader.realName)) + .andExpect(jsonPath("$.data[0].username").value(acceptLeader.username)) + .andExpect(jsonPath("$.data[0].organizationName").value( + data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?", + String.class, acceptOrgId))) + .andExpect(jsonPath("$.data[1].userId").value(acceptMember.userId)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + // 部门有成员时不得混入回退候选 + JsonNode candidates = objectMapper.readTree(response).path("data"); + for (JsonNode candidate : candidates) { + assertThat(candidate.path("userId").asInt()) + .isNotEqualTo(staffNoOrg.userId) + .isNotEqualTo(disabled.userId); + } + } + + @Test + void fallsBackToTenantUsersExcludingConsumers() throws Exception { + long instanceId = reachDispatch("回退候选池"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + + // 受理部门成员全部调离后部门为空,派单待办仍在负责人手中 + data.setUserOrganization(acceptLeader.userId, reportOrgId); + data.setUserOrganization(acceptMember.userId, reportOrgId); + + String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), + acceptLeader, reportOrgId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode candidates = objectMapper.readTree(response).path("data"); + List ids = new java.util.ArrayList<>(); + candidates.forEach(node -> ids.add(node.path("userId").asInt())); + + // 无部门但持有业务角色的账号保留(含调离受理部门后的成员) + assertThat(ids).contains(staffNoOrg.userId, reporter.userId, acceptLeader.userId, acceptMember.userId); + // 无部门且角色仅有 user/guest、或无角色的普通消费用户被排除 + assertThat(ids).doesNotContain(consumerUser.userId, guestUser.userId, noRoleUser.userId); + } + + @Test + void dispatchesToSelectedCandidateAndCreatesBothConfirmTasks() throws Exception { + long instanceId = reachDispatch("派单存档"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + long otherDispatchTask = pendingTaskId(instanceId, acceptMember.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + acceptMember.userId + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")) + .andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId)) + .andExpect(jsonPath("$.data.dispatchTargetName").value(acceptMember.realName)) + .andExpect(jsonPath("$.data.myTask").doesNotExist()); + + // 派单任务完成并快照派单对象,同节点其余派单待办取消 + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask)) + .isEqualTo("DONE"); + assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_task WHERE id = ?", + Integer.class, dispatchTask)).isEqualTo(acceptMember.userId); + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, otherDispatchTask)) + .isEqualTo("CANCELLED"); + assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isEqualTo(acceptMember.userId); + assertThat(data.queryMaster("SELECT dispatch_target_name FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo(acceptMember.realName); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("confirm"); + + // 派单记录 + 报送人与被派单人的两条存档待办 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'DISPATCH' AND operator_id = ?", instanceId, acceptLeader.userId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING'", instanceId, acceptMember.userId)).isEqualTo(1); + // 被派单人不产生审批任务 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND node_type = 'APPROVE'", instanceId, acceptMember.userId)).isZero(); + + // 被派单人立即可见:详情、附件与待办 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), acceptMember, acceptOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId)) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM")) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(true)); + mockMvc.perform(authed(get("/api/approval/todo/page"), acceptMember, acceptOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)); + mockMvc.perform(authed(get("/api/approval/todo/count"), acceptMember, acceptOrgId, LIST)) + .andExpect(jsonPath("$.data").value(1)); + + // 重复派单返回「任务已处理」 + mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + acceptLeader.userId + "}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + } + + @Test + void rejectsTargetOutsideCandidatePool() throws Exception { + long instanceId = reachDispatch("越界派单"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + reporter.userId + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("派单对象不在候选范围内"))); + + mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请选择派单对象"))); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask)) + .isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isNull(); + } + + @Test + void concurrentDispatchOnlySucceedsOnce() throws Exception { + long instanceId = reachDispatch("并发派单"); + long leaderTask = pendingTaskId(instanceId, acceptLeader.userId); + long memberTask = pendingTaskId(instanceId, acceptMember.userId); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + AtomicInteger success = new AtomicInteger(); + AtomicInteger conflict = new AtomicInteger(); + try { + dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId, + acceptMember.userId, success, conflict); + dispatchAsync(executor, start, done, memberTask, acceptMember, acceptOrgId, + acceptLeader.userId, success, conflict); + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } finally { + executor.shutdownNow(); + } + + assertThat(success.get()).isEqualTo(1); + assertThat(conflict.get()).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'DISPATCH'", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'DISPATCH' AND status = 'DONE'", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'DISPATCH' AND status = 'CANCELLED'", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2); + } + + @Test + void requiresBothConfirmsBeforeArchive() throws Exception { + long instanceId = reachDispatch("双方存档"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + dispatch(dispatchTask, acceptLeader, acceptOrgId, acceptMember.userId); + + // 只有报送人存档:实例仍审批中 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"), + reporter, reportOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")) + .andExpect(jsonPath("$.data.confirms[0].status").value("DONE")) + .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING")); + assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?", + Object.class, instanceId)).isNull(); + + // 被派单人存档后双方齐全 → 审批完成并生成归档任务 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptMember.userId) + "/confirm"), + acceptMember, acceptOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.statusName").value("审批完成")) + .andExpect(jsonPath("$.data.approveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'CONFIRM'", instanceId)).isEqualTo(2); + + long archiveTask = pendingTaskId(instanceId, archivist.userId); + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, reportOrgId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.statusName").value("已归档")) + .andExpect(jsonPath("$.data.archiveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.archivistId").value(archivist.userId)) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)); + + // 归档后只读 + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/dispatch"), + archivist, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + acceptMember.userId + "}")) + .andExpect(jsonPath("$.code").value(1)); + } + + @Test + void rejectsCandidatesAndDispatchForNonDispatchTaskOrOutsider() throws Exception { + long instanceId = reachDispatch("派单校验"); + long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId); + + // 非派单节点任务 + long reportTask = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'APPROVE' AND assignee_id = ? LIMIT 1", Long.class, instanceId, reportLeader.userId); + mockMvc.perform(authed(get("/api/approval/task/" + reportTask + "/candidates"), reportLeader, reportOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("当前任务不支持派单"))); + + // 他人任务 + mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), reporter, reportOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务"))); + + // PC 端与员工端使用同一权限串:缺少 approve 一律 403 + mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), + acceptLeader, acceptOrgId, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"), + acceptLeader, acceptOrgId, LIST) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + acceptMember.userId + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + } + + // ---------- helpers ---------- + + private void dispatchAsync(ExecutorService executor, CountDownLatch start, CountDownLatch done, + long taskId, ApprovalTestData.TestUser actor, Integer org, + int targetUserId, AtomicInteger success, AtomicInteger conflict) { + executor.submit(() -> { + try { + start.await(); + boolean ok = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"), + actor, org, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + targetUserId + "}")) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8) + .contains("\"code\":0"); + if (ok) { + success.incrementAndGet(); + } else { + conflict.incrementAndGet(); + } + } catch (Exception e) { + conflict.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + private void dispatch(long taskId, ApprovalTestData.TestUser actor, Integer org, int targetUserId) + throws Exception { + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"), actor, org, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + targetUserId + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + /** + * 创建并推进内部协调单到派单节点:提交 → 报送负责人通过 → 受理负责人通过。 + */ + private long reachDispatch(String title) throws Exception { + String body = "{\"acceptOrgId\":" + acceptOrgId + + ",\"title\":" + quote(title) + ",\"content\":\"协调内容\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId) + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意协调\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")); + + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptLeader.userId) + "/approve"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意派单\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")) + .andExpect(jsonPath("$.data.permissions.canDispatch").value(true)); + return instanceId; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + reportOrgId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From 252e99421e97bcb7fee33cff288ac4f3d8a4cd7d Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 22:28:05 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(approval):=20=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E5=8F=97=E7=90=86=E9=83=A8=E9=97=A8=E8=B4=9F?= =?UTF-8?q?=E8=B4=A3=E4=BA=BA=E3=80=81PC=20=E5=85=A8=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E6=94=B9=E6=B4=BE=E4=B8=8E=20keywords=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 协调单流程定义升至 v2:dispatch 节点 resolver 改为 acceptOrgLeader,v1 行不可变保留;getOrCreate 取最新版本 - 详情新增 pendingTasks,canAssign 在持有 assign 权限且实例 PENDING 时可用 - /todo/page 支持 scope=all(仅 assign 权限),详情/列表/导出可见性 SQL 与 countVisible 一致 - 查询参数 keyword 统一为 BaseParam.keywords;术语统一为报送人 - 删除 ApprovalDirectoryService.hasDepartmentLeader 与 ApprovalFileService.getById 死代码 --- .../approval/constant/ApprovalConstants.java | 19 +++++- .../approval/mapper/ApprovalExportMapper.java | 3 +- .../mapper/ApprovalInstanceMapper.java | 3 +- .../approval/mapper/ApprovalQueryMapper.java | 3 +- .../approval/mapper/ApprovalTaskMapper.java | 6 ++ .../mapper/xml/ApprovalExportMapper.xml | 11 ++-- .../mapper/xml/ApprovalInstanceMapper.xml | 1 + .../mapper/xml/ApprovalQueryMapper.xml | 13 ++-- .../mapper/xml/ApprovalTaskMapper.xml | 36 +++++++++++ .../param/ApprovalCoordinationParam.java | 2 +- .../approval/param/ApprovalQueryParam.java | 5 +- .../approval/param/ApprovalTaskListParam.java | 4 ++ .../service/ApprovalAttachmentService.java | 2 +- .../service/ApprovalDirectoryService.java | 5 -- .../service/ApprovalExportService.java | 14 ++++- .../approval/service/ApprovalFileService.java | 11 ---- .../approval/service/ApprovalFlowService.java | 40 +++++++++---- .../service/ApprovalInstanceService.java | 60 +++++++++++++++++-- .../service/ApprovalQueryService.java | 14 ++++- .../approval/vo/ApprovalInstanceVO.java | 3 + .../approval/vo/ApprovalPendingTaskVO.java | 27 +++++++++ .../approval/ApprovalCoordinationIT.java | 20 +++++-- .../approval/ApprovalDispatchIT.java | 23 ++++--- .../gxwebsoft/approval/ApprovalExportIT.java | 10 ++-- .../gxwebsoft/approval/ApprovalPcTodoIT.java | 48 +++++++++++++++ .../gxwebsoft/approval/ApprovalQueryIT.java | 16 ++--- 26 files changed, 317 insertions(+), 82 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalPendingTaskVO.java diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index ed5bbaf..cd8b795 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -23,7 +23,7 @@ public final class ApprovalConstants { public static final String STATUS_APPROVED = "APPROVED"; public static final String STATUS_ARCHIVED = "ARCHIVED"; - /** 发起人可编辑、可提交的状态。 */ + /** 报送人可编辑、可提交的状态。 */ public static final Set EDITABLE_STATUSES = Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList( STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN))); @@ -72,6 +72,14 @@ public final class ApprovalConstants { public static final String ROLE_USER = "user"; public static final String ROLE_GUEST = "guest"; + // 权限串(与 spec 约定一致;菜单/按钮共用) + public static final String PERMISSION_LIST = "approval:instance:list"; + public static final String PERMISSION_SUBMIT = "approval:instance:submit"; + public static final String PERMISSION_APPROVE = "approval:instance:approve"; + public static final String PERMISSION_ARCHIVE = "approval:instance:archive"; + public static final String PERMISSION_ASSIGN = "approval:instance:assign"; + public static final String PERMISSION_EXPORT = "approval:instance:export"; + // 附件规则 public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; public static final int MAX_ATTACHMENT_COUNT = 9; @@ -141,11 +149,16 @@ public final class ApprovalConstants { + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + "]"; - /** 内部协调单流程定义(version=1,表单与派单由后续迭代启用)。 */ + /** + * 内部协调单流程定义(version=2)。 + * + *

version=1 的派单节点按受理部门全员生成任务,导致普通成员也能派单且部门无成员时卡单; + * v2 改为受理部门负责人(或签)。版本不可变:已发布实例仍引用其定义版本。

+ */ public static final String COORDINATION_NODES_JSON = "[" + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"}," + "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"}," - + "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgMembers\",\"name\":\"派单\"}," + + "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgLeader\",\"name\":\"派单\"}," + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"}," + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + "]"; diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java index 2663ca4..e2053fb 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java @@ -18,5 +18,6 @@ public interface ApprovalExportMapper { */ List selectExportRows(@Param("param") ApprovalQueryParam param, @Param("userId") Integer userId, - @Param("allVisible") Integer allVisible); + @Param("allVisible") Integer allVisible, + @Param("assignVisible") Integer assignVisible); } diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java index 0212f77..3754eb8 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java @@ -24,7 +24,8 @@ public interface ApprovalInstanceMapper extends BaseMapper { */ int countVisible(@Param("id") Long id, @Param("userId") Integer userId, - @Param("allVisible") Integer allVisible); + @Param("allVisible") Integer allVisible, + @Param("assignVisible") Integer assignVisible); /** * 我发起的列表。 diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java index e3ae64c..45f0817 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java @@ -18,5 +18,6 @@ public interface ApprovalQueryMapper { List selectInstancePage(@Param("page") IPage page, @Param("param") ApprovalQueryParam param, @Param("userId") Integer userId, - @Param("allVisible") Integer allVisible); + @Param("allVisible") Integer allVisible, + @Param("assignVisible") Integer assignVisible); } diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java index ece5152..6399fb4 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java @@ -22,6 +22,12 @@ public interface ApprovalTaskMapper extends BaseMapper { @Param("userId") Integer userId, @Param("taskStatus") String taskStatus); + /** + * 全租户待办列表:任一 PENDING 任务对应的实例,按实例去重。 + */ + List selectPendingInstancePage(@Param("page") IPage page, + @Param("param") ApprovalTaskListParam param); + /** * 本人待办实例数量(同一实例多任务只算一条)。 */ diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml index 837c511..985bafc 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml @@ -31,6 +31,7 @@ i.reporter_id = #{userId} OR (i.status != 'DRAFT' AND ( #{allVisible} = 1 + OR (#{assignVisible} = 1 AND i.status = 'PENDING') OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId}) OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId}) OR i.dispatch_target_id = #{userId} @@ -54,11 +55,11 @@ AND i.status = #{param.status} - - AND (i.biz_no LIKE CONCAT('%', #{param.keyword}, '%') - OR f.name LIKE CONCAT('%', #{param.keyword}, '%') - OR co.title LIKE CONCAT('%', #{param.keyword}, '%') - OR f.implement_org LIKE CONCAT('%', #{param.keyword}, '%')) + + AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%') + OR f.name LIKE CONCAT('%', #{param.keywords}, '%') + OR co.title LIKE CONCAT('%', #{param.keywords}, '%') + OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%')) AND i.submit_time >= #{param.submitTimeStart} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml index e934317..0e84228 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -17,6 +17,7 @@ i.reporter_id = #{userId} OR (i.status != 'DRAFT' AND ( #{allVisible} = 1 + OR (#{assignVisible} = 1 AND i.status = 'PENDING') OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId}) OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId}) OR i.dispatch_target_id = #{userId} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml index 1b13427..6056a81 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml @@ -2,7 +2,7 @@ - + + +