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