diff --git a/sql/08-approval-vehicle.sql b/sql/08-approval-vehicle.sql
new file mode 100644
index 0000000..6ac56c6
--- /dev/null
+++ b/sql/08-approval-vehicle.sql
@@ -0,0 +1,39 @@
+-- 车辆免收费登记表业务表与明细子表(可重复执行)
+-- 目标库:db_gxtyzx(master)
+-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql
+-- -> 04-approval_role_grants.sql -> 05-approval_phase2.sql -> 08-approval-vehicle.sql
+--
+-- 覆盖 ADR 0010(车辆免收费登记表):一单多车、一车一行、车辆归属逐车存储、
+-- 有效期用起止日期区间。整单没有业务字段(统计时间即实例提交时间,不重复落库),
+-- 主表只承担「实例 1:1 业务行」的挂载点;单据标题与检索文本由单据实现计算落库。
+
+CREATE TABLE IF NOT EXISTS approval_vehicle_form (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_instance (instance_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆免收费登记表';
+
+CREATE TABLE IF NOT EXISTS approval_vehicle_item (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ line_no INT NOT NULL COMMENT '行号,按提交顺序重排为 1..N',
+ unit_name VARCHAR(100) NOT NULL COMMENT '单位名称',
+ person_info VARCHAR(50) NOT NULL COMMENT '人员信息',
+ plate_no VARCHAR(20) NOT NULL COMMENT '车牌号,统一存大写',
+ new_energy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否新能源,0 否 1 是',
+ contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
+ valid_start_date DATE NOT NULL COMMENT '有效期起',
+ valid_end_date DATE NOT NULL COMMENT '有效期止,不得早于有效期起',
+ remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
+ vehicle_owner_type VARCHAR(40) NOT NULL COMMENT '车辆归属编码,8 类固定编码',
+ 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_line (instance_id, line_no),
+ KEY idx_plate (tenant_id, plate_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆免收费登记明细(一车一行)';
diff --git a/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleForm.java b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleForm.java
new file mode 100644
index 0000000..f0665a9
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleForm.java
@@ -0,0 +1,34 @@
+package com.gxwebsoft.approval.doc.vehicle;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 车辆免收费登记表(ADR 0010):整单没有业务字段,只作实例 1:1 的挂载点。
+ *
+ *
「统计时间」就是系统提交时间({@code approval_instance.submit_time}),不落字段;
+ * 车辆归属是逐车字段,落在明细子表。
+ */
+@Data
+@ApiModel(description = "车辆免收费登记表")
+@TableName("approval_vehicle_form")
+public class ApprovalVehicleForm implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleItem.java b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleItem.java
new file mode 100644
index 0000000..7bcfac5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleItem.java
@@ -0,0 +1,64 @@
+package com.gxwebsoft.approval.doc.vehicle;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 车辆免收费登记明细行(ADR 0010):一行一车。
+ *
+ * 车辆归属是逐车字段而不是整单枚举:同一单里可以有合作俱乐部的车与主办方的车。
+ */
+@Data
+@ApiModel(description = "车辆免收费登记明细")
+@TableName("approval_vehicle_item")
+public class ApprovalVehicleItem implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ /** 行号:按提交顺序重排为 1..N。 */
+ private Integer lineNo;
+
+ /** 单位名称。 */
+ private String unitName;
+
+ /** 人员信息(姓名/工号等自由文本)。 */
+ private String personInfo;
+
+ /** 车牌号,统一存大写。 */
+ private String plateNo;
+
+ /** 是否新能源车。 */
+ private Boolean newEnergy;
+
+ /** 联系电话。 */
+ private String contactPhone;
+
+ /** 有效期起。 */
+ private LocalDate validStartDate;
+
+ /** 有效期止,不得早于有效期起。 */
+ private LocalDate validEndDate;
+
+ /** 备注。 */
+ private String remark;
+
+ /** 车辆归属编码,8 类固定编码见 {@link VehicleDocumentType}。 */
+ private String vehicleOwnerType;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleParam.java b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleParam.java
new file mode 100644
index 0000000..2d6569e
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/vehicle/ApprovalVehicleParam.java
@@ -0,0 +1,16 @@
+package com.gxwebsoft.approval.doc.vehicle;
+
+import com.gxwebsoft.approval.document.ApprovalDocumentParam;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 车辆免收费登记表草稿参数;id 为审批实例id,更新草稿时传入,逐车明细走基类 lines。
+ *
+ * 整单没有业务字段:报送部门与报送人由服务端按登录态带出,车辆归属在每行里。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ApprovalVehicleParam extends ApprovalDocumentParam {
+ private static final long serialVersionUID = 1L;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehicleDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehicleDocumentType.java
new file mode 100644
index 0000000..2de51f5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehicleDocumentType.java
@@ -0,0 +1,344 @@
+package com.gxwebsoft.approval.doc.vehicle;
+
+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.document.ApprovalLineSupport;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.mapper.ApprovalVehicleFormMapper;
+import com.gxwebsoft.approval.mapper.ApprovalVehicleItemMapper;
+import com.gxwebsoft.approval.support.ApprovalLineSpec;
+import com.gxwebsoft.approval.support.ApprovalTenantContext;
+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.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * 车辆免收费登记表(ADR 0010):一单多车、一车一行、车辆归属逐车存储。
+ *
+ * 节点链:报送人 → 报送部门负责人 → 运营管理部负责人(固定组织编码 APPROVAL_OPS)→
+ * 报送部门分管领导 → 录入员存档确认({@code approval_typist} 角色成员,不派单)→ 归档。
+ * 录入员走 CONFIRM 而非回填:模板页脚只有「录入日期」(即确认时间),没有可回填的编号。
+ *
+ * 整单没有业务字段:「统计时间」就是实例提交时间,有效期在每行里;主表只作实例挂载点。
+ * 车牌唯一性按「同租户 + 有效期区间重叠 + 实例未作废」判定,续期是常态,所以不做永久唯一。
+ */
+@Component
+public class VehicleDocumentType extends AbstractApprovalDocumentType {
+
+ /** 节点链:运营管理部负责人按固定组织编码解析,录入员按租户岗位角色解析。 */
+ static final String NODES_JSON = "["
+ + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ + "{\"key\":\"opsLeader\",\"type\":\"APPROVE\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_OPS\","
+ + "\"name\":\"运营管理部负责人\"},"
+ + "{\"key\":\"deputyLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgDeputy\",\"name\":\"分管领导\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"roleTypist\",\"name\":\"录入员存档\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ + "]";
+
+ /** 8 类车辆归属固定编码(ADR 0010):存编码不存中文文案。 */
+ private static final Map OWNER_TYPES;
+
+ static {
+ Map types = new LinkedHashMap<>();
+ types.put("GROUP_EMPLOYEE", "威宁集团公司员工车辆");
+ types.put("CULTURE_TOURISM_EMPLOYEE", "南宁文旅集团公司员工车辆");
+ types.put("GROUP_SUBSIDIARY_EMPLOYEE", "威宁集团下属公司员工车辆");
+ types.put("WENNING_CULTURE_SPORTS_EMPLOYEE", "威宁文体公司员工车辆");
+ types.put("COOPERATING_CLUB_EMPLOYEE", "合作俱乐部员工车辆");
+ types.put("TENANT_EMPLOYEE", "广西体育中心租户员工车辆");
+ types.put("LARGE_EVENT_ORGANIZER", "大型活动主办方车辆");
+ types.put("OTHER", "其他车辆");
+ OWNER_TYPES = Collections.unmodifiableMap(types);
+ }
+
+ /** 明细列(ADR 0004 / 0010):一车一行,车辆归属在行内。 */
+ private static final List LINE_COLUMNS;
+
+ static {
+ List options = new ArrayList<>();
+ for (Map.Entry entry : OWNER_TYPES.entrySet()) {
+ options.add(new ApprovalLineSpec.Option(entry.getKey(), entry.getValue()));
+ }
+ LINE_COLUMNS = Collections.unmodifiableList(Arrays.asList(
+ ApprovalLineSpec.text("unitName", "单位名称", true, 100),
+ ApprovalLineSpec.text("personInfo", "人员信息", true, 50),
+ ApprovalLineSpec.text("plateNo", "车牌号", true, 20),
+ ApprovalLineSpec.bool("newEnergy", "是否新能源", true),
+ ApprovalLineSpec.text("contactPhone", "联系电话", true, 20),
+ ApprovalLineSpec.date("validStartDate", "有效期起", true),
+ ApprovalLineSpec.date("validEndDate", "有效期止", true),
+ ApprovalLineSpec.text("remark", "备注", false, 200),
+ ApprovalLineSpec.enumOf("vehicleOwnerType", "车辆归属", true, options)
+ ));
+ }
+
+ /**
+ * 车牌格式:省份简称 + 字母 + 5~6 位字母数字。
+ *
+ * 覆盖新能源号牌(如 {@code 桂AD12345})与普通号牌;同一车牌在小程序端可能带
+ * 空格或小写,统一去空格转大写后校验与存储。
+ */
+ private static final Pattern PLATE_PATTERN = Pattern.compile(
+ "^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{5,6}$");
+
+ /** 视为作废、不参与车牌冲突判定的实例状态(ADR 0010)。 */
+ private static final List VOID_STATUSES = Collections.unmodifiableList(
+ Arrays.asList(ApprovalConstants.STATUS_REJECTED, ApprovalConstants.STATUS_WITHDRAWN));
+
+ @Resource
+ private ApprovalVehicleFormMapper vehicleFormMapper;
+ @Resource
+ private ApprovalVehicleItemMapper vehicleItemMapper;
+
+ @Override
+ public String flowCode() {
+ return ApprovalConstants.FLOW_VEHICLE;
+ }
+
+ @Override
+ public String docType() {
+ return ApprovalConstants.FLOW_VEHICLE;
+ }
+
+ @Override
+ public String flowName() {
+ return "车辆免收费登记表";
+ }
+
+ @Override
+ public String nodesJson() {
+ return NODES_JSON;
+ }
+
+ @Override
+ public String noPrefix() {
+ return ApprovalConstants.NO_PREFIX_VEHICLE;
+ }
+
+ @Override
+ public List lineColumns() {
+ return LINE_COLUMNS;
+ }
+
+ /** 不要求特定附件分类,沿用「整单至少一张附件」(ADR 0010)。 */
+ @Override
+ public String requiredAttachmentCategory() {
+ return null;
+ }
+
+ @Override
+ public Class extends ApprovalDocumentParam> paramClass() {
+ return ApprovalVehicleParam.class;
+ }
+
+ @Override
+ public void validate(ApprovalDocumentParam param, User user) {
+ ApprovalVehicleParam vehicle = cast(param);
+ // 结构校验(必填/长度/类型/枚举行内取值),再做车牌与有效期的业务校验
+ validateRows(ApprovalLineSupport.normalize(vehicle.getLines(), LINE_COLUMNS));
+ }
+
+ /**
+ * 写业务表:明细整表替换(先删后插),line_no 由校验器重排为 1..N。
+ *
+ * 调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。
+ */
+ @Override
+ public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
+ ApprovalVehicleParam vehicle = cast(param);
+ List> rows = ApprovalLineSupport.normalize(vehicle.getLines(), LINE_COLUMNS);
+ validateRows(rows);
+
+ ApprovalVehicleForm form = bizId == null || bizId <= 0 ? null : vehicleFormMapper.selectById(bizId);
+ if (form == null) {
+ form = new ApprovalVehicleForm();
+ form.setInstanceId(instanceId);
+ }
+ if (form.getId() == null) {
+ vehicleFormMapper.insert(form);
+ } else {
+ vehicleFormMapper.updateById(form);
+ }
+
+ vehicleItemMapper.delete(new LambdaQueryWrapper()
+ .eq(ApprovalVehicleItem::getInstanceId, instanceId));
+ for (Map row : rows) {
+ ApprovalVehicleItem item = new ApprovalVehicleItem();
+ item.setInstanceId(instanceId);
+ item.setLineNo((Integer) row.get(ApprovalLineSupport.KEY_LINE_NO));
+ item.setUnitName(text(row, "unitName"));
+ item.setPersonInfo(text(row, "personInfo"));
+ item.setPlateNo(text(row, "plateNo"));
+ item.setNewEnergy((Boolean) row.get("newEnergy"));
+ item.setContactPhone(text(row, "contactPhone"));
+ item.setValidStartDate((LocalDate) row.get("validStartDate"));
+ item.setValidEndDate((LocalDate) row.get("validEndDate"));
+ item.setRemark(text(row, "remark"));
+ item.setVehicleOwnerType(text(row, "vehicleOwnerType"));
+ vehicleItemMapper.insert(item);
+ }
+ return form.getId();
+ }
+
+ /**
+ * 提交前校验车牌区间冲突(ADR 0010):草稿可以带着冲突存下来,提交那一刻必须拦住。
+ *
+ * 冲突判定 = 同租户 + 同车牌 + 有效期闭区间重叠 + 实例状态不属于
+ * REJECTED/WITHDRAWN;本单自己已排除,重提不会撞上自己。
+ */
+ @Override
+ public void validateBeforeSubmit(ApprovalInstance instance) {
+ List items = items(instance);
+ Integer tenantId = ApprovalTenantContext.currentTenantId();
+ for (ApprovalVehicleItem item : items) {
+ VehiclePlateConflict conflict = vehicleItemMapper.selectPlateConflicts(tenantId,
+ item.getPlateNo(), item.getValidStartDate(), item.getValidEndDate(), instance.getId(), VOID_STATUSES)
+ .stream().findFirst().orElse(null);
+ if (conflict != null) {
+ throw new BusinessException(conflictMessage(conflict));
+ }
+ }
+ }
+
+ @Override
+ public void load(ApprovalInstance instance, ApprovalDetail detail) {
+ if (findByInstance(instance.getId()) == null) {
+ return;
+ }
+ detail.setLines(lines(instance));
+ }
+
+ @Override
+ public List> lines(ApprovalInstance instance) {
+ List items = items(instance);
+ List> rows = new ArrayList<>(items.size());
+ for (ApprovalVehicleItem item : items) {
+ Map row = new LinkedHashMap<>();
+ row.put(ApprovalLineSupport.KEY_LINE_NO, item.getLineNo());
+ row.put("unitName", item.getUnitName());
+ row.put("personInfo", item.getPersonInfo());
+ row.put("plateNo", item.getPlateNo());
+ row.put("newEnergy", item.getNewEnergy());
+ row.put("contactPhone", item.getContactPhone());
+ row.put("validStartDate", item.getValidStartDate());
+ row.put("validEndDate", item.getValidEndDate());
+ row.put("remark", item.getRemark());
+ row.put("vehicleOwnerType", item.getVehicleOwnerType());
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ /** 本单没有金额列,合计恒为 null(导出时该列留空)。 */
+ @Override
+ public java.math.BigDecimal lineAmountTotal(ApprovalInstance instance) {
+ return null;
+ }
+
+ /** 列表标题:车辆免收费登记(N 辆)。 */
+ @Override
+ public String title(ApprovalInstance instance) {
+ ApprovalVehicleForm form = findByInstance(instance.getId());
+ if (form == null) {
+ return null;
+ }
+ int count = items(instance).size();
+ return count == 0 ? "车辆免收费登记" : "车辆免收费登记(" + count + " 辆)";
+ }
+
+ /** 检索文本:本单全部车牌号优先,其后是单位名称(列表按车牌核实是否已登记免费)。 */
+ @Override
+ public String searchText(ApprovalInstance instance) {
+ List items = items(instance);
+ LinkedHashSet parts = new LinkedHashSet<>();
+ for (ApprovalVehicleItem item : items) {
+ if (item.getPlateNo() != null) {
+ parts.add(item.getPlateNo());
+ }
+ }
+ for (ApprovalVehicleItem item : items) {
+ if (item.getUnitName() != null) {
+ parts.add(item.getUnitName());
+ }
+ }
+ return String.join(" ", parts);
+ }
+
+ /**
+ * 明细业务校验:车牌格式、有效期止不得早于起。
+ *
+ * 车牌统一去空格转大写,写回归一化后的行;非法时消息带出该车牌便于定位。
+ */
+ private void validateRows(List> rows) {
+ for (Map row : rows) {
+ int lineNo = (Integer) row.get(ApprovalLineSupport.KEY_LINE_NO);
+ String plateNo = normalizePlate(text(row, "plateNo"));
+ if (plateNo == null || !PLATE_PATTERN.matcher(plateNo).matches()) {
+ throw new BusinessException("第" + lineNo + "行车牌号「"
+ + (plateNo == null ? "" : plateNo) + "」格式不正确");
+ }
+ row.put("plateNo", plateNo);
+ LocalDate start = (LocalDate) row.get("validStartDate");
+ LocalDate end = (LocalDate) row.get("validEndDate");
+ if (start != null && end != null && end.isBefore(start)) {
+ throw new BusinessException("第" + lineNo + "行有效期止不能早于有效期起");
+ }
+ }
+ }
+
+ /** 车牌归一化:去空白、转大写;空值归一为 null。 */
+ private String normalizePlate(String plateNo) {
+ if (plateNo == null) {
+ return null;
+ }
+ String normalized = plateNo.replaceAll("\\s+", "").toUpperCase();
+ return normalized.isEmpty() ? null : normalized;
+ }
+
+ private String conflictMessage(VehiclePlateConflict conflict) {
+ String bizNo = conflict.getBizNo() == null || conflict.getBizNo().isEmpty()
+ ? "(草稿,尚无单号)" : conflict.getBizNo();
+ return "车牌 " + conflict.getPlateNo() + " 已被单据 " + bizNo + " 登记("
+ + conflict.getValidStartDate() + " 至 " + conflict.getValidEndDate() + ")";
+ }
+
+ private List items(ApprovalInstance instance) {
+ return vehicleItemMapper.selectList(new LambdaQueryWrapper()
+ .eq(ApprovalVehicleItem::getInstanceId, instance.getId())
+ .orderByAsc(ApprovalVehicleItem::getLineNo));
+ }
+
+ private ApprovalVehicleForm findByInstance(Long instanceId) {
+ List forms = vehicleFormMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalVehicleForm::getInstanceId, instanceId)
+ .last("LIMIT 1"));
+ return forms.isEmpty() ? null : forms.get(0);
+ }
+
+ private String text(Map row, String key) {
+ Object value = row.get(key);
+ return value == null ? null : String.valueOf(value);
+ }
+
+ private ApprovalVehicleParam cast(ApprovalDocumentParam param) {
+ if (!(param instanceof ApprovalVehicleParam)) {
+ throw new BusinessException("单据类型不匹配");
+ }
+ return (ApprovalVehicleParam) param;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehiclePlateConflict.java b/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehiclePlateConflict.java
new file mode 100644
index 0000000..8a6b2d9
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/vehicle/VehiclePlateConflict.java
@@ -0,0 +1,28 @@
+package com.gxwebsoft.approval.doc.vehicle;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+
+/**
+ * 车牌冲突命中的既有登记(同租户 + 有效期区间重叠 + 实例未作废)。
+ *
+ * 只用于给用户报出「被哪张单、哪段有效期占用」,不落库。
+ */
+@Data
+public class VehiclePlateConflict implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 占用车牌的单据实例 id(草稿没有单号时用于定位)。 */
+ private Long instanceId;
+
+ /** 占用车牌的既有单号;草稿未提交时为空。 */
+ private String bizNo;
+
+ private String plateNo;
+
+ private LocalDate validStartDate;
+
+ private LocalDate validEndDate;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
index 09ed6e7..9adf768 100644
--- a/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
+++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
@@ -111,6 +111,15 @@ public interface ApprovalDocumentType {
default void onRoundReset(ApprovalInstance instance, User user) {
}
+ /**
+ * 提交前校验:单据可在提交时拒绝(如车牌有效期与既有登记重叠)。
+ *
+ * 草稿允许带着待解决的数据问题保存,提交那一刻才拦截,避免用户还没填完就被挡在保存。
+ * 失败抛 BusinessException。
+ */
+ default void validateBeforeSubmit(ApprovalInstance instance) {
+ }
+
/**
* 归档前校验:单据可在归档时拒绝(如价格表不允许追溯生效日期)。
* 失败抛 BusinessException。
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleFormMapper.java
new file mode 100644
index 0000000..b377fc0
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleFormMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.doc.vehicle.ApprovalVehicleForm;
+
+/**
+ * 车辆免收费登记表Mapper。
+ */
+public interface ApprovalVehicleFormMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleItemMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleItemMapper.java
new file mode 100644
index 0000000..459d1ad
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalVehicleItemMapper.java
@@ -0,0 +1,35 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.doc.vehicle.ApprovalVehicleItem;
+import com.gxwebsoft.approval.doc.vehicle.VehiclePlateConflict;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 车辆免收费登记明细Mapper:除基础 CRUD 外,提供车牌区间冲突查询。
+ */
+public interface ApprovalVehicleItemMapper extends BaseMapper {
+
+ /**
+ * 查同租户、有效期区间重叠、实例状态不属于作废态的既有车牌登记。
+ *
+ * 排除本单自己(更新草稿时用 {@code excludeInstanceId}),否则草稿重存会撞上自己。
+ * 区间重叠判定用 {@code start <= other.end AND end >= other.start},闭区间。
+ *
+ * @param tenantId 租户 id
+ * @param plateNo 车牌号(大写)
+ * @param validStartDate 本行有效期起
+ * @param validEndDate 本行有效期止
+ * @param excludeInstanceId 需要排除的实例 id(可为 null)
+ * @param rejectedStatuses 视为作废、不参与冲突的状态编码
+ */
+ List selectPlateConflicts(@Param("tenantId") Integer tenantId,
+ @Param("plateNo") String plateNo,
+ @Param("validStartDate") LocalDate validStartDate,
+ @Param("validEndDate") LocalDate validEndDate,
+ @Param("excludeInstanceId") Long excludeInstanceId,
+ @Param("rejectedStatuses") List rejectedStatuses);
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalVehicleItemMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalVehicleItemMapper.xml
new file mode 100644
index 0000000..0a0f39b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalVehicleItemMapper.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ SELECT i.id AS instance_id,
+ i.biz_no AS biz_no,
+ it.plate_no AS plate_no,
+ it.valid_start_date AS valid_start_date,
+ it.valid_end_date AS valid_end_date
+ FROM approval_vehicle_item it
+ JOIN approval_instance i ON i.id = it.instance_id
+ WHERE it.tenant_id = #{tenantId}
+ AND i.tenant_id = #{tenantId}
+ AND it.plate_no = #{plateNo}
+ AND it.valid_start_date <= #{validEndDate}
+ AND it.valid_end_date >= #{validStartDate}
+ AND i.status NOT IN
+
+ #{status}
+
+
+ AND it.instance_id <> #{excludeInstanceId}
+
+ ORDER BY it.valid_start_date, it.id
+ LIMIT 1
+
+
+
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java
index 129377b..7f87830 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java
@@ -41,9 +41,11 @@ public class ApprovalApproverResolver {
if (hasGroups(node)) {
return resolveGroups(instance, node);
}
- List approvers = resolveSingle(instance, node.getResolver(), null);
+ // 单节点也可带固定组织编码(如「运营管理部负责人」),解析器不认识该编码时忽略
+ List approvers = resolveSingle(instance, node.getResolver(), node.getOrgCode());
if (approvers.isEmpty()) {
- return Resolution.missing(missingMessage(instance, node.getResolver(), null, node.getName()));
+ return Resolution.missing(missingMessage(instance, node.getResolver(), node.getOrgCode(),
+ node.getName()));
}
return Resolution.resolved(approvers);
}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java
index 16e769d..05ca62c 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java
@@ -98,6 +98,10 @@ public class ApprovalDocumentService {
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
throw new BusinessException("收文员角色缺配");
}
+ // 单据自身的提交前业务校验(如车牌有效期区间冲突):草稿可保存,提交必须拦住
+ if (type != null) {
+ type.validateBeforeSubmit(instance);
+ }
}
/** 单据实现;未知流程返回 null。 */
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java
index 18cd04b..350de93 100644
--- a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java
@@ -24,6 +24,14 @@ public class ApprovalNode {
/** 审批人解析器标识,由 ApprovalDirectoryService 负责解析。 */
private String resolver;
+ /**
+ * 固定组织编码:仅对 orgLeaderByCode / orgDeputyByCode 生效。
+ *
+ * 单节点也能按固定组织编码取负责人(如车辆单的「运营管理部负责人」),
+ * 不必为了带编码而把节点包成只有一个组的会签节点。
+ */
+ private String orgCode;
+
/** 节点显示名称。 */
private String name;
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalVehicleIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalVehicleIT.java
new file mode 100644
index 0000000..b21a4f2
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalVehicleIT.java
@@ -0,0 +1,613 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.common.core.config.ConfigProperties;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.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.io.File;
+import java.io.FileInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+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;
+
+/**
+ * 车辆免收费登记表:一单多车、车辆归属逐车、车牌有效期区间冲突、节点链与明细导出。
+ *
+ * 不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
+ * 引擎表与车辆业务表由 {@link ApprovalTestData#cleanup()} 按实例清理。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalVehicleIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+ private static final String ARCHIVE = "approval:instance:archive";
+ private static final String EXPORT = "approval:instance:export";
+ private static final String FLOW = "vehicle";
+
+ /** 车辆归属编码(ADR 0010)。 */
+ private static final String OWNER_GROUP = "GROUP_EMPLOYEE";
+ private static final String OWNER_CLUB = "COOPERATING_CLUB_EMPLOYEE";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+ @Autowired
+ private ConfigProperties config;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private Integer reportOrgId;
+ private ApprovalTestData.TestUser reportLeader;
+ private ApprovalTestData.TestUser opsLeader;
+ private ApprovalTestData.TestUser deputy;
+ private ApprovalTestData.TestUser typist;
+ private ApprovalTestData.TestUser archivist;
+
+ private final List exportedFiles = new ArrayList<>();
+ private final List userIds = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ String suffix = data.uniqueSuffix();
+ reportOrgId = data.createOrganization("车辆登记部门" + suffix, 136);
+
+ reporter = createUser("车辆报送人" + suffix, reportOrgId);
+ reportLeader = bindRole(data.leaderRoleCode(reportOrgId), "部门负责人",
+ "车辆部门负责人" + suffix, reportOrgId);
+ opsLeader = bindRole("dept_leader_APPROVAL_OPS", "运营管理部负责人",
+ "车辆运营负责人" + suffix, reportOrgId);
+ deputy = bindRole("dept_deputy_" + data.organizationCode(reportOrgId), "分管领导",
+ "车辆分管领导" + suffix, reportOrgId);
+ typist = bindRole(ApprovalConstants.ROLE_TYPIST, "录入员",
+ "车辆录入员" + suffix, reportOrgId);
+ archivist = bindRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员",
+ "车辆收文员" + suffix, reportOrgId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ for (Path path : exportedFiles) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (Exception ignored) {
+ // 文件清理失败不影响断言
+ }
+ }
+ exportedFiles.clear();
+ try {
+ // 导出操作日志异步写入
+ Thread.sleep(300);
+ for (Integer userId : userIds) {
+ data.server().update(
+ "DELETE FROM sys_operation_record WHERE user_id = ? AND url LIKE '%/api/approval/export%'", userId);
+ }
+ } catch (Exception ignored) {
+ // 共享库清理尽力而为
+ }
+ userIds.clear();
+ data.cleanup();
+ }
+
+ @Test
+ void runsEndToEndThroughTypistConfirmAndArchive() throws Exception {
+ long instanceId = saveDraft(List.of(
+ row("广西威宁集团", "张三 工号A001", "桂A12345", true, "13800000000",
+ "2026-01-01", "2026-06-30", OWNER_GROUP),
+ row("南宁大型活动主办方", "李四 身份证尾号1234", "桂AD67890", false, "13800000001",
+ "2026-03-01", "2026-12-31", OWNER_CLUB)), null);
+
+ // 草稿:整单无业务字段,一车一行,车辆归属逐车
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("vehicle");
+ assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("vehicle");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_item WHERE instance_id = ?",
+ instanceId)).isEqualTo(2);
+ assertThat(data.queryMaster("SELECT plate_no FROM approval_vehicle_item WHERE instance_id = ? AND line_no = 1",
+ String.class, instanceId)).isEqualTo("桂A12345");
+ assertThat(data.queryMaster("SELECT vehicle_owner_type FROM approval_vehicle_item "
+ + "WHERE instance_id = ? AND line_no = 2", String.class, instanceId)).isEqualTo(OWNER_CLUB);
+ assertThat(data.queryMaster("SELECT new_energy FROM approval_vehicle_item "
+ + "WHERE instance_id = ? AND line_no = 1", Boolean.class, instanceId)).isTrue();
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.docTypeName").value("车辆免收费登记表"))
+ .andExpect(jsonPath("$.data.statusName").value("未提交"))
+ .andExpect(jsonPath("$.data.document.lineColumns.length()").value(9))
+ .andExpect(jsonPath("$.data.document.lineColumns[0].key").value("unitName"))
+ .andExpect(jsonPath("$.data.document.lineColumns[2].key").value("plateNo"))
+ .andExpect(jsonPath("$.data.document.lineColumns[8].key").value("vehicleOwnerType"))
+ .andExpect(jsonPath("$.data.document.lineColumns[8].type").value("ENUM"))
+ .andExpect(jsonPath("$.data.document.lineColumns[8].options.length()").value(8))
+ .andExpect(jsonPath("$.data.document.lines.length()").value(2))
+ .andExpect(jsonPath("$.data.document.lines[0].lineNo").value(1))
+ .andExpect(jsonPath("$.data.document.lines[0].plateNo").value("桂A12345"))
+ .andExpect(jsonPath("$.data.document.lines[0].newEnergy").value(true))
+ .andExpect(jsonPath("$.data.document.lines[0].vehicleOwnerType").value(OWNER_GROUP))
+ .andExpect(jsonPath("$.data.document.lines[1].plateNo").value("桂AD67890"))
+ .andExpect(jsonPath("$.data.document.lines[1].vehicleOwnerType").value(OWNER_CLUB))
+ .andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(2))
+ .andExpect(jsonPath("$.data.document.lineSummary.amountTotal").doesNotExist())
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true));
+
+ // 列表标题与检索文本含全部车牌(列表按车牌核实用)
+ assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("车辆免收费登记(2 辆)");
+ String searchText = data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
+ String.class, instanceId);
+ assertThat(searchText).contains("桂A12345").contains("桂AD67890");
+
+ // 提交预检:至少一张附件(不要求分类)
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value("请至少上传一张附件"));
+ uploadAttachment(instanceId, ApprovalConstants.CATEGORY_OTHER);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.bizNo").value(org.hamcrest.Matchers.matchesPattern("CL\\d{8}-\\d{4}")))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
+
+ String bizNo = bizNo(instanceId);
+ assertThat(pendingTaskIds(instanceId)).contains(reportLeader.userId);
+ // 节点链固定:报送部门负责人 → 运营管理部负责人(APPROVAL_OPS)→ 分管领导 → 录入员存档 → 归档
+ String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'vehicle' ORDER BY version DESC LIMIT 1", String.class, TENANT);
+ assertThat(nodes).contains("\"resolver\": \"reporterOrgLeader\"")
+ .contains("\"resolver\": \"orgLeaderByCode\"").contains("APPROVAL_OPS")
+ .contains("\"resolver\": \"reporterOrgDeputy\"")
+ .contains("\"resolver\": \"roleTypist\"").contains("\"resolver\": \"archivist\"");
+
+ // 报送部门负责人
+ mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId)
+ + "/approve"), reportLeader, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意登记\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("运营管理部负责人"));
+
+ // 运营管理部负责人:按固定组织编码 APPROVAL_OPS 解析
+ long opsTask = pendingTaskId(instanceId, opsLeader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + opsTask + "/approve"), opsLeader, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"核验通过\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("分管领导"));
+
+ // 分管领导
+ mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, deputy.userId)
+ + "/approve"), deputy, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("录入员存档"));
+
+ // 录入员:CONFIRM 存档确认,不派单;本单录入员按 approval_typist 角色成员解析
+ long typistTask = pendingTaskId(instanceId, typist.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + typistTask + "/confirm"), typist, APPROVE))
+ .andExpect(jsonPath("$.code").value(0));
+ // 共享测试库里该角色可能还有其它成员:同节点同轮全部确认后才推进
+ confirmAllPendingInNode(instanceId, "confirm");
+ assertThat(instanceStatus(instanceId)).isEqualTo("APPROVED");
+ assertThat(currentNodeKey(instanceId)).isEqualTo("archive");
+
+ // 归档:归档节点或签,任务可能分配给角色内任一成员,按实际指派人操作
+ long archiveTask = pendingTaskIdInNode(instanceId, "archive");
+ mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"),
+ assigneeUser(archiveTask), ARCHIVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("ARCHIVED"))
+ .andExpect(jsonPath("$.data.statusName").value("已归档"));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("ARCHIVED");
+ assertThat(bizNo(instanceId)).isEqualTo(bizNo);
+ // 录入员角色在共享测试库可能有多名成员,确认记录数按实际确认人数至少 1 条
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'CONFIRM'", instanceId)).isGreaterThanOrEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'ARCHIVE'", instanceId)).isEqualTo(1);
+ // 明细不随审批推进丢失
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_item WHERE instance_id = ?",
+ instanceId)).isEqualTo(2);
+ }
+
+ @Test
+ void blocksOverlappingPlateAndAllowsAfterWithdraw() throws Exception {
+ long first = saveDraft(List.of(row("合作俱乐部", "王五", "桂A55555", false, "13800000002",
+ "2026-01-01", "2026-06-30", OWNER_CLUB)), null);
+ uploadAttachment(first, ApprovalConstants.CATEGORY_OTHER);
+ submit(first);
+ String firstBizNo = bizNo(first);
+
+ // 第二单重叠区间:提交被拦,消息带出冲突单号与已登记有效期
+ long overlapping = saveDraft(List.of(row("主办方", "赵六", "桂A55555", false, "13800000003",
+ "2026-03-01", "2026-09-01", OWNER_CLUB)), null);
+ uploadAttachment(overlapping, ApprovalConstants.CATEGORY_OTHER);
+ mockMvc.perform(authed(post("/api/approval/instance/" + overlapping + "/submit"), reporter, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("桂A55555")))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(firstBizNo)))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("2026-01-01")))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("2026-06-30")));
+ assertThat(instanceStatus(overlapping)).isEqualTo("DRAFT");
+ assertThat(bizNo(overlapping)).isNull();
+
+ // 第一单撤回后成为作废态,第二单可以提交(且不会撞上自己)
+ mockMvc.perform(authed(post("/api/approval/instance/" + first + "/withdraw"), reporter, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("WITHDRAWN"));
+ mockMvc.perform(authed(post("/api/approval/instance/" + overlapping + "/submit"), reporter, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"));
+ assertThat(bizNo(overlapping)).isNotBlank();
+
+ // 未重叠区间不受影响(第二单已占 2026-03-01 至 2026-09-01)
+ long adjacent = saveDraft(List.of(row("合作俱乐部", "王五", "桂A55555", false, "13800000002",
+ "2026-10-01", "2026-12-31", OWNER_CLUB)), null);
+ uploadAttachment(adjacent, ApprovalConstants.CATEGORY_OTHER);
+ mockMvc.perform(authed(post("/api/approval/instance/" + adjacent + "/submit"), reporter, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"));
+ }
+
+ @Test
+ void keepsVehicleOwnerTypePerLineAndNormalizesPlate() throws Exception {
+ long instanceId = saveDraft(List.of(
+ row("威宁集团", "员工甲", " 桂a12345 ", true, "13800000004", "2026-01-01", "2026-12-31",
+ OWNER_GROUP),
+ row("大型活动主办方", "主办方乙", "桂A66666", false, "13800000005", "2026-02-01", "2026-05-31",
+ "LARGE_EVENT_ORGANIZER")), null);
+
+ // 车牌去空格转大写后入库
+ assertThat(plateNos(instanceId)).containsExactly("桂A12345", "桂A66666");
+ // 同一单里两种归属并存,逐车存储编码而非整单枚举
+ assertThat(ownerTypes(instanceId)).containsExactly(OWNER_GROUP, "LARGE_EVENT_ORGANIZER");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_form WHERE instance_id = ?",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void validatesPlateFormatAndValidDateRange() throws Exception {
+ // 有效期止早于起
+ expectSaveFailure(List.of(row("单位", "员工", "桂A77777", false, "13800000006",
+ "2026-06-30", "2026-01-01", OWNER_GROUP)), "第1行有效期止不能早于有效期起");
+ // 车牌格式非法时消息带出该车牌
+ expectSaveFailure(List.of(row("单位", "员工", "桂A123", false, "13800000006",
+ "2026-01-01", "2026-06-30", OWNER_GROUP)), "车牌号「桂A123」格式不正确");
+ expectSaveFailure(List.of(row("单位", "员工", "A12345", false, "13800000006",
+ "2026-01-01", "2026-06-30", OWNER_GROUP)), "车牌号「A12345」格式不正确");
+ // 必填列缺失与长度上限(共享明细校验器)
+ expectSaveFailure(List.of(row("单位", "员工", "桂A88888", false, "13800000006",
+ "2026-01-01", "2026-06-30", "")), "第1行车辆归属不能为空");
+ expectSaveFailure(List.of(row("单位", "员工", "桂A88888", false, "13800000006",
+ "2026-01-01", "2026-06-30", "NOT_A_TYPE")), "第1行车辆归属取值不正确");
+ expectSaveFailure(List.of(row("单".repeat(101), "员工", "桂A88888", false, "13800000006",
+ "2026-01-01", "2026-06-30", OWNER_GROUP)), "第1行单位名称不能超过100字");
+ // 明细至少一行
+ expectSaveFailure(List.of(), "请至少填写一行明细");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_form")).isZero();
+ }
+
+ @Test
+ void findsByPlateKeywordsInPcListAndMineList() throws Exception {
+ String uniquePlate = "桂A99999";
+ long instanceId = saveDraft(List.of(row("检索单位", "员工丙", uniquePlate, false, "13800000007",
+ "2026-01-01", "2026-12-31", OWNER_GROUP)), null);
+
+ mockMvc.perform(authed(get("/api/approval/instance/page").param("keywords", uniquePlate), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.list[0].flowCode").value("vehicle"))
+ .andExpect(jsonPath("$.data.list[0].title").value("车辆免收费登记(1 辆)"));
+
+ mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", uniquePlate), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
+
+ // 别的车牌不命中本单
+ mockMvc.perform(authed(get("/api/approval/instance/page").param("keywords", "桂A00000"), reporter, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+ }
+
+ @Test
+ void exportsLineCountInSummaryAndVehicleLines() throws Exception {
+ String mark = "车辆导出" + data.uniqueSuffix();
+ long instanceId = saveDraft(List.of(
+ row(mark, "员工丁", "桂A10001", true, "13800000008", "2026-01-01", "2026-06-30", OWNER_GROUP),
+ row(mark, "员工戊", "桂A10002", false, "13800000009", "2026-02-01", "2026-08-31", OWNER_CLUB)), null);
+ String reportOrgName = data.server().queryForObject(
+ "SELECT organization_name FROM sys_organization WHERE organization_id = ?", String.class, reportOrgId);
+
+ // 跨单据总表:明细行数 = 2,合计金额留空(无金额列)
+ String response = mockMvc.perform(authed(post("/api/approval/export"), reporter, LIST, EXPORT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"keywords\":" + quote("桂A10002") + "}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ File summary = exportFile(response);
+ try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(summary))) {
+ Sheet sheet = workbook.getSheetAt(0);
+ assertThat(cellText(sheet.getRow(0).getCell(16))).isEqualTo("明细行数");
+ Row row = sheet.getRow(1);
+ assertThat(cellText(row.getCell(1))).isEqualTo("车辆免收费登记表");
+ assertThat(cellText(row.getCell(16))).isEqualTo("2");
+ assertThat(cellText(row.getCell(17))).isEmpty();
+ }
+
+ // 按类型明细导出:逐行车牌
+ String lineResponse = mockMvc.perform(authed(post("/api/approval/export/" + FLOW + "/lines"),
+ reporter, LIST, EXPORT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"flowCode\":\"vehicle\",\"keywords\":" + quote("桂A10001") + "}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条明细")))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ File lines = exportFile(lineResponse);
+ try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(lines))) {
+ Sheet sheet = workbook.getSheetAt(0);
+ assertThat(sheet.getSheetName()).isEqualTo("车辆免收费登记表");
+ assertThat(cellText(sheet.getRow(0).getCell(0))).isEqualTo("单号");
+ assertThat(cellText(sheet.getRow(0).getCell(3))).isEqualTo("报送人");
+ assertThat(cellText(sheet.getRow(0).getCell(4))).isEqualTo("单位名称");
+ assertThat(cellText(sheet.getRow(0).getCell(6))).isEqualTo("车牌号");
+ assertThat(cellText(sheet.getRow(0).getCell(12))).isEqualTo("车辆归属");
+
+ Row first = sheet.getRow(1);
+ assertThat(cellText(first.getCell(1))).isEqualTo("未提交");
+ assertThat(cellText(first.getCell(2))).isEqualTo(reportOrgName);
+ assertThat(cellText(first.getCell(3))).isEqualTo(reporter.username);
+ assertThat(cellText(first.getCell(4))).isEqualTo(mark);
+ assertThat(cellText(first.getCell(6))).isEqualTo("桂A10001");
+ assertThat(cellText(first.getCell(7))).isEqualTo("是");
+ assertThat(cellText(first.getCell(9))).isEqualTo("2026-01-01");
+ assertThat(cellText(first.getCell(10))).isEqualTo("2026-06-30");
+ assertThat(cellText(first.getCell(12))).isEqualTo(OWNER_GROUP);
+
+ Row second = sheet.getRow(2);
+ assertThat(cellText(second.getCell(6))).isEqualTo("桂A10002");
+ assertThat(cellText(second.getCell(12))).isEqualTo(OWNER_CLUB);
+ }
+ }
+
+ // ---------- helpers ----------
+
+ private Map row(String unitName, String personInfo, String plateNo, boolean newEnergy,
+ String contactPhone, String validStartDate, String validEndDate,
+ String vehicleOwnerType) {
+ Map row = new LinkedHashMap<>();
+ row.put("unitName", unitName);
+ row.put("personInfo", personInfo);
+ row.put("plateNo", plateNo);
+ row.put("newEnergy", newEnergy);
+ row.put("contactPhone", contactPhone);
+ row.put("validStartDate", validStartDate);
+ row.put("validEndDate", validEndDate);
+ row.put("remark", "测试备注");
+ row.put("vehicleOwnerType", vehicleOwnerType);
+ return row;
+ }
+
+ private long saveDraft(List> lines, Long id) throws Exception {
+ Map body = new LinkedHashMap<>();
+ if (id != null) {
+ body.put("id", id);
+ }
+ body.put("lines", lines);
+ String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long instanceId = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(instanceId);
+ return instanceId;
+ }
+
+ private void expectSaveFailure(List> lines, String expectedMessage) throws Exception {
+ Map body = new LinkedHashMap<>();
+ body.put("lines", lines);
+ mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
+ }
+
+ private void submit(long instanceId) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private ApprovalTestData.TestUser bindRole(String roleCode, String roleName, String realName,
+ Integer organizationId) {
+ ApprovalTestData.TestUser member = createUser(realName, organizationId);
+ data.bind(member.userId, ensureRole(roleCode, roleName));
+ return member;
+ }
+
+ /** 共享库可能已有同名角色(如运营管理部负责人/录入员),存在就复用,不存在才新建。 */
+ private Integer ensureRole(String roleCode, String roleName) {
+ List ids = data.server().queryForList(
+ "SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ? AND deleted = 0 "
+ + "ORDER BY role_id LIMIT 1", Integer.class, roleCode, TENANT);
+ if (!ids.isEmpty()) {
+ return ids.get(0);
+ }
+ return data.createRole(roleCode, roleName, TENANT).roleId;
+ }
+
+ private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
+ ApprovalTestData.TestUser user = data.createUser(realName, organizationId, TENANT);
+ userIds.add(user.userId);
+ return user;
+ }
+
+ private void uploadAttachment(long instanceId, String category) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", category)
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId,
+ TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ /** 同节点同轮的全部待办逐个确认(共享库里该角色可能有其它成员)。 */
+ private void confirmAllPendingInNode(long instanceId, String nodeKey) throws Exception {
+ for (int guard = 0; guard < 20; guard++) {
+ List> tasks = data.master().queryForList(
+ "SELECT id, assignee_id, assignee_name FROM approval_task WHERE instance_id = ? "
+ + "AND node_key = ? AND status = 'PENDING' ORDER BY id", instanceId, nodeKey);
+ if (tasks.isEmpty()) {
+ return;
+ }
+ for (Map task : tasks) {
+ long taskId = ((Number) task.get("id")).longValue();
+ String name = (String) task.get("assignee_name");
+ ApprovalTestData.TestUser assignee =
+ new ApprovalTestData.TestUser(((Number) task.get("assignee_id")).intValue(), name, name);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/confirm"), assignee, APPROVE))
+ .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 long pendingTaskIdInNode(long instanceId, String nodeKey) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND node_key = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, nodeKey);
+ assertThat(taskId).as(nodeKey + "待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ /** 任务实际指派人:归档/录入员这类岗位角色在共享库可能有多名成员。 */
+ private ApprovalTestData.TestUser assigneeUser(long taskId) {
+ Map row = data.master().queryForMap(
+ "SELECT assignee_id, assignee_name FROM approval_task WHERE id = ?", taskId);
+ String name = (String) row.get("assignee_name");
+ return new ApprovalTestData.TestUser(((Number) row.get("assignee_id")).intValue(), name, name);
+ }
+
+ private List pendingTaskIds(long instanceId) {
+ return data.master().queryForList("SELECT assignee_id FROM approval_task "
+ + "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
+ }
+
+ private List plateNos(long instanceId) {
+ return data.master().queryForList("SELECT plate_no FROM approval_vehicle_item "
+ + "WHERE instance_id = ? ORDER BY line_no", String.class, instanceId);
+ }
+
+ private List ownerTypes(long instanceId) {
+ return data.master().queryForList("SELECT vehicle_owner_type FROM approval_vehicle_item "
+ + "WHERE instance_id = ? ORDER BY line_no", String.class, instanceId);
+ }
+
+ private String instanceStatus(long instanceId) {
+ return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String bizNo(long instanceId) {
+ return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String currentNodeKey(long instanceId) {
+ return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId);
+ }
+
+ private File exportFile(String response) throws Exception {
+ JsonNode node = objectMapper.readTree(response).path("data");
+ File file = new File(node.path("path").asText());
+ assertThat(file).exists();
+ exportedFiles.add(file.toPath());
+ return file;
+ }
+
+ private String cellText(Cell cell) {
+ if (cell == null) {
+ return "";
+ }
+ if (cell.getCellType() == CellType.NUMERIC) {
+ return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros()
+ .toPlainString();
+ }
+ return cell.getStringCellValue();
+ }
+
+ private 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("\"", "\\\"") + "\"";
+ }
+}