合并培训:集成本期课程配置P0能力
This commit is contained in:
commit
db217341f7
15 files changed
+1957
-8
No files matched your search
+75
@@ -0,0 +1,75 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
import com.ruoyi.train.service.PlanAppService;
|
||||
|
||||
/**
|
||||
* 本期课程配置 API (ISSUE-02-02)。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-14
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/train/plans/{planId}/courses")
|
||||
public class TrainPlanCourseController extends BaseController
|
||||
{
|
||||
private final PlanAppService planAppService;
|
||||
|
||||
@Autowired
|
||||
public TrainPlanCourseController(PlanAppService planAppService)
|
||||
{
|
||||
this.planAppService = planAppService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询本期已配置课程列表。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:plan:query')")
|
||||
@GetMapping
|
||||
public AjaxResult list(@PathVariable String planId)
|
||||
{
|
||||
List<PlanCourseItemVO> list = planAppService.listPlanCourses(planId);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置本期课程 (UC-03)。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
|
||||
@Log(title = "本期课程配置", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public AjaxResult add(@PathVariable String planId, @Validated @RequestBody PlanCourseAddDTO dto)
|
||||
{
|
||||
planAppService.configureCourses(planId, dto, SecurityUtils.getUsername());
|
||||
return success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除本期课程。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
|
||||
@Log(title = "移除本期课程", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{planCourseId}")
|
||||
public AjaxResult remove(@PathVariable String planId, @PathVariable Long planCourseId)
|
||||
{
|
||||
planAppService.removePlanCourse(planId, planCourseId, SecurityUtils.getUsername());
|
||||
return success();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public final class PlanErrorCode
|
||||
public static final int PLAN_STATUS_LOCKED = 40012;
|
||||
public static final int PLAN_LESSON_OUT_OF_BOUNDS = 40013;
|
||||
public static final int PLAN_ALREADY_COMPLETED = 40014;
|
||||
public static final int PLAN_COURSE_DUPLICATED = 40020;
|
||||
public static final int PLAN_COURSE_HAS_LESSONS = 40021;
|
||||
public static final int PLAN_COURSE_HAS_SCORE_ITEMS = 40022;
|
||||
public static final int PLAN_NOT_FOUND = 40410;
|
||||
|
||||
public static final String PLAN_TIME_INVALID_MESSAGE = "培训计划起止时间不合法 (结束时间必须晚于开始时间)";
|
||||
@@ -15,7 +18,14 @@ public final class PlanErrorCode
|
||||
public static final String PLAN_STATUS_LOCKED_MESSAGE = "当前培训计划已结训或取消,不允许修改";
|
||||
public static final String PLAN_LESSON_OUT_OF_BOUNDS_MESSAGE = "调整培训时间导致现有已排课次超出计划周期";
|
||||
public static final String PLAN_ALREADY_COMPLETED_MESSAGE = "培训计划已经结训,不可重复执行结训";
|
||||
public static final String PLAN_COURSE_DUPLICATED_MESSAGE = "该课程已添加到本期培训计划中";
|
||||
public static final String PLAN_COURSE_HAS_LESSONS_MESSAGE = "该课程已排定课次,请先删除关联课次后再移除课程";
|
||||
public static final String PLAN_COURSE_HAS_SCORE_ITEMS_MESSAGE = "该课程已关联成绩考核项目,不可直接移除";
|
||||
public static final String PLAN_NOT_FOUND_MESSAGE = "培训计划不存在";
|
||||
public static final String COURSE_NOT_FOUND_MESSAGE = "课程不存在或已被删除";
|
||||
public static final String COURSE_INACTIVE_MESSAGE = "课程已被停用,无法配置到本期培训计划中";
|
||||
public static final String PLAN_COURSE_NOT_FOUND_MESSAGE = "本期课程配置不存在或不属于该培训计划";
|
||||
public static final String PLAN_COURSE_HAS_STUDY_DURATIONS_MESSAGE = "该课程已产生学习记录,不可直接移除";
|
||||
|
||||
private PlanErrorCode()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ruoyi.train.domain;
|
||||
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 培训班与课程关联对象 train_plan_course
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-14
|
||||
*/
|
||||
public class TrainPlanCourse extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 培训课程实例ID (核心ID之一) */
|
||||
private Long planCourseId;
|
||||
|
||||
/** 培训班ID */
|
||||
@Excel(name = "培训班ID")
|
||||
private String planId;
|
||||
|
||||
/** 课程ID */
|
||||
@Excel(name = "课程ID")
|
||||
private String cId;
|
||||
|
||||
/** 排课先后顺序号 */
|
||||
@Excel(name = "排课先后顺序号")
|
||||
private Integer sortNo;
|
||||
|
||||
public Long getPlanCourseId()
|
||||
{
|
||||
return planCourseId;
|
||||
}
|
||||
|
||||
public void setPlanCourseId(Long planCourseId)
|
||||
{
|
||||
this.planCourseId = planCourseId;
|
||||
}
|
||||
|
||||
public String getPlanId()
|
||||
{
|
||||
return planId;
|
||||
}
|
||||
|
||||
public void setPlanId(String planId)
|
||||
{
|
||||
this.planId = planId;
|
||||
}
|
||||
|
||||
public String getCId()
|
||||
{
|
||||
return cId;
|
||||
}
|
||||
|
||||
public void setCId(String cId)
|
||||
{
|
||||
this.cId = cId;
|
||||
}
|
||||
|
||||
public Integer getSortNo()
|
||||
{
|
||||
return sortNo;
|
||||
}
|
||||
|
||||
public void setSortNo(Integer sortNo)
|
||||
{
|
||||
this.sortNo = sortNo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
|
||||
.append("planCourseId", getPlanCourseId())
|
||||
.append("planId", getPlanId())
|
||||
.append("cId", getCId())
|
||||
.append("sortNo", getSortNo())
|
||||
.append("createBy", getCreateBy())
|
||||
.append("createTime", getCreateTime())
|
||||
.append("remark", getRemark())
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ruoyi.train.domain.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 本期课程配置请求 DTO (UC-03)。
|
||||
*/
|
||||
public class PlanCourseAddDTO implements Serializable
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 课程ID列表 (Contract V2 标准字段) */
|
||||
@JsonProperty("cIds")
|
||||
@NotEmpty(message = "课程列表不能为空")
|
||||
private List<String> cIds = new ArrayList<>();
|
||||
|
||||
/** 备注说明 */
|
||||
@Size(max = 500, message = "备注长度不能超过500个字符")
|
||||
private String remark;
|
||||
|
||||
@JsonProperty("cIds")
|
||||
public List<String> getCIds()
|
||||
{
|
||||
return cIds;
|
||||
}
|
||||
|
||||
@JsonProperty("cIds")
|
||||
public void setCIds(List<String> cIds)
|
||||
{
|
||||
this.cIds = cIds;
|
||||
}
|
||||
|
||||
public String getRemark()
|
||||
{
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark)
|
||||
{
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.ruoyi.train.domain.vo;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
/**
|
||||
* 本期已配置课程视图 VO。
|
||||
*/
|
||||
public class PlanCourseItemVO implements Serializable
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 本期课程实例主键 */
|
||||
private Long planCourseId;
|
||||
|
||||
/** 培训计划ID */
|
||||
private String planId;
|
||||
|
||||
/** 课程ID */
|
||||
private String cId;
|
||||
|
||||
/** 课程业务编码 */
|
||||
private String cCode;
|
||||
|
||||
/** 课程名称 */
|
||||
private String cName;
|
||||
|
||||
/** 课程类型 */
|
||||
private String cType;
|
||||
|
||||
/** 课程学时 */
|
||||
private Integer cDuration;
|
||||
|
||||
/** 教学目标 */
|
||||
private String teachingObjective;
|
||||
|
||||
/** 排课先后顺序号 */
|
||||
private Integer sortNo;
|
||||
|
||||
/** 备注说明 */
|
||||
private String remark;
|
||||
|
||||
/** 创建时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
|
||||
public Long getPlanCourseId()
|
||||
{
|
||||
return planCourseId;
|
||||
}
|
||||
|
||||
public void setPlanCourseId(Long planCourseId)
|
||||
{
|
||||
this.planCourseId = planCourseId;
|
||||
}
|
||||
|
||||
public String getPlanId()
|
||||
{
|
||||
return planId;
|
||||
}
|
||||
|
||||
public void setPlanId(String planId)
|
||||
{
|
||||
this.planId = planId;
|
||||
}
|
||||
|
||||
public String getCId()
|
||||
{
|
||||
return cId;
|
||||
}
|
||||
|
||||
public void setCId(String cId)
|
||||
{
|
||||
this.cId = cId;
|
||||
}
|
||||
|
||||
public String getCCode()
|
||||
{
|
||||
return cCode;
|
||||
}
|
||||
|
||||
public void setCCode(String cCode)
|
||||
{
|
||||
this.cCode = cCode;
|
||||
}
|
||||
|
||||
public String getCName()
|
||||
{
|
||||
return cName;
|
||||
}
|
||||
|
||||
public void setCName(String cName)
|
||||
{
|
||||
this.cName = cName;
|
||||
}
|
||||
|
||||
public String getCType()
|
||||
{
|
||||
return cType;
|
||||
}
|
||||
|
||||
public void setCType(String cType)
|
||||
{
|
||||
this.cType = cType;
|
||||
}
|
||||
|
||||
public Integer getCDuration()
|
||||
{
|
||||
return cDuration;
|
||||
}
|
||||
|
||||
public void setCDuration(Integer cDuration)
|
||||
{
|
||||
this.cDuration = cDuration;
|
||||
}
|
||||
|
||||
public String getTeachingObjective()
|
||||
{
|
||||
return teachingObjective;
|
||||
}
|
||||
|
||||
public void setTeachingObjective(String teachingObjective)
|
||||
{
|
||||
this.teachingObjective = teachingObjective;
|
||||
}
|
||||
|
||||
public Integer getSortNo()
|
||||
{
|
||||
return sortNo;
|
||||
}
|
||||
|
||||
public void setSortNo(Integer sortNo)
|
||||
{
|
||||
this.sortNo = sortNo;
|
||||
}
|
||||
|
||||
public String getRemark()
|
||||
{
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark)
|
||||
{
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public Date getCreateTime()
|
||||
{
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Date createTime)
|
||||
{
|
||||
this.createTime = createTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.train.domain.TrainPlanCourse;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
|
||||
/**
|
||||
* 培训班与课程关联Mapper接口。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-14
|
||||
*/
|
||||
public interface TrainPlanCourseMapper
|
||||
{
|
||||
/**
|
||||
* 查询本期课程记录。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 本期课程信息
|
||||
*/
|
||||
TrainPlanCourse selectTrainPlanCourseById(@Param("planCourseId") Long planCourseId);
|
||||
|
||||
/**
|
||||
* 根据计划ID查询本期已配置课程列表(联查课程主数据)。
|
||||
*
|
||||
* @param planId 培训计划ID
|
||||
* @return 课程列表VO
|
||||
*/
|
||||
List<PlanCourseItemVO> selectPlanCourseListByPlanId(@Param("planId") String planId);
|
||||
|
||||
/**
|
||||
* 查询指定计划下已存在的课程ID集合。
|
||||
*
|
||||
* @param planId 培训计划ID
|
||||
* @param cIds 候选课程ID集合
|
||||
* @return 已存在的课程ID列表
|
||||
*/
|
||||
List<String> selectExistingCIdsByPlanId(@Param("planId") String planId, @Param("cIds") List<String> cIds);
|
||||
|
||||
/**
|
||||
* 单条新增本期课程。
|
||||
*
|
||||
* @param trainPlanCourse 培训班与课程关联信息
|
||||
* @return 结果
|
||||
*/
|
||||
int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse);
|
||||
|
||||
/**
|
||||
* 批量新增本期课程。
|
||||
*
|
||||
* @param list 待插入列表
|
||||
* @return 插入行数
|
||||
*/
|
||||
int batchInsertTrainPlanCourse(@Param("list") List<TrainPlanCourse> list);
|
||||
|
||||
/**
|
||||
* 移除本期课程。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 删除行数
|
||||
*/
|
||||
int deleteTrainPlanCourseById(@Param("planCourseId") Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程排定的课次数(train_lesson 下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用课次数
|
||||
*/
|
||||
int countLessonsByPlanCourseId(@Param("planCourseId") Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程关联的成绩项目数(train_score_item 下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用成绩项目数
|
||||
*/
|
||||
int countScoreItemsByPlanCourseId(@Param("planCourseId") Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程关联的外部学习记录数(train_study_duration 下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用学习记录数
|
||||
*/
|
||||
int countStudyDurationsByPlanCourseId(@Param("planCourseId") Long planCourseId);
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.train.domain.TrainPlanCourse;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
|
||||
/**
|
||||
* 培训班与课程关联Service接口。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-14
|
||||
*/
|
||||
public interface ITrainPlanCourseService
|
||||
{
|
||||
/**
|
||||
* 查询本期课程记录。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 本期课程
|
||||
*/
|
||||
TrainPlanCourse selectTrainPlanCourseById(Long planCourseId);
|
||||
|
||||
/**
|
||||
* 查询指定培训班已配置的本期课程列表。
|
||||
*
|
||||
* @param planId 培训计划ID
|
||||
* @return 课程列表VO
|
||||
*/
|
||||
List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId);
|
||||
|
||||
/**
|
||||
* 查询指定计划下已存在的课程ID集合。
|
||||
*
|
||||
* @param planId 培训计划ID
|
||||
* @param cIds 待校验课程ID集合
|
||||
* @return 已存在的课程ID列表
|
||||
*/
|
||||
List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds);
|
||||
|
||||
/**
|
||||
* 新增本期课程记录。
|
||||
*
|
||||
* @param trainPlanCourse 本期课程信息
|
||||
* @return 结果
|
||||
*/
|
||||
int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse);
|
||||
|
||||
/**
|
||||
* 批量新增本期课程。
|
||||
*
|
||||
* @param list 待新增列表
|
||||
* @return 插入行数
|
||||
*/
|
||||
int batchInsertTrainPlanCourse(List<TrainPlanCourse> list);
|
||||
|
||||
/**
|
||||
* 移除本期课程。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 结果
|
||||
*/
|
||||
int deleteTrainPlanCourseById(Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程排定的课次数(下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用课次数
|
||||
*/
|
||||
int countLessonsByPlanCourseId(Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程关联的成绩项目数(下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用成绩项数
|
||||
*/
|
||||
int countScoreItemsByPlanCourseId(Long planCourseId);
|
||||
|
||||
/**
|
||||
* 统计指定本期课程关联的外部学习记录数(下游引用)。
|
||||
*
|
||||
* @param planCourseId 本期课程ID
|
||||
* @return 引用学习记录数
|
||||
*/
|
||||
int countStudyDurationsByPlanCourseId(Long planCourseId);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
|
||||
import com.ruoyi.train.domain.dto.PlanCreateDTO;
|
||||
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
|
||||
import com.ruoyi.train.domain.query.PlanQuery;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
import com.ruoyi.train.domain.vo.PlanDetailVO;
|
||||
import com.ruoyi.train.domain.vo.PlanItemVO;
|
||||
|
||||
/** 培训计划生命周期应用服务。 */
|
||||
/** 培训计划生命周期与课程配置应用服务。 */
|
||||
public interface PlanAppService
|
||||
{
|
||||
List<PlanItemVO> listPlans(PlanQuery query);
|
||||
@@ -19,4 +21,19 @@ public interface PlanAppService
|
||||
void updatePlan(String planId, PlanUpdateDTO dto, String operator);
|
||||
|
||||
void completePlan(String planId, String operator);
|
||||
|
||||
/**
|
||||
* 查询本期已配置课程列表。
|
||||
*/
|
||||
List<PlanCourseItemVO> listPlanCourses(String planId);
|
||||
|
||||
/**
|
||||
* 配置本期课程 (UC-03)。
|
||||
*/
|
||||
void configureCourses(String planId, PlanCourseAddDTO dto, String operator);
|
||||
|
||||
/**
|
||||
* 移除本期课程。
|
||||
*/
|
||||
void removePlanCourse(String planId, Long planCourseId, String operator);
|
||||
}
|
||||
+174
-3
@@ -1,8 +1,11 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -13,16 +16,22 @@ import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.train.constant.PlanErrorCode;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.TrainPlanCourse;
|
||||
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
|
||||
import com.ruoyi.train.domain.dto.PlanCreateDTO;
|
||||
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
|
||||
import com.ruoyi.train.domain.query.PlanQuery;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
import com.ruoyi.train.domain.vo.PlanDetailVO;
|
||||
import com.ruoyi.train.domain.vo.PlanItemVO;
|
||||
import com.ruoyi.train.service.ITrainClassPlanService;
|
||||
import com.ruoyi.train.service.ITrainCourseService;
|
||||
import com.ruoyi.train.service.ITrainPlanCourseService;
|
||||
import com.ruoyi.train.service.PlanAppService;
|
||||
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
|
||||
|
||||
/** 培训计划生命周期应用服务实现。 */
|
||||
/** 培训计划生命周期与课程配置应用服务实现。 */
|
||||
@Service
|
||||
public class PlanAppServiceImpl implements PlanAppService
|
||||
{
|
||||
@@ -31,21 +40,36 @@ public class PlanAppServiceImpl implements PlanAppService
|
||||
private static final int STATUS_CANCELLED = 3;
|
||||
|
||||
private final ITrainClassPlanService planService;
|
||||
private final ITrainPlanCourseService planCourseService;
|
||||
private final ITrainCourseService courseService;
|
||||
private final List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts;
|
||||
|
||||
@Autowired
|
||||
public PlanAppServiceImpl(ITrainClassPlanService planService,
|
||||
ITrainPlanCourseService planCourseService,
|
||||
ITrainCourseService courseService,
|
||||
ObjectProvider<PlanEnrollmentCompletionPort> enrollmentCompletionPortProvider)
|
||||
{
|
||||
this(planService, enrollmentCompletionPortProvider.orderedStream()
|
||||
this(planService, planCourseService, courseService, enrollmentCompletionPortProvider.orderedStream()
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
/** Test seam; production has no fake/no-op Enrollment adapter before ISSUE-03-01. */
|
||||
/** 兼容早期仅测试培训计划生命周期的测试构造方法 */
|
||||
PlanAppServiceImpl(ITrainClassPlanService planService,
|
||||
List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts)
|
||||
{
|
||||
this(planService, null, null, enrollmentCompletionPorts);
|
||||
}
|
||||
|
||||
/** 完整业务编排构造方法 (支持 JVM Fake 单元测试注入) */
|
||||
PlanAppServiceImpl(ITrainClassPlanService planService,
|
||||
ITrainPlanCourseService planCourseService,
|
||||
ITrainCourseService courseService,
|
||||
List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts)
|
||||
{
|
||||
this.planService = planService;
|
||||
this.planCourseService = planCourseService;
|
||||
this.courseService = courseService;
|
||||
this.enrollmentCompletionPorts = enrollmentCompletionPorts == null
|
||||
? Collections.emptyList() : enrollmentCompletionPorts;
|
||||
}
|
||||
@@ -148,6 +172,153 @@ public class PlanAppServiceImpl implements PlanAppService
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlanCourseItemVO> listPlanCourses(String planId)
|
||||
{
|
||||
requirePlan(planService.selectTrainClassPlanByPlanId(planId));
|
||||
return planCourseService.selectPlanCourseListByPlanId(planId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void configureCourses(String planId, PlanCourseAddDTO dto, String operator)
|
||||
{
|
||||
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
|
||||
if (isLocked(plan.getPlanStatus()))
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
|
||||
}
|
||||
if (dto == null || dto.getCIds() == null || dto.getCIds().isEmpty())
|
||||
{
|
||||
throw new ServiceException("请至少选择一门课程");
|
||||
}
|
||||
|
||||
// 校验输入列表内部是否有重复
|
||||
Set<String> distinctCIds = new LinkedHashSet<>();
|
||||
for (String cId : dto.getCIds())
|
||||
{
|
||||
if (StringUtils.isEmpty(cId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String trimmed = cId.trim();
|
||||
if (!distinctCIds.add(trimmed))
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
}
|
||||
}
|
||||
if (distinctCIds.isEmpty())
|
||||
{
|
||||
throw new ServiceException("请至少选择一门有效课程");
|
||||
}
|
||||
|
||||
// 校验课程在公共课程库中的存在性与状态
|
||||
for (String cId : distinctCIds)
|
||||
{
|
||||
TrainCourse course = courseService.selectTrainCourseByCId(cId);
|
||||
if (course == null || "2".equals(course.getDelFlag()))
|
||||
{
|
||||
throw new ServiceException(PlanErrorCode.COURSE_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
if (Integer.valueOf(0).equals(course.getStatus()))
|
||||
{
|
||||
throw new ServiceException(PlanErrorCode.COURSE_INACTIVE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验是否已在当前计划中配置过
|
||||
List<String> existingCIds = planCourseService.selectExistingCIdsByPlanId(planId, new ArrayList<>(distinctCIds));
|
||||
if (existingCIds != null && !existingCIds.isEmpty())
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
}
|
||||
|
||||
// 批量新增,原子事务入库并捕获并发唯一约束冲突 (uq_plan_course)
|
||||
Date now = DateUtils.getNowDate();
|
||||
List<TrainPlanCourse> insertList = new ArrayList<>(distinctCIds.size());
|
||||
int sort = 0;
|
||||
for (String cId : distinctCIds)
|
||||
{
|
||||
TrainPlanCourse pc = new TrainPlanCourse();
|
||||
pc.setPlanId(planId);
|
||||
pc.setCId(cId);
|
||||
pc.setSortNo(sort++);
|
||||
pc.setCreateBy(operator);
|
||||
pc.setCreateTime(now);
|
||||
pc.setRemark(dto.getRemark());
|
||||
insertList.add(pc);
|
||||
}
|
||||
try
|
||||
{
|
||||
planCourseService.batchInsertTrainPlanCourse(insertList);
|
||||
}
|
||||
catch (org.springframework.dao.DuplicateKeyException e)
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (isUniqueConstraintViolation(e))
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_DUPLICATED_MESSAGE, PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUniqueConstraintViolation(Throwable throwable)
|
||||
{
|
||||
Throwable current = throwable;
|
||||
while (current != null)
|
||||
{
|
||||
String msg = current.getMessage();
|
||||
if (msg != null && (msg.contains("uq_plan_course") || msg.contains("unique constraint") || msg.contains("Duplicate entry")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removePlanCourse(String planId, Long planCourseId, String operator)
|
||||
{
|
||||
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
|
||||
if (isLocked(plan.getPlanStatus()))
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
|
||||
}
|
||||
if (planCourseId == null)
|
||||
{
|
||||
throw new ServiceException("本期课程ID不能为空");
|
||||
}
|
||||
TrainPlanCourse existing = planCourseService.selectTrainPlanCourseById(planCourseId);
|
||||
if (existing == null || !planId.equals(existing.getPlanId()))
|
||||
{
|
||||
throw new ServiceException(PlanErrorCode.PLAN_COURSE_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
// 检查下游课次引用 (train_lesson)
|
||||
if (planCourseService.countLessonsByPlanCourseId(planCourseId) > 0)
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_HAS_LESSONS_MESSAGE, PlanErrorCode.PLAN_COURSE_HAS_LESSONS);
|
||||
}
|
||||
// 检查下游成绩项引用 (train_score_item)
|
||||
if (planCourseService.countScoreItemsByPlanCourseId(planCourseId) > 0)
|
||||
{
|
||||
throw error(PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS_MESSAGE, PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS);
|
||||
}
|
||||
// 检查下游学习时长记录引用 (train_study_duration)
|
||||
if (planCourseService.countStudyDurationsByPlanCourseId(planCourseId) > 0)
|
||||
{
|
||||
throw new ServiceException(PlanErrorCode.PLAN_COURSE_HAS_STUDY_DURATIONS_MESSAGE);
|
||||
}
|
||||
|
||||
planCourseService.deleteTrainPlanCourseById(planCourseId);
|
||||
}
|
||||
|
||||
private List<PlanEnrollmentCompletionPort> requireEnrollmentCompletionPorts()
|
||||
{
|
||||
if (enrollmentCompletionPorts.isEmpty())
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.train.domain.TrainPlanCourse;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
|
||||
import com.ruoyi.train.service.ITrainPlanCourseService;
|
||||
|
||||
/**
|
||||
* 培训班与课程关联Service业务层处理。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-14
|
||||
*/
|
||||
@Service
|
||||
public class TrainPlanCourseServiceImpl implements ITrainPlanCourseService
|
||||
{
|
||||
private final TrainPlanCourseMapper trainPlanCourseMapper;
|
||||
|
||||
@Autowired
|
||||
public TrainPlanCourseServiceImpl(TrainPlanCourseMapper trainPlanCourseMapper)
|
||||
{
|
||||
this.trainPlanCourseMapper = trainPlanCourseMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId)
|
||||
{
|
||||
return trainPlanCourseMapper.selectTrainPlanCourseById(planCourseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId)
|
||||
{
|
||||
return trainPlanCourseMapper.selectPlanCourseListByPlanId(planId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds)
|
||||
{
|
||||
if (cIds == null || cIds.isEmpty())
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return trainPlanCourseMapper.selectExistingCIdsByPlanId(planId, cIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse)
|
||||
{
|
||||
return trainPlanCourseMapper.insertTrainPlanCourse(trainPlanCourse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int batchInsertTrainPlanCourse(List<TrainPlanCourse> list)
|
||||
{
|
||||
if (list == null || list.isEmpty())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return trainPlanCourseMapper.batchInsertTrainPlanCourse(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainPlanCourseById(Long planCourseId)
|
||||
{
|
||||
return trainPlanCourseMapper.deleteTrainPlanCourseById(planCourseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countLessonsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return trainPlanCourseMapper.countLessonsByPlanCourseId(planCourseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countScoreItemsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return trainPlanCourseMapper.countScoreItemsByPlanCourseId(planCourseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countStudyDurationsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return trainPlanCourseMapper.countStudyDurationsByPlanCourseId(planCourseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainPlanCourseMapper">
|
||||
|
||||
<resultMap type="TrainPlanCourse" id="TrainPlanCourseResult">
|
||||
<result property="planCourseId" column="plan_course_id" />
|
||||
<result property="planId" column="plan_id" />
|
||||
<result property="cId" column="c_id" />
|
||||
<result property="sortNo" column="sort_no" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="PlanCourseItemVO" id="PlanCourseItemResult">
|
||||
<result property="planCourseId" column="plan_course_id" />
|
||||
<result property="planId" column="plan_id" />
|
||||
<result property="cId" column="c_id" />
|
||||
<result property="cCode" column="c_code" />
|
||||
<result property="cName" column="c_name" />
|
||||
<result property="cType" column="c_type" />
|
||||
<result property="cDuration" column="c_duration" />
|
||||
<result property="teachingObjective" column="teaching_objective" />
|
||||
<result property="sortNo" column="sort_no" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="createTime" column="create_time" />
|
||||
</resultMap>
|
||||
|
||||
<select id="selectTrainPlanCourseById" parameterType="Long" resultMap="TrainPlanCourseResult">
|
||||
select plan_course_id, plan_id, c_id, sort_no, create_by, create_time, remark
|
||||
from train_plan_course
|
||||
where plan_course_id = #{planCourseId}
|
||||
</select>
|
||||
|
||||
<select id="selectPlanCourseListByPlanId" parameterType="String" resultMap="PlanCourseItemResult">
|
||||
select pc.plan_course_id,
|
||||
pc.plan_id,
|
||||
pc.c_id,
|
||||
c.c_code,
|
||||
c.c_name,
|
||||
c.c_type,
|
||||
c.c_duration,
|
||||
c.teaching_objective,
|
||||
pc.sort_no,
|
||||
pc.remark,
|
||||
pc.create_time
|
||||
from train_plan_course pc
|
||||
left join train_course c on pc.c_id = c.c_id
|
||||
where pc.plan_id = #{planId}
|
||||
order by pc.sort_no asc, pc.plan_course_id asc
|
||||
</select>
|
||||
|
||||
<select id="selectExistingCIdsByPlanId" resultType="String">
|
||||
select c_id from train_plan_course
|
||||
where plan_id = #{planId}
|
||||
<if test="cIds != null and cIds.size() > 0">
|
||||
and c_id in
|
||||
<foreach collection="cIds" item="cId" open="(" separator="," close=")">
|
||||
#{cId}
|
||||
</foreach>
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertTrainPlanCourse" parameterType="TrainPlanCourse" useGeneratedKeys="true" keyProperty="planCourseId">
|
||||
insert into train_plan_course(
|
||||
plan_id,
|
||||
c_id,
|
||||
sort_no,
|
||||
create_by,
|
||||
create_time,
|
||||
remark
|
||||
)values(
|
||||
#{planId},
|
||||
#{cId},
|
||||
coalesce(#{sortNo}, 0),
|
||||
#{createBy},
|
||||
coalesce(#{createTime}, CURRENT_TIMESTAMP),
|
||||
#{remark}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="batchInsertTrainPlanCourse" parameterType="java.util.List">
|
||||
insert into train_plan_course(
|
||||
plan_id,
|
||||
c_id,
|
||||
sort_no,
|
||||
create_by,
|
||||
create_time,
|
||||
remark
|
||||
) values
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(
|
||||
#{item.planId},
|
||||
#{item.cId},
|
||||
coalesce(#{item.sortNo}, 0),
|
||||
#{item.createBy},
|
||||
coalesce(#{item.createTime}, CURRENT_TIMESTAMP),
|
||||
#{item.remark}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<delete id="deleteTrainPlanCourseById" parameterType="Long">
|
||||
delete from train_plan_course where plan_course_id = #{planCourseId}
|
||||
</delete>
|
||||
|
||||
<select id="countLessonsByPlanCourseId" parameterType="Long" resultType="int">
|
||||
select count(*) from train_lesson where plan_course_id = #{planCourseId}
|
||||
</select>
|
||||
|
||||
<select id="countScoreItemsByPlanCourseId" parameterType="Long" resultType="int">
|
||||
select count(*) from train_score_item where plan_course_id = #{planCourseId}
|
||||
</select>
|
||||
|
||||
<select id="countStudyDurationsByPlanCourseId" parameterType="Long" resultType="int">
|
||||
select count(*) from train_study_duration where plan_course_id = #{planCourseId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,644 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.constant.PlanErrorCode;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.TrainPlanCourse;
|
||||
import com.ruoyi.train.domain.dto.PlanCourseAddDTO;
|
||||
import com.ruoyi.train.domain.query.PlanQuery;
|
||||
import com.ruoyi.train.domain.vo.CourseDetailVO;
|
||||
import com.ruoyi.train.domain.vo.PlanCourseItemVO;
|
||||
import com.ruoyi.train.domain.vo.PlanItemVO;
|
||||
import com.ruoyi.train.service.ITrainClassPlanService;
|
||||
import com.ruoyi.train.service.ITrainCourseService;
|
||||
import com.ruoyi.train.service.ITrainPlanCourseService;
|
||||
|
||||
/**
|
||||
* ISSUE-02-02 本期培训课程引入与配置单元测试。
|
||||
*/
|
||||
public class PlanCourseTest
|
||||
{
|
||||
private InMemoryPlanService planRepository;
|
||||
private InMemoryPlanCourseService planCourseRepository;
|
||||
private InMemoryCourseService courseRepository;
|
||||
private PlanAppServiceImpl appService;
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
planRepository = new InMemoryPlanService();
|
||||
planCourseRepository = new InMemoryPlanCourseService();
|
||||
courseRepository = new InMemoryCourseService();
|
||||
|
||||
// 预置公共课程
|
||||
TrainCourse c1 = createCourse("C001", "CRH380A-01", "CRH380A型动车组驾驶制动原理", "理论", 16, 1);
|
||||
TrainCourse c2 = createCourse("C002", "SIG-ETCS-02", "CTCS-3级列控车载设备维护", "实训", 24, 1);
|
||||
TrainCourse c3 = createCourse("C003", "SAFE-RULE-03", "铁路行车安全规则与典型事故案例", "理论", 8, 1);
|
||||
TrainCourse disabledCourse = createCourse("C004", "OLD-SYS-04", "老式内燃机车基础(已停用)", "理论", 10, 0);
|
||||
|
||||
courseRepository.addCourse(c1);
|
||||
courseRepository.addCourse(c2);
|
||||
courseRepository.addCourse(c3);
|
||||
courseRepository.addCourse(disabledCourse);
|
||||
|
||||
// 预置培训计划
|
||||
TrainClassPlan plan = new TrainClassPlan();
|
||||
plan.setPlanId("PLAN01");
|
||||
plan.setPlanName("2026年第一期高铁司机提职培训班");
|
||||
plan.setPlanStatus(0); // 未开始
|
||||
plan.setDelFlag("0");
|
||||
planRepository.insertTrainClassPlan(plan);
|
||||
|
||||
appService = new PlanAppServiceImpl(planRepository, planCourseRepository, courseRepository, Collections.emptyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_Success()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Arrays.asList("C001", "C002"));
|
||||
dto.setRemark("专业核心课");
|
||||
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
|
||||
assertEquals(2, list.size());
|
||||
assertEquals("PLAN01", list.get(0).getPlanId());
|
||||
assertEquals("C001", list.get(0).getCId());
|
||||
assertEquals("CRH380A型动车组驾驶制动原理", list.get(0).getCName());
|
||||
assertNotNull(list.get(0).getPlanCourseId());
|
||||
assertEquals("C002", list.get(1).getCId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectDuplicateInRequest()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Arrays.asList("C001", "C001"));
|
||||
dto.setRemark("重复选课");
|
||||
|
||||
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
|
||||
() -> appService.configureCourses("PLAN01", dto, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectAlreadyConfiguredCourse()
|
||||
{
|
||||
// 第一次成功添加 C001
|
||||
PlanCourseAddDTO dto1 = new PlanCourseAddDTO();
|
||||
dto1.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto1, "admin");
|
||||
|
||||
// 再次添加 C001,触发 AC-01
|
||||
PlanCourseAddDTO dto2 = new PlanCourseAddDTO();
|
||||
dto2.setCIds(Arrays.asList("C001", "C002"));
|
||||
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
|
||||
() -> appService.configureCourses("PLAN01", dto2, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectMissingPlan()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
|
||||
assertError(PlanErrorCode.PLAN_NOT_FOUND,
|
||||
() -> appService.configureCourses("MISSING_PLAN", dto, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectMissingCourse()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("NON_EXIST_COURSE"));
|
||||
|
||||
try
|
||||
{
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
fail("课程不存在时必须拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue("课程不存在不得占用40020错误码", e.getCode() == null || e.getCode() != PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
assertEquals(PlanErrorCode.COURSE_NOT_FOUND_MESSAGE, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectInactiveCourse()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C004")); // 状态为0停用
|
||||
|
||||
try
|
||||
{
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
fail("停用课程禁止引入");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue("课程停用不得占用40020错误码", e.getCode() == null || e.getCode() != PlanErrorCode.PLAN_COURSE_DUPLICATED);
|
||||
assertEquals(PlanErrorCode.COURSE_INACTIVE_MESSAGE, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPlanCourseAddDTO_SingleJsonFieldContract() throws Exception
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Arrays.asList("C001", "C002"));
|
||||
dto.setRemark("仅暴露冻结契约字段");
|
||||
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
String json = mapper.writeValueAsString(dto);
|
||||
|
||||
assertTrue("JSON 必须包含 cIds 字段", json.contains("\"cIds\""));
|
||||
assertTrue("JSON 必须包含 remark 字段", json.contains("\"remark\""));
|
||||
assertTrue("JSON 严禁包含 courseIds 字段", !json.contains("\"courseIds\""));
|
||||
|
||||
// 反射检查 getter/setter
|
||||
for (java.lang.reflect.Method method : PlanCourseAddDTO.class.getDeclaredMethods())
|
||||
{
|
||||
assertTrue("DTO 严禁暴露 courseIds 相关方法", !method.getName().toLowerCase().contains("courseids"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_ConcurrentUniqueConstraintConflictMappedTo40020()
|
||||
{
|
||||
// 模拟并发场景:前置查询未拦截,底层数据库抛出唯一约束冲突异常
|
||||
planCourseRepository.simulateDuplicateKeyException = true;
|
||||
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C003"));
|
||||
dto.setRemark("并发冲突模拟");
|
||||
|
||||
assertError(PlanErrorCode.PLAN_COURSE_DUPLICATED,
|
||||
() -> appService.configureCourses("PLAN01", dto, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectCompletedPlan()
|
||||
{
|
||||
planRepository.store.get("PLAN01").setPlanStatus(2); // 已结束
|
||||
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
|
||||
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
|
||||
() -> appService.configureCourses("PLAN01", dto, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_RejectCancelledPlan()
|
||||
{
|
||||
planRepository.store.get("PLAN01").setPlanStatus(3); // 已取消
|
||||
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
|
||||
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
|
||||
() -> appService.configureCourses("PLAN01", dto, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConfigure_TransactionalAnnotation() throws NoSuchMethodException
|
||||
{
|
||||
Transactional tx = PlanAppServiceImpl.class
|
||||
.getMethod("configureCourses", String.class, PlanCourseAddDTO.class, String.class)
|
||||
.getAnnotation(Transactional.class);
|
||||
assertNotNull("批量选课必须声明 @Transactional", tx);
|
||||
assertEquals(Exception.class, tx.rollbackFor()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testList_Success()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Arrays.asList("C001", "C002"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
|
||||
assertEquals(2, list.size());
|
||||
assertEquals("CRH380A-01", list.get(0).getCCode());
|
||||
assertEquals("CTCS-3级列控车载设备维护", list.get(1).getCName());
|
||||
assertEquals(Integer.valueOf(24), list.get(1).getCDuration());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testList_RejectMissingPlan()
|
||||
{
|
||||
assertError(PlanErrorCode.PLAN_NOT_FOUND,
|
||||
() -> appService.listPlanCourses("NOT_EXIST_PLAN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_Success()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
|
||||
Long planCourseId = list.get(0).getPlanCourseId();
|
||||
|
||||
appService.removePlanCourse("PLAN01", planCourseId, "admin");
|
||||
|
||||
assertEquals(0, appService.listPlanCourses("PLAN01").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectLockedPlan()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
List<PlanCourseItemVO> list = appService.listPlanCourses("PLAN01");
|
||||
Long planCourseId = list.get(0).getPlanCourseId();
|
||||
|
||||
planRepository.store.get("PLAN01").setPlanStatus(2); // 锁定状态
|
||||
|
||||
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
|
||||
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectMissingPlanCourse()
|
||||
{
|
||||
try
|
||||
{
|
||||
appService.removePlanCourse("PLAN01", 999999L, "admin");
|
||||
fail("删除不存在的 planCourseId 必须拒绝");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("不存在") || e.getMessage().contains("不属于"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectCrossPlanCourse()
|
||||
{
|
||||
// 创建第二个计划并配置 C001
|
||||
TrainClassPlan plan2 = new TrainClassPlan();
|
||||
plan2.setPlanId("PLAN02");
|
||||
plan2.setPlanName("2026年第二期培训班");
|
||||
plan2.setPlanStatus(0);
|
||||
planRepository.insertTrainClassPlan(plan2);
|
||||
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN02", dto, "admin");
|
||||
|
||||
Long plan2CourseId = appService.listPlanCourses("PLAN02").get(0).getPlanCourseId();
|
||||
|
||||
// 尝试用 PLAN01 越权删除 PLAN02 的本期课程记录
|
||||
try
|
||||
{
|
||||
appService.removePlanCourse("PLAN01", plan2CourseId, "admin");
|
||||
fail("跨计划删除必须拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("不属于") || e.getMessage().contains("不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectWhenHasLessons()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
|
||||
|
||||
// 模拟已存在下游课次
|
||||
planCourseRepository.lessonCountMap.put(planCourseId, 1);
|
||||
|
||||
assertError(PlanErrorCode.PLAN_COURSE_HAS_LESSONS,
|
||||
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectWhenHasScoreItems()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
|
||||
|
||||
// 模拟已存在成绩考核项
|
||||
planCourseRepository.scoreItemCountMap.put(planCourseId, 1);
|
||||
|
||||
assertError(PlanErrorCode.PLAN_COURSE_HAS_SCORE_ITEMS,
|
||||
() -> appService.removePlanCourse("PLAN01", planCourseId, "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemove_RejectWhenHasStudyDurations()
|
||||
{
|
||||
PlanCourseAddDTO dto = new PlanCourseAddDTO();
|
||||
dto.setCIds(Collections.singletonList("C001"));
|
||||
appService.configureCourses("PLAN01", dto, "admin");
|
||||
|
||||
Long planCourseId = appService.listPlanCourses("PLAN01").get(0).getPlanCourseId();
|
||||
|
||||
// 模拟已存在外部学习记录
|
||||
planCourseRepository.studyDurationCountMap.put(planCourseId, 1);
|
||||
|
||||
try
|
||||
{
|
||||
appService.removePlanCourse("PLAN01", planCourseId, "admin");
|
||||
fail("存在下游外部学习记录时必须拒绝删除");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("学习记录"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPlanDomainInvariants()
|
||||
{
|
||||
for (Field field : TrainClassPlan.class.getDeclaredFields())
|
||||
{
|
||||
assertTrue("Plan 实体严禁包含单课程字段 (cId / courseId)",
|
||||
!"cId".equalsIgnoreCase(field.getName()) && !"courseId".equalsIgnoreCase(field.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
private TrainCourse createCourse(String cId, String cCode, String cName, String cType, int duration, int status)
|
||||
{
|
||||
TrainCourse course = new TrainCourse();
|
||||
course.setCId(cId);
|
||||
course.setCCode(cCode);
|
||||
course.setCName(cName);
|
||||
course.setCType(cType);
|
||||
course.setCDuration(duration);
|
||||
course.setStatus(status);
|
||||
course.setDelFlag("0");
|
||||
return course;
|
||||
}
|
||||
|
||||
private void assertError(int code, ThrowingAction action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action.run();
|
||||
fail("Expected ServiceException code " + code);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(Integer.valueOf(code), e.getCode());
|
||||
assertTrue(e.getMessage() != null && !e.getMessage().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private interface ThrowingAction
|
||||
{
|
||||
void run();
|
||||
}
|
||||
|
||||
private static class InMemoryPlanService implements ITrainClassPlanService
|
||||
{
|
||||
private final Map<String, TrainClassPlan> store = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public TrainClassPlan selectTrainClassPlanByPlanId(String planId)
|
||||
{
|
||||
return store.get(planId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
|
||||
{
|
||||
return store.get(planId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByPlanName(String planName, String excludePlanId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainClassPlan(TrainClassPlan trainClassPlan)
|
||||
{
|
||||
store.put(trainClassPlan.getPlanId(), trainClassPlan);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainClassPlan(TrainClassPlan trainClassPlan)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryPlanCourseService implements ITrainPlanCourseService
|
||||
{
|
||||
private final AtomicLong idSeq = new AtomicLong(1000);
|
||||
private final Map<Long, TrainPlanCourse> store = new LinkedHashMap<>();
|
||||
private final Map<Long, Integer> lessonCountMap = new HashMap<>();
|
||||
private final Map<Long, Integer> scoreItemCountMap = new HashMap<>();
|
||||
private final Map<Long, Integer> studyDurationCountMap = new HashMap<>();
|
||||
private boolean simulateDuplicateKeyException = false;
|
||||
|
||||
@Override
|
||||
public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId)
|
||||
{
|
||||
return store.get(planCourseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlanCourseItemVO> selectPlanCourseListByPlanId(String planId)
|
||||
{
|
||||
List<PlanCourseItemVO> result = new ArrayList<>();
|
||||
for (TrainPlanCourse pc : store.values())
|
||||
{
|
||||
if (planId.equals(pc.getPlanId()))
|
||||
{
|
||||
PlanCourseItemVO vo = new PlanCourseItemVO();
|
||||
vo.setPlanCourseId(pc.getPlanCourseId());
|
||||
vo.setPlanId(pc.getPlanId());
|
||||
vo.setCId(pc.getCId());
|
||||
vo.setSortNo(pc.getSortNo());
|
||||
vo.setRemark(pc.getRemark());
|
||||
vo.setCreateTime(pc.getCreateTime());
|
||||
|
||||
TrainCourse course = courseRepository.selectTrainCourseByCId(pc.getCId());
|
||||
if (course != null)
|
||||
{
|
||||
vo.setCCode(course.getCCode());
|
||||
vo.setCName(course.getCName());
|
||||
vo.setCType(course.getCType());
|
||||
vo.setCDuration(course.getCDuration());
|
||||
vo.setTeachingObjective(course.getTeachingObjective());
|
||||
}
|
||||
result.add(vo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> selectExistingCIdsByPlanId(String planId, List<String> cIds)
|
||||
{
|
||||
List<String> exists = new ArrayList<>();
|
||||
for (TrainPlanCourse pc : store.values())
|
||||
{
|
||||
if (planId.equals(pc.getPlanId()) && cIds.contains(pc.getCId()))
|
||||
{
|
||||
exists.add(pc.getCId());
|
||||
}
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse)
|
||||
{
|
||||
long id = idSeq.incrementAndGet();
|
||||
trainPlanCourse.setPlanCourseId(id);
|
||||
store.put(id, trainPlanCourse);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int batchInsertTrainPlanCourse(List<TrainPlanCourse> list)
|
||||
{
|
||||
if (simulateDuplicateKeyException)
|
||||
{
|
||||
throw new org.springframework.dao.DuplicateKeyException("Unique constraint violation on uq_plan_course");
|
||||
}
|
||||
for (TrainPlanCourse item : list)
|
||||
{
|
||||
insertTrainPlanCourse(item);
|
||||
}
|
||||
return list.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainPlanCourseById(Long planCourseId)
|
||||
{
|
||||
return store.remove(planCourseId) != null ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countLessonsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return lessonCountMap.getOrDefault(planCourseId, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countScoreItemsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return scoreItemCountMap.getOrDefault(planCourseId, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countStudyDurationsByPlanCourseId(Long planCourseId)
|
||||
{
|
||||
return studyDurationCountMap.getOrDefault(planCourseId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static class InMemoryCourseService implements ITrainCourseService
|
||||
{
|
||||
private final Map<String, TrainCourse> courseMap = new HashMap<>();
|
||||
|
||||
public void addCourse(TrainCourse course)
|
||||
{
|
||||
courseMap.put(course.getCId(), course);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CourseDetailVO selectCourseDetailByCId(String cId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainCourse selectTrainCourseByCId(String cId)
|
||||
{
|
||||
return courseMap.get(cId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainCourse> selectTrainCourseList(TrainCourse trainCourse)
|
||||
{
|
||||
return new ArrayList<>(courseMap.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainCourse(TrainCourse trainCourse)
|
||||
{
|
||||
courseMap.put(trainCourse.getCId(), trainCourse);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainCourse(TrainCourse trainCourse)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainCourseByCIds(String[] cIds)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkCCodeUnique(TrainCourse trainCourse)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainCourse selectTrainCourseByCCode(String cCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainCourseByCId(String cId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询本期已配置课程列表
|
||||
export function listPlanCourses(planId) {
|
||||
return request({
|
||||
url: `/train/plans/${planId}/courses`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 配置本期课程 (UC-03)
|
||||
export function configurePlanCourses(planId, data) {
|
||||
return request({
|
||||
url: `/train/plans/${planId}/courses`,
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 移除本期课程
|
||||
export function removePlanCourse(planId, planCourseId) {
|
||||
return request({
|
||||
url: `/train/plans/${planId}/courses/${planCourseId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -60,10 +60,25 @@
|
||||
<el-table-column label="报到截止" align="center" prop="reportDeadline" width="165" />
|
||||
<el-table-column label="培训地点" align="left" prop="trainAddress" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="计划人数" align="center" prop="recruitNum" width="90" />
|
||||
<el-table-column label="课程数" align="center" prop="courseCount" width="80" />
|
||||
<el-table-column label="参训人数" align="center" prop="enrolledCount" width="90" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="170" fixed="right">
|
||||
<el-table-column label="课程数" align="center" prop="courseCount" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-hasPermi="['train:plan:query']"
|
||||
type="text"
|
||||
@click="handleCourseConfig(scope.row)"
|
||||
>{{ scope.row.courseCount || 0 }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参训人数" align="center" prop="enrolledCount" width="90" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="230" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-hasPermi="['train:plan:query']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-reading"
|
||||
@click="handleCourseConfig(scope.row)"
|
||||
>课程配置</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['train:plan:edit']"
|
||||
size="mini"
|
||||
@@ -92,6 +107,7 @@
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
<!-- 创建/编辑培训计划对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="660px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="培训班名称" prop="planName">
|
||||
@@ -136,11 +152,116 @@
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 本期课程配置对话框 -->
|
||||
<el-dialog :title="courseDialog.title" :visible.sync="courseDialog.open" width="900px" append-to-body>
|
||||
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<span>班级状态:</span>
|
||||
<el-tag :type="statusTagType(courseDialog.planStatus)">{{ statusLabel(courseDialog.planStatus) }}</el-tag>
|
||||
<span style="margin-left: 15px; color: #909399; font-size: 13px;">已配置课程:{{ courseDialog.list.length }} 门</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button
|
||||
v-hasPermi="['train:plan:edit']"
|
||||
type="primary"
|
||||
size="mini"
|
||||
icon="el-icon-plus"
|
||||
:disabled="isLocked(courseDialog.planStatus)"
|
||||
@click="handleOpenCourseSelector"
|
||||
>引入课程</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="courseDialog.loading" :data="courseDialog.list" border style="width: 100%">
|
||||
<el-table-column label="序号" type="index" width="55" align="center" />
|
||||
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" prop="cType" width="80" align="center" />
|
||||
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
|
||||
<el-table-column label="排序号" prop="sortNo" width="70" align="center" />
|
||||
<el-table-column label="配置说明" prop="remark" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" align="center" width="80" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-hasPermi="['train:plan:edit']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
style="color: #F56C6C;"
|
||||
:disabled="isLocked(courseDialog.planStatus)"
|
||||
@click="handleRemoveCourse(scope.row)"
|
||||
>移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="courseDialog.open = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 引入公共课程选择器弹窗 -->
|
||||
<el-dialog title="引入公共课程" :visible.sync="selectorDialog.open" width="800px" append-to-body>
|
||||
<el-form :model="selectorDialog.queryParams" size="small" :inline="true">
|
||||
<el-form-item label="课程名称">
|
||||
<el-input v-model="selectorDialog.queryParams.cName" placeholder="课程名称" clearable @keyup.enter.native="getCoursePool" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程编码">
|
||||
<el-input v-model="selectorDialog.queryParams.cCode" placeholder="课程编码" clearable @keyup.enter.native="getCoursePool" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="getCoursePool">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetCoursePool">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
ref="courseTable"
|
||||
v-loading="selectorDialog.loading"
|
||||
:data="selectorDialog.list"
|
||||
height="320"
|
||||
@selection-change="handleCourseSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" :selectable="checkCourseSelectable" />
|
||||
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="课程类型" prop="cType" width="80" align="center" />
|
||||
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
|
||||
<el-table-column label="状态" align="center" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="isAlreadyAdded(scope.row.cId)" type="info" size="mini">已配置</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === 0" type="danger" size="mini">已停用</el-tag>
|
||||
<el-tag v-else type="success" size="mini">可选</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<el-form size="small" label-width="110px">
|
||||
<el-form-item label="配置说明 / 备注">
|
||||
<el-input v-model="selectorDialog.remark" placeholder="请输入本期课程配置备注(可选)" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="selectedCourses.length === 0"
|
||||
:loading="selectorDialog.submitting"
|
||||
@click="submitCourseSelection"
|
||||
>确定引入 ({{ selectedCourses.length }})</el-button>
|
||||
<el-button @click="selectorDialog.open = false">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addPlan, completePlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
|
||||
import { listPlanCourses, configurePlanCourses, removePlanCourse } from '@/api/train/planCourse'
|
||||
import { listCourse } from '@/api/train/course'
|
||||
|
||||
export default {
|
||||
name: 'Plan',
|
||||
@@ -172,7 +293,31 @@ export default {
|
||||
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
|
||||
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
|
||||
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
|
||||
}
|
||||
},
|
||||
// 本期课程配置对话框
|
||||
courseDialog: {
|
||||
open: false,
|
||||
loading: false,
|
||||
title: '',
|
||||
planId: null,
|
||||
planName: '',
|
||||
planStatus: null,
|
||||
list: []
|
||||
},
|
||||
// 公共课程选择器
|
||||
selectorDialog: {
|
||||
open: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
remark: '',
|
||||
queryParams: {
|
||||
cName: null,
|
||||
cCode: null,
|
||||
status: 1
|
||||
},
|
||||
list: []
|
||||
},
|
||||
selectedCourses: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -283,6 +428,81 @@ export default {
|
||||
this.$modal.msgSuccess('计划已结束')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
// ---- ISSUE-02-02 本期课程配置方法 ----
|
||||
handleCourseConfig(row) {
|
||||
this.courseDialog.planId = row.planId
|
||||
this.courseDialog.planName = row.planName
|
||||
this.courseDialog.planStatus = row.planStatus
|
||||
this.courseDialog.title = `本期课程配置 - ${row.planName}`
|
||||
this.courseDialog.open = true
|
||||
this.loadPlanCourses(row.planId)
|
||||
},
|
||||
loadPlanCourses(planId) {
|
||||
this.courseDialog.loading = true
|
||||
listPlanCourses(planId).then(response => {
|
||||
this.courseDialog.list = response.data || []
|
||||
}).finally(() => {
|
||||
this.courseDialog.loading = false
|
||||
})
|
||||
},
|
||||
handleOpenCourseSelector() {
|
||||
this.selectorDialog.remark = ''
|
||||
this.selectedCourses = []
|
||||
this.selectorDialog.queryParams.cName = null
|
||||
this.selectorDialog.queryParams.cCode = null
|
||||
this.selectorDialog.open = true
|
||||
this.getCoursePool()
|
||||
},
|
||||
getCoursePool() {
|
||||
this.selectorDialog.loading = true
|
||||
listCourse(this.selectorDialog.queryParams).then(response => {
|
||||
this.selectorDialog.list = response.rows || []
|
||||
}).finally(() => {
|
||||
this.selectorDialog.loading = false
|
||||
})
|
||||
},
|
||||
resetCoursePool() {
|
||||
this.selectorDialog.queryParams.cName = null
|
||||
this.selectorDialog.queryParams.cCode = null
|
||||
this.getCoursePool()
|
||||
},
|
||||
isAlreadyAdded(cId) {
|
||||
return this.courseDialog.list.some(item => item.cId === cId)
|
||||
},
|
||||
checkCourseSelectable(row) {
|
||||
return !this.isAlreadyAdded(row.cId) && row.status === 1
|
||||
},
|
||||
handleCourseSelectionChange(selection) {
|
||||
this.selectedCourses = selection
|
||||
},
|
||||
submitCourseSelection() {
|
||||
if (this.selectedCourses.length === 0) {
|
||||
this.$modal.msgWarning('请选择至少一门课程')
|
||||
return
|
||||
}
|
||||
this.selectorDialog.submitting = true
|
||||
const cIds = this.selectedCourses.map(item => item.cId)
|
||||
configurePlanCourses(this.courseDialog.planId, {
|
||||
cIds: cIds,
|
||||
remark: this.selectorDialog.remark
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('成功引入课程')
|
||||
this.selectorDialog.open = false
|
||||
this.loadPlanCourses(this.courseDialog.planId)
|
||||
this.getList()
|
||||
}).finally(() => {
|
||||
this.selectorDialog.submitting = false
|
||||
})
|
||||
},
|
||||
handleRemoveCourse(row) {
|
||||
this.$modal.confirm(`确定要从本期培训计划中移除课程【${row.cName}】吗?`).then(() => {
|
||||
return removePlanCourse(this.courseDialog.planId, row.planCourseId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('移除成功')
|
||||
this.loadPlanCourses(this.courseDialog.planId)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# ISSUE-02-02 本期培训课程引入与配置 开发与验收记录
|
||||
|
||||
## 1. 基线与分支信息
|
||||
- **基线 Commit:** `4682df0399d86fa73105ea23ba2daee06cfb0fa0` (`integration/wave1a-foundation`)
|
||||
- **开发分支:** `feat/issue-02-02-plan-course`
|
||||
- **Worktree 路径:** `/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-02-02-plan-course`
|
||||
|
||||
---
|
||||
|
||||
## 2. 3 个正式公开 Endpoint
|
||||
| 序号 | HTTP Method | Path | 权限标识 | 业务说明 |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| 1 | `GET` | `/train/plans/{planId}/courses` | `train:plan:query` | 查询指定培训班本期已配置课程列表及课程元数据 |
|
||||
| 2 | `POST` | `/train/plans/{planId}/courses` | `train:plan:edit` | 引入课程库公共课程配置至本期计划 (UC-03) |
|
||||
| 3 | `DELETE` | `/train/plans/{planId}/courses/{planCourseId}` | `train:plan:edit` | 移除本期课程(受下游引用与生命周期守护) |
|
||||
|
||||
- **控制器归属:** `com.ruoyi.web.controller.train.TrainPlanCourseController`
|
||||
- **应用编排归属:** `com.ruoyi.train.service.PlanAppService` (严格复用既有 11 个应用服务之一,未新增第 12 个 Application Service)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 请求与响应模型 (DTO / VO)
|
||||
### 3.1 请求对象 (`PlanCourseAddDTO`)
|
||||
- 严格遵循 API Contract V1.0.2 契约,仅暴露以下两个 JSON 字段:
|
||||
- `cIds`: `List<String>`, `@NotEmpty` (课程主键 ID 集合,绑定 `@JsonProperty("cIds")`)
|
||||
- `remark`: `String`, `@Size(max = 500)` (配置说明)
|
||||
- 经单元测试反射与 Jackson 序列化静态校验,确认不存在 `courseIds` 等多余或模糊属性。
|
||||
|
||||
### 3.2 响应对象 (`PlanCourseItemVO`)
|
||||
- `planCourseId`: `Long` (本期课程实例唯一主键)
|
||||
- `planId`: `String` (培训班编号)
|
||||
- `cId`: `String` (课程主数据编号)
|
||||
- `cCode`: `String` (课程业务编码)
|
||||
- `cName`: `String` (课程名称)
|
||||
- `cType`: `String` (课程类型)
|
||||
- `cDuration`: `Integer` (标准学时)
|
||||
- `teachingObjective`: `String` (教学目标)
|
||||
- `sortNo`: `Integer` (排序号)
|
||||
- `remark`: `String` (配置说明)
|
||||
- `createTime`: `Date` (创建时间)
|
||||
|
||||
---
|
||||
|
||||
## 4. 生命周期规则与复用
|
||||
- 严格复用 `TrainClassPlan` 冻结的生命周期与计算规则 (`effective_plan_status`):
|
||||
- `status = 0` (未开始) 与 `status = 1` (进行中):允许配置课程与移除课程;
|
||||
- `status = 2` (已结束) 与 `status = 3` (已取消):只读锁定,禁止任何增删改课程操作,抛出 `40012` (`PLAN_STATUS_LOCKED`)。
|
||||
- 不在课程服务层自行发明独立的时间比对判断,确保前台视图与后台判断无状态分裂。
|
||||
|
||||
---
|
||||
|
||||
## 5. 唯一约束与并发边界 (UNIQUE & Concurrency)
|
||||
- 数据库级约束:`CONSTRAINT uq_plan_course UNIQUE (plan_id, c_id)`。
|
||||
- 应用层前置校验:
|
||||
- 请求体内自包含查重(使用 `LinkedHashSet` 校验,重复抛 `40020` `PLAN_COURSE_DUPLICATED`);
|
||||
- 已存在配置查重(`selectExistingCIdsByPlanId` 查重,存在交集抛 `40020` `PLAN_COURSE_DUPLICATED`)。
|
||||
- 并发兜底机制:
|
||||
- 当高并发下两个事务同时穿透前置检查并执行批量插入时,数据库触发 `uq_plan_course` 唯一约束异常;
|
||||
- 应用层 `PlanAppServiceImpl` 捕获 `DuplicateKeyException` 及 `uq_plan_course` 冲突异常,精确转换为 `40020` `PLAN_COURSE_DUPLICATED`,防止裸 500 或数据库异常泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 下游引用保护与删除阻断
|
||||
- **课次引用 (`train_lesson`):** 若存在已排定课次,阻断删除并返回 `40021` (`PLAN_COURSE_HAS_LESSONS`);
|
||||
- **成绩项引用 (`train_score_item`):** 若已关联成绩考核项目,阻断删除并返回 `40022` (`PLAN_COURSE_HAS_SCORE_ITEMS`);
|
||||
- **外部学习记录 (`train_study_duration`):** 若已产生学习时长记录,阻断删除并抛出业务异常;
|
||||
- **级联删除:** 数据库外键定义为 `ON DELETE NO ACTION`,代码层严格拦截,严禁任何形式的 cascade delete。
|
||||
|
||||
---
|
||||
|
||||
## 7. Contract Gap 登记
|
||||
1. **Course 不存在 / 停用错误码缺失:**
|
||||
- API Contract `10_permission_and_error_codes.md` 仅针对开班计划定义了 `40410` (`PLAN_NOT_FOUND`),未给课程库分配 `COURSE_NOT_FOUND` 或 `COURSE_INACTIVE` 的专有 40xxx 错误码。
|
||||
- 实现方案:抛出带明确错误信息的 `ServiceException`(消息为“课程不存在或已被删除”、“课程已被停用,无法配置到本期培训计划中”),坚决不复用或污染 `40020`。
|
||||
2. **StudyDuration 下游引用错误码缺失:**
|
||||
- 错误码 Catalog 仅定义了 `40021` (Lessons) 与 `40022` (ScoreItems),未定义 `PLAN_COURSE_HAS_STUDY_DURATIONS` 的专属错误码。
|
||||
- 实现方案:fail closed 拦截,抛出提示“该课程已产生学习记录,不可直接移除”,禁止物理级联删除。
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动化测试结果
|
||||
- 单元测试套件:`com.ruoyi.train.service.impl.PlanCourseTest` 与 `PlanAppServiceImplTest`
|
||||
- 测试总数:35 个测试用例全部通过(0 failures, 0 errors, 0 skipped)。
|
||||
- 覆盖场景:
|
||||
1. 正常批量配置课程与元数据返回;
|
||||
2. 请求体内自重复课程拒绝 (`40020`);
|
||||
3. 数据库已配置课程重复添加拦截 (`40020`, AC-01);
|
||||
4. 计划不存在拦截 (`40410`);
|
||||
5. 课程不存在拦截(独立异常语义,非 40020);
|
||||
6. 课程停用拦截(独立异常语义,非 40020);
|
||||
7. 已结束/已取消计划拒绝修改 (`40012`);
|
||||
8. 批量选课 `@Transactional` 原子事务注解验证;
|
||||
9. 本期课程列表查询与字段回显 (AC-02);
|
||||
10. 正常移除本期课程;
|
||||
11. 锁定状态计划禁止移除课程 (`40012`);
|
||||
12. 移除不存在或跨计划的课程配置拦截;
|
||||
13. 存在排定课次时拒绝删除 (`40021`);
|
||||
14. 存在成绩考核项时拒绝删除 (`40022`);
|
||||
15. 存在外部学习时长记录时拒绝删除;
|
||||
16. `PlanCourseAddDTO` 严格单一 JSON 属性契约测试;
|
||||
17. 并发插入数据库唯一键冲突转换映射为 `40020` 测试;
|
||||
18. 实体不变量检验(确保 `TrainClassPlan` 不含有单课程字段)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 规范与红线遵循
|
||||
- **DB 真实写入:** 否(测试全部使用 JVM InMemory Fake Repository,未连接真实数据库)。
|
||||
- **Generator 执行:** 否(未执行任何代码生成器覆写或额外 DDL)。
|
||||
- **Commit 状态:** 未执行 `git commit` / `git merge` / `git rebase` / `git tag`。
|
||||
Reference in new issue
Block a user