From 601894ab23ab1e7930e1e3d6e31b0318b3b34204 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 14 Sep 2026 12:38:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9F=B9=E8=AE=AD=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=9C=AC=E6=9C=9F=E8=AF=BE=E7=A8=8B=E9=85=8D?= =?UTF-8?q?=E7=BD=AEP0=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../train/TrainPlanCourseController.java | 75 ++ .../ruoyi/train/constant/PlanErrorCode.java | 10 + .../ruoyi/train/domain/TrainPlanCourse.java | 86 +++ .../train/domain/dto/PlanCourseAddDTO.java | 48 ++ .../train/domain/vo/PlanCourseItemVO.java | 157 +++++ .../train/mapper/TrainPlanCourseMapper.java | 88 +++ .../service/ITrainPlanCourseService.java | 87 +++ .../ruoyi/train/service/PlanAppService.java | 19 +- .../service/impl/PlanAppServiceImpl.java | 177 ++++- .../impl/TrainPlanCourseServiceImpl.java | 90 +++ .../mapper/train/TrainPlanCourseMapper.xml | 121 ++++ .../train/service/impl/PlanCourseTest.java | 644 ++++++++++++++++++ build_ui/src/api/train/planCourse.js | 26 + build_ui/src/views/train/plan/index.vue | 228 ++++++- docs/development/issue-02-02-plan-course.md | 109 +++ 15 files changed, 1957 insertions(+), 8 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanCourseController.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainPlanCourse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCourseAddDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanCourseItemVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPlanCourseMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainPlanCourseService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainPlanCourseServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPlanCourseMapper.xml create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java create mode 100644 build_ui/src/api/train/planCourse.js create mode 100644 docs/development/issue-02-02-plan-course.md diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanCourseController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanCourseController.java new file mode 100644 index 000000000..700c98629 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanCourseController.java @@ -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 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(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java index 5eeb4dde6..ad2b97a88 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java @@ -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() { diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainPlanCourse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainPlanCourse.java new file mode 100644 index 000000000..5bab18a8b --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainPlanCourse.java @@ -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(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCourseAddDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCourseAddDTO.java new file mode 100644 index 000000000..848c37851 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCourseAddDTO.java @@ -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 cIds = new ArrayList<>(); + + /** 备注说明 */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + + @JsonProperty("cIds") + public List getCIds() + { + return cIds; + } + + @JsonProperty("cIds") + public void setCIds(List cIds) + { + this.cIds = cIds; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanCourseItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanCourseItemVO.java new file mode 100644 index 000000000..066aa7a3a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanCourseItemVO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPlanCourseMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPlanCourseMapper.java new file mode 100644 index 000000000..f2bc65b70 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainPlanCourseMapper.java @@ -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 selectPlanCourseListByPlanId(@Param("planId") String planId); + + /** + * 查询指定计划下已存在的课程ID集合。 + * + * @param planId 培训计划ID + * @param cIds 候选课程ID集合 + * @return 已存在的课程ID列表 + */ + List selectExistingCIdsByPlanId(@Param("planId") String planId, @Param("cIds") List cIds); + + /** + * 单条新增本期课程。 + * + * @param trainPlanCourse 培训班与课程关联信息 + * @return 结果 + */ + int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse); + + /** + * 批量新增本期课程。 + * + * @param list 待插入列表 + * @return 插入行数 + */ + int batchInsertTrainPlanCourse(@Param("list") List 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainPlanCourseService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainPlanCourseService.java new file mode 100644 index 000000000..742a264fd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainPlanCourseService.java @@ -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 selectPlanCourseListByPlanId(String planId); + + /** + * 查询指定计划下已存在的课程ID集合。 + * + * @param planId 培训计划ID + * @param cIds 待校验课程ID集合 + * @return 已存在的课程ID列表 + */ + List selectExistingCIdsByPlanId(String planId, List cIds); + + /** + * 新增本期课程记录。 + * + * @param trainPlanCourse 本期课程信息 + * @return 结果 + */ + int insertTrainPlanCourse(TrainPlanCourse trainPlanCourse); + + /** + * 批量新增本期课程。 + * + * @param list 待新增列表 + * @return 插入行数 + */ + int batchInsertTrainPlanCourse(List 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java index 4f305bcbf..50f1448d1 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java @@ -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 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 listPlanCourses(String planId); + + /** + * 配置本期课程 (UC-03)。 + */ + void configureCourses(String planId, PlanCourseAddDTO dto, String operator); + + /** + * 移除本期课程。 + */ + void removePlanCourse(String planId, Long planCourseId, String operator); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java index a7a9760ca..6ef4af0e0 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java @@ -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 enrollmentCompletionPorts; @Autowired public PlanAppServiceImpl(ITrainClassPlanService planService, + ITrainPlanCourseService planCourseService, + ITrainCourseService courseService, ObjectProvider 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 enrollmentCompletionPorts) + { + this(planService, null, null, enrollmentCompletionPorts); + } + + /** 完整业务编排构造方法 (支持 JVM Fake 单元测试注入) */ + PlanAppServiceImpl(ITrainClassPlanService planService, + ITrainPlanCourseService planCourseService, + ITrainCourseService courseService, + List 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 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 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 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 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 requireEnrollmentCompletionPorts() { if (enrollmentCompletionPorts.isEmpty()) diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainPlanCourseServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainPlanCourseServiceImpl.java new file mode 100644 index 000000000..31fbcde2c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainPlanCourseServiceImpl.java @@ -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 selectPlanCourseListByPlanId(String planId) + { + return trainPlanCourseMapper.selectPlanCourseListByPlanId(planId); + } + + @Override + public List selectExistingCIdsByPlanId(String planId, List 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 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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPlanCourseMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPlanCourseMapper.xml new file mode 100644 index 000000000..a7a2bec3e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPlanCourseMapper.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 into train_plan_course( + plan_id, + c_id, + sort_no, + create_by, + create_time, + remark + ) values + + ( + #{item.planId}, + #{item.cId}, + coalesce(#{item.sortNo}, 0), + #{item.createBy}, + coalesce(#{item.createTime}, CURRENT_TIMESTAMP), + #{item.remark} + ) + + + + + delete from train_plan_course where plan_course_id = #{planCourseId} + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java new file mode 100644 index 000000000..21bda30ee --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanCourseTest.java @@ -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 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 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 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 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 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 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 store = new LinkedHashMap<>(); + private final Map lessonCountMap = new HashMap<>(); + private final Map scoreItemCountMap = new HashMap<>(); + private final Map studyDurationCountMap = new HashMap<>(); + private boolean simulateDuplicateKeyException = false; + + @Override + public TrainPlanCourse selectTrainPlanCourseById(Long planCourseId) + { + return store.get(planCourseId); + } + + @Override + public List selectPlanCourseListByPlanId(String planId) + { + List 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 selectExistingCIdsByPlanId(String planId, List cIds) + { + List 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 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 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 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; + } + } +} diff --git a/build_ui/src/api/train/planCourse.js b/build_ui/src/api/train/planCourse.js new file mode 100644 index 000000000..d20493f6a --- /dev/null +++ b/build_ui/src/api/train/planCourse.js @@ -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' + }) +} diff --git a/build_ui/src/views/train/plan/index.vue b/build_ui/src/views/train/plan/index.vue index 0cffc5149..071e9ddae 100644 --- a/build_ui/src/views/train/plan/index.vue +++ b/build_ui/src/views/train/plan/index.vue @@ -60,10 +60,25 @@ - - - + + + + +