zlwu преди 2 месеца
родител
ревизия
672f45befd
променени са 18 файла, в които са добавени 17850 реда и са изтрити 2 реда
  1. 51 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgMajorMergeController.java
  2. 39 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/XgMajorMergeBatch.java
  3. 33 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/XgMajorMergeRowSnapshot.java
  4. 33 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/bo/XgMajorMergeBo.java
  5. 49 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgMajorMergePreviewVo.java
  6. 44 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgMajorMergeResultVo.java
  7. 10 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgMajorMergeBatchMapper.java
  8. 10 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgMajorMergeRowSnapshotMapper.java
  9. 21 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/IXgMajorMergeService.java
  10. 785 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgMajorMergeServiceImpl.java
  11. 395 0
      docs/业务模块/专业信息管理/专业合并功能实施计划.md
  12. 83 0
      docs/开发文档/API/专业信息管理API.md
  13. 60 0
      docs/设计文档/database/专业信息管理/专业合并快照表结构.sql
  14. 19 0
      docs/设计文档/database/专业信息管理/专业合并菜单权限.sql
  15. 18 0
      web/src/api/xg/xsjcxx/classMsg/majormsg/index.ts
  16. 37 0
      web/src/api/xg/xsjcxx/classMsg/majormsg/types.ts
  17. 141 2
      web/src/views/xg/xsjcxx/classmsg/majormsg/index.vue
  18. 16022 0
      生产环境的专业数据梳理/hzdyjs_202608171113.sql

+ 51 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgMajorMergeController.java

@@ -0,0 +1,51 @@
+package org.dromara.xg.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.xg.domain.bo.XgMajorMergeBo;
+import org.dromara.xg.domain.vo.XgMajorMergePreviewVo;
+import org.dromara.xg.domain.vo.XgMajorMergeResultVo;
+import org.dromara.xg.service.IXgMajorMergeService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 专业合并
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/xg/major/merge")
+public class XgMajorMergeController extends BaseController {
+
+    private final IXgMajorMergeService majorMergeService;
+
+    /**
+     * 专业合并影响预览
+     */
+    @SaCheckPermission("majormsg:major:merge")
+    @PostMapping("/preview")
+    public R<XgMajorMergePreviewVo> preview(@Valid @RequestBody XgMajorMergeBo bo) {
+        return R.ok(majorMergeService.preview(bo));
+    }
+
+    /**
+     * 执行专业合并
+     */
+    @SaCheckPermission("majormsg:major:merge")
+    @Log(title = "专业合并", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping
+    public R<XgMajorMergeResultVo> merge(@Valid @RequestBody XgMajorMergeBo bo) {
+        return R.ok(majorMergeService.merge(bo));
+    }
+}

+ 39 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/XgMajorMergeBatch.java

@@ -0,0 +1,39 @@
+package org.dromara.xg.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+
+/**
+ * 专业合并批次 xg_major_merge_batch
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("xg_major_merge_batch")
+public class XgMajorMergeBatch extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id")
+    private Long id;
+
+    @TableLogic(value = "0", delval = "1")
+    private Long deleted;
+
+    private Long collegeId;
+    private Long targetMajorId;
+    private String targetMajorName;
+    private String sourceMajorIds;
+    private String sourceMajorNames;
+    /** RUNNING / SUCCESS / FAILED / ROLLED_BACK */
+    private String status;
+    private String summaryJson;
+    private Integer snapshotRowCount;
+    private String remark;
+}

+ 33 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/XgMajorMergeRowSnapshot.java

@@ -0,0 +1,33 @@
+package org.dromara.xg.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+
+/**
+ * 专业合并行级快照 xg_major_merge_row_snapshot
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("xg_major_merge_row_snapshot")
+public class XgMajorMergeRowSnapshot extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id")
+    private Long id;
+
+    @TableLogic(value = "0", delval = "1")
+    private Long deleted;
+
+    private Long mergeBatchId;
+    private String tableName;
+    private String rowPk;
+    private String rowJson;
+}

+ 33 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/bo/XgMajorMergeBo.java

@@ -0,0 +1,33 @@
+package org.dromara.xg.domain.bo;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 专业合并请求
+ */
+@Data
+public class XgMajorMergeBo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 目标专业ID(必须在已选专业中)
+     */
+    @NotNull(message = "目标专业不能为空")
+    private Long targetMajorId;
+
+    /**
+     * 已选专业ID列表(含目标专业,至少2个)
+     */
+    @NotEmpty(message = "请至少选择两个专业")
+    @Size(min = 2, message = "请至少选择两个专业")
+    private List<Long> selectedMajorIds;
+}

+ 49 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgMajorMergePreviewVo.java

@@ -0,0 +1,49 @@
+package org.dromara.xg.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 专业合并预览结果
+ */
+@Data
+public class XgMajorMergePreviewVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Long targetMajorId;
+    private String targetMajorName;
+    private String targetMajorCategory;
+    private Long collegeId;
+    private String collegeName;
+    private List<SourceMajorImpact> sources = new ArrayList<>();
+    private List<TableHit> tableHits = new ArrayList<>();
+    private List<String> warnings = new ArrayList<>();
+    private int totalClassCount;
+    private int totalStudentCount;
+
+    @Data
+    public static class SourceMajorImpact implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+        private Long majorId;
+        private String majorName;
+        private String majorCategory;
+        private int classCount;
+        private int studentCount;
+    }
+
+    @Data
+    public static class TableHit implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+        private String table;
+        private String strategy;
+        private long rows;
+    }
+}

+ 44 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgMajorMergeResultVo.java

@@ -0,0 +1,44 @@
+package org.dromara.xg.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 专业合并执行结果
+ */
+@Data
+public class XgMajorMergeResultVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Boolean success;
+    private Long mergeBatchId;
+    private Long targetMajorId;
+    private String targetMajorName;
+    private int movedClassCount;
+    private int updatedStudentCount;
+    private int disabledMajorCount;
+    private int snapshotRowCount;
+    private List<TableUpdateSummary> tableUpdateSummary = new ArrayList<>();
+
+    @Data
+    public static class TableUpdateSummary implements Serializable {
+        @Serial
+        private static final long serialVersionUID = 1L;
+        private String table;
+        private int updated;
+
+        public TableUpdateSummary() {
+        }
+
+        public TableUpdateSummary(String table, int updated) {
+            this.table = table;
+            this.updated = updated;
+        }
+    }
+}

+ 10 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgMajorMergeBatchMapper.java

@@ -0,0 +1,10 @@
+package org.dromara.xg.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.xg.domain.XgMajorMergeBatch;
+
+/**
+ * 专业合并批次 Mapper
+ */
+public interface XgMajorMergeBatchMapper extends BaseMapperPlus<XgMajorMergeBatch, XgMajorMergeBatch> {
+}

+ 10 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgMajorMergeRowSnapshotMapper.java

@@ -0,0 +1,10 @@
+package org.dromara.xg.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.xg.domain.XgMajorMergeRowSnapshot;
+
+/**
+ * 专业合并行级快照 Mapper
+ */
+public interface XgMajorMergeRowSnapshotMapper extends BaseMapperPlus<XgMajorMergeRowSnapshot, XgMajorMergeRowSnapshot> {
+}

+ 21 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/IXgMajorMergeService.java

@@ -0,0 +1,21 @@
+package org.dromara.xg.service;
+
+import org.dromara.xg.domain.bo.XgMajorMergeBo;
+import org.dromara.xg.domain.vo.XgMajorMergePreviewVo;
+import org.dromara.xg.domain.vo.XgMajorMergeResultVo;
+
+/**
+ * 专业合并服务
+ */
+public interface IXgMajorMergeService {
+
+    /**
+     * 合并影响预览
+     */
+    XgMajorMergePreviewVo preview(XgMajorMergeBo bo);
+
+    /**
+     * 执行专业合并(含行级快照)
+     */
+    XgMajorMergeResultVo merge(XgMajorMergeBo bo);
+}

+ 785 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgMajorMergeServiceImpl.java

