diff --git a/sql/09-approval-venue.sql b/sql/09-approval-venue.sql new file mode 100644 index 0000000..c6541c1 --- /dev/null +++ b/sql/09-approval-venue.sql @@ -0,0 +1,26 @@ +-- 场地使用申请表(#55,可重复执行) +-- 目标库:db_gxtyzx(master) +-- 执行顺序:01-approval.sql -> ... -> 05-approval_phase2.sql -> 本文件 +-- +-- 对应 ADR 0009。场地主数据(booking_site)在 modules 服务,不在本库, +-- 因此本服务只保存 site_id 与 site_name 快照,不建外键、不 JOIN。 + +CREATE TABLE IF NOT EXISTS approval_venue_form ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + applicant_name VARCHAR(100) NOT NULL COMMENT '申请单位(人)名称,外部签署方,仅作检索主语', + applicant_type VARCHAR(20) NOT NULL COMMENT '申请单位(人)类型:ORG 单位 / PERSON 个人', + use_start_time DATETIME NOT NULL COMMENT '使用开始时间', + use_end_time DATETIME NOT NULL COMMENT '使用结束时间,须晚于开始时间且为同一自然日', + site_id INT NOT NULL COMMENT '场地id(来源:modules 服务的场地主数据)', + site_name VARCHAR(100) NOT NULL COMMENT '场地名称快照,主数据改名不影响历史单据', + purpose VARCHAR(1000) NOT NULL COMMENT '用途说明', + activity_fee DECIMAL(12,2) NOT NULL COMMENT '活动费用(元)', + 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), + KEY idx_applicant (tenant_id, applicant_name), + KEY idx_create_time (tenant_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地使用申请表'; diff --git a/src/main/java/com/gxwebsoft/approval/doc/venue/VenueDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/venue/VenueDocumentType.java new file mode 100644 index 0000000..8321102 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/venue/VenueDocumentType.java @@ -0,0 +1,244 @@ +package com.gxwebsoft.approval.doc.venue; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.document.AbstractApprovalDocumentType; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalVenueForm; +import com.gxwebsoft.approval.mapper.ApprovalVenueFormMapper; +import com.gxwebsoft.approval.param.ApprovalVenueParam; +import com.gxwebsoft.approval.vo.ApprovalDetail; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 场地使用申请表(ADR 0009):报送部门负责人 → 分管领导(取报送部门)→ 总经理 → + * 报送人存档 → 归档。 + * + *

申请单位(人)是外部签署方,不占审批节点、不设系统账号,仅作检索主语。 + * 场地在另一个服务(modules)的场地主数据中,本服务只快照 id 与名称; + * 主数据改名不影响历史单据。

+ * + *

模板上的 8 条费用与安全条款是固定签约文本,只在详情里渲染(term1..term8), + * 不落库、不逐条勾选;第 1 条把场地名与活动费用渲染进去。

+ */ +@Component +public class VenueDocumentType extends AbstractApprovalDocumentType { + + static final String NODES_JSON = "[" + + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"}," + + "{\"key\":\"deputyLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgDeputy\",\"name\":\"分管领导\"}," + + "{\"key\":\"generalManager\",\"type\":\"APPROVE\",\"resolver\":\"roleGeneralManager\",\"name\":\"总经理\"}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"报送人存档\"}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + + "]"; + + /** 申请单位(人)类型:单位。 */ + public static final String APPLICANT_TYPE_ORG = "ORG"; + /** 申请单位(人)类型:个人。 */ + public static final String APPLICANT_TYPE_PERSON = "PERSON"; + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Resource + private ApprovalVenueFormMapper venueFormMapper; + + @Override + public String flowCode() { + return ApprovalConstants.FLOW_VENUE; + } + + /** doc_type 与 flow_code 同值(本单只有一个流程);复用流程常量避免两处写字面量。 */ + @Override + public String docType() { + return ApprovalConstants.FLOW_VENUE; + } + + @Override + public String flowName() { + return "场地使用申请表"; + } + + @Override + public String nodesJson() { + return NODES_JSON; + } + + @Override + public String noPrefix() { + return ApprovalConstants.NO_PREFIX_VENUE; + } + + @Override + public Class paramClass() { + return ApprovalVenueParam.class; + } + + @Override + public void validate(ApprovalDocumentParam param, User user) { + ApprovalVenueParam venue = cast(param); + requireText("申请单位(人)", venue.getApplicantName(), 100); + requireApplicantType(venue.getApplicantType()); + requireUseTime(venue.getUseStartTime(), venue.getUseEndTime()); + if (venue.getSiteId() == null || venue.getSiteId() <= 0) { + throw new BusinessException("请选择场地"); + } + requireText("场地名称", venue.getSiteName(), 100); + requireText("用途说明", venue.getPurpose(), 1000); + requireFee(venue.getActivityFee()); + } + + @Override + public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) { + ApprovalVenueParam venue = cast(param); + ApprovalVenueForm form = bizId == null || bizId <= 0 ? null : venueFormMapper.selectById(bizId); + if (form == null) { + form = new ApprovalVenueForm(); + form.setInstanceId(instanceId); + } + form.setApplicantName(venue.getApplicantName().trim()); + form.setApplicantType(venue.getApplicantType().trim()); + form.setUseStartTime(venue.getUseStartTime()); + form.setUseEndTime(venue.getUseEndTime()); + form.setSiteId(venue.getSiteId()); + form.setSiteName(venue.getSiteName().trim()); + form.setPurpose(venue.getPurpose().trim()); + form.setActivityFee(venue.getActivityFee()); + if (form.getId() == null) { + venueFormMapper.insert(form); + } else { + venueFormMapper.updateById(form); + } + return form.getId(); + } + + @Override + public void load(ApprovalInstance instance, ApprovalDetail detail) { + ApprovalVenueForm form = findByInstance(instance.getId()); + if (form == null) { + return; + } + detail.addField("applicantName", "申请单位(人)", form.getApplicantName()); + detail.addField("applicantType", "申请单位(人)类型", applicantTypeName(form.getApplicantType())); + detail.addField("useStartTime", "使用开始时间", timeText(form.getUseStartTime())); + detail.addField("useEndTime", "使用结束时间", timeText(form.getUseEndTime())); + detail.addField("siteName", "拟申请场地", form.getSiteName()); + detail.addField("purpose", "用途说明", form.getPurpose()); + detail.addField("activityFee", "活动费用(元)", feeText(form.getActivityFee())); + List terms = VenueTerms.clauses(form.getSiteName(), form.getActivityFee()); + for (int i = 0; i < terms.size(); i++) { + detail.addField("term" + (i + 1), "条款" + (i + 1), terms.get(i)); + } + } + + @Override + public String title(ApprovalInstance instance) { + ApprovalVenueForm form = findByInstance(instance.getId()); + return form == null ? null : form.getApplicantName(); + } + + @Override + public String searchText(ApprovalInstance instance) { + ApprovalVenueForm form = findByInstance(instance.getId()); + if (form == null) { + return null; + } + return join(form.getApplicantName(), form.getSiteName(), form.getPurpose()); + } + + /** 申请单位(人)类型展示名:单位/个人。 */ + public static String applicantTypeName(String applicantType) { + if (APPLICANT_TYPE_ORG.equals(applicantType)) { + return "单位"; + } + if (APPLICANT_TYPE_PERSON.equals(applicantType)) { + return "个人"; + } + return applicantType; + } + + private void requireApplicantType(String applicantType) { + String value = applicantType == null ? "" : applicantType.trim(); + if (value.isEmpty()) { + throw new BusinessException("申请单位(人)类型不能为空"); + } + if (!APPLICANT_TYPE_ORG.equals(value) && !APPLICANT_TYPE_PERSON.equals(value)) { + throw new BusinessException("申请单位(人)类型只能是单位或个人"); + } + } + + /** + * 单次时段:结束必须晚于开始,且起止为同一自然日。 + * + *

两条规则分别报错,便于前端把提示落到对应输入项。

+ */ + private void requireUseTime(LocalDateTime start, LocalDateTime end) { + if (start == null) { + throw new BusinessException("使用开始时间不能为空"); + } + if (end == null) { + throw new BusinessException("使用结束时间不能为空"); + } + if (!end.isAfter(start)) { + throw new BusinessException("使用结束时间必须晚于使用开始时间"); + } + if (!end.toLocalDate().equals(start.toLocalDate())) { + throw new BusinessException("使用时间不能跨自然日,请按单日时段填写"); + } + } + + private void requireFee(BigDecimal fee) { + if (fee == null) { + throw new BusinessException("活动费用不能为空"); + } + if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 + || fee.compareTo(ApprovalConstants.MAX_FEE) > 0) { + throw new BusinessException("活动费用必须为0到99999999.99之间的两位小数"); + } + } + + private ApprovalVenueForm findByInstance(Long instanceId) { + List forms = venueFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalVenueForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private String timeText(LocalDateTime time) { + return time == null ? null : TIME_FORMAT.format(time); + } + + private String feeText(BigDecimal fee) { + return fee == null ? null : fee.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(); + } + + private String join(String... parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (part == null || part.trim().isEmpty()) { + continue; + } + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(part.trim()); + } + return sb.toString(); + } + + private ApprovalVenueParam cast(ApprovalDocumentParam param) { + if (!(param instanceof ApprovalVenueParam)) { + throw new BusinessException("单据类型不匹配"); + } + return (ApprovalVenueParam) param; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/venue/VenueTerms.java b/src/main/java/com/gxwebsoft/approval/doc/venue/VenueTerms.java new file mode 100644 index 0000000..465fe27 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/venue/VenueTerms.java @@ -0,0 +1,79 @@ +package com.gxwebsoft.approval.doc.venue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 场地使用申请表模板上的 8 条费用与安全条款(固定签约文本,只展示不填报)。 + * + *

原文取自《广西体育中心场地使用申请表》;第 1 条把场地名称与活动费用渲染进去, + * 其余条款原样输出。条款是合同内容,不是待办清单,不做逐条勾选(ADR 0009)。

+ */ +public final class VenueTerms { + + /** 第 1 条附带的收款账户(模板原文)。 */ + private static final String ACCOUNT_GROUP = + "账户信息如下:单位名称:南宁威宁资产经营集团有限责任公司;" + + "开户行:中国建设银行股份有限公司南宁淡村路支行;" + + "银行账号:4505 0160 4952 0900 0033。"; + + /** 第 8 条附带的空调费/停车票收款账户(模板原文)。 */ + private static final String ACCOUNT_CULTURE = + "转账账户信息如下:单位名称:南宁威宁文化体育发展有限公司;" + + "开户行:中国建设银行股份有限公司南宁淡村路支行;" + + "银行账号:4505 0160 4952 0900 0003。"; + + /** 第 2~7 条(不含第 1、8 条的账户段)。 */ + private static final List REST = Collections.unmodifiableList(Arrays.asList( + "费用交付完成后方可进行布场。", + "使用方已对体育场地的状况进行了充分了解,愿意按场地的现状使用。", + "使用方入场前应对所有进场人员的身体状况等进行核实,确认其身体状况符合进场运动、工作的条件。" + + "如使用方组织的人员因故意、疏忽等原因导致进场人员的身体状况出现问题的,由使用方承担全部法律责任," + + "由此导致管理方遭受损失的,管理方有权追偿。", + "对场地的使用和车辆进出、停放及人员进出须遵守管理方的规定,并服从管理方的管理。" + + "使用方人员参加活动期间不得乱扔垃圾,除运动垃圾外,厨余垃圾、广告垃圾清理费另行结算。", + "如需使用空调,应另行与南宁威宁文化体育发展有限公司现场签单结算,收费标准为2000元/小时," + + "使用不满1小时的,按1小时结算。", + "广西体育中心停车前三小时免费,如需使用停车票,应与南宁威宁文化体育发展有限公司另行结算," + + "收费标准为5元/天/车。" + )); + + private VenueTerms() { + } + + /** + * 渲染 8 条条款:第 1 条为场地 + 活动费用 + 收款账户,第 8 条为附加费用账户。 + * + * @param siteName 场地名称快照,可为空(草稿未选场地时按空串渲染) + * @param activityFee 活动费用,可为空 + */ + public static List clauses(String siteName, BigDecimal activityFee) { + return Arrays.asList( + clause1(siteName, activityFee), + REST.get(0), + REST.get(1), + REST.get(2), + REST.get(3), + REST.get(4), + REST.get(5), + "如产生空调费以及停车票费用," + ACCOUNT_CULTURE + ); + } + + /** 第 1 条:模板原文「使用___(场地),活动费用为___元,账户信息如下:…」。 */ + public static String clause1(String siteName, BigDecimal activityFee) { + String site = siteName == null || siteName.trim().isEmpty() ? "(未选场地)" : siteName.trim(); + return "使用" + site + "(场地),活动费用为" + feeText(activityFee) + "元," + ACCOUNT_GROUP; + } + + /** 金额展示:统一两位小数;空值显示为「待定」。 */ + private static String feeText(BigDecimal fee) { + if (fee == null) { + return "待定"; + } + return fee.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalVenueForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalVenueForm.java new file mode 100644 index 0000000..58e733c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalVenueForm.java @@ -0,0 +1,61 @@ +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.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 场地使用申请表。 + * + *

场地来自 modules 服务的场地主数据,本服务只快照 site_id 与 site_name: + * 主数据改名不影响历史单据(ADR 0009)。申请单位(人)是外部签署方, + * 不占审批节点、不设系统账号,只作检索主语。

+ */ +@Data +@ApiModel(description = "场地使用申请表") +@TableName("approval_venue_form") +public class ApprovalVenueForm implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + @ApiModelProperty("申请单位(人)名称") + private String applicantName; + + @ApiModelProperty("申请单位(人)类型:ORG 单位 / PERSON 个人") + private String applicantType; + + @ApiModelProperty("使用开始时间") + private LocalDateTime useStartTime; + + @ApiModelProperty("使用结束时间") + private LocalDateTime useEndTime; + + @ApiModelProperty("场地 id(来自场地主数据)") + private Integer siteId; + + @ApiModelProperty("场地名称快照") + private String siteName; + + @ApiModelProperty("用途说明") + private String purpose; + + @ApiModelProperty("活动费用(元)") + private BigDecimal activityFee; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVenueFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVenueFormMapper.java new file mode 100644 index 0000000..023bd75 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVenueFormMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalVenueForm; + +/** + * 场地使用申请表Mapper。 + */ +public interface ApprovalVenueFormMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalVenueParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalVenueParam.java new file mode 100644 index 0000000..5b822b7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalVenueParam.java @@ -0,0 +1,48 @@ +package com.gxwebsoft.approval.param; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 场地使用申请表草稿参数;id 为审批实例id,更新草稿时传入。 + * + *

场地名称由前端从场地主数据选中后随 siteId 一并提交,服务端只做快照, + * 不反向查询主数据(主数据在 modules 服务,不在本库)。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalVenueParam extends ApprovalDocumentParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("申请单位(人)名称") + private String applicantName; + + @ApiModelProperty("申请单位(人)类型:ORG 单位 / PERSON 个人") + private String applicantType; + + @ApiModelProperty("使用开始时间,yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime useStartTime; + + @ApiModelProperty("使用结束时间,yyyy-MM-dd HH:mm:ss,须晚于开始时间且与开始时间为同一天") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime useEndTime; + + @ApiModelProperty("场地id(来自场地主数据)") + private Integer siteId; + + @ApiModelProperty("场地名称快照(随场地主数据选择带出)") + private String siteName; + + @ApiModelProperty("用途说明") + private String purpose; + + @ApiModelProperty("活动费用(元),0~99999999.99") + private BigDecimal activityFee; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalVenueIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalVenueIT.java new file mode 100644 index 0000000..59e34f5 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalVenueIT.java @@ -0,0 +1,443 @@ +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.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.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; + +/** + * 场地使用申请表(#55):字段/时段校验、场地快照、8 条条款渲染、列表检索与五节点闭环。 + * + *

不开启测试事务:提交与审批各自提交,校验跨库的组织/角色解析;数据由 + * {@link ApprovalTestData#cleanup()} 与本地 {@code approval_venue_form} 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalVenueIT { + + /** 独立租户:角色成员按 (tenant_id, role_code) 匹配,专属租户避免与其它测试类串味。 */ + private static final int TENANT = 10026; + private static final String FLOW = "venue"; + 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"; + + private static final String SITE_NAME = "广西体育中心体育场"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer organizationId; + private final List instanceIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("场地报送部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("场地报送人" + data.uniqueSuffix(), organizationId, TENANT); + } + + @AfterEach + void tearDown() { + for (Long instanceId : instanceIds) { + data.master().update("DELETE FROM approval_venue_form WHERE instance_id = ?", instanceId); + } + instanceIds.clear(); + data.cleanup(); + } + + @Test + void completesFiveNodeFlowWithVenueSnapshot() throws Exception { + ApprovalTestData.TestUser deptLeader = grantDeptRole("dept_leader_", "部门负责人"); + ApprovalTestData.TestUser deputy = grantDeptRole("dept_deputy_", "分管领导"); + ApprovalTestData.TestUser generalManager = grantRole("approval_general_manager", "总经理"); + ApprovalTestData.TestUser archivist = grantRole("approval_archivist", "收文员"); + + long instanceId = saveVenue("广西某某文化传媒有限公司", "ORG", "2026-11-08 09:00:00", + "2026-11-08 18:00:00", "举办企业运动会", "1234.5"); + + // 草稿:标题=申请单位(人),无单号,场地快照落库 + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("venue"); + assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("venue"); + assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("广西某某文化传媒有限公司"); + assertThat(data.queryMaster("SELECT applicant_type FROM approval_venue_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("ORG"); + assertThat(data.queryMaster("SELECT site_id FROM approval_venue_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(88001); + assertThat(data.queryMaster("SELECT site_name FROM approval_venue_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo(SITE_NAME); + assertThat(data.queryMaster("SELECT activity_fee FROM approval_venue_form WHERE instance_id = ?", + java.math.BigDecimal.class, instanceId)).isEqualByComparingTo("1234.50"); + + // 节点定义:五个节点按顺序发布,存档节点为报送人(JSON 列会重排空格,按关键片段断言) + String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'venue' ORDER BY version DESC LIMIT 1", String.class, TENANT); + assertThat(nodes).contains("reporterOrgLeader").contains("reporterOrgDeputy") + .contains("roleGeneralManager").contains("archivist"); + assertThat(nodes.indexOf("deptLeader")).isLessThan(nodes.indexOf("deputyLeader")); + assertThat(nodes.indexOf("deputyLeader")).isLessThan(nodes.indexOf("generalManager")); + assertThat(nodes.indexOf("generalManager")).isLessThan(nodes.indexOf("confirm")); + assertThat(nodes.indexOf("confirm")).isLessThan(nodes.indexOf("archive")); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.attachments.length()").value(1)); + assertThat(bizNo(instanceId)).matches("CD\\d{8}-\\d{4,}"); + + approve(instanceId, deptLeader, "分管领导", "同意使用"); + approve(instanceId, deputy, "总经理", "同意"); + approve(instanceId, generalManager, "报送人存档", "批准"); + + // 存档节点:报送人确认后才进入归档 + long confirmTask = pendingTaskId(instanceId, reporter.userId); + mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.statusName").value("审批完成")) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")); + + long archiveTask = pendingTaskId(instanceId, archivist.userId); + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/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.archivistName").value(archivist.username)); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE'", instanceId)).isEqualTo(3); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'CONFIRM'", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'ARCHIVE'", instanceId)).isEqualTo(1); + } + + @Test + void rejectsCrossDayAndReversedUseTime() throws Exception { + // 跨自然日:起止不在同一天 + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body("跨天单位", "ORG", "2026-11-08 09:00:00", "2026-11-09 18:00:00", + SITE_NAME, "跨天活动", "100.00"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不能跨自然日"))); + + // 结束早于开始:同一自然日但时间倒挂 + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body("倒挂单位", "ORG", "2026-11-08 18:00:00", "2026-11-08 09:00:00", + SITE_NAME, "倒挂活动", "100.00"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("必须晚于使用开始时间"))); + + // 起止相同也算不晚于 + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body("同刻单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 09:00:00", + SITE_NAME, "同刻活动", "100.00"))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("必须晚于使用开始时间"))); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_venue_form " + + "WHERE applicant_name IN ('跨天单位', '倒挂单位', '同刻单位')")).isZero(); + } + + @Test + void validatesVenueFields() throws Exception { + expectSaveFailure(body("", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "100.00"), "申请单位(人)不能为空"); + expectSaveFailure(body("单位", null, "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "100.00"), "申请单位(人)类型不能为空"); + expectSaveFailure(body("单位", "COMPANY", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "100.00"), "申请单位(人)类型只能是单位或个人"); + expectSaveFailure(body("单位", "ORG", null, "2026-11-08 18:00:00", + SITE_NAME, "用途", "100.00"), "使用开始时间不能为空"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", null, + SITE_NAME, "用途", "100.00"), "使用结束时间不能为空"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "-1"), "活动费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "100000000"), "活动费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "用途", "1.234"), "活动费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + "", "用途", "100.00"), "场地名称不能为空"); + expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + SITE_NAME, "", "100.00"), "用途说明不能为空"); + + // 场地 id 必填 + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"applicantName\":\"单位\",\"applicantType\":\"ORG\"," + + "\"useStartTime\":\"2026-11-08 09:00:00\",\"useEndTime\":\"2026-11-08 18:00:00\"," + + "\"siteName\":\"" + SITE_NAME + "\",\"purpose\":\"用途\",\"activityFee\":100.00}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请选择场地"))); + + // 个人申请合法落库 + long instanceId = saveVenue("张三", "PERSON", "2026-11-08 09:00:00", "2026-11-08 12:00:00", + "个人健身", "0"); + assertThat(data.queryMaster("SELECT applicant_type FROM approval_venue_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("PERSON"); + } + + @Test + void rendersApplicantTypeAndEightTermsInDetail() throws Exception { + long instanceId = saveVenue("广西某某体育俱乐部", "ORG", "2026-11-08 09:00:00", + "2026-11-08 18:00:00", "举办篮球邀请赛", "8888"); + + Map fields = detailFields(instanceId); + + // 审批人能从详情看出是单位还是个人 + assertThat(fields).containsEntry("applicantType", "单位"); + assertThat(fields).containsEntry("applicantName", "广西某某体育俱乐部"); + assertThat(fields).containsEntry("siteName", SITE_NAME); + assertThat(fields).containsEntry("activityFee", "8888.00"); + + // 8 条条款逐条输出,第 1 条渲染场地名与活动费用 + for (int i = 1; i <= 8; i++) { + assertThat(fields).as("条款" + i).containsKey("term" + i); + assertThat(fields.get("term" + i)).isNotBlank(); + } + assertThat(fields.get("term1")).contains(SITE_NAME).contains("8888.00元"); + assertThat(fields.get("term2")).contains("费用交付完成"); + assertThat(fields.get("term8")).contains("空调费"); + + // 个人申请在详情显示「个人」 + long personInstance = saveVenue("李四", "PERSON", "2026-11-08 09:00:00", + "2026-11-08 18:00:00", "个人活动", "100"); + assertThat(detailFields(personInstance)).containsEntry("applicantType", "个人"); + } + + @Test + void listFindsByApplicantNameKeywords() throws Exception { + saveVenue("广西甲体育馆运营有限公司", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00", + "甲方活动", "100"); + long target = saveVenue("南宁乙文化传播有限公司", "PERSON", "2026-11-09 09:00:00", + "2026-11-09 18:00:00", "乙方活动", "200"); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, LIST) + .param("keywords", "乙文化")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) target)) + .andExpect(jsonPath("$.data.list[0].title").value("南宁乙文化传播有限公司")) + .andExpect(jsonPath("$.data.list[0].docTypeName").value("场地使用申请表")); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, LIST) + .param("keywords", "甲体育馆")) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].title").value("广西甲体育馆运营有限公司")); + + mockMvc.perform(authed(get("/api/approval/instance/page"), reporter, organizationId, LIST) + .param("keywords", "乙文化")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) target)); + } + + @Test + void rejectsSubmitWithoutAttachment() throws Exception { + grantDeptRole("dept_leader_", "部门负责人"); + grantDeptRole("dept_deputy_", "分管领导"); + grantRole("approval_general_manager", "总经理"); + grantRole("approval_archivist", "收文员"); + + long instanceId = saveVenue("无附件单位", "ORG", "2026-11-08 09:00:00", + "2026-11-08 18:00:00", "无附件活动", "100"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, 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 rejectsSubmitWhenDeputyLeaderMissing() throws Exception { + grantDeptRole("dept_leader_", "部门负责人"); + grantRole("approval_archivist", "收文员"); + long instanceId = saveVenue("缺分管单位", "ORG", "2026-11-08 09:00:00", + "2026-11-08 18:00:00", "缺分管活动", "100"); + uploadAttachment(instanceId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门分管领导角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + // ---------- helpers ---------- + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, 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 saveVenue(String applicantName, String applicantType, String start, String end, + String purpose, String activityFee) throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), + reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(applicantName, applicantType, start, end, SITE_NAME, purpose, activityFee))) + .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); + instanceIds.add(instanceId); + return instanceId; + } + + private void approve(long instanceId, ApprovalTestData.TestUser approver, + String expectedNextNodeName, String opinion) throws Exception { + long taskId = pendingTaskId(instanceId, approver.userId); + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), approver, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":" + quote(opinion) + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value(expectedNextNodeName)); + } + + private Map detailFields(long instanceId) throws Exception { + String response = mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), + reporter, organizationId, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode fields = objectMapper.readTree(response).path("data").path("document").path("fields"); + Map result = new LinkedHashMap<>(); + for (JsonNode field : fields) { + result.put(field.path("key").asText(), field.path("value").asText()); + } + return result; + } + + private ApprovalTestData.TestUser grantDeptRole(String prefix, String roleName) { + String roleCode = prefix + data.organizationCode(organizationId); + ApprovalTestData.TestRole role = data.createRole(roleCode, roleName, TENANT); + ApprovalTestData.TestUser member = data.createUser(roleName + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(member.userId, role.roleId, TENANT); + return member; + } + + private ApprovalTestData.TestUser grantRole(String roleCode, String roleName) { + ApprovalTestData.TestRole role = data.createRole(roleCode, roleName, TENANT); + ApprovalTestData.TestUser member = data.createUser(roleName + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(member.userId, role.roleId, TENANT); + return member; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "quote.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", "OTHER") + .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 body(String applicantName, String applicantType, String start, String end, + String siteName, String purpose, String activityFee) { + return "{\"applicantName\":" + quote(applicantName) + + ",\"applicantType\":" + quote(applicantType) + + ",\"useStartTime\":" + quote(start) + + ",\"useEndTime\":" + quote(end) + + ",\"siteId\":88001" + + ",\"siteName\":" + quote(siteName) + + ",\"purpose\":" + quote(purpose) + + ",\"activityFee\":" + activityFee + + "}"; + } + + private String quote(String value) { + if (value == null) { + return "null"; + } + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + 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); + } +}