From eff048874df15dae9a79d25ced138c14aa7ecdec Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 14 Sep 2026 12:07:57 +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=E5=BC=80=E7=8F=AD=E8=AE=A1=E5=88=92P0?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../train/TrainClassPlanController.java | 104 ----- .../controller/train/TrainPlanController.java | 85 ++++ RuoYi-Vue/ruoyi-train/pom.xml | 9 +- .../ruoyi/train/constant/PlanErrorCode.java | 23 + .../ruoyi/train/domain/TrainClassPlan.java | 75 ++- .../ruoyi/train/domain/dto/PlanCreateDTO.java | 57 +++ .../ruoyi/train/domain/dto/PlanUpdateDTO.java | 57 +++ .../ruoyi/train/domain/query/PlanQuery.java | 26 ++ .../ruoyi/train/domain/vo/PlanDetailVO.java | 57 +++ .../com/ruoyi/train/domain/vo/PlanItemVO.java | 43 ++ .../train/mapper/TrainClassPlanMapper.java | 33 +- .../train/service/ITrainClassPlanService.java | 26 +- .../ruoyi/train/service/PlanAppService.java | 22 + .../service/impl/PlanAppServiceImpl.java | 230 ++++++++++ .../impl/TrainClassPlanServiceImpl.java | 42 +- .../port/PlanEnrollmentCompletionPort.java | 12 + .../mapper/train/TrainClassPlanMapper.xml | 156 ++++--- .../service/impl/PlanAppServiceImplTest.java | 429 ++++++++++++++++++ build_ui/src/api/train/plan.js | 18 +- build_ui/src/views/train/plan/index.vue | 416 +++++++---------- docs/development/issue-02-01-plan.md | 167 +++++++ 21 files changed, 1561 insertions(+), 526 deletions(-) delete mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassPlanController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/port/PlanEnrollmentCompletionPort.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java create mode 100644 docs/development/issue-02-01-plan.md diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassPlanController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassPlanController.java deleted file mode 100644 index c3de051b5..000000000 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassPlanController.java +++ /dev/null @@ -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 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 list = trainClassPlanService.selectTrainClassPlanList(trainClassPlan); - ExcelUtil util = new ExcelUtil(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)); - } -} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java new file mode 100644 index 000000000..df69df8c6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java @@ -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 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(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/pom.xml b/RuoYi-Vue/ruoyi-train/pom.xml index dc4985cb5..4debe3374 100644 --- a/RuoYi-Vue/ruoyi-train/pom.xml +++ b/RuoYi-Vue/ruoyi-train/pom.xml @@ -23,6 +23,13 @@ ruoyi-common + + junit + junit + 4.13.2 + test + + - \ No newline at end of file + 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 new file mode 100644 index 000000000..5eeb4dde6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/PlanErrorCode.java @@ -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() + { + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java index 7de1acb25..b2f86d5d2 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainClassPlan.java @@ -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(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java new file mode 100644 index 000000000..c807fe7bd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanCreateDTO.java @@ -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; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java new file mode 100644 index 000000000..5f94d1057 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/PlanUpdateDTO.java @@ -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; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java new file mode 100644 index 000000000..ab3ddf3f3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/PlanQuery.java @@ -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; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java new file mode 100644 index 000000000..53dfd3ccf --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanDetailVO.java @@ -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; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java new file mode 100644 index 000000000..b9ab2ae20 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/PlanItemVO.java @@ -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; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java index 25541f69c..c04172216 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassPlanMapper.java @@ -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 selectTrainClassPlanList(TrainClassPlan trainClassPlan); + public List 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); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java index 14cd9429d..f47cb8bad 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainClassPlanService.java @@ -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 selectTrainClassPlanList(TrainClassPlan trainClassPlan); + public List 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); } 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 new file mode 100644 index 000000000..4f305bcbf --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/PlanAppService.java @@ -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 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); +} 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 new file mode 100644 index 000000000..a7a9760ca --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/PlanAppServiceImpl.java @@ -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 enrollmentCompletionPorts; + + @Autowired + public PlanAppServiceImpl(ITrainClassPlanService planService, + ObjectProvider 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 enrollmentCompletionPorts) + { + this.planService = planService; + this.enrollmentCompletionPorts = enrollmentCompletionPorts == null + ? Collections.emptyList() : enrollmentCompletionPorts; + } + + @Override + public List 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 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 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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java index 3775e56f8..6012479c6 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainClassPlanServiceImpl.java @@ -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 selectTrainClassPlanList(TrainClassPlan trainClassPlan) + public List 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); } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/port/PlanEnrollmentCompletionPort.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/port/PlanEnrollmentCompletionPort.java new file mode 100644 index 000000000..5c81c4689 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/port/PlanEnrollmentCompletionPort.java @@ -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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml index 1299ba3cc..edffddff1 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassPlanMapper.xml @@ -7,43 +7,101 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + - - + + + - - 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 + + + + + + + + + + + + + + + + case when plan_status = 0 and start_time <= CURRENT_TIMESTAMP then 1 + else plan_status end - + 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 ( + + ) p + + p.del_flag = '0' + + and p.plan_name like concat('%', #{planName}, '%') + + + and p.plan_status = #{planStatus} + + + and p.start_time >= #{startTimeBegin} + + + and p.start_time <= #{startTimeEnd} + + order by p.start_time desc, p.create_time desc + + + + @@ -51,32 +109,34 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" plan_id, plan_name, - c_id, start_time, end_time, + report_deadline, train_address, - t_id, recruit_num, apply_condition, plan_status, - create_user, + del_flag, + create_by, create_time, + update_by, update_time, remark, #{planId}, #{planName}, - #{cId}, #{startTime}, #{endTime}, + #{reportDeadline}, #{trainAddress}, - #{tId}, #{recruitNum}, #{applyCondition}, #{planStatus}, - #{createUser}, + #{delFlag}, + #{createBy}, #{createTime}, + #{updateBy}, #{updateTime}, #{remark}, @@ -84,32 +144,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update train_class_plan - - plan_name = #{planName}, - c_id = #{cId}, - start_time = #{startTime}, - end_time = #{endTime}, - train_address = #{trainAddress}, - t_id = #{tId}, - recruit_num = #{recruitNum}, - apply_condition = #{applyCondition}, - plan_status = #{planStatus}, - create_user = #{createUser}, - create_time = #{createTime}, - update_time = #{updateTime}, - remark = #{remark}, - - 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' - - delete from train_class_plan where plan_id = #{planId} - - - - delete from train_class_plan where plan_id in - - #{planId} - - - \ No newline at end of file + + update train_class_plan + set plan_status = #{planStatus}, + update_by = #{updateBy}, + update_time = #{updateTime} + where plan_id = #{planId} and del_flag = '0' + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java new file mode 100644 index 000000000..f529ead1c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanAppServiceImplTest.java @@ -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 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 store = new LinkedHashMap<>(); + private List 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 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; + } + } +} diff --git a/build_ui/src/api/train/plan.js b/build_ui/src/api/train/plan.js index a415646a1..4aca4cd70 100644 --- a/build_ui/src/api/train/plan.js +++ b/build_ui/src/api/train/plan.js @@ -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' }) } diff --git a/build_ui/src/views/train/plan/index.vue b/build_ui/src/views/train/plan/index.vue index 2fa5b229d..0cffc5149 100644 --- a/build_ui/src/views/train/plan/index.vue +++ b/build_ui/src/views/train/plan/index.vue @@ -1,68 +1,30 @@