@@ -0,0 +1,785 @@
+package org.dromara.xg.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.json.JSONUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.system.domain.SysDept;
+import org.dromara.system.mapper.SysDeptMapper;
+import org.dromara.xg.domain.XgMajorMergeBatch;
+import org.dromara.xg.domain.XgMajorMergeRowSnapshot;
+import org.dromara.xg.domain.bo.XgMajorMergeBo;
+import org.dromara.xg.domain.vo.XgMajorMergePreviewVo;
+import org.dromara.xg.domain.vo.XgMajorMergeResultVo;
+import org.dromara.xg.mapper.XgMajorMergeBatchMapper;
+import org.dromara.xg.mapper.XgMajorMergeRowSnapshotMapper;
+import org.dromara.xg.service.IXgMajorMergeService;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 专业合并服务实现
+ * <p>
+ * 规则:同学院多选合并;班级改挂目标专业(班级ID不变);major_id 与 major_name 同改;
+ * 源专业停用;改前写入行级快照。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XgMajorMergeServiceImpl implements IXgMajorMergeService {
+
+    private final SysDeptMapper sysDeptMapper;
+    private final XgMajorMergeBatchMapper batchMapper;
+    private final XgMajorMergeRowSnapshotMapper snapshotMapper;
+    private final JdbcTemplate jdbcTemplate;
+
+    /** 白名单:按 major_id(+name/+code) 更新的业务表 */
+    private static final List<MajorFieldSpec> MAJOR_FIELD_SPECS = List.of(
+        new MajorFieldSpec("xg_class_msg", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_student_msg", "id", "major_id", "major_name", null, "deleted = 0", true),
+        new MajorFieldSpec("xg_student_msg_batch", "id", "major_id", "major_name", null, "deleted = 0", true),
+        new MajorFieldSpec("xg_student_leave", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_student_cancel_leave", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_dorm_application", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_dorm_bed_student", "id", "major_id", null, null, "deleted = 0", false),
+        new MajorFieldSpec("xg_dorm_hygiene_student_score", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_dorm_roll_call_detail", "id", "major_id", "major_name", null, null, false),
+        new MajorFieldSpec("xg_dorm_violation", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_holiday_stay", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xg_stu_col_batch_class", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xljk_evaluation", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xskh_assessment_application", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xskh_assessment_application_approval", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xskh_discipline_record", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("xskh_reward_record", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("jcbt_poverty_application", "id", "major_id", "major_name", "major_code", "deleted = 0", false),
+        new MajorFieldSpec("jcbt_scholarship_application", "id", "major_id", null, null, "deleted = 0", false),
+        new MajorFieldSpec("zsjy_graduate_info", "id", "major_id", "major_name", null, "deleted = 0", false),
+        new MajorFieldSpec("zsjy_internship_info", "id", "major_id", null, null, "deleted = 0", false)
+        // zsjy_enrollment_major 有 uk_major_dept 唯一约束,单独处理(冲突时逻辑删除源配置)
+    );
+
+    @Override
+    public XgMajorMergePreviewVo preview(XgMajorMergeBo bo) {
+        MergeContext ctx = validateAndBuildContext(bo);
+        XgMajorMergePreviewVo vo = new XgMajorMergePreviewVo();
+        vo.setTargetMajorId(ctx.target.getDeptId());
+        vo.setTargetMajorName(ctx.target.getDeptName());
+        vo.setTargetMajorCategory(ctx.target.getDeptCategory());
+        vo.setCollegeId(ctx.target.getParentId());
+        SysDept college = sysDeptMapper.selectById(ctx.target.getParentId());
+        vo.setCollegeName(college != null ? college.getDeptName() : null);
+
+        int totalClass = 0;
+        int totalStudent = 0;
+        for (SysDept source : ctx.sources) {
+            XgMajorMergePreviewVo.SourceMajorImpact impact = new XgMajorMergePreviewVo.SourceMajorImpact();
+            impact.setMajorId(source.getDeptId());
+            impact.setMajorName(source.getDeptName());
+            impact.setMajorCategory(source.getDeptCategory());
+            int classCount = countClassUnderMajor(source.getDeptId());
+            int studentCount = countStudentUnderMajor(source.getDeptId());
+            impact.setClassCount(classCount);
+            impact.setStudentCount(studentCount);
+            totalClass += classCount;
+            totalStudent += studentCount;
+            vo.getSources().add(impact);
+        }
+        vo.setTotalClassCount(totalClass);
+        vo.setTotalStudentCount(totalStudent);
+
+        List<Long> sourceIds = ctx.sourceIds;
+        List<String> sourceNames = ctx.sources.stream().map(SysDept::getDeptName).toList();
+        for (MajorFieldSpec spec : MAJOR_FIELD_SPECS) {
+            if (!tableExists(spec.tableName)) {
+                continue;
+            }
+            long rows = countByMajorSpec(spec, sourceIds);
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable(spec.tableName);
+                hit.setStrategy("BY_ID");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+            }
+        }
+        // 勤工助学:仅名称
+        if (tableExists("jcbt_work_study_application")) {
+            long rows = countByMajorNames("jcbt_work_study_application", "major_name", sourceNames, "deleted = 0");
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable("jcbt_work_study_application");
+                hit.setStrategy("BY_NAME_ONLY");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+            }
+        }
+        if (tableExists("xg_student_manager_source")) {
+            long rows = countSimpleIn("xg_student_manager_source", "auth_source", sourceIds, "deleted = '0'");
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable("xg_student_manager_source");
+                hit.setStrategy("AUTH_SOURCE");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+            }
+        }
+        if (tableExists("jcbt_scholarship_quota")) {
+            long rows = countSimpleIn("jcbt_scholarship_quota", "target_dept_id", sourceIds, "deleted = 0");
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable("jcbt_scholarship_quota");
+                hit.setStrategy("TARGET_DEPT");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+            }
+        }
+        if (tableExists("sys_role_dept")) {
+            long rows = countSimpleIn("sys_role_dept", "dept_id", sourceIds, null);
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable("sys_role_dept");
+                hit.setStrategy("DEPT_ID");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+            }
+        }
+        if (tableExists("zsjy_enrollment_major")) {
+            long rows = countSimpleIn("zsjy_enrollment_major", "major_dept_id", sourceIds, "deleted = 0");
+            if (rows > 0) {
+                XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+                hit.setTable("zsjy_enrollment_major");
+                hit.setStrategy("ENROLLMENT_DEDUP");
+                hit.setRows(rows);
+                vo.getTableHits().add(hit);
+                Integer targetExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(1) FROM zsjy_enrollment_major WHERE major_dept_id = ? AND deleted = 0",
+                    Integer.class, ctx.target.getDeptId());
+                if (targetExists != null && targetExists > 0) {
+                    vo.getWarnings().add("招生专业配置中目标专业已存在,合并时将逻辑删除源专业配置行(避免唯一键冲突)");
+                }
+            }
+        }
+        long userOnMajor = countSimpleIn("sys_user", "dept_id", sourceIds, "del_flag = '0'");
+        if (userOnMajor > 0) {
+            XgMajorMergePreviewVo.TableHit hit = new XgMajorMergePreviewVo.TableHit();
+            hit.setTable("sys_user");
+            hit.setStrategy("DEPT_ID");
+            hit.setRows(userOnMajor);
+            vo.getTableHits().add(hit);
+            vo.getWarnings().add("存在 " + userOnMajor + " 个用户直接挂在源专业下,合并时将改挂到目标专业");
+        }
+
+        vo.getWarnings().add("同学院校验通过");
+        vo.getWarnings().add("班级仅改归属,同名班级不做合并");
+        vo.getWarnings().add("源专业合并后将停用;改前写入行级快照");
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public XgMajorMergeResultVo merge(XgMajorMergeBo bo) {
+        MergeContext ctx = validateAndBuildContext(bo);
+        Long batchId = IdUtil.getSnowflakeNextId();
+        Long userId = safeUserId();
+        Long deptId = safeDeptId();
+        Date now = new Date();
+
+        XgMajorMergeBatch batch = new XgMajorMergeBatch();
+        batch.setId(batchId);
+        batch.setCollegeId(ctx.target.getParentId());
+        batch.setTargetMajorId(ctx.target.getDeptId());
+        batch.setTargetMajorName(ctx.target.getDeptName());
+        batch.setSourceMajorIds(JSONUtil.toJsonStr(ctx.sourceIds));
+        batch.setSourceMajorNames(JSONUtil.toJsonStr(ctx.sources.stream().map(SysDept::getDeptName).toList()));
+        batch.setStatus("RUNNING");
+        batch.setSnapshotRowCount(0);
+        batch.setCreateBy(userId);
+        batch.setCreateDept(deptId);
+        batch.setCreateTime(now);
+        batch.setUpdateBy(userId);
+        batch.setUpdateTime(now);
+        batch.setTenantId(LoginHelper.getTenantId());
+        batchMapper.insert(batch);
+
+        List<XgMajorMergeResultVo.TableUpdateSummary> summaries = new ArrayList<>();
+        int snapshotCount = 0;
+        String targetName = ctx.target.getDeptName();
+        String targetCategory = ctx.target.getDeptCategory();
+        Long targetId = ctx.target.getDeptId();
+        List<String> sourceNames = ctx.sources.stream().map(SysDept::getDeptName).toList();
+
+        try {
+            // 1) 业务表白名单:快照 + 更新
+            for (MajorFieldSpec spec : MAJOR_FIELD_SPECS) {
+                if (!tableExists(spec.tableName)) {
+                    continue;
+                }
+                int snapped = snapshotByMajorSpec(batchId, spec, ctx.sourceIds, userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = updateByMajorSpec(spec, ctx.sourceIds, targetId, targetName, targetCategory);
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary(spec.tableName, updated));
+                }
+            }
+
+            // 勤工助学:仅名称
+            if (tableExists("jcbt_work_study_application")) {
+                int snapped = snapshotByNames(batchId, "jcbt_work_study_application", "id", "major_name",
+                    sourceNames, "deleted = 0", userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = updateByNames("jcbt_work_study_application", "major_name", sourceNames, targetName, "deleted = 0");
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("jcbt_work_study_application", updated));
+                }
+            }
+
+            // 招生开放专业配置:唯一键 uk_major_dept,冲突时逻辑删除源行
+            if (tableExists("zsjy_enrollment_major")) {
+                int snapped = snapshotSimple(batchId, "zsjy_enrollment_major", "id", "major_dept_id",
+                    ctx.sourceIds, "deleted = 0", userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = remountEnrollmentMajor(ctx.sourceIds, targetId, targetName, targetCategory);
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("zsjy_enrollment_major", updated));
+                }
+            }
+
+            // 管理员授权
+            if (tableExists("xg_student_manager_source")) {
+                int snapped = snapshotSimple(batchId, "xg_student_manager_source", "id", "auth_source",
+                    ctx.sourceIds, "deleted = '0'", userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = updateSimpleId("xg_student_manager_source", "auth_source", ctx.sourceIds, targetId, "deleted = '0'");
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("xg_student_manager_source", updated));
+                }
+            }
+
+            // 奖学金名额
+            if (tableExists("jcbt_scholarship_quota")) {
+                assertNoQuotaConflict(ctx.sourceIds, targetId);
+                int snapped = snapshotSimple(batchId, "jcbt_scholarship_quota", "id", "target_dept_id",
+                    ctx.sourceIds, "deleted = 0", userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = updateSimpleId("jcbt_scholarship_quota", "target_dept_id", ctx.sourceIds, targetId, "deleted = 0");
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("jcbt_scholarship_quota", updated));
+                }
+            }
+
+            // 角色部门
+            if (tableExists("sys_role_dept")) {
+                int snapped = snapshotRoleDept(batchId, ctx.sourceIds, userId, deptId, now);
+                snapshotCount += snapped;
+                int updated = remountRoleDept(ctx.sourceIds, targetId);
+                if (updated > 0 || snapped > 0) {
+                    summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("sys_role_dept", updated));
+                }
+            }
+
+            // 用户直接挂在专业上
+            int userSnapped = snapshotSimple(batchId, "sys_user", "user_id", "dept_id",
+                ctx.sourceIds, "del_flag = '0'", userId, deptId, now);
+            snapshotCount += userSnapped;
+            int userUpdated = updateSimpleId("sys_user", "dept_id", ctx.sourceIds, targetId, "del_flag = '0'");
+            if (userUpdated > 0 || userSnapped > 0) {
+                summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("sys_user", userUpdated));
+            }
+
+            // 2) 组织树:班级改挂
+            int movedClass = remountClasses(batchId, ctx, userId, deptId, now);
+            snapshotCount += movedClass; // remountClasses 内部已快照,这里 movedClass 是班级数;快照数在方法内累加需返回
+            // remountClasses returns class count and adds snapshots itself - fix: return snapshot added
+            summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("sys_dept(class)", movedClass));
+
+            // 3) 停用源专业
+            int disabled = disableSourceMajors(batchId, ctx, userId, deptId, now);
+            snapshotCount += disabled;
+            summaries.add(new XgMajorMergeResultVo.TableUpdateSummary("sys_dept(major-disable)", disabled));
+
+            // 4) 复查残留
+            assertNoResidue(ctx.sourceIds);
+
+            int studentUpdated = summaries.stream()
+                .filter(s -> "xg_student_msg".equals(s.getTable()))
+                .mapToInt(XgMajorMergeResultVo.TableUpdateSummary::getUpdated)
+                .findFirst().orElse(0);
+
+            batch.setStatus("SUCCESS");
+            batch.setSnapshotRowCount(snapshotCount);
+            batch.setSummaryJson(JSONUtil.toJsonStr(summaries));
+            batch.setUpdateBy(userId);
+            batch.setUpdateTime(new Date());
+            batchMapper.updateById(batch);
+
+            XgMajorMergeResultVo result = new XgMajorMergeResultVo();
+            result.setSuccess(true);
+            result.setMergeBatchId(batchId);
+            result.setTargetMajorId(targetId);
+            result.setTargetMajorName(targetName);
+            result.setMovedClassCount(movedClass);
+            result.setUpdatedStudentCount(studentUpdated);
+            result.setDisabledMajorCount(disabled);
+            result.setSnapshotRowCount(snapshotCount);
+            result.setTableUpdateSummary(summaries);
+            return result;
+        } catch (RuntimeException ex) {
+            log.error("专业合并失败 batchId={}", batchId, ex);
+            throw ex;
+        }
+    }
+
+    // -------------------- 校验与上下文 --------------------
+
+    private MergeContext validateAndBuildContext(XgMajorMergeBo bo) {
+        if (bo.getSelectedMajorIds() == null || bo.getSelectedMajorIds().size() < 2) {
+            throw new ServiceException("请至少选择两个专业进行合并");
+        }
+        if (bo.getTargetMajorId() == null) {
+            throw new ServiceException("请选择目标专业");
+        }
+        if (!bo.getSelectedMajorIds().contains(bo.getTargetMajorId())) {
+            throw new ServiceException("目标专业必须在已选专业列表中");
+        }
+
+        Set<Long> distinct = new LinkedHashSet<>(bo.getSelectedMajorIds());
+        if (distinct.size() < 2) {
+            throw new ServiceException("请至少选择两个不同的专业");
+        }
+
+        SysDept target = sysDeptMapper.selectById(bo.getTargetMajorId());
+        if (target == null || !"0".equals(String.valueOf(target.getDelFlag()))) {
+            throw new ServiceException("目标专业不存在");
+        }
+        if (!StringUtils.startsWith(target.getDeptCategory(), "zy-")) {
+            throw new ServiceException("目标必须是专业节点(zy-)");
+        }
+        if (!"0".equals(target.getStatus())) {
+            throw new ServiceException("目标专业已停用,请选择正常状态的专业");
+        }
+
+        List<SysDept> sources = new ArrayList<>();
+        for (Long id : distinct) {
+            if (Objects.equals(id, bo.getTargetMajorId())) {
+                continue;
+            }
+            SysDept source = sysDeptMapper.selectById(id);
+            if (source == null || !"0".equals(String.valueOf(source.getDelFlag()))) {
+                throw new ServiceException("源专业不存在:" + id);
+            }
+            if (!StringUtils.startsWith(source.getDeptCategory(), "zy-")) {
+                throw new ServiceException("只能合并专业节点:" + source.getDeptName());
+            }
+            if (!Objects.equals(source.getParentId(), target.getParentId())) {
+                throw new ServiceException("仅允许同一学院下的专业合并:" + source.getDeptName());
+            }
+            sources.add(source);
+        }
+        if (sources.isEmpty()) {
+            throw new ServiceException("没有可合并的源专业");
+        }
+
+        MergeContext ctx = new MergeContext();
+        ctx.target = target;
+        ctx.sources = sources;
+        ctx.sourceIds = sources.stream().map(SysDept::getDeptId).toList();
+        return ctx;
+    }
+
+    // -------------------- 组织树 --------------------
+
+    private int remountClasses(Long batchId, MergeContext ctx, Long userId, Long createDept, Date now) {
+        String inSql = placeholders(ctx.sourceIds.size());
+        List<Object> args = new ArrayList<>(ctx.sourceIds);
+        List<Map<String, Object>> classes = jdbcTemplate.queryForList(
+            "SELECT * FROM sys_dept WHERE parent_id IN (" + inSql + ") AND del_flag = '0'",
+            args.toArray());
+        if (classes.isEmpty()) {
+            return 0;
+        }
+        String newAncestors = ctx.target.getAncestors() + "," + ctx.target.getDeptId();
+        for (Map<String, Object> row : classes) {
+            saveSnapshotRow(batchId, "sys_dept", String.valueOf(row.get("dept_id")), row, userId, createDept, now);
+            jdbcTemplate.update(
+                "UPDATE sys_dept SET parent_id = ?, ancestors = ?, update_by = ?, update_time = NOW() WHERE dept_id = ?",
+                ctx.target.getDeptId(), newAncestors, userId, row.get("dept_id"));
+        }
+        return classes.size();
+    }
+
+    private int disableSourceMajors(Long batchId, MergeContext ctx, Long userId, Long createDept, Date now) {
+        int count = 0;
+        for (SysDept source : ctx.sources) {
+            Map<String, Object> row = jdbcTemplate.queryForMap(
+                "SELECT * FROM sys_dept WHERE dept_id = ?", source.getDeptId());
+            saveSnapshotRow(batchId, "sys_dept", String.valueOf(source.getDeptId()), row, userId, createDept, now);
+            jdbcTemplate.update(
+                "UPDATE sys_dept SET status = '1', update_by = ?, update_time = NOW() WHERE dept_id = ?",
+                userId, source.getDeptId());
+            count++;
+        }
+        return count;
+    }
+
+    private void assertNoResidue(List<Long> sourceIds) {
+        // 班级不应再挂在源专业下
+        String inSql = placeholders(sourceIds.size());
+        Integer classLeft = jdbcTemplate.queryForObject(
+            "SELECT COUNT(1) FROM sys_dept WHERE parent_id IN (" + inSql + ") AND del_flag = '0'",
+            Integer.class, sourceIds.toArray());
+        if (classLeft != null && classLeft > 0) {
+            throw new ServiceException("合并复查失败:仍有班级挂在源专业下");
+        }
+        for (MajorFieldSpec spec : MAJOR_FIELD_SPECS) {
+            if (!tableExists(spec.tableName)) {
+                continue;
+            }
+            long left = countByMajorSpec(spec, sourceIds);
+            if (left > 0) {
+                throw new ServiceException("合并复查失败:表 " + spec.tableName + " 仍有 " + left + " 行指向源专业");
+            }
+        }
+    }
+
+    // -------------------- 白名单更新 / 快照 --------------------
+
+    private int snapshotByMajorSpec(Long batchId, MajorFieldSpec spec, List<Long> sourceIds,
+                                    Long userId, Long createDept, Date now) {
+        if (sourceIds.isEmpty()) {
+            return 0;
+        }
+        String where = buildMajorIdWhere(spec, sourceIds.size());
+        String sql = "SELECT * FROM " + spec.tableName + " WHERE " + where;
+        if (StringUtils.isNotBlank(spec.extraWhere)) {
+            sql += " AND (" + spec.extraWhere + ")";
+        }
+        List<Object> args = buildMajorIdArgs(spec, sourceIds);
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, args.toArray());
+        for (Map<String, Object> row : rows) {
+            Object pk = row.get(spec.pkColumn);
+            saveSnapshotRow(batchId, spec.tableName, String.valueOf(pk), row, userId, createDept, now);
+        }
+        return rows.size();
+    }
+
+    private int updateByMajorSpec(MajorFieldSpec spec, List<Long> sourceIds,
+                                  Long targetId, String targetName, String targetCategory) {
+        if (sourceIds.isEmpty()) {
+            return 0;
+        }
+        StringBuilder set = new StringBuilder();
+        List<Object> args = new ArrayList<>();
+        if (spec.idAsString) {
+            set.append(spec.idColumn).append(" = ?");
+            args.add(String.valueOf(targetId));
+        } else {
+            set.append(spec.idColumn).append(" = ?");
+            args.add(targetId);
+        }
+        if (StringUtils.isNotBlank(spec.nameColumn)) {
+            set.append(", ").append(spec.nameColumn).append(" = ?");
+            args.add(targetName);
+        }
+        if (StringUtils.isNotBlank(spec.codeColumn)) {
+            set.append(", ").append(spec.codeColumn).append(" = ?");
+            args.add(targetCategory);
+        }
+        String where = buildMajorIdWhere(spec, sourceIds.size());
+        String sql = "UPDATE " + spec.tableName + " SET " + set + " WHERE " + where;
+        if (StringUtils.isNotBlank(spec.extraWhere)) {
+            sql += " AND (" + spec.extraWhere + ")";
+        }
+        args.addAll(buildMajorIdArgs(spec, sourceIds));
+        return jdbcTemplate.update(sql, args.toArray());
+    }
+
+    private String buildMajorIdWhere(MajorFieldSpec spec, int size) {
+        if (spec.idAsString) {
+            // varchar major_id:同时兼容字符串与数字比较
+            return "(" + spec.idColumn + " IN (" + placeholders(size) + ")"
+                + " OR CAST(" + spec.idColumn + " AS CHAR) IN (" + placeholders(size) + "))";
+        }
+        return spec.idColumn + " IN (" + placeholders(size) + ")";
+    }
+
+    private List<Object> buildMajorIdArgs(MajorFieldSpec spec, List<Long> sourceIds) {
+        List<Object> args = new ArrayList<>();
+        if (spec.idAsString) {
+            for (Long id : sourceIds) {
+                args.add(String.valueOf(id));
+            }
+            for (Long id : sourceIds) {
+                args.add(String.valueOf(id));
+            }
+        } else {
+            args.addAll(sourceIds);
+        }
+        return args;
+    }
+
+    private long countByMajorSpec(MajorFieldSpec spec, List<Long> sourceIds) {
+        String where = buildMajorIdWhere(spec, sourceIds.size());
+        String sql = "SELECT COUNT(1) FROM " + spec.tableName + " WHERE " + where;
+        if (StringUtils.isNotBlank(spec.extraWhere)) {
+            sql += " AND (" + spec.extraWhere + ")";
+        }
+        List<Object> args = buildMajorIdArgs(spec, sourceIds);
+        Long count = jdbcTemplate.queryForObject(sql, Long.class, args.toArray());
+        return count == null ? 0 : count;
+    }
+
+    private int snapshotByNames(Long batchId, String table, String pk, String nameCol,
+                                List<String> names, String extraWhere,
+                                Long userId, Long createDept, Date now) {
+        if (CollUtil.isEmpty(names)) {
+            return 0;
+        }
+        String sql = "SELECT * FROM " + table + " WHERE " + nameCol + " IN (" + placeholders(names.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, names.toArray());
+        for (Map<String, Object> row : rows) {
+            saveSnapshotRow(batchId, table, String.valueOf(row.get(pk)), row, userId, createDept, now);
+        }
+        return rows.size();
+    }
+
+    private int updateByNames(String table, String nameCol, List<String> names, String targetName, String extraWhere) {
+        if (CollUtil.isEmpty(names)) {
+            return 0;
+        }
+        String sql = "UPDATE " + table + " SET " + nameCol + " = ? WHERE " + nameCol + " IN (" + placeholders(names.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        List<Object> args = new ArrayList<>();
+        args.add(targetName);
+        args.addAll(names);
+        return jdbcTemplate.update(sql, args.toArray());
+    }
+
+    private long countByMajorNames(String table, String nameCol, List<String> names, String extraWhere) {
+        if (CollUtil.isEmpty(names)) {
+            return 0;
+        }
+        String sql = "SELECT COUNT(1) FROM " + table + " WHERE " + nameCol + " IN (" + placeholders(names.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        Long count = jdbcTemplate.queryForObject(sql, Long.class, names.toArray());
+        return count == null ? 0 : count;
+    }
+
+    private int snapshotSimple(Long batchId, String table, String pk, String idCol,
+                               List<Long> ids, String extraWhere,
+                               Long userId, Long createDept, Date now) {
+        String sql = "SELECT * FROM " + table + " WHERE " + idCol + " IN (" + placeholders(ids.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, ids.toArray());
+        for (Map<String, Object> row : rows) {
+            saveSnapshotRow(batchId, table, String.valueOf(row.get(pk)), row, userId, createDept, now);
+        }
+        return rows.size();
+    }
+
+    private int updateSimpleId(String table, String idCol, List<Long> sourceIds, Long targetId, String extraWhere) {
+        String sql = "UPDATE " + table + " SET " + idCol + " = ? WHERE " + idCol + " IN (" + placeholders(sourceIds.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        List<Object> args = new ArrayList<>();
+        args.add(targetId);
+        args.addAll(sourceIds);
+        return jdbcTemplate.update(sql, args.toArray());
+    }
+
+    private long countSimpleIn(String table, String idCol, List<Long> ids, String extraWhere) {
+        if (!tableExists(table)) {
+            return 0;
+        }
+        String sql = "SELECT COUNT(1) FROM " + table + " WHERE " + idCol + " IN (" + placeholders(ids.size()) + ")";
+        if (StringUtils.isNotBlank(extraWhere)) {
+            sql += " AND (" + extraWhere + ")";
+        }
+        Long count = jdbcTemplate.queryForObject(sql, Long.class, ids.toArray());
+        return count == null ? 0 : count;
+    }
+
+    private int snapshotRoleDept(Long batchId, List<Long> sourceIds, Long userId, Long createDept, Date now) {
+        String sql = "SELECT * FROM sys_role_dept WHERE dept_id IN (" + placeholders(sourceIds.size()) + ")";
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, sourceIds.toArray());
+        for (Map<String, Object> row : rows) {
+            String pk = row.get("role_id") + "_" + row.get("dept_id");
+            saveSnapshotRow(batchId, "sys_role_dept", pk, row, userId, createDept, now);
+        }
+        return rows.size();
+    }
+
+    private int remountRoleDept(List<Long> sourceIds, Long targetId) {
+        String sql = "SELECT role_id, dept_id FROM sys_role_dept WHERE dept_id IN (" + placeholders(sourceIds.size()) + ")";
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, sourceIds.toArray());
+        int affected = 0;
+        for (Map<String, Object> row : rows) {
+            Long roleId = ((Number) row.get("role_id")).longValue();
+            Long oldDeptId = ((Number) row.get("dept_id")).longValue();
+            Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(1) FROM sys_role_dept WHERE role_id = ? AND dept_id = ?",
+                Integer.class, roleId, targetId);
+            if (exists != null && exists > 0) {
+                affected += jdbcTemplate.update(
+                    "DELETE FROM sys_role_dept WHERE role_id = ? AND dept_id = ?", roleId, oldDeptId);
+            } else {
+                affected += jdbcTemplate.update(
+                    "UPDATE sys_role_dept SET dept_id = ? WHERE role_id = ? AND dept_id = ?",
+                    targetId, roleId, oldDeptId);
+            }
+        }
+        return affected;
+    }
+
+    /**
+     * 招生开放专业配置:uk_major_dept(major_dept_id, tenant_id, deleted) 唯一。
+     * 目标已存在则逻辑删除源行;否则改挂到目标专业。
+     */
+    private int remountEnrollmentMajor(List<Long> sourceIds, Long targetId,
+                                       String targetName, String targetCategory) {
+        String sql = "SELECT id, major_dept_id FROM zsjy_enrollment_major WHERE deleted = 0 AND major_dept_id IN ("
+            + placeholders(sourceIds.size()) + ")";
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, sourceIds.toArray());
+        if (rows.isEmpty()) {
+            return 0;
+        }
+        Integer targetExists = jdbcTemplate.queryForObject(
+            "SELECT COUNT(1) FROM zsjy_enrollment_major WHERE major_dept_id = ? AND deleted = 0",
+            Integer.class, targetId);
+        boolean hasTarget = targetExists != null && targetExists > 0;
+        int affected = 0;
+        for (Map<String, Object> row : rows) {
+            Long id = ((Number) row.get("id")).longValue();
+            if (hasTarget) {
+                affected += jdbcTemplate.update(
+                    "UPDATE zsjy_enrollment_major SET deleted = 1, update_time = NOW() WHERE id = ? AND deleted = 0",
+                    id);
+            } else {
+                affected += jdbcTemplate.update(
+                    "UPDATE zsjy_enrollment_major SET major_dept_id = ?, major_name = ?, major_code = ?, update_time = NOW() WHERE id = ? AND deleted = 0",
+                    targetId, targetName, targetCategory, id);
+                // 第一条改挂成功后,后续源行若再改会撞唯一键,改为逻辑删除
+                hasTarget = true;
+            }
+        }
+        return affected;
+    }
+
+    private void assertNoQuotaConflict(List<Long> sourceIds, Long targetId) {
+        String sql = """
+            SELECT COUNT(1) FROM jcbt_scholarship_quota s
+            WHERE s.deleted = 0 AND s.target_dept_id IN (%s)
+              AND EXISTS (
+                SELECT 1 FROM jcbt_scholarship_quota t
+                WHERE t.deleted = 0 AND t.target_dept_id = ?
+                  AND t.batch_id = s.batch_id AND t.scholarship_type_id = s.scholarship_type_id
+              )
+            """.formatted(placeholders(sourceIds.size()));
+        List<Object> args = new ArrayList<>(sourceIds);
+        args.add(targetId);
+        Long conflict = jdbcTemplate.queryForObject(sql, Long.class, args.toArray());
+        if (conflict != null && conflict > 0) {
+            throw new ServiceException("奖学金名额表存在目标专业冲突(同批次同类型已有目标专业名额),请先手工处理后再合并");
+        }
+    }
+
+    private void saveSnapshotRow(Long batchId, String tableName, String rowPk,
+                                 Map<String, Object> row, Long userId, Long createDept, Date now) {
+        XgMajorMergeRowSnapshot snap = new XgMajorMergeRowSnapshot();
+        snap.setId(IdUtil.getSnowflakeNextId());
+        snap.setMergeBatchId(batchId);
+        snap.setTableName(tableName);
+        snap.setRowPk(rowPk);
+        snap.setRowJson(JSONUtil.toJsonStr(row));
+        snap.setCreateBy(userId);
+        snap.setCreateDept(createDept);
+        snap.setCreateTime(now);
+        snap.setUpdateBy(userId);
+        snap.setUpdateTime(now);
+        snap.setTenantId(LoginHelper.getTenantId());
+        snapshotMapper.insert(snap);
+    }
+
+    // -------------------- 统计辅助 --------------------
+
+    private int countClassUnderMajor(Long majorId) {
+        Integer c = jdbcTemplate.queryForObject(
+            "SELECT COUNT(1) FROM sys_dept WHERE parent_id = ? AND del_flag = '0'",
+            Integer.class, majorId);
+        return c == null ? 0 : c;
+    }
+
+    private int countStudentUnderMajor(Long majorId) {
+        if (!tableExists("xg_student_msg")) {
+            return 0;
+        }
+        Integer c = jdbcTemplate.queryForObject(
+            "SELECT COUNT(1) FROM xg_student_msg WHERE deleted = 0 AND (major_id = ? OR major_id = ?)",
+            Integer.class, String.valueOf(majorId), majorId);
+        return c == null ? 0 : c;
+    }
+
+    private boolean tableExists(String tableName) {
+        Integer count = jdbcTemplate.queryForObject(
+            "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?",
+            Integer.class, tableName);
+        return count != null && count > 0;
+    }
+
+    private static String placeholders(int n) {
+        return String.join(",", Collections.nCopies(n, "?"));
+    }
+
+    private Long safeUserId() {
+        try {
+            return LoginHelper.getUserId();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private Long safeDeptId() {
+        try {
+            return LoginHelper.getDeptId();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    // -------------------- 内部类型 --------------------
+
+    private static class MergeContext {
+        private SysDept target;
+        private List<SysDept> sources;
+        private List<Long> sourceIds;
+    }
+
+    private record MajorFieldSpec(
+        String tableName,
+        String pkColumn,
+        String idColumn,
+        String nameColumn,
+        String codeColumn,
+        String extraWhere,
+        boolean idAsString
+    ) {
+    }
+}

+ 395 - 0
docs/业务模块/专业信息管理/专业合并功能实施计划.md

@@ -0,0 +1,395 @@
+# 专业合并功能实施计划
+
+> 状态:P0 已落地(待联调验收)  
+> 更新时间:2026-08-18  
+> 入口页面:`/student/classmsg/majormsg`(专业信息)  
+> 背景:生产环境存在重复专业定义(如「中药」与「中药(技师)」),需将源专业下班级/学生及业务当前归属合并到目标专业。
+
+### 已落地代码位置
+
+| 项 | 路径 |
+|----|------|
+| 预览/合并 API | `POST /xg/major/merge/preview`、`POST /xg/major/merge` |
+| Controller | `ruoyi-xg/.../XgMajorMergeController.java` |
+| Service | `ruoyi-xg/.../XgMajorMergeServiceImpl.java` |
+| 前端 | `web/src/views/xg/xsjcxx/classmsg/majormsg/index.vue` |
+| DDL | `docs/设计文档/database/专业信息管理/专业合并快照表结构.sql` |
+| 权限 | `docs/设计文档/database/专业信息管理/专业合并菜单权限.sql` |
+
+---
+
+## 1. 目标与原则
+
+### 1.1 业务目标
+
+在专业信息界面多选专业 → 点击【专业合并】→ 弹窗从已选中指定**一个目标专业** → 其余源专业的**当前归属数据**全部改挂到目标专业 → 源专业**停用**。
+
+### 1.2 已确认规则
+
+| 规则 | 结论 |
+|------|------|
+| 班级处理 | 班级整体挂到目标专业;**班级 ID 不变**;同名班**不合并** |
+| 学生处理 | 只改专业归属,**不改班级** |
+| 源专业处理后 | 本版仅 **停用**(`status=1`),是否逻辑删除后续再定 |
+| 学院约束 | 仅允许**同一学院**下的专业互相合并 |
+| 历史快照 | 转班等「原专业/新专业」历史记录 **不回写** |
+
+### 1.3 核心原则(防漏改)
+
+**凡表示「当前专业归属」的字段,`major_id` 与 `major_name`(以及存在的 `major_code`)必须成对同步更新。**
+
+禁止只改 ID、不改名称:列表/导出/筛选会出现「ID 已是中药、名称仍显示中药(技师)」的异常。
+
+更新条件统一为:
+
+```text
+WHERE major_id IN (源专业ID列表)
+SET major_id = 目标专业ID,
+    major_name = 目标专业名称
+-- 若表有 major_code / major_dept_id,一并改为目标专业对应值
+```
+
+---
+
+## 2. 改造范围总表
+
+### 2.1 组织与主数据(必改,事务内优先)
+
+| 序号 | 表 | 动作 | ID / 名称字段 |
+|------|-----|------|----------------|
+| 1 | `sys_dept`(班级节点 `bj-*`) | `parent_id` 改为目标专业;重算 `ancestors` | 无冗余名称 |
+| 2 | `sys_dept`(源专业 `zy-*`) | `status = '1'`(停用) | `dept_name` 保留作追溯 |
+| 3 | `xg_class_msg` | 当前归属改挂 | **`major_id` + `major_name` 同改** |
+| 4 | `xg_student_msg` | 当前归属改挂 | **`major_id` + `major_name` 同改** |
+| 5 | `xg_student_manager_source` | `data_id` 若等于源专业 ID,改为目标专业 ID | 无名称字段 |
+
+说明:`sys_user.dept_id` 本系统学生通常挂**班级**;合并时班级 ID 不变则用户表一般无需改。合并前应用 SQL 探测是否存在 `dept_id IN (源专业ID)`,有则改为目标专业 ID。
+
+### 2.2 业务冗余表(必改:当前归属 = 源专业时)
+
+原则:**有 ID 改 ID,有名称改名称,有编码改编码;能按 ID 定位的优先按 ID。**
+
+| 模块 | 表 | 同改字段 | 备注 |
+|------|-----|----------|------|
+| 请假 | `xg_student_leave` | `major_id` + `major_name` | |
+| 销假 | `xg_student_cancel_leave` | `major_id` + `major_name` | |
+| 宿舍申请 | `xg_dorm_application` | `major_id` + `major_name` | |
+| 床位分配 | `xg_dorm_bed_student` | `major_id` | 无名称字段 |
+| 卫生评分 | `xg_dorm_hygiene_student_score` | `major_id` + `major_name` | |
+| 查寝明细 | `xg_dorm_roll_call_detail` | `major_id` + `major_name` | |
+| 违纪 | `xg_dorm_violation` | `major_id` + `major_name` | |
+| 假期留宿 | `xg_holiday_stay` | `major_id` + `major_name` | |
+| 信息收集批次班级 | `xg_stu_col_batch_class` | `major_id` + `major_name` | |
+| 学生信息批次 | `xg_student_msg_batch` | `major_id` + `major_name` | |
+| 心理测评 | `xljk_evaluation` | `major_id` + `major_name` | |
+| 考核申请 | `xskh_assessment_application` | `major_id` + `major_name` | |
+| 考核审批 | `xskh_assessment_application_approval` | `major_id` + `major_name` | |
+| 处分 | `xskh_discipline_record` | `major_id` + `major_name` | |
+| 奖励 | `xskh_reward_record` | `major_id` + `major_name` | |
+| 贫困认定 | `jcbt_poverty_application` | `major_id` + `major_name`(+`major_code` 若有) | |
+| 奖学金申请 | `jcbt_scholarship_application` | `major_id` | 无名称字段 |
+| 勤工助学 | `jcbt_work_study_application` | **仅有 `major_name`** | 见 §2.4 特殊处理 |
+| 毕业生 | `zsjy_graduate_info` | `major_id` + `major_name` | |
+| 实习 | `zsjy_internship_info` | `major_id` | 无名称字段 |
+| 招生专业配置 | `zsjy_enrollment_major` | `major_dept_id` + `major_name` + `major_code` | **特殊**:存在 `uk_major_dept` 唯一键;目标已有配置则**逻辑删除源行**,否则改挂 |
+
+### 2.3 配置 / 权限类(有则改)
+
+| 表 | 动作 |
+|----|------|
+| `jcbt_scholarship_quota` | `target_dept_id` 若等于源专业 ID → 改为目标专业 ID(注意唯一约束冲突时需合并名额行) |
+| `sys_role_dept` | `dept_id` 若等于源专业 ID → 改为目标专业 ID(注意主键冲突去重) |
+
+### 2.4 特殊处理表
+
+| 表 | 策略 |
+|----|------|
+| `jcbt_work_study_application` | 仅有 `major_name`:按**源专业名称精确匹配**更新为目标专业名称;合并前日志记录影响行数 |
+| `xg_student_class_change` | **不更新**(`original_*` / `new_*` 为历史真相) |
+| `*_his`、备份表、`sys_oper_log` | **不更新** |
+| `cx_zysj` / `cx_bjsj` / `cx_xssj` | **本版不更新**;文档标注超星同步回写风险,P2 做映射 |
+| `zsjy_major_info` / `his_*` | 非学工主链路组织树;本版不改,避免误伤招生目录 |
+
+### 2.5 班级维度表(一般不必改专业)
+
+以下按 `class_id` 关联,班级 ID 不变则**无需因合并改行**(除非另有 `major_id`):
+
+- `xg_conduct_score`
+- `xg_class_pos_msg`
+- `xg_class_teacher_counselor_rel`
+
+合并后统计「按专业」时走班级 → `xg_class_msg.major_id`,因此班级主数据改对即可。
+
+---
+
+## 3. 防漏改机制(解决「范围不够」)
+
+### 3.1 后端统一更新器(推荐)
+
+不要散落多处手写 UPDATE。在服务内维护**白名单清单**:
+
+```text
+MajorMergeTableSpec {
+  tableName
+  idColumn          // major_id / major_dept_id / data_id / target_dept_id / dept_id
+  nameColumn?       // major_name,可空
+  codeColumn?       // major_code,可空
+  extraWhere?       // 如 deleted=0
+  strategy          // BY_ID | BY_NAME_ONLY | DEPT_TREE | SKIP_HISTORY
+}
+```
+
+合并服务循环执行:
+
+1. 预检 COUNT(按源 ID / 源名称)→ 写入合并结果 VO / 操作日志  
+2. 事务内按清单 UPDATE  
+3. 组织树改挂 + 源专业停用  
+4. 合并后复查:各表白名单 `COUNT(源专业ID) == 0`,否则整单回滚并报错
+
+### 3.2 合并前预检 SQL(上线前必跑)
+
+对每个候选源专业 ID,输出各表命中行数;发现「新表有 `major_id` 但不在白名单」时,**禁止上线**,先补清单。
+
+可维护仓库脚本:`test/专业合并影响行数预检.sql`(开发阶段补充)。
+
+### 3.3 名称同步硬规则
+
+```text
+IF 表同时存在 major_id 与 major_name:
+    禁止只更新其中一个
+IF 仅存在 major_name:
+    使用源专业名称精确匹配更新(并记录日志)
+IF 仅存在 major_id:
+    只更新 ID
+```
+
+目标专业名称取自合并时刻 `sys_dept.dept_name`(目标节点),保证与组织树一致。
+
+---
+
+## 3.4 合并前行级快照备份(P0 必做,便于错误恢复)
+
+### 为什么要做 / 为什么不做整表备份
+
+| 方式 | 结论 |
+|------|------|
+| 整表 `CREATE TABLE AS SELECT` | **不采用**:学生/业务表体量大,每次合并磁盘与耗时不可控 |
+| 仅事务回滚 | **不够**:合并**成功提交后**若发现选错目标专业,事务已结束无法回退 |
+| **只备份将被修改的行** | **采用**:体积小、可按 `merge_batch_id` 追溯与回滚 |
+
+### 表设计
+
+**1)合并批次主表 `xg_major_merge_batch`**
+
+| 字段 | 说明 |
+|------|------|
+| `id` | 雪花 ID,即 `merge_batch_id` |
+| `target_major_id` / `target_major_name` | 目标专业 |
+| `source_major_ids` / `source_major_names` | JSON,源专业列表 |
+| `college_id` | 所属学院 |
+| `status` | `SUCCESS` / `FAILED` / `ROLLED_BACK` |
+| `summary_json` | 各表更新行数摘要 |
+| `operator_id` / `create_time` 等 | 标准审计字段 |
+
+**2)行快照表 `xg_major_merge_row_snapshot`**
+
+| 字段 | 说明 |
+|------|------|
+| `id` | 雪花 ID |
+| `merge_batch_id` | 关联批次 |
+| `table_name` | 原表名,如 `xg_student_msg` |
+| `row_pk` | 原表主键值(字符串,兼容联合主键时用约定拼接) |
+| `row_json` | 更新前整行 JSON(或至少还原所需列:专业相关字段 + 主键 + 组织树字段) |
+| `create_time` | 快照时间 |
+
+说明:`sys_dept` 班级改挂、源专业停用前,同样写入快照(记录改前的 `parent_id`/`ancestors`/`status`)。
+
+### 执行顺序(必须先快照再改数)
+
+```text
+1. 校验参数与同学院
+2. 生成 merge_batch_id,写入 batch 主表(status 可先 SUCCESS 前用 RUNNING)
+3. 按白名单:把「即将被 UPDATE 的行」INSERT 进 row_snapshot
+4. 再执行各表 UPDATE / 组织树改挂 / 停用源专业
+5. 复查无源专业 ID 残留
+6. 更新 batch.summary_json、status=SUCCESS
+失败则事务回滚(含已写入的本批快照);若需「成功后回滚」见下
+```
+
+事务策略建议:
+
+- **同事务**:快照 + 改数同一事务 → 失败自动干净回滚。  
+- **成功后误操作恢复**:另提供运维接口/SQL:按 `merge_batch_id` 读取 `row_json` 回写原表(P0 至少落库快照并文档化手工回滚步骤;P1 再做一键 `rollback` API)。
+
+### 回滚注意事项
+
+- 回滚只能恢复「本批改过的行」;合并后若业务又改过这些行,回滚会覆盖后续人工修改 → 回滚前需人工确认。  
+- `sys_role_dept` / 名额表若发生「删源行 + 合并到目标行」,快照需记录删除前整行,回滚逻辑单独处理。  
+- 快照表本身不做业务查询用途,可按保留策略定期归档(如保留 180 天)。
+
+---
+
+## 4. 接口设计
+
+### 4.1 影响预览(建议 P0 带上,降低误操作)
+
+```http
+POST /system/dept/major/merge/preview
+权限: majormsg:major:merge
+
+Body:
+{
+  "targetMajorId": 20251223133304109,
+  "sourceMajorIds": [20251223133304116]
+}
+
+Response data:
+{
+  "targetMajorId": ...,
+  "targetMajorName": "中药",
+  "sources": [
+    {
+      "majorId": ...,
+      "majorName": "中药(技师)",
+      "classCount": 1,
+      "studentCount": 50,
+      "tableHits": [
+        { "table": "xg_student_leave", "rows": 0 },
+        ...
+      ]
+    }
+  ],
+  "warnings": ["同学院校验通过", "..."]
+}
+```
+
+### 4.2 执行合并
+
+```http
+POST /system/dept/major/merge
+权限: majormsg:major:merge
+
+Body: 同 preview
+
+Response data:
+{
+  "success": true,
+  "mergeBatchId": 2089...,
+  "movedClassCount": 1,
+  "updatedStudentCount": 50,
+  "disabledMajorCount": 1,
+  "snapshotRowCount": 120,
+  "tableUpdateSummary": [ { "table": "xg_class_msg", "updated": 1 }, ... ]
+}
+```
+
+校验失败示例:
+
+- 源列表为空 / 未含目标 / 目标不在已选集合  
+- 所选不足 2 个专业  
+- 跨学院  
+- 目标已停用或不存在  
+- 复查仍有源专业 ID 残留  
+
+### 4.3 权限与菜单
+
+- 新增按钮权限:`majormsg:major:merge`  
+- 菜单/角色授权与现有 `majormsg:major:*` 一并配置 SQL  
+
+---
+
+## 5. 前端设计(`majormsg/index.vue`)
+
+1. 工具栏增加【专业合并】(`v-hasPermi="['majormsg:major:merge']"`)  
+2. `selection.length < 2` 时禁用  
+3. 弹窗:  
+   - 展示已选专业列表(名称 + 类别编码)  
+   - 单选「目标专业」(仅能选已选中的项)  
+   - 可选:调用 preview 展示班级数/学生数/业务命中  
+   - 二次确认文案:明确「源专业将停用;班级不合并;名称与 ID 同步改挂」  
+4. 成功后刷新列表  
+
+---
+
+## 6. 后端实现要点
+
+| 项 | 说明 |
+|----|------|
+| 模块位置 | 建议放在系统部门能力旁:`SysDept` 相关 Service,或学工模块包装后调部门服务;**事务边界在同一服务方法** |
+| 事务 | `@Transactional(rollbackFor = Exception.class)` |
+| 祖先路径 | 班级 `ancestors = 目标专业.ancestors + ',' + 目标专业.deptId` |
+| 操作日志 | `@Log` 记录目标/源/摘要 |
+| 幂等 | 源专业已停用且无子班级时可提示「无需合并」 |
+
+伪代码顺序:
+
+```text
+1. 校验参数与同学院
+2. 创建 merge_batch,按白名单把「将改行」写入 xg_major_merge_row_snapshot
+3. 更新各业务表白名单(ID+名称同改)
+4. 更新 xg_student_msg / xg_class_msg
+5. 更新 sys_dept 班级 parent/ancestors
+6. 处理 manager_source / role_dept / scholarship_quota
+7. 停用源专业
+8. 复查 COUNT(源ID)==0,否则抛错回滚
+9. 回写 batch 摘要与 SUCCESS;接口返回 mergeBatchId
+```
+
+---
+
+## 7. 分期交付
+
+| 阶段 | 内容 | 验收 |
+|------|------|------|
+| **P0** | 前端按钮+弹窗;preview+merge API;**行级快照表+批次表**;`sys_dept`+`xg_class_msg`+`xg_student_msg`+manager_source;白名单业务表 ID+名称同改;源专业停用;权限+日志;文档化按 batch 手工回滚步骤 | 用「中药(技师)→中药」演练:班级挂对、学生名称变「中药」、源专业停用、复查无残留;快照行数与更新行数一致 |
+| **P1** | 预检脚本入库;名额/角色冲突处理增强;**一键按 mergeBatchId 回滚 API**;合并结果导出 | 成功合并后可一键恢复到合并前 |
+| **P2** | 超星同步映射;可选逻辑删除源专业;快照归档策略 | 同步不回写重复专业 |
+
+---
+
+## 8. 测试用例(最小集)
+
+1. 同学院 2 个专业合并成功;目标名称写入所有命中业务行  
+2. 只改 ID 漏改名称的回归:抽查 `xg_student_msg`、`xg_student_leave` 等 `major_id` 与 `major_name` 一致  
+3. 跨学院拒绝  
+4. 选 1 个专业无法合并  
+5. 同名班级并存(不合并班级)  
+6. `xg_student_class_change` 历史行不变  
+7. 事务中途失败整单回滚(含本批快照)  
+8. 无权限看不到按钮 / 接口 403  
+9. 合并成功后快照可查;按文档步骤可手工恢复抽样表  
+
+---
+
+## 9. 文档与配置同步(开发时)
+
+- API:`docs/开发文档/API/` 下增补「专业合并」章节(或写入专业信息管理 API)  
+- 权限初始化 SQL  
+- 快照/批次表 DDL 放入 `docs/设计文档/database/`  
+- 本实施计划随代码落地更新「状态:已完成」  
+
+---
+
+## 10. 开放问题(实现前可默认)
+
+| 问题 | 默认建议 |
+|------|----------|
+| 勤工助学仅名称字段误伤同名? | 精确匹配源 `dept_name`;合并前 preview 展示行数人工确认 |
+| 奖学金名额行冲突 | 目标已有名额则累加 `total_quota` 后删源行,或报错人工处理(P0 可报错) |
+| 超星 | P0 合并后口头告知运营:勿从超星再灌该源专业码 |
+| 快照保留多久 | 默认 180 天,可配置 |
+
+---
+
+## 11. 评审确认清单
+
+请确认后进入开发:
+
+- [ ] ID 与名称(及 code)同改原则同意  
+- [ ] §2 表白名单范围同意(可增删)  
+- [ ] 历史表 / 超星本版不改同意  
+- [ ] P0 含 preview 同意  
+- [ ] 权限码 `majormsg:major:merge` 同意  
+- [x] **合并前行级快照备份**同意(非整表;P0 落库 + 手工回滚说明;P1 一键回滚)  
+
+确认后按本计划实现前后端与预检 SQL。

+ 83 - 0
docs/开发文档/API/专业信息管理API.md

@@ -44,6 +44,8 @@
 | 接口名称         | HTTP 方法 | 路径                | 权限要求              | 功能描述         |
 | ---------------- | --------- | ------------------- | --------------------- | ---------------- |
 | 获取学院树结构   | GET       | `/system/dept/list` | `majormsg:major:list` | 获取二级学院树结构 |
+| 专业合并预览     | POST      | `/xg/major/merge/preview` | `majormsg:major:merge` | 预览合并影响范围 |
+| 执行专业合并     | POST      | `/xg/major/merge` | `majormsg:major:merge` | 执行专业合并(含行级快照) |
 
 ### 通用响应格式
 
@@ -456,6 +458,80 @@ curl -X GET "http://localhost:8080/system/dept/list?parentId=101" \
 
 ---
 
+## 🔀 专业合并接口
+
+> 权限:`majormsg:major:merge`  
+> 说明:仅允许同一学院下专业合并;班级 ID 不变只改归属;`major_id` 与 `major_name`(及 code)同改;源专业停用;改前写入行级快照。
+
+### 1. 合并影响预览
+
+**接口地址**: `POST /xg/major/merge/preview`
+
+#### 请求体
+
+```json
+{
+  "targetMajorId": 20251223133304109,
+  "selectedMajorIds": [20251223133304109, 20251223133304116]
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| targetMajorId | Long | Y | 目标专业 ID(必须在 selectedMajorIds 中) |
+| selectedMajorIds | Long[] | Y | 已选专业(含目标,至少 2 个) |
+
+#### 响应 data 要点
+
+- `sources[]`:每个源专业的班级数、学生数
+- `tableHits[]`:各业务表预计影响行数
+- `warnings[]`:提示信息
+- `totalClassCount` / `totalStudentCount`
+
+### 2. 执行合并
+
+**接口地址**: `POST /xg/major/merge`
+
+请求体同预览。成功返回:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "success": true,
+    "mergeBatchId": 2090,
+    "targetMajorId": 20251223133304109,
+    "targetMajorName": "中药",
+    "movedClassCount": 1,
+    "updatedStudentCount": 50,
+    "disabledMajorCount": 1,
+    "snapshotRowCount": 120,
+    "tableUpdateSummary": [{ "table": "xg_class_msg", "updated": 1 }]
+  }
+}
+```
+
+### 3. 相关 SQL
+
+- 表结构:`docs/设计文档/database/专业信息管理/专业合并快照表结构.sql`
+- 按钮权限:`docs/设计文档/database/专业信息管理/专业合并菜单权限.sql`
+- 实施计划:`docs/业务模块/专业信息管理/专业合并功能实施计划.md`
+
+### 4. 手工回滚说明(P0)
+
+合并成功后若需恢复,可按 `mergeBatchId` 查询:
+
+```sql
+SELECT table_name, row_pk, row_json
+FROM xg_major_merge_row_snapshot
+WHERE merge_batch_id = ? AND deleted = 0;
+```
+
+按 `table_name` 将 `row_json` 中的专业相关字段回写原表(注意:回滚会覆盖合并后的再次手工修改)。一键回滚 API 列入 P1。
+
+---
+
 ## 📋 数据字典
 
 ### 专业状态枚举
@@ -553,6 +629,13 @@ describe('专业信息管理 API Tests', () => {
 
 ## 🔄 版本历史
 
+### v1.1.0 - 2026-08-18
+
+-   ✅ 新增专业合并预览 / 执行接口(`/xg/major/merge`)
+-   ✅ 同学院多选合并;班级改挂;专业 ID 与名称同改;源专业停用
+-   ✅ 合并前行级快照(`xg_major_merge_batch` / `xg_major_merge_row_snapshot`)
+-   ✅ 前端专业信息页增加【专业合并】按钮与弹窗
+
 ### v1.0.0 - 2025-12-26
 
 -   ✅ 初始版本发布

+ 60 - 0
docs/设计文档/database/专业信息管理/专业合并快照表结构.sql

@@ -0,0 +1,60 @@
+-- ================================================
+-- 专业合并:批次表 + 行级快照表
+-- 用途:合并前备份将被修改的行,便于错误恢复
+-- 日期:2026-08-18
+-- ================================================
+
+-- ----------------------------
+-- 专业合并批次表
+-- ----------------------------
+DROP TABLE IF EXISTS `xg_major_merge_batch`;
+CREATE TABLE `xg_major_merge_batch` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID(merge_batch_id)',
+  `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `deleted` tinyint(1) DEFAULT 0 COMMENT '删除标志(0代表存在 1代表删除)',
+  `college_id` bigint(20) DEFAULT NULL COMMENT '所属学院ID',
+  `target_major_id` bigint(20) NOT NULL COMMENT '目标专业ID',
+  `target_major_name` varchar(100) DEFAULT NULL COMMENT '目标专业名称',
+  `source_major_ids` varchar(2000) NOT NULL COMMENT '源专业ID列表(JSON数组)',
+  `source_major_names` varchar(2000) DEFAULT NULL COMMENT '源专业名称列表(JSON数组)',
+  `status` varchar(20) NOT NULL DEFAULT 'RUNNING' COMMENT '状态:RUNNING/SUCCESS/FAILED/ROLLED_BACK',
+  `summary_json` mediumtext COMMENT '各表更新行数摘要(JSON)',
+  `snapshot_row_count` int(11) DEFAULT 0 COMMENT '快照行数',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_deleted` (`deleted`),
+  KEY `idx_target_major_id` (`target_major_id`),
+  KEY `idx_status` (`status`),
+  KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专业合并批次表';
+
+-- ----------------------------
+-- 专业合并行级快照表
+-- ----------------------------
+DROP TABLE IF EXISTS `xg_major_merge_row_snapshot`;
+CREATE TABLE `xg_major_merge_row_snapshot` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `deleted` tinyint(1) DEFAULT 0 COMMENT '删除标志(0代表存在 1代表删除)',
+  `merge_batch_id` bigint(20) NOT NULL COMMENT '合并批次ID',
+  `table_name` varchar(128) NOT NULL COMMENT '原表名',
+  `row_pk` varchar(200) NOT NULL COMMENT '原表主键(联合主键用下划线拼接)',
+  `row_json` mediumtext NOT NULL COMMENT '更新前整行JSON',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_deleted` (`deleted`),
+  KEY `idx_merge_batch_id` (`merge_batch_id`),
+  KEY `idx_table_name` (`table_name`),
+  KEY `idx_batch_table` (`merge_batch_id`, `table_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专业合并行级快照表';

+ 19 - 0
docs/设计文档/database/专业信息管理/专业合并菜单权限.sql

@@ -0,0 +1,19 @@
+-- ================================================
+-- 专业合并按钮权限
+-- 挂在「专业信息」菜单下(parent_id = 2004409734941229057)
+-- 日期:2026-08-18
+-- ================================================
+
+-- 按钮:专业合并
+INSERT INTO sys_menu (
+  menu_id, menu_name, parent_id, order_num, path, component, query_param,
+  is_frame, is_cache, menu_type, visible, status, perms, icon,
+  create_dept, create_by, create_time, update_by, update_time, remark
+) VALUES (
+  2090000000000000001, '专业合并', 2004409734941229057, 4, '', NULL, NULL,
+  1, 0, 'F', '0', '0', 'majormsg:major:merge', '#',
+  100, 1, NOW(), 1, NOW(), '专业信息-专业合并'
+);
+
+-- 如需授权给超级管理员角色(role_id=1),可执行:
+-- INSERT IGNORE INTO sys_role_menu (role_id, menu_id) VALUES (1, 2090000000000000001);

+ 18 - 0
web/src/api/xg/xsjcxx/classMsg/majormsg/index.ts

@@ -53,3 +53,21 @@ export const getCollegeTree = () => {
     params: { parentId: 101 } // 获取二级学院
   });
 };
+
+/** 专业合并影响预览 */
+export const previewMajorMerge = (data: { targetMajorId: string | number; selectedMajorIds: Array<string | number> }) => {
+  return request({
+    url: '/xg/major/merge/preview',
+    method: 'post',
+    data
+  });
+};
+
+/** 执行专业合并 */
+export const mergeMajors = (data: { targetMajorId: string | number; selectedMajorIds: Array<string | number> }) => {
+  return request({
+    url: '/xg/major/merge',
+    method: 'post',
+    data
+  });
+};

+ 37 - 0
web/src/api/xg/xsjcxx/classMsg/majormsg/types.ts

@@ -52,3 +52,40 @@ export interface CollegeTreeNode {
   parentId: number | string;
   children?: CollegeTreeNode[];
 }
+
+/** 专业合并预览 */
+export interface MajorMergePreviewVO {
+  targetMajorId: number | string;
+  targetMajorName: string;
+  targetMajorCategory?: string;
+  collegeId?: number | string;
+  collegeName?: string;
+  totalClassCount: number;
+  totalStudentCount: number;
+  sources: Array<{
+    majorId: number | string;
+    majorName: string;
+    majorCategory?: string;
+    classCount: number;
+    studentCount: number;
+  }>;
+  tableHits: Array<{
+    table: string;
+    strategy: string;
+    rows: number;
+  }>;
+  warnings: string[];
+}
+
+/** 专业合并结果 */
+export interface MajorMergeResultVO {
+  success: boolean;
+  mergeBatchId: number | string;
+  targetMajorId: number | string;
+  targetMajorName: string;
+  movedClassCount: number;
+  updatedStudentCount: number;
+  disabledMajorCount: number;
+  snapshotRowCount: number;
+  tableUpdateSummary: Array<{ table: string; updated: number }>;
+}

+ 141 - 2
web/src/views/xg/xsjcxx/classmsg/majormsg/index.vue

@@ -84,6 +84,17 @@
                   >删除</el-button
                 >
               </el-col>
+              <el-col :span="1.5">
+                <el-button
+                  type="warning"
+                  plain
+                  icon="Connection"
+                  :disabled="ids.length < 2"
+                  @click="handleMergeOpen"
+                  v-hasPermi="['majormsg:major:merge']"
+                  >专业合并</el-button
+                >
+              </el-col>
               <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
             </el-row>
           </template>
@@ -141,6 +152,58 @@
       </div>
     </div>
 
+    <!-- 专业合并对话框 -->
+    <el-dialog title="专业合并" v-model="mergeDialog.visible" width="720px" append-to-body destroy-on-close>
+      <el-alert
+        type="warning"
+        :closable="false"
+        show-icon
+        title="合并说明"
+        description="将把源专业下的班级挂到目标专业(班级ID不变、同名班不合并);学生及业务表中的专业ID与专业名称同步改为目标专业;源专业将被停用。操作前会写入行级快照。"
+        class="mb-3"
+      />
+      <el-form label-width="100px">
+        <el-form-item label="目标专业" required>
+          <el-radio-group v-model="mergeDialog.targetMajorId" @change="loadMergePreview">
+            <el-radio v-for="item in mergeDialog.selectedRows" :key="item.deptId" :value="item.deptId" class="mr-4 mb-2">
+              {{ item.deptName }}({{ item.deptCategory }})
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="已选专业">
+          <el-tag v-for="item in mergeDialog.selectedRows" :key="'tag-' + item.deptId" class="mr-2 mb-1" type="info">
+            {{ item.deptName }}
+          </el-tag>
+        </el-form-item>
+      </el-form>
+      <div v-if="mergeDialog.preview" class="merge-preview">
+        <div class="mb-2 text-sm">
+          影响预览:班级 {{ mergeDialog.preview.totalClassCount }} 个,学生 {{ mergeDialog.preview.totalStudentCount }} 人
+        </div>
+        <el-table :data="mergeDialog.preview.sources" border size="small" class="mb-3">
+          <el-table-column label="源专业" prop="majorName" min-width="160" />
+          <el-table-column label="类别编码" prop="majorCategory" min-width="140" />
+          <el-table-column label="班级数" prop="classCount" width="90" align="center" />
+          <el-table-column label="学生数" prop="studentCount" width="90" align="center" />
+        </el-table>
+        <el-table v-if="mergeDialog.preview.tableHits?.length" :data="mergeDialog.preview.tableHits" border size="small" max-height="220">
+          <el-table-column label="业务表" prop="table" min-width="220" />
+          <el-table-column label="策略" prop="strategy" width="120" />
+          <el-table-column label="影响行数" prop="rows" width="100" align="center" />
+        </el-table>
+        <div v-if="mergeDialog.preview.warnings?.length" class="mt-2 text-xs text-gray-500">
+          <div v-for="(w, idx) in mergeDialog.preview.warnings" :key="idx">• {{ w }}</div>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="mergeDialog.previewLoading" @click="loadMergePreview">刷新预览</el-button>
+          <el-button :loading="mergeDialog.submitting" type="primary" @click="submitMerge">确认合并</el-button>
+          <el-button @click="mergeDialog.visible = false">取 消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
     <!-- 添加或修改专业对话框 -->
     <el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body>
       <el-form ref="majorFormRef" :model="form" :rules="rules" label-width="100px">
@@ -176,8 +239,8 @@
 </template>
 
 <script setup name="MajorMsg" lang="ts">
-import { listMajor, getMajor, delMajor, addMajor, updateMajor, getCollegeTree } from '@/api/xg/xsjcxx/classmsg/majormsg';
-import { MajorVO, MajorQuery, MajorForm, CollegeTreeNode } from '@/api/xg/xsjcxx/classmsg/majormsg/types';
+import { listMajor, getMajor, delMajor, addMajor, updateMajor, getCollegeTree, previewMajorMerge, mergeMajors } from '@/api/xg/xsjcxx/classmsg/majormsg';
+import { MajorVO, MajorQuery, MajorForm, CollegeTreeNode, MajorMergePreviewVO } from '@/api/xg/xsjcxx/classmsg/majormsg/types';
 import { ComponentInternalInstance, ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
 import type { FormInstance as ElFormInstance } from 'element-plus';
 import type { ElTree } from 'element-plus';
@@ -200,6 +263,7 @@ const buttonLoading = ref(false);
 const loading = ref(false);
 const showSearch = ref(true);
 const ids = ref<Array<string | number>>([]);
+const selectedRows = ref<MajorVO[]>([]);
 const single = ref(true);
 const multiple = ref(true);
 const total = ref(0);
@@ -222,6 +286,15 @@ const dialog = reactive<DialogOption>({
   title: ''
 });
 
+const mergeDialog = reactive({
+  visible: false,
+  targetMajorId: undefined as string | number | undefined,
+  selectedRows: [] as MajorVO[],
+  preview: null as MajorMergePreviewVO | null,
+  previewLoading: false,
+  submitting: false
+});
+
 const initFormData: MajorForm = {
   deptId: undefined,
   parentId: undefined,
@@ -349,10 +422,76 @@ const resetQuery = () => {
 /** 多选框选中数据 */
 const handleSelectionChange = (selection: MajorVO[]) => {
   ids.value = selection.map((item) => item.deptId);
+  selectedRows.value = selection;
   single.value = selection.length != 1;
   multiple.value = !selection.length;
 };
 
+/** 打开专业合并弹窗 */
+const handleMergeOpen = async () => {
+  if (selectedRows.value.length < 2) {
+    proxy?.$modal.msgWarning('请至少选择两个专业');
+    return;
+  }
+  const collegeIds = [...new Set(selectedRows.value.map((item) => String(item.parentId)))];
+  if (collegeIds.length > 1) {
+    proxy?.$modal.msgWarning('仅允许同一学院下的专业合并');
+    return;
+  }
+  mergeDialog.selectedRows = [...selectedRows.value];
+  mergeDialog.targetMajorId = mergeDialog.selectedRows[0]?.deptId;
+  mergeDialog.preview = null;
+  mergeDialog.visible = true;
+  await loadMergePreview();
+};
+
+/** 加载合并影响预览 */
+const loadMergePreview = async () => {
+  if (!mergeDialog.targetMajorId) {
+    proxy?.$modal.msgWarning('请选择目标专业');
+    return;
+  }
+  mergeDialog.previewLoading = true;
+  try {
+    const res: any = await previewMajorMerge({
+      targetMajorId: mergeDialog.targetMajorId,
+      selectedMajorIds: mergeDialog.selectedRows.map((item) => item.deptId)
+    });
+    mergeDialog.preview = res.data;
+  } finally {
+    mergeDialog.previewLoading = false;
+  }
+};
+
+/** 提交专业合并 */
+const submitMerge = async () => {
+  if (!mergeDialog.targetMajorId) {
+    proxy?.$modal.msgWarning('请选择目标专业');
+    return;
+  }
+  const target = mergeDialog.selectedRows.find((item) => item.deptId === mergeDialog.targetMajorId);
+  const sources = mergeDialog.selectedRows.filter((item) => item.deptId !== mergeDialog.targetMajorId);
+  const sourceNames = sources.map((item) => item.deptName).join('、');
+  await proxy?.$modal.confirm(
+    `确认将【${sourceNames}】合并到目标专业【${target?.deptName}】?\n源专业将停用;班级只改归属不做班级合并;专业ID与名称将同步更新。此操作写入快照后执行。`
+  );
+  mergeDialog.submitting = true;
+  try {
+    const res: any = await mergeMajors({
+      targetMajorId: mergeDialog.targetMajorId,
+      selectedMajorIds: mergeDialog.selectedRows.map((item) => item.deptId)
+    });
+    const data = res.data || {};
+    proxy?.$modal.msgSuccess(
+      `合并成功:迁移班级 ${data.movedClassCount || 0} 个,更新学生 ${data.updatedStudentCount || 0} 人,快照 ${data.snapshotRowCount || 0} 行(批次 ${data.mergeBatchId || '-'})`
+    );
+    mergeDialog.visible = false;
+    await getList();
+  } finally {
+    mergeDialog.submitting = false;
+  }
+};
+
 /** 新增按钮操作 */
 const handleAdd = () => {
   if (!selectedCollegeId.value) {

Файловите разлики са ограничени, защото са твърде много
+ 16022 - 0
生产环境的专业数据梳理/hzdyjs_202608171113.sql


Някои файлове не бяха показани, защото твърде много файлове са промени