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