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("\"", "\\\"") + "\""; + } +}