合并培训:集成开班计划P0能力

This commit is contained in:
wangzhibiao committed 2026-09-14 12:16:01 +08:00
commit 4bd2f54856
21 files changed
+1561 -526

No files matched your search

@@ -1,104 +0,0 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.train.domain.TrainClassPlan;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 开班计划Controller
*
* @author ruoyi
* @date 2026-09-08
*/
@RestController
@RequestMapping("/train/plan")
public class TrainClassPlanController extends BaseController
{
@Autowired
private ITrainClassPlanService trainClassPlanService;
/**
* 查询开班计划列表
*/
@PreAuthorize("@ss.hasPermi('system:plan:list')")
@GetMapping("/list")
public TableDataInfo list(TrainClassPlan trainClassPlan)
{
startPage();
List<TrainClassPlan> list = trainClassPlanService.selectTrainClassPlanList(trainClassPlan);
return getDataTable(list);
}
/**
* 导出开班计划列表
*/
@PreAuthorize("@ss.hasPermi('system:plan:export')")
@Log(title = "开班计划", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainClassPlan trainClassPlan)
{
List<TrainClassPlan> list = trainClassPlanService.selectTrainClassPlanList(trainClassPlan);
ExcelUtil<TrainClassPlan> util = new ExcelUtil<TrainClassPlan>(TrainClassPlan.class);
util.exportExcel(response, list, "开班计划数据");
}
/**
* 获取开班计划详细信息
*/
@PreAuthorize("@ss.hasPermi('system:plan:query')")
@GetMapping(value = "/{planId}")
public AjaxResult getInfo(@PathVariable("planId") String planId)
{
return success(trainClassPlanService.selectTrainClassPlanByPlanId(planId));
}
/**
* 新增开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:add')")
@Log(title = "开班计划", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainClassPlan trainClassPlan)
{
return toAjax(trainClassPlanService.insertTrainClassPlan(trainClassPlan));
}
/**
* 修改开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:edit')")
@Log(title = "开班计划", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainClassPlan trainClassPlan)
{
return toAjax(trainClassPlanService.updateTrainClassPlan(trainClassPlan));
}
/**
* 删除开班计划
*/
@PreAuthorize("@ss.hasPermi('system:plan:remove')")
@Log(title = "开班计划", businessType = BusinessType.DELETE)
@DeleteMapping("/{planIds}")
public AjaxResult remove(@PathVariable String[] planIds)
{
return toAjax(trainClassPlanService.deleteTrainClassPlanByPlanIds(planIds));
}
}
@@ -0,0 +1,85 @@
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
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.PlanItemVO;
import com.ruoyi.train.service.PlanAppService;
/** 培训计划生命周期 API。 */
@RestController
@RequestMapping("/train/plans")
public class TrainPlanController extends BaseController
{
private final PlanAppService planAppService;
@Autowired
public TrainPlanController(PlanAppService planAppService)
{
this.planAppService = planAppService;
}
/** 分页查询培训计划列表。 */
@PreAuthorize("@ss.hasPermi('train:plan:list')")
@GetMapping
public TableDataInfo list(PlanQuery query)
{
startPage();
List<PlanItemVO> list = planAppService.listPlans(query);
return getDataTable(list);
}
/** 获取培训计划基本信息。 */
@PreAuthorize("@ss.hasPermi('train:plan:query')")
@GetMapping("/{planId}")
public AjaxResult getInfo(@PathVariable String planId)
{
return success(planAppService.getPlan(planId));
}
/** 创建培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:add')")
@Log(title = "培训计划", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody PlanCreateDTO dto)
{
return success(planAppService.createPlan(dto, SecurityUtils.getUsername()));
}
/** 编辑培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:edit')")
@Log(title = "培训计划", businessType = BusinessType.UPDATE)
@PutMapping("/{planId}")
public AjaxResult edit(@PathVariable String planId, @Validated @RequestBody PlanUpdateDTO dto)
{
planAppService.updatePlan(planId, dto, SecurityUtils.getUsername());
return success();
}
/** 结束培训计划。 */
@PreAuthorize("@ss.hasPermi('train:plan:complete')")
@Log(title = "培训计划结训", businessType = BusinessType.UPDATE)
@PostMapping("/{planId}/complete")
public AjaxResult complete(@PathVariable String planId)
{
planAppService.completePlan(planId, SecurityUtils.getUsername());
return success();
}
}
+8 -1
View File
@@ -23,6 +23,13 @@
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>
@@ -0,0 +1,23 @@
package com.ruoyi.train.constant;
/** 培训计划正式业务错误码(API Contract V1.0.1)。 */
public final class PlanErrorCode
{
public static final int PLAN_TIME_INVALID = 40010;
public static final int PLAN_NAME_DUPLICATED = 40011;
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_NOT_FOUND = 40410;
public static final String PLAN_TIME_INVALID_MESSAGE = "培训计划起止时间不合法 (结束时间必须晚于开始时间)";
public static final String PLAN_NAME_DUPLICATED_MESSAGE = "同名培训计划已存在";
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_NOT_FOUND_MESSAGE = "培训计划不存在";
private PlanErrorCode()
{
}
}
@@ -24,28 +24,25 @@ public class TrainClassPlan extends BaseEntity
@Excel(name = "开班名称")
private String planName;
/** 所属课程编号,关联train_course.c_id */
@Excel(name = "所属课程编号,关联train_course.c_id")
private String cId;
/** 开班时间 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "开班时间", width = 30, dateFormat = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "开班时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
/** 结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 报到截止时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "报到截止时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
/** 培训地点 */
@Excel(name = "培训地点")
private String trainAddress;
/** 授课教师工号,关联train_teacher.t_id */
@Excel(name = "授课教师工号,关联train_teacher.t_id")
private String tId;
/** 招生人数 */
@Excel(name = "招生人数")
private Long recruitNum;
@@ -58,9 +55,8 @@ public class TrainClassPlan extends BaseEntity
@Excel(name = "0 未开始 1 进行中 2 已结束 3 已取消")
private Integer planStatus;
/** 创建管理员账号 */
@Excel(name = "创建管理员账号")
private String createUser;
/** 删除标志(0代表存在 2代表删除) */
private String delFlag;
public void setPlanId(String planId)
{
@@ -82,16 +78,6 @@ public class TrainClassPlan extends BaseEntity
return planName;
}
public void setcId(String cId)
{
this.cId = cId;
}
public String getcId()
{
return cId;
}
public void setStartTime(Date startTime)
{
this.startTime = startTime;
@@ -112,26 +98,26 @@ public class TrainClassPlan extends BaseEntity
return endTime;
}
public void setTrainAddress(String trainAddress)
public void setReportDeadline(Date reportDeadline)
{
this.reportDeadline = reportDeadline;
}
public Date getReportDeadline()
{
return reportDeadline;
}
public void setTrainAddress(String trainAddress)
{
this.trainAddress = trainAddress;
}
public String getTrainAddress()
public String getTrainAddress()
{
return trainAddress;
}
public void settId(String tId)
{
this.tId = tId;
}
public String gettId()
{
return tId;
}
public void setRecruitNum(Long recruitNum)
{
this.recruitNum = recruitNum;
@@ -162,14 +148,14 @@ public class TrainClassPlan extends BaseEntity
return planStatus;
}
public void setCreateUser(String createUser)
public void setDelFlag(String delFlag)
{
this.createUser = createUser;
this.delFlag = delFlag;
}
public String getCreateUser()
public String getDelFlag()
{
return createUser;
return delFlag;
}
@Override
@@ -177,16 +163,17 @@ public class TrainClassPlan extends BaseEntity
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("planId", getPlanId())
.append("planName", getPlanName())
.append("cId", getcId())
.append("startTime", getStartTime())
.append("endTime", getEndTime())
.append("reportDeadline", getReportDeadline())
.append("trainAddress", getTrainAddress())
.append("tId", gettId())
.append("recruitNum", getRecruitNum())
.append("applyCondition", getApplyCondition())
.append("planStatus", getPlanStatus())
.append("createUser", getCreateUser())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.dto;
import java.util.Date;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 创建培训计划请求。 */
public class PlanCreateDTO
{
@NotBlank(message = "培训班名称不能为空")
@Size(max = 150, message = "培训班名称不能超过150个字符")
private String planName;
@NotNull(message = "开班时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@NotNull(message = "结束时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@NotNull(message = "报到截止时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
@NotNull(message = "招生人数不能为空")
@Min(value = 1, message = "招生人数必须大于0")
private Long recruitNum;
@Size(max = 200, message = "培训地点不能超过200个字符")
private String trainAddress;
private String applyCondition;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.dto;
import java.util.Date;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 编辑培训计划请求。生命周期状态不接受客户端直接修改。 */
public class PlanUpdateDTO
{
@NotBlank(message = "培训班名称不能为空")
@Size(max = 150, message = "培训班名称不能超过150个字符")
private String planName;
@NotNull(message = "开班时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@NotNull(message = "结束时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@NotNull(message = "报到截止时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
@NotNull(message = "招生人数不能为空")
@Min(value = 1, message = "招生人数必须大于0")
private Long recruitNum;
@Size(max = 200, message = "培训地点不能超过200个字符")
private String trainAddress;
private String applyCondition;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
}
@@ -0,0 +1,26 @@
package com.ruoyi.train.domain.query;
import java.util.Date;
import org.springframework.format.annotation.DateTimeFormat;
/** 培训计划分页筛选条件。分页参数由 RuoYi BaseController 接管。 */
public class PlanQuery
{
private String planName;
private Integer planStatus;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTimeBegin;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTimeEnd;
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTimeBegin() { return startTimeBegin; }
public void setStartTimeBegin(Date startTimeBegin) { this.startTimeBegin = startTimeBegin; }
public Date getStartTimeEnd() { return startTimeEnd; }
public void setStartTimeEnd(Date startTimeEnd) { this.startTimeEnd = startTimeEnd; }
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 培训计划基本信息视图。 */
public class PlanDetailVO
{
private String planId;
private String planName;
private Integer planStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
private String trainAddress;
private Long recruitNum;
private String applyCondition;
private String remark;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public String getApplyCondition() { return applyCondition; }
public void setApplyCondition(String applyCondition) { this.applyCondition = applyCondition; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getUpdateBy() { return updateBy; }
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}
@@ -0,0 +1,43 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 培训计划列表项。 */
public class PlanItemVO
{
private String planId;
private String planName;
private Integer planStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportDeadline;
private String trainAddress;
private Long recruitNum;
private Long courseCount;
private Long enrolledCount;
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getPlanStatus() { return planStatus; }
public void setPlanStatus(Integer planStatus) { this.planStatus = planStatus; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public Date getReportDeadline() { return reportDeadline; }
public void setReportDeadline(Date reportDeadline) { this.reportDeadline = reportDeadline; }
public String getTrainAddress() { return trainAddress; }
public void setTrainAddress(String trainAddress) { this.trainAddress = trainAddress; }
public Long getRecruitNum() { return recruitNum; }
public void setRecruitNum(Long recruitNum) { this.recruitNum = recruitNum; }
public Long getCourseCount() { return courseCount; }
public void setCourseCount(Long courseCount) { this.courseCount = courseCount; }
public Long getEnrolledCount() { return enrolledCount; }
public void setEnrolledCount(Long enrolledCount) { this.enrolledCount = enrolledCount; }
}
@@ -2,6 +2,9 @@ package com.ruoyi.train.mapper;
import java.util.List;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
import org.apache.ibatis.annotations.Param;
/**
* 开班计划Mapper接口
@@ -19,13 +22,24 @@ public interface TrainClassPlanMapper
*/
public TrainClassPlan selectTrainClassPlanByPlanId(String planId);
/**
* 查询并锁定培训计划,供生命周期动作使用。
*/
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId);
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划集合
*/
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan);
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query);
/**
* 按规范化名称查询重复计划。
*/
public int countTrainClassPlanByName(@Param("planName") String planName,
@Param("excludePlanId") String excludePlanId);
/**
* 新增开班计划
@@ -44,18 +58,7 @@ public interface TrainClassPlanMapper
public int updateTrainClassPlan(TrainClassPlan trainClassPlan);
/**
* 删除开班计划
*
* @param planId 开班计划主键
* @return 结果
* 更新生命周期状态。
*/
public int deleteTrainClassPlanByPlanId(String planId);
/**
* 批量删除开班计划
*
* @param planIds 需要删除的数据主键集合
* @return 结果
*/
public int deleteTrainClassPlanByPlanIds(String[] planIds);
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan);
}
@@ -2,6 +2,8 @@ package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
/**
* 开班计划Service接口
@@ -19,13 +21,17 @@ public interface ITrainClassPlanService
*/
public TrainClassPlan selectTrainClassPlanByPlanId(String planId);
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId);
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划集合
*/
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan);
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query);
public boolean existsByPlanName(String planName, String excludePlanId);
/**
* 新增开班计划
@@ -43,19 +49,5 @@ public interface ITrainClassPlanService
*/
public int updateTrainClassPlan(TrainClassPlan trainClassPlan);
/**
* 批量删除开班计划
*
* @param planIds 需要删除的开班计划主键集合
* @return 结果
*/
public int deleteTrainClassPlanByPlanIds(String[] planIds);
/**
* 删除开班计划信息
*
* @param planId 开班计划主键
* @return 结果
*/
public int deleteTrainClassPlanByPlanId(String planId);
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan);
}
@@ -0,0 +1,22 @@
package com.ruoyi.train.service;
import java.util.List;
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.PlanDetailVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
/** 培训计划生命周期应用服务。 */
public interface PlanAppService
{
List<PlanItemVO> listPlans(PlanQuery query);
PlanDetailVO getPlan(String planId);
String createPlan(PlanCreateDTO dto, String operator);
void updatePlan(String planId, PlanUpdateDTO dto, String operator);
void completePlan(String planId, String operator);
}
@@ -0,0 +1,230 @@
package com.ruoyi.train.service.impl;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
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.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
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.PlanAppService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
/** 培训计划生命周期应用服务实现。 */
@Service
public class PlanAppServiceImpl implements PlanAppService
{
private static final int STATUS_NOT_STARTED = 0;
private static final int STATUS_COMPLETED = 2;
private static final int STATUS_CANCELLED = 3;
private final ITrainClassPlanService planService;
private final List<PlanEnrollmentCompletionPort> enrollmentCompletionPorts;
@Autowired
public PlanAppServiceImpl(ITrainClassPlanService planService,
ObjectProvider<PlanEnrollmentCompletionPort> enrollmentCompletionPortProvider)
{
this(planService, 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 = planService;
this.enrollmentCompletionPorts = enrollmentCompletionPorts == null
? Collections.emptyList() : enrollmentCompletionPorts;
}
@Override
public List<PlanItemVO> listPlans(PlanQuery query)
{
return planService.selectTrainClassPlanList(query == null ? new PlanQuery() : query);
}
@Override
public PlanDetailVO getPlan(String planId)
{
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanId(planId));
return toDetailVO(plan);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String createPlan(PlanCreateDTO dto, String operator)
{
validatePlan(dto.getStartTime(), dto.getEndTime(), dto.getReportDeadline(), dto.getRecruitNum());
String planName = normalizePlanName(dto.getPlanName());
ensureUniqueName(planName, null);
Date now = DateUtils.getNowDate();
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(IdUtils.fastSimpleUUID());
plan.setPlanName(planName);
plan.setStartTime(dto.getStartTime());
plan.setEndTime(dto.getEndTime());
plan.setReportDeadline(dto.getReportDeadline());
plan.setTrainAddress(dto.getTrainAddress());
plan.setRecruitNum(dto.getRecruitNum());
plan.setApplyCondition(dto.getApplyCondition());
plan.setPlanStatus(STATUS_NOT_STARTED);
plan.setDelFlag("0");
plan.setCreateBy(operator);
plan.setCreateTime(now);
plan.setRemark(dto.getRemark());
planService.insertTrainClassPlan(plan);
return plan.getPlanId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updatePlan(String planId, PlanUpdateDTO dto, String operator)
{
TrainClassPlan existing = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (isLocked(existing.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
validatePlan(dto.getStartTime(), dto.getEndTime(), dto.getReportDeadline(), dto.getRecruitNum());
String planName = normalizePlanName(dto.getPlanName());
ensureUniqueName(planName, planId);
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(planId);
plan.setPlanName(planName);
plan.setStartTime(dto.getStartTime());
plan.setEndTime(dto.getEndTime());
plan.setReportDeadline(dto.getReportDeadline());
plan.setTrainAddress(dto.getTrainAddress());
plan.setRecruitNum(dto.getRecruitNum());
plan.setApplyCondition(dto.getApplyCondition());
plan.setUpdateBy(operator);
plan.setUpdateTime(DateUtils.getNowDate());
plan.setRemark(dto.getRemark());
planService.updateTrainClassPlan(plan);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void completePlan(String planId, String operator)
{
List<PlanEnrollmentCompletionPort> completionPorts = requireEnrollmentCompletionPorts();
TrainClassPlan plan = requirePlan(planService.selectTrainClassPlanByPlanIdForUpdate(planId));
if (Integer.valueOf(STATUS_COMPLETED).equals(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_ALREADY_COMPLETED_MESSAGE,
PlanErrorCode.PLAN_ALREADY_COMPLETED);
}
if (Integer.valueOf(STATUS_CANCELLED).equals(plan.getPlanStatus()))
{
throw error(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
Date completeTime = DateUtils.getNowDate();
TrainClassPlan statusUpdate = new TrainClassPlan();
statusUpdate.setPlanId(planId);
statusUpdate.setPlanStatus(STATUS_COMPLETED);
statusUpdate.setUpdateBy(operator);
statusUpdate.setUpdateTime(completeTime);
planService.updateTrainClassPlanStatus(statusUpdate);
for (PlanEnrollmentCompletionPort port : completionPorts)
{
port.completeEnrollments(planId, operator, completeTime);
}
}
private List<PlanEnrollmentCompletionPort> requireEnrollmentCompletionPorts()
{
if (enrollmentCompletionPorts.isEmpty())
{
throw new IllegalStateException(
"PlanEnrollmentCompletionPort is not configured; plan completion is disabled");
}
return enrollmentCompletionPorts;
}
private TrainClassPlan requirePlan(TrainClassPlan plan)
{
if (plan == null)
{
throw error(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
return plan;
}
private void ensureUniqueName(String planName, String excludePlanId)
{
if (planService.existsByPlanName(planName, excludePlanId))
{
throw error(PlanErrorCode.PLAN_NAME_DUPLICATED_MESSAGE,
PlanErrorCode.PLAN_NAME_DUPLICATED);
}
}
private String normalizePlanName(String planName)
{
return StringUtils.trim(planName);
}
private void validatePlan(Date startTime, Date endTime, Date reportDeadline, Long recruitNum)
{
if (startTime == null || endTime == null || !startTime.before(endTime))
{
throw error(PlanErrorCode.PLAN_TIME_INVALID_MESSAGE, PlanErrorCode.PLAN_TIME_INVALID);
}
if (reportDeadline == null || reportDeadline.after(startTime))
{
throw error("报到截止时间不得晚于开班时间", PlanErrorCode.PLAN_TIME_INVALID);
}
if (recruitNum == null || recruitNum.longValue() <= 0)
{
throw new ServiceException("计划招生人数必须大于0");
}
}
private boolean isLocked(Integer status)
{
return Integer.valueOf(STATUS_COMPLETED).equals(status)
|| Integer.valueOf(STATUS_CANCELLED).equals(status);
}
private PlanDetailVO toDetailVO(TrainClassPlan plan)
{
PlanDetailVO vo = new PlanDetailVO();
vo.setPlanId(plan.getPlanId());
vo.setPlanName(plan.getPlanName());
vo.setPlanStatus(plan.getPlanStatus());
vo.setStartTime(plan.getStartTime());
vo.setEndTime(plan.getEndTime());
vo.setReportDeadline(plan.getReportDeadline());
vo.setTrainAddress(plan.getTrainAddress());
vo.setRecruitNum(plan.getRecruitNum());
vo.setApplyCondition(plan.getApplyCondition());
vo.setRemark(plan.getRemark());
vo.setCreateBy(plan.getCreateBy());
vo.setCreateTime(plan.getCreateTime());
vo.setUpdateBy(plan.getUpdateBy());
vo.setUpdateTime(plan.getUpdateTime());
return vo;
}
private ServiceException error(String message, int code)
{
return new ServiceException(message, code);
}
}
@@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.service.ITrainClassPlanService;
/**
@@ -32,16 +34,28 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService
return trainClassPlanMapper.selectTrainClassPlanByPlanId(planId);
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return trainClassPlanMapper.selectTrainClassPlanByPlanIdForUpdate(planId);
}
/**
* 查询开班计划列表
*
* @param trainClassPlan 开班计划
* @param query 培训计划筛选条件
* @return 开班计划
*/
@Override
public List<TrainClassPlan> selectTrainClassPlanList(TrainClassPlan trainClassPlan)
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return trainClassPlanMapper.selectTrainClassPlanList(trainClassPlan);
return trainClassPlanMapper.selectTrainClassPlanList(query);
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
return trainClassPlanMapper.countTrainClassPlanByName(planName, excludePlanId) > 0;
}
/**
@@ -70,27 +84,9 @@ public class TrainClassPlanServiceImpl implements ITrainClassPlanService
return trainClassPlanMapper.updateTrainClassPlan(trainClassPlan);
}
/**
* 批量删除开班计划
*
* @param planIds 需要删除的开班计划主键
* @return 结果
*/
@Override
public int deleteTrainClassPlanByPlanIds(String[] planIds)
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan)
{
return trainClassPlanMapper.deleteTrainClassPlanByPlanIds(planIds);
}
/**
* 删除开班计划信息
*
* @param planId 开班计划主键
* @return 结果
*/
@Override
public int deleteTrainClassPlanByPlanId(String planId)
{
return trainClassPlanMapper.deleteTrainClassPlanByPlanId(planId);
return trainClassPlanMapper.updateTrainClassPlanStatus(trainClassPlan);
}
}
@@ -0,0 +1,12 @@
package com.ruoyi.train.service.port;
import java.util.Date;
/**
* 计划结训时的参训档案归档边界。
* ISSUE-03-01 实现该端口后,调用将与计划状态变更处于同一事务。
*/
public interface PlanEnrollmentCompletionPort
{
void completeEnrollments(String planId, String operator, Date completeTime);
}
@@ -7,43 +7,101 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<resultMap type="TrainClassPlan" id="TrainClassPlanResult">
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="cId" column="c_id" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="reportDeadline" column="report_deadline" />
<result property="trainAddress" column="train_address" />
<result property="tId" column="t_id" />
<result property="recruitNum" column="recruit_num" />
<result property="applyCondition" column="apply_condition" />
<result property="planStatus" column="plan_status" />
<result property="createUser" column="create_user" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectTrainClassPlanVo">
select plan_id, plan_name, c_id, start_time, end_time, train_address, t_id, recruit_num, apply_condition, plan_status, create_user, create_time, update_time, remark from train_class_plan
<resultMap type="PlanItemVO" id="PlanItemResult">
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="reportDeadline" column="report_deadline" />
<result property="trainAddress" column="train_address" />
<result property="recruitNum" column="recruit_num" />
<result property="planStatus" column="effective_plan_status" />
<result property="courseCount" column="course_count" />
<result property="enrolledCount" column="enrolled_count" />
</resultMap>
<!-- 有效状态的唯一生产规则:持久化状态 0 在开始时间到达后只读呈现为 1。 -->
<sql id="effectivePlanStatusPolicy">
case when plan_status = 0 and start_time &lt;= CURRENT_TIMESTAMP then 1
else plan_status end
</sql>
<select id="selectTrainClassPlanList" parameterType="TrainClassPlan" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
<where>
<if test="planName != null and planName != ''"> and plan_name like concat('%', #{planName}, '%')</if>
<if test="cId != null and cId != ''"> and c_id = #{cId}</if>
<if test="startTime != null "> and start_time = #{startTime}</if>
<if test="endTime != null "> and end_time = #{endTime}</if>
<if test="trainAddress != null and trainAddress != ''"> and train_address = #{trainAddress}</if>
<if test="tId != null and tId != ''"> and t_id = #{tId}</if>
<if test="recruitNum != null "> and recruit_num = #{recruitNum}</if>
<if test="applyCondition != null and applyCondition != ''"> and apply_condition = #{applyCondition}</if>
<if test="planStatus != null "> and plan_status = #{planStatus}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
<sql id="selectTrainClassPlanVo">
select plan_id, plan_name, start_time, end_time, report_deadline, train_address,
recruit_num, apply_condition,
<include refid="effectivePlanStatusPolicy"/> as plan_status,
del_flag, create_by, create_time,
update_by, update_time, remark
from train_class_plan
</sql>
<select id="selectTrainClassPlanList" parameterType="PlanQuery" resultMap="PlanItemResult">
select p.plan_id,
p.plan_name,
p.start_time,
p.end_time,
p.report_deadline,
p.train_address,
p.recruit_num,
p.plan_status as effective_plan_status,
(select count(*) from train_plan_course pc where pc.plan_id = p.plan_id) as course_count,
(select count(*) from train_enrollment e
where e.plan_id = p.plan_id and e.enrollment_status != 3) as enrolled_count
from (
<include refid="selectTrainClassPlanVo"/>
) p
<where>
p.del_flag = '0'
<if test="planName != null and planName != ''">
and p.plan_name like concat('%', #{planName}, '%')
</if>
<if test="planStatus != null">
and p.plan_status = #{planStatus}
</if>
<if test="startTimeBegin != null">
and p.start_time &gt;= #{startTimeBegin}
</if>
<if test="startTimeEnd != null">
and p.start_time &lt;= #{startTimeEnd}
</if>
</where>
order by p.start_time desc, p.create_time desc
</select>
<select id="selectTrainClassPlanByPlanId" parameterType="String" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
where plan_id = #{planId}
where plan_id = #{planId} and del_flag = '0'
</select>
<select id="selectTrainClassPlanByPlanIdForUpdate" parameterType="String" resultMap="TrainClassPlanResult">
<include refid="selectTrainClassPlanVo"/>
where plan_id = #{planId} and del_flag = '0'
for update
</select>
<select id="countTrainClassPlanByName" resultType="int">
select count(*)
from train_class_plan
where del_flag = '0'
and lower(trim(plan_name)) = lower(trim(#{planName}))
<if test="excludePlanId != null and excludePlanId != ''">
and plan_id != #{excludePlanId}
</if>
</select>
<insert id="insertTrainClassPlan" parameterType="TrainClassPlan">
@@ -51,32 +109,34 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="planId != null">plan_id,</if>
<if test="planName != null and planName != ''">plan_name,</if>
<if test="cId != null and cId != ''">c_id,</if>
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
<if test="reportDeadline != null">report_deadline,</if>
<if test="trainAddress != null">train_address,</if>
<if test="tId != null and tId != ''">t_id,</if>
<if test="recruitNum != null">recruit_num,</if>
<if test="applyCondition != null">apply_condition,</if>
<if test="planStatus != null">plan_status,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null">#{planId},</if>
<if test="planName != null and planName != ''">#{planName},</if>
<if test="cId != null and cId != ''">#{cId},</if>
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
<if test="reportDeadline != null">#{reportDeadline},</if>
<if test="trainAddress != null">#{trainAddress},</if>
<if test="tId != null and tId != ''">#{tId},</if>
<if test="recruitNum != null">#{recruitNum},</if>
<if test="applyCondition != null">#{applyCondition},</if>
<if test="planStatus != null">#{planStatus},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
@@ -84,32 +144,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="updateTrainClassPlan" parameterType="TrainClassPlan">
update train_class_plan
<trim prefix="SET" suffixOverrides=",">
<if test="planName != null and planName != ''">plan_name = #{planName},</if>
<if test="cId != null and cId != ''">c_id = #{cId},</if>
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="trainAddress != null">train_address = #{trainAddress},</if>
<if test="tId != null and tId != ''">t_id = #{tId},</if>
<if test="recruitNum != null">recruit_num = #{recruitNum},</if>
<if test="applyCondition != null">apply_condition = #{applyCondition},</if>
<if test="planStatus != null">plan_status = #{planStatus},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where plan_id = #{planId}
set plan_name = #{planName},
start_time = #{startTime},
end_time = #{endTime},
report_deadline = #{reportDeadline},
train_address = #{trainAddress},
recruit_num = #{recruitNum},
apply_condition = #{applyCondition},
update_by = #{updateBy},
update_time = #{updateTime},
remark = #{remark}
where plan_id = #{planId} and del_flag = '0'
</update>
<delete id="deleteTrainClassPlanByPlanId" parameterType="String">
delete from train_class_plan where plan_id = #{planId}
</delete>
<delete id="deleteTrainClassPlanByPlanIds" parameterType="String">
delete from train_class_plan where plan_id in
<foreach item="planId" collection="array" open="(" separator="," close=")">
#{planId}
</foreach>
</delete>
</mapper>
<update id="updateTrainClassPlanStatus" parameterType="TrainClassPlan">
update train_class_plan
set plan_status = #{planStatus},
update_by = #{updateBy},
update_time = #{updateTime}
where plan_id = #{planId} and del_flag = '0'
</update>
</mapper>
@@ -0,0 +1,429 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.dto.PlanCreateDTO;
import com.ruoyi.train.domain.dto.PlanUpdateDTO;
import com.ruoyi.train.domain.query.PlanQuery;
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.port.PlanEnrollmentCompletionPort;
/** ISSUE-02-01 培训计划生命周期单元测试。 */
public class PlanAppServiceImplTest
{
private InMemoryPlanService repository;
private RecordingEnrollmentPort enrollmentPort;
private PlanAppServiceImpl appService;
@Before
public void setUp()
{
repository = new InMemoryPlanService();
enrollmentPort = new RecordingEnrollmentPort();
appService = new PlanAppServiceImpl(repository, Collections.singletonList(enrollmentPort));
}
@Test
public void createPlanShouldGenerateIdAndForceInitialStatus()
{
String planId = appService.createPlan(validCreate(" 2026年信号培训班 "), "admin");
TrainClassPlan saved = repository.selectTrainClassPlanByPlanId(planId);
assertNotNull(planId);
assertEquals(32, planId.length());
assertEquals("2026年信号培训班", saved.getPlanName());
assertEquals(Integer.valueOf(0), saved.getPlanStatus());
assertEquals("admin", saved.getCreateBy());
for (Field field : TrainClassPlan.class.getDeclaredFields())
{
assertTrue("Plan Domain 不得恢复单课程/单教师字段",
!"cId".equals(field.getName()) && !"tId".equals(field.getName()));
}
}
@Test
public void createPlanShouldRejectReversedOrEqualTime()
{
PlanCreateDTO reversed = validCreate("倒序计划");
reversed.setStartTime(date("2026-09-15 08:00:00"));
reversed.setEndTime(date("2026-09-10 08:00:00"));
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(reversed, "admin"));
PlanCreateDTO equal = validCreate("同一时刻计划");
equal.setEndTime(equal.getStartTime());
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(equal, "admin"));
}
@Test
public void createPlanShouldRejectReportDeadlineAfterStart()
{
PlanCreateDTO dto = validCreate("超时报到计划");
dto.setReportDeadline(date("2030-10-10 09:00:00"));
assertError(PlanErrorCode.PLAN_TIME_INVALID, () -> appService.createPlan(dto, "admin"));
}
@Test
public void createPlanShouldRejectDuplicatedName()
{
appService.createPlan(validCreate("重复计划"), "admin");
assertError(PlanErrorCode.PLAN_NAME_DUPLICATED,
() -> appService.createPlan(validCreate(" 重复计划 "), "admin"));
}
@Test
public void getPlanShouldReturnDetailAndRejectMissingPlan()
{
String planId = appService.createPlan(validCreate("查询计划"), "admin");
PlanDetailVO detail = appService.getPlan(planId);
assertEquals(planId, detail.getPlanId());
assertEquals("查询计划", detail.getPlanName());
assertError(PlanErrorCode.PLAN_NOT_FOUND, () -> appService.getPlan("missing"));
}
@Test
public void getPlanShouldExposeInProgressStatusAfterStartTime()
{
PlanCreateDTO dto = validCreate("已开班计划");
dto.setReportDeadline(date("2020-01-01 08:00:00"));
dto.setStartTime(date("2020-01-02 08:00:00"));
dto.setEndTime(date("2030-01-03 08:00:00"));
String planId = appService.createPlan(dto, "admin");
assertEquals(Integer.valueOf(1), appService.getPlan(planId).getPlanStatus());
assertEquals("有效状态查询不得回写持久化状态", Integer.valueOf(0),
repository.store.get(planId).getPlanStatus());
}
@Test
public void updatePlanShouldUpdateEditablePlan()
{
String planId = appService.createPlan(validCreate("原计划"), "admin");
PlanUpdateDTO update = validUpdate("新计划");
update.setTrainAddress("新基地");
appService.updatePlan(planId, update, "editor");
TrainClassPlan saved = repository.selectTrainClassPlanByPlanId(planId);
assertEquals("新计划", saved.getPlanName());
assertEquals("新基地", saved.getTrainAddress());
assertEquals("editor", saved.getUpdateBy());
}
@Test
public void updatePlanShouldRejectCompletedOrCancelledPlan()
{
String completedId = appService.createPlan(validCreate("已结束计划"), "admin");
repository.store.get(completedId).setPlanStatus(2);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.updatePlan(completedId, validUpdate("修改已结束"), "admin"));
String cancelledId = appService.createPlan(validCreate("已取消计划"), "admin");
repository.store.get(cancelledId).setPlanStatus(3);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.updatePlan(cancelledId, validUpdate("修改已取消"), "admin"));
}
@Test
public void completePlanShouldUpdateStatusAndInvokeEnrollmentBoundary() throws NoSuchMethodException
{
String planId = appService.createPlan(validCreate("待结训计划"), "admin");
appService.completePlan(planId, "operator");
assertEquals(Integer.valueOf(2), repository.store.get(planId).getPlanStatus());
assertEquals(1, enrollmentPort.invocationCount);
assertEquals(planId, enrollmentPort.lastPlanId);
assertEquals("operator", enrollmentPort.lastOperator);
assertNotNull(enrollmentPort.lastCompleteTime);
Transactional transaction = PlanAppServiceImpl.class
.getMethod("completePlan", String.class, String.class)
.getAnnotation(Transactional.class);
assertNotNull(transaction);
assertEquals(Exception.class, transaction.rollbackFor()[0]);
}
@Test
public void completePlanShouldPropagateEnrollmentPortFailure()
{
InMemoryPlanService failingRepository = new InMemoryPlanService();
RuntimeException boundaryFailure = new IllegalStateException("Enrollment completion failed");
PlanEnrollmentCompletionPort failingPort = (id, user, time) ->
{
throw boundaryFailure;
};
PlanAppServiceImpl failingService = new PlanAppServiceImpl(failingRepository,
Collections.singletonList(failingPort));
String failingPlanId = failingService.createPlan(validCreate("事务异常计划"), "admin");
try
{
failingService.completePlan(failingPlanId, "operator");
fail("Enrollment 端口异常必须向事务代理传播");
}
catch (RuntimeException e)
{
assertSame(boundaryFailure, e);
}
assertEquals(1, failingRepository.statusUpdateInvocationCount);
}
@Test
public void completePlanShouldFailClosedWhenEnrollmentPortMissing()
{
InMemoryPlanService noPortRepository = new InMemoryPlanService();
PlanAppServiceImpl noPortService = new PlanAppServiceImpl(noPortRepository,
Collections.emptyList());
String planId = noPortService.createPlan(validCreate("缺少联动适配器计划"), "admin");
try
{
noPortService.completePlan(planId, "operator");
fail("缺少 Enrollment Completion Port 时必须 fail closed");
}
catch (IllegalStateException e)
{
assertTrue(e.getMessage().contains("PlanEnrollmentCompletionPort"));
}
assertEquals(0, noPortRepository.statusUpdateInvocationCount);
assertEquals(Integer.valueOf(0), noPortRepository.store.get(planId).getPlanStatus());
}
@Test
public void completePlanShouldRejectRepeatedCompletion()
{
String planId = appService.createPlan(validCreate("幂等计划"), "admin");
appService.completePlan(planId, "admin");
assertError(PlanErrorCode.PLAN_ALREADY_COMPLETED,
() -> appService.completePlan(planId, "admin"));
assertEquals(1, enrollmentPort.invocationCount);
}
@Test
public void completePlanShouldRejectMissingOrCancelledPlan()
{
assertError(PlanErrorCode.PLAN_NOT_FOUND,
() -> appService.completePlan("missing", "admin"));
String planId = appService.createPlan(validCreate("取消计划"), "admin");
repository.store.get(planId).setPlanStatus(3);
assertError(PlanErrorCode.PLAN_STATUS_LOCKED,
() -> appService.completePlan(planId, "admin"));
}
@Test
public void listPlansShouldDelegatePlanQuery()
{
PlanItemVO item = new PlanItemVO();
item.setPlanId("P001");
repository.listResult = Collections.singletonList(item);
List<PlanItemVO> result = appService.listPlans(new PlanQuery());
assertEquals(1, result.size());
assertEquals("P001", result.get(0).getPlanId());
}
private PlanCreateDTO validCreate(String name)
{
PlanCreateDTO dto = new PlanCreateDTO();
dto.setPlanName(name);
dto.setReportDeadline(date("2030-10-09 18:00:00"));
dto.setStartTime(date("2030-10-10 08:30:00"));
dto.setEndTime(date("2030-10-24 17:30:00"));
dto.setTrainAddress("培训基地");
dto.setRecruitNum(40L);
dto.setApplyCondition("在岗职工");
dto.setRemark("测试计划");
return dto;
}
private PlanUpdateDTO validUpdate(String name)
{
PlanUpdateDTO dto = new PlanUpdateDTO();
dto.setPlanName(name);
dto.setReportDeadline(date("2030-10-09 18:00:00"));
dto.setStartTime(date("2030-10-10 08:30:00"));
dto.setEndTime(date("2030-10-24 17:30:00"));
dto.setTrainAddress("培训基地");
dto.setRecruitNum(40L);
dto.setApplyCondition("在岗职工");
dto.setRemark("更新测试");
return dto;
}
private Date date(String value)
{
try
{
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(value);
}
catch (ParseException e)
{
throw new IllegalArgumentException(e);
}
}
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 RecordingEnrollmentPort implements PlanEnrollmentCompletionPort
{
private int invocationCount;
private String lastPlanId;
private String lastOperator;
private Date lastCompleteTime;
@Override
public void completeEnrollments(String planId, String operator, Date completeTime)
{
invocationCount++;
lastPlanId = planId;
lastOperator = operator;
lastCompleteTime = completeTime;
}
}
private static class InMemoryPlanService implements ITrainClassPlanService
{
private final Map<String, TrainClassPlan> store = new LinkedHashMap<>();
private List<PlanItemVO> listResult = new ArrayList<>();
private int statusUpdateInvocationCount;
@Override
public TrainClassPlan selectTrainClassPlanByPlanId(String planId)
{
return effectiveView(store.get(planId));
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return effectiveView(store.get(planId));
}
@Override
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return listResult;
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
for (TrainClassPlan plan : store.values())
{
if (plan.getPlanName().trim().equalsIgnoreCase(planName.trim())
&& !plan.getPlanId().equals(excludePlanId))
{
return true;
}
}
return false;
}
@Override
public int insertTrainClassPlan(TrainClassPlan plan)
{
store.put(plan.getPlanId(), plan);
return 1;
}
@Override
public int updateTrainClassPlan(TrainClassPlan update)
{
TrainClassPlan existing = store.get(update.getPlanId());
existing.setPlanName(update.getPlanName());
existing.setStartTime(update.getStartTime());
existing.setEndTime(update.getEndTime());
existing.setReportDeadline(update.getReportDeadline());
existing.setTrainAddress(update.getTrainAddress());
existing.setRecruitNum(update.getRecruitNum());
existing.setApplyCondition(update.getApplyCondition());
existing.setUpdateBy(update.getUpdateBy());
existing.setUpdateTime(update.getUpdateTime());
existing.setRemark(update.getRemark());
return 1;
}
@Override
public int updateTrainClassPlanStatus(TrainClassPlan update)
{
statusUpdateInvocationCount++;
TrainClassPlan existing = store.get(update.getPlanId());
existing.setPlanStatus(update.getPlanStatus());
existing.setUpdateBy(update.getUpdateBy());
existing.setUpdateTime(update.getUpdateTime());
return 1;
}
/** Mirrors the mapper's read-only effectivePlanStatusPolicy for this JVM fake. */
private TrainClassPlan effectiveView(TrainClassPlan source)
{
if (source == null)
{
return null;
}
TrainClassPlan view = new TrainClassPlan();
view.setPlanId(source.getPlanId());
view.setPlanName(source.getPlanName());
view.setStartTime(source.getStartTime());
view.setEndTime(source.getEndTime());
view.setReportDeadline(source.getReportDeadline());
view.setTrainAddress(source.getTrainAddress());
view.setRecruitNum(source.getRecruitNum());
view.setApplyCondition(source.getApplyCondition());
view.setPlanStatus(source.getPlanStatus());
view.setDelFlag(source.getDelFlag());
view.setCreateBy(source.getCreateBy());
view.setCreateTime(source.getCreateTime());
view.setUpdateBy(source.getUpdateBy());
view.setUpdateTime(source.getUpdateTime());
view.setRemark(source.getRemark());
if (Integer.valueOf(0).equals(source.getPlanStatus())
&& source.getStartTime() != null
&& !source.getStartTime().after(new Date()))
{
view.setPlanStatus(1);
}
return view;
}
}
}
+9 -9
View File
@@ -3,7 +3,7 @@ import request from '@/utils/request'
// 查询开班计划列表
export function listPlan(query) {
return request({
url: '/train/plan/list',
url: '/train/plans',
method: 'get',
params: query
})
@@ -12,7 +12,7 @@ export function listPlan(query) {
// 查询开班计划详细
export function getPlan(planId) {
return request({
url: '/train/plan/' + planId,
url: '/train/plans/' + planId,
method: 'get'
})
}
@@ -20,25 +20,25 @@ export function getPlan(planId) {
// 新增开班计划
export function addPlan(data) {
return request({
url: '/train/plan',
url: '/train/plans',
method: 'post',
data: data
})
}
// 修改开班计划
export function updatePlan(data) {
export function updatePlan(planId, data) {
return request({
url: '/train/plan',
url: '/train/plans/' + planId,
method: 'put',
data: data
})
}
// 删除开班计划
export function delPlan(planId) {
// 结束培训计划
export function completePlan(planId) {
return request({
url: '/train/plan/' + planId,
method: 'delete'
url: '/train/plans/' + planId + '/complete',
method: 'post'
})
}
+155 -261
View File
@@ -1,68 +1,30 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="开班名称" prop="planName">
<el-input
v-model="queryParams.planName"
placeholder="请输入开班名称"
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="88px"
>
<el-form-item label="培训班名称" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入培训班名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="计划状态" prop="planStatus">
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开班时间">
<el-date-picker
v-model="startTimeRange"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="所属课程编号,关联train_course.c_id" prop="cId">
<el-input
v-model="queryParams.cId"
placeholder="请输入所属课程编号,关联train_course.c_id"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="开班时间" prop="startTime">
<el-date-picker clearable
v-model="queryParams.startTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择开班时间">
</el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker clearable
v-model="queryParams.endTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择结束时间">
</el-date-picker>
</el-form-item>
<el-form-item label="培训地点" prop="trainAddress">
<el-input
v-model="queryParams.trainAddress"
placeholder="请输入培训地点"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="授课教师工号,关联train_teacher.t_id" prop="tId">
<el-input
v-model="queryParams.tId"
placeholder="请输入授课教师工号,关联train_teacher.t_id"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="招生人数" prop="recruitNum">
<el-input
v-model="queryParams.recruitNum"
placeholder="请输入招生人数"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input
v-model="queryParams.createUser"
placeholder="请输入创建管理员账号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
@@ -74,141 +36,99 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:plan:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['train:plan:add']"
>新增</el-button>
>新增计划</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['train:plan:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['train:plan:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['train:plan:export']"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="planList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="开班计划ID主键" align="center" prop="planId" />
<el-table-column label="开班名称" align="center" prop="planName" />
<el-table-column label="所属课程编号,关联train_course.c_id" align="center" prop="cId" />
<el-table-column label="开班时间" align="center" prop="startTime" width="180">
<el-table v-loading="loading" :data="planList">
<el-table-column label="计划编号" align="center" prop="planId" min-width="220" show-overflow-tooltip />
<el-table-column label="培训班名称" align="left" prop="planName" min-width="230" show-overflow-tooltip />
<el-table-column label="状态" align="center" prop="planStatus" width="100">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
<el-tag :type="statusTagType(scope.row.planStatus)">{{ statusLabel(scope.row.planStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" prop="endTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="培训地点" align="center" prop="trainAddress" />
<el-table-column label="授课教师工号,关联train_teacher.t_id" align="center" prop="tId" />
<el-table-column label="招生人数" align="center" prop="recruitNum" />
<el-table-column label="报名条件" align="center" prop="applyCondition" />
<el-table-column label="0 未开始 1 进行中 2 已结束 3 已取消" align="center" prop="planStatus" />
<el-table-column label="创建管理员账号" align="center" prop="createUser" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column label="开班时间" align="center" prop="startTime" width="165" />
<el-table-column label="结束时间" align="center" prop="endTime" width="165" />
<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">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isLocked(scope.row.planStatus)"
@click="handleUpdate(scope.row)"
v-hasPermi="['train:plan:edit']"
>修改</el-button>
<el-button
v-hasPermi="['train:plan:complete']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['train:plan:remove']"
>删除</el-button>
icon="el-icon-finished"
:disabled="isLocked(scope.row.planStatus)"
@click="handleComplete(scope.row)"
>结束计划</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改开班计划对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="开班名称" prop="planName">
<el-input v-model="form.planName" placeholder="请输入开班名称" />
</el-form-item>
<el-form-item label="所属课程编号,关联train_course.c_id" prop="cId">
<el-input v-model="form.cId" placeholder="请输入所属课程编号,关联train_course.c_id" />
</el-form-item>
<el-form-item label="开班时间" prop="startTime">
<el-date-picker clearable
v-model="form.startTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择开班时间">
</el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker clearable
v-model="form.endTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择结束时间">
</el-date-picker>
<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">
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训班名称" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开班时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择开班时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择结束时间" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="报到截止" prop="reportDeadline">
<el-date-picker v-model="form.reportDeadline" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择报到截止时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划人数" prop="recruitNum">
<el-input-number v-model="form.recruitNum" :min="1" :precision="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="培训地点" prop="trainAddress">
<el-input v-model="form.trainAddress" placeholder="请输入培训地点" />
</el-form-item>
<el-form-item label="授课教师工号,关联train_teacher.t_id" prop="tId">
<el-input v-model="form.tId" placeholder="请输入授课教师工号,关联train_teacher.t_id" />
</el-form-item>
<el-form-item label="招生人数" prop="recruitNum">
<el-input v-model="form.recruitNum" placeholder="请输入招生人数" />
<el-input v-model="form.trainAddress" maxlength="200" show-word-limit placeholder="请输入培训地点" />
</el-form-item>
<el-form-item label="报名条件" prop="applyCondition">
<el-input v-model="form.applyCondition" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="创建管理员账号" prop="createUser">
<el-input v-model="form.createUser" placeholder="请输入创建管理员账号" />
<el-input v-model="form.applyCondition" type="textarea" :rows="3" placeholder="请输入报名条件" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -220,67 +140,38 @@
</template>
<script>
import { listPlan, getPlan, delPlan, addPlan, updatePlan } from "@/api/train/plan"
import { addPlan, completePlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
export default {
name: "Plan",
name: 'Plan',
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 开班计划表格数据
planList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
startTimeRange: [],
title: '',
open: false,
// 查询参数
statusOptions: [
{ value: 0, label: '未开始' },
{ value: 1, label: '进行中' },
{ value: 2, label: '已结束' },
{ value: 3, label: '已取消' }
],
queryParams: {
pageNum: 1,
pageSize: 10,
planName: null,
cId: null,
startTime: null,
endTime: null,
trainAddress: null,
tId: null,
recruitNum: null,
applyCondition: null,
planStatus: null,
createUser: null,
planStatus: null
},
// 表单参数
form: {},
// 表单校验
rules: {
planName: [
{ required: true, message: "开班名称不能为空", trigger: "blur" }
],
cId: [
{ required: true, message: "所属课程编号,关联train_course.c_id不能为空", trigger: "blur" }
],
startTime: [
{ required: true, message: "开班时间不能为空", trigger: "blur" }
],
endTime: [
{ required: true, message: "结束时间不能为空", trigger: "blur" }
],
tId: [
{ required: true, message: "授课教师工号,关联train_teacher.t_id不能为空", trigger: "blur" }
],
createUser: [
{ required: true, message: "创建管理员账号不能为空", trigger: "blur" }
],
planName: [{ required: true, message: '培训班名称不能为空', trigger: 'blur' }],
startTime: [{ required: true, message: '开班时间不能为空', trigger: 'change' }],
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
}
}
},
@@ -288,107 +179,110 @@ export default {
this.getList()
},
methods: {
/** 查询开班计划列表 */
getList() {
this.loading = true
listPlan(this.queryParams).then(response => {
const query = { ...this.queryParams }
if (this.startTimeRange && this.startTimeRange.length === 2) {
query.startTimeBegin = this.startTimeRange[0]
query.startTimeEnd = this.startTimeRange[1]
}
listPlan(query).then(response => {
this.planList = response.rows
this.total = response.total
}).finally(() => {
this.loading = false
})
},
// 取消按钮
statusLabel(status) {
const option = this.statusOptions.find(item => item.value === status)
return option ? option.label : '未知'
},
statusTagType(status) {
return { 0: 'info', 1: 'primary', 2: 'success', 3: 'danger' }[status] || 'info'
},
isLocked(status) {
return status === 2 || status === 3
},
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
planId: null,
planName: null,
cId: null,
startTime: null,
endTime: null,
reportDeadline: null,
trainAddress: null,
tId: null,
recruitNum: null,
recruitNum: 1,
applyCondition: null,
planStatus: null,
createUser: null,
createTime: null,
updateTime: null,
remark: null
}
this.resetForm("form")
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.startTimeRange = []
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.planId)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = "添加开班计划"
this.title = '创建培训计划'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const planId = row.planId || this.ids
getPlan(planId).then(response => {
getPlan(row.planId).then(response => {
this.form = response.data
this.open = true
this.title = "修改开班计划"
this.title = '编辑培训计划'
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.planId != null) {
updatePlan(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addPlan(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.startTime >= this.form.endTime) {
this.$modal.msgError('结束时间必须晚于开班时间')
return
}
if (this.form.reportDeadline > this.form.startTime) {
this.$modal.msgError('报到截止时间不得晚于开班时间')
return
}
const request = this.form.planId
? updatePlan(this.form.planId, this.planPayload())
: addPlan(this.planPayload())
request.then(() => {
this.$modal.msgSuccess(this.form.planId ? '修改成功' : '创建成功')
this.open = false
this.getList()
})
})
},
/** 删除按钮操作 */
handleDelete(row) {
const planIds = row.planId || this.ids
this.$modal.confirm('是否确认删除开班计划编号为"' + planIds + '"的数据项?').then(function() {
return delPlan(planIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
planPayload() {
return {
planName: this.form.planName,
startTime: this.form.startTime,
endTime: this.form.endTime,
reportDeadline: this.form.reportDeadline,
trainAddress: this.form.trainAddress,
recruitNum: this.form.recruitNum,
applyCondition: this.form.applyCondition,
remark: this.form.remark
}
},
/** 导出按钮操作 */
handleExport() {
this.download('train/plan/export', {
...this.queryParams
}, `plan_${new Date().getTime()}.xlsx`)
handleComplete(row) {
this.$modal.confirm('结束计划后将锁定计划信息并保留全部历史记录,是否继续?').then(() => {
return completePlan(row.planId)
}).then(() => {
this.$modal.msgSuccess('计划已结束')
this.getList()
}).catch(() => {})
}
}
}
+167
View File
@@ -0,0 +1,167 @@
# ISSUE-02-01 培训计划生命周期与基础管理开发验收
## 1. 基线与范围
- 分支:`feat/issue-02-01-plan`
- 起始 HEAD:`7fcaea29b8810546fd972ba0c9d366c71a357d63`
- Issue 基线:`issues-v1.0`
- API 基线:`api-v2-contract-v1.0.1`(`4873e5f555ecfe7d1d21d313007c2c8f6f65d6b6`)
- Generator 策略为 `PARTIAL`,本轮未执行 Generator Import。
- 本 Issue 只实现 Plan 自身的创建、编辑、列表、详情、结束和稳定上下文;未实现 PlanCourse、Enrollment、Lesson、Accommodation、Attendance、Score、Study、Statistics。
## 2. 旧实现审计
| 旧文件 | 判定 | 处理 |
|---|---|---|
| `TrainClassPlan.java` | MODIFY | 按 Database V2 删除 `cId/tId/createUser`,增加 `reportDeadline/delFlag`,使用标准审计字段。 |
| `TrainClassPlanMapper.java` | MODIFY | 移除物理删除能力,增加名称预查重、生命周期行锁读取和状态更新。 |
| `TrainClassPlanMapper.xml` | REPLACE | 旧单课程/单教师 SQL 替换为 V2 字段、有效状态、列表筛选、概览只读计数及行锁 SQL。 |
| `ITrainClassPlanService.java` | MODIFY | 保留为基础持久化边界,不承载生命周期编排。 |
| `TrainClassPlanServiceImpl.java` | MODIFY | 只封装 Mapper。 |
| `TrainClassPlanController.java` | DELETE | 删除旧 `/train/plan` CRUD、删除和导出端点。 |
| `TrainPlanController.java` | REPLACE | 使用正式 `/train/plans` Contract,只依赖 `PlanAppService`。 |
| `build_ui/src/api/train/plan.js` | MODIFY | 使用正式复数路径,删除 delete API,增加 complete Command。 |
| `build_ui/src/views/train/plan/index.vue` | REPLACE | 仅呈现和编辑 Plan 自身,不绑定单课程或单教师。 |
旧模型的 `Plan = 单课程 + 单教师` 已从 Domain、Mapper、Controller DTO/VO、前端 API 和页面中消除。课程属于 ISSUE-02-02 的 `PlanCourse`,教师属于具体 `Lesson`;本 Issue 未提前实现这些写能力。
## 3. 正式 P0 API 与 Controller 契约
| Priority | Method | Path | Permission | Request | Response |
|---|---|---|---|---|---|
| P0 | GET | `/train/plans` | `train:plan:list` | `PlanQuery`:`pageNum/pageSize` 由分页框架接管,业务字段为 `planName/planStatus/startTimeBegin/startTimeEnd` | `TableDataInfo<PlanItemVO>` |
| P0 | POST | `/train/plans` | `train:plan:add` | `PlanCreateDTO` | Contract 未命名专用响应 VO;当前为标准 `AjaxResult`,`data` 为 `plan_id` |
| P0 | GET | `/train/plans/{planId}` | `train:plan:query` | Path `planId` | `AjaxResult<PlanDetailVO>` |
| P0 | PUT | `/train/plans/{planId}` | `train:plan:edit` | Path `planId` + `PlanUpdateDTO` | 标准空 `AjaxResult` |
| P0 | POST | `/train/plans/{planId}/complete` | `train:plan:complete` | Path `planId`,无 Request Body | 标准空 `AjaxResult` |
`TrainPlanController` 没有 Contract 外 Endpoint;旧 `TrainClassPlanController` 已删除,不存在双 Controller 路由。Controller 未接收或返回 `TrainClassPlan` Persistence Domain。
### 3.1 实际 DTO/VO 清单
- `PlanCreateDTO`:`planName/startTime/endTime/reportDeadline/recruitNum/trainAddress/applyCondition/remark`。来源以 Module B 1.2 的具体 JSON 和校验规则为准。
- `PlanUpdateDTO`:`planName/startTime/endTime/reportDeadline/recruitNum/trainAddress/applyCondition/remark`,不接受 `planStatus`。Module B 1.4 明列名称、地址、人数、报到截止和备注;Issue 业务目标及 Service Boundary 同时要求起止时间维护与课次边界检查,因此保留起止时间。`applyCondition` 的更新字段在目录中未逐项展开,登记为 Contract 表述缺口,不修改冻结文档。
- `PlanQuery`:`planName/planStatus/startTimeBegin/startTimeEnd`;分页字段由 RuoYi 接管。
- `PlanItemVO`:`planId/planName/planStatus/startTime/endTime/reportDeadline/trainAddress/recruitNum/courseCount/enrolledCount`。
- `PlanDetailVO`:`planId/planName/planStatus/startTime/endTime/reportDeadline/trainAddress/recruitNum/applyCondition/remark/createBy/createTime/updateBy/updateTime`。
`docs/api-v2/06_dto_vo_catalog.md` 的 `PlanCreateDTO` 摘要未列 `applyCondition`,但 `docs/api-v2/03_module_b_plan_enrollment_schedule_api.md` 1.2 的确认 JSON 明确包含它;实现采用更具体的 Module B 定义,此差异作为 Contract Gap 记录。
## 4. PlanAppService 边界
- `listPlans`:读取分页概览,不做写操作。
- `getPlan`:读取 `PlanDetailVO`,不存在时抛出 Plan not found。
- `createPlan`:名称规范化与友好查重、时间/人数校验、生成技术主键、强制初始状态 0。
- `updatePlan`:行锁读取、有效状态校验、名称和字段校验后更新;客户端不能直接改状态。
- `completePlan`:先确认至少一个真实 Enrollment Completion Port 已接线,再行锁读取、校验并更新 Plan 状态,随后在同一同步事务调用 Port。
Controller 只处理 HTTP、Bean Validation、权限、日志和标准响应,不调用 Mapper 或编排多步业务。
## 5. 生命周期与 effective status
- 创建只写 `plan_status=0`。
- DB 不会因时间到达而将 0 写成 1;`TrainClassPlanMapper.xml#effectivePlanStatusPolicy` 是唯一生产规则源:持久化状态为 0 且 `start_time <= CURRENT_TIMESTAMP` 时,只读结果呈现为 1。
- 列表输出、`planStatus` 筛选、详情、编辑的 `FOR UPDATE` 读取、complete 的 `FOR UPDATE` 读取均复用该 SQL fragment。Application Service 不再重复时间判断。
- 查询不会写数据库,也不会根据 `end_time` 自动结束;结束只能由正式 complete Command 写状态 2。
- 状态 0 与派生状态 1 均允许 complete;Contract 未要求 `now >= end_time`,实现未增加该条件。
- 状态 2 重复 complete 返回 `40014`;状态 3 complete 返回 `40012`。
- 状态 2/3 禁止编辑。状态 3 仅作历史只读兼容;没有 cancel Endpoint,前端没有取消按钮。
- 后续业务若需判断 Plan 状态,应通过 `ITrainClassPlanService` 的正式读取/锁读取取得已应用 effective status 的 Plan,而不是自行复制时间判断。
## 6. Enrollment Completion Port 审计
- 接口:`PlanEnrollmentCompletionPort`。
- Main/生产实现类:无。
- `@Configuration/@Bean` 绑定:无。
- 构造注入:`PlanAppServiceImpl` 使用 `ObjectProvider<PlanEnrollmentCompletionPort>` 收集已注册 adapter;Bean 缺失不阻断应用启动,但执行 complete 时会 fail closed。
- 测试实现:测试类内部 `RecordingEnrollmentPort`,另有 lambda throwing fake 用于异常传播;都不进入生产 runtime。
- 分类:**Case C — 只有接口与测试 Fake,生产尚未接线**。
没有注册 silent no-op 实现,也没有伪造 Enrollment UPDATE。当前没有生产 adapter 时,`completePlan` 在任何 Plan 查询或状态更新前抛出无业务错误码的内部 `IllegalStateException`;请求失败,Plan 不会产生单边 `status=2`。ISSUE-03-01 必须实现真实 adapter,并以集成测试验证 `enrollment_status=4`、`complete_time` 以及任一步失败时的跨表回滚。
### AC-03 拆分
- **AC-03a — UNIT_PASS**:单元测试验证 Plan 状态更新、Completion Port 恰好调用一次、缺失 Port 时 fail closed、端口异常不被吞掉;同时确认 `completePlan` 使用 `@Transactional(rollbackFor=Exception.class)` 且端口在方法内同步调用。
- **AC-03b — INTEGRATION_PENDING (ISSUE-03-01)**:真实 Enrollment 批量 `status=4 + complete_time` 及跨表提交/回滚尚未验证。
- **Integration Safety — UNIT_PASS**:Port missing 时在 Plan update 前失败,状态更新调用次数为 0,Plan 保持原状态。
当前证据只能表述为“transaction orchestration seam 已验证”;不能表述为完整跨表事务或 Enrollment runtime 已通过。
## 7. 错误码映射与 Contract Gap
| 常量 | 值 | 冻结语义与来源 | 当前触发位置 |
|---|---:|---|---|
| `PLAN_TIME_INVALID` | 40010 | API 10 错误码表;Module B 1.2;ISSUE-02-01 | create/update 的 `start >= end`;`reportDeadline > startTime` 同属计划时间参数校验 |
| `PLAN_NAME_DUPLICATED` | 40011 | API 10;Module B 1.2;ISSUE-02-01 | create/update 的 Service 友好名称预查重 |
| `PLAN_STATUS_LOCKED` | 40012 | API 10;ISSUE-02-01 | 状态 2/3 编辑;状态 3 complete |
| `PLAN_LESSON_OUT_OF_BOUNDS` | 40013 | API 10(UC-02) | 本 Issue 不实现 Lesson,当前无触发点;待 Scheduling 边界接入,未伪造校验 |
| `PLAN_ALREADY_COMPLETED` | 40014 | API 10(UC-29);ISSUE-02-01 | 状态 2 重复 complete |
| `PLAN_NOT_FOUND` | 40410 | 冻结 Issue Backlog 的多个 Plan 依赖用例 | detail/update/complete 的计划不存在 |
API 10 的全局错误码表未列 40410、Module B 1.3 也未为详情逐项列 not-found code,但冻结 Issue Backlog 已使用 `PLAN_NOT_FOUND (40410)`。实现未发明新码;此目录间缺口仅登记,不修改冻结文档。招生人数由 DTO Bean Validation 处理,Service 防御校验使用普通 `ServiceException`,不再错误复用 40010,也未新增业务错误码。
## 8. 名称唯一与技术主键
- Database V2 没有 `UNIQUE(plan_name)`;当前只有 Service 层忽略首尾空格与大小写的友好重复预检查,并发强唯一性不保证。本 Issue 不新增 UNIQUE。
- “自动编号”仅指 `plan_id = IdUtils.fastSimpleUUID()` 生成的 32 位 UUID 技术主键。没有、也未发明 `plan_code` 或 `plan_no` 物理字段。
## 9. 测试依赖与 14 个测试方法
`ruoyi-train/pom.xml` 仅新增:
```xml
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
```
没有新增 Mockito、Spring Boot Test、H2 或生产 runtime 依赖。全部 14 个测试使用 JVM `InMemoryPlanService` Fake 和测试 Port,不 Mock JDBC、不连接也不写真实数据库:
| # | `@Test` 方法 | 验证规则 |
|---:|---|---|
| 1 | `createPlanShouldGenerateIdAndForceInitialStatus` | 32 位 `plan_id`、名称 trim、强制状态 0、审计人,并通过反射确认 Domain 无 `cId/tId` |
| 2 | `createPlanShouldRejectReversedOrEqualTime` | `start > end` 与 `start == end` 均返回 40010 |
| 3 | `createPlanShouldRejectReportDeadlineAfterStart` | 报到截止晚于开班返回 40010 |
| 4 | `createPlanShouldRejectDuplicatedName` | 名称 trim/忽略大小写预查重返回 40011 |
| 5 | `getPlanShouldReturnDetailAndRejectMissingPlan` | detail VO 映射及不存在返回 40410 |
| 6 | `getPlanShouldExposeInProgressStatusAfterStartTime` | 0→1 有效状态只读呈现,且持久化 Fake 中仍为 0,证明查询不回写 |
| 7 | `updatePlanShouldUpdateEditablePlan` | 状态 0/1 可编辑与审计更新 |
| 8 | `updatePlanShouldRejectCompletedOrCancelledPlan` | 状态 2、状态 3 分别禁止 edit,均返回 40012 |
| 9 | `completePlanShouldUpdateStatusAndInvokeEnrollmentBoundary` | Plan→2、Port 参数/次数及事务注解 |
| 10 | `completePlanShouldPropagateEnrollmentPortFailure` | Port 运行时异常保持原异常向事务代理传播;Fake 记录更新发生在 Port 调用前 |
| 11 | `completePlanShouldFailClosedWhenEnrollmentPortMissing` | 无 Port 时抛内部异常、状态更新调用为 0、Plan 保持状态 0 |
| 12 | `completePlanShouldRejectRepeatedCompletion` | 重复 complete 返回 40014,Port 不重复调用 |
| 13 | `completePlanShouldRejectMissingOrCancelledPlan` | 不存在返回 40410;状态 3 complete 返回 40012 |
| 14 | `listPlansShouldDelegatePlanQuery` | list 委托并返回 `PlanItemVO` |
该单元测试不能证明真实 Spring 事务回滚或 Enrollment SQL;这些证据属于 AC-03b 集成门禁。
## 10. Database 与权限影响
- 未执行 Generator Import;无 DDL、迁移、ALTER/CREATE/DROP。
- 未修改 `007_dicts.sql`、`sys_menu`、`sys_dict`、`sys_role`、`sys_role_menu`。
- 未写任何测试/正式业务数据;测试均为 JVM 内存数据。
- 后端权限严格为 `train:plan:list/query/add/edit/complete`;前端按钮只使用 `train:plan:add/edit/complete`,无 `system:plan:*`。
## 11. Acceptance Criteria
| AC | 状态 | 证据与边界 |
|---|---|---|
| AC-01 逆序日期返回 40010 | UNIT_PASS | 逆序和相等边界均覆盖。 |
| AC-02 状态 2 修改返回 40012 | UNIT_PASS | 同时覆盖历史状态 3。 |
| AC-03a Plan complete orchestration seam | UNIT_PASS | 状态更新、Port 一次调用、缺失 Port fail closed、异常传播及事务注解已验证。 |
| AC-03b 30 名 Enrollment 真实联动 | INTEGRATION_PENDING (ISSUE-03-01) | 未实现 Enrollment adapter/SQL,后续必须验证 status=4、complete_time 与跨表回滚。 |
| Integration Safety | UNIT_PASS | Port missing 时 update 调用为 0,Plan 不会单边持久化为 2。 |
## 12. 验证记录
- `mvn test -pl ruoyi-train -am -f RuoYi-Vue/pom.xml`:14 tests,0 failures,0 errors,0 skipped,`BUILD SUCCESS`。
- `mvn compile -DskipTests -f RuoYi-Vue/pom.xml`:8 个 Reactor 模块全部成功,`BUILD SUCCESS`。
- 评审指定 ESLint(主目录已安装 executable + `--resolve-plugins-relative-to`,工作目录仍为本 Issue worktree):退出码 0,无 error/warning。
- `npm run build:prod`:build complete;仅有仓库既有 `src/views/graph/antv/index.vue` 的 `@antv/x6 Addon` warning,与本 Issue 文件无关。
- Final Review 本轮未修改任何前端文件;沿用上一轮 ESLint/build 结果。
- 最终 `git diff --check`、`git status --short`、`git diff --stat` 结果以交付回复为准。
- 未执行 commit/merge/rebase,等待技术负责人复审。