This commit is contained in:
zlwu committed 2026-08-20 18:15:07 +08:00
1 parent 6a0f1206fa
commit 672f45befd
18 files changed
+624504 -2

No files matched your search

@@ -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));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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> {
}
@@ -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> {
}
@@ -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);
}
@@ -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
) {
}
}
@@ -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。
@@ -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
- ✅ 初始版本发布
@@ -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='专业合并行级快照表';
@@ -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);
@@ -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
});
};
@@ -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 }>;
}
@@ -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) {
File diff suppressed because it is too large. Load diff