提交
This commit is contained in:
1 parent
6a0f1206fa
commit
672f45befd
18 files changed
+624504
-2
No files matched your search
+51
@@ -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,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,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,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,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,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,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,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,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,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
Reference in new issue
Block a user