新增培训:实现参训人员P0能力

This commit is contained in:
wangzhibiao committed 2026-09-14 15:19:26 +08:00
1 parent 665ef040d8
commit e340db872c
20 files changed
+3840

No files matched your search

@@ -0,0 +1,122 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
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.EnrollmentAddDTO;
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.BatchImportResultVO;
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.service.EnrollmentAppService;
/**
* 参训人员全生命周期管理 Controller (API Contract Module B 第 3 部分 / ISSUE-03-01 / P0)。
* 严格遵循规范:Controller 仅负责参数校验、鉴权拦截与统一 AjaxResult/TableDataInfo 返回。
*
* @author ruoyi
* @date 2026-09-14
*/
@RestController
@RequestMapping("/train")
public class TrainEnrollmentController extends BaseController
{
private final EnrollmentAppService enrollmentAppService;
@Autowired
public TrainEnrollmentController(EnrollmentAppService enrollmentAppService)
{
this.enrollmentAppService = enrollmentAppService;
}
/**
* 查看本期参训名单列表 (UC-06 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:list')")
@GetMapping("/plans/{planId}/enrollments")
public TableDataInfo listEnrollments(@PathVariable String planId, EnrollmentQuery query)
{
startPage();
List<EnrollmentItemVO> list = enrollmentAppService.listEnrollments(planId, query);
return getDataTable(list);
}
/**
* 添加单名参训人员 (UC-04 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:add')")
@Log(title = "添加参训学员", businessType = BusinessType.INSERT)
@PostMapping("/plans/{planId}/enrollments")
public AjaxResult addEnrollment(@PathVariable String planId, @Validated @RequestBody EnrollmentAddDTO dto)
{
Long enrollmentId = enrollmentAppService.addEnrollment(planId, dto, SecurityUtils.getUsername());
return success(enrollmentId);
}
/**
* Excel 批量导入参训人员 (UC-05 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
@Log(title = "批量导入参训人员", businessType = BusinessType.IMPORT)
@PostMapping("/plans/{planId}/enrollments/batch")
public AjaxResult batchImportEnrollments(@PathVariable String planId,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "autoCreateStudent", required = false, defaultValue = "false") Boolean autoCreateStudent)
{
BatchImportResultVO result = enrollmentAppService.batchImportEnrollments(planId, file, autoCreateStudent, SecurityUtils.getUsername());
return success(result);
}
/**
* 获取参训档案详情 (P0)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:query')")
@GetMapping("/enrollments/{enrollmentId}")
public AjaxResult getDetail(@PathVariable Long enrollmentId)
{
EnrollmentDetailVO detail = enrollmentAppService.getEnrollmentDetail(enrollmentId);
return success(detail);
}
/**
* 变更参训状态 (UC-06 / P1 冻结范围)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:edit')")
@Log(title = "变更参训状态", businessType = BusinessType.UPDATE)
@PutMapping("/enrollments/{enrollmentId}/status")
public AjaxResult updateStatus(@PathVariable Long enrollmentId, @Validated @RequestBody EnrollmentStatusUpdateDTO dto)
{
enrollmentAppService.updateEnrollmentStatus(enrollmentId, dto, SecurityUtils.getUsername());
return success();
}
/**
* 移除参训人员 (P1 冻结范围)。
*/
@PreAuthorize("@ss.hasPermi('train:enrollment:remove')")
@Log(title = "移除参训人员", businessType = BusinessType.DELETE)
@DeleteMapping("/enrollments/{enrollmentId}")
public AjaxResult removeEnrollment(@PathVariable Long enrollmentId)
{
enrollmentAppService.removeEnrollment(enrollmentId, SecurityUtils.getUsername());
return success();
}
}
@@ -0,0 +1,111 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.lang.reflect.Method;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestMapping;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
/**
* ISSUE-03-01 参训管理 Controller 契约与安全边界测试。
*/
public class EnrollmentApiTest
{
@Test
public void shouldExposeExactFrozenEndpointsAndPermissions() throws Exception
{
// 验证类级别映射
RequestMapping classMapping = TrainEnrollmentController.class.getAnnotation(RequestMapping.class);
assertNotNull(classMapping);
assertEquals("/train", classMapping.value()[0]);
// 1. GET /train/plans/{planId}/enrollments
Method listMethod = TrainEnrollmentController.class.getMethod("listEnrollments", String.class, EnrollmentQuery.class);
GetMapping listGet = listMethod.getAnnotation(GetMapping.class);
assertNotNull(listGet);
assertEquals("/plans/{planId}/enrollments", listGet.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:list')", listMethod.getAnnotation(PreAuthorize.class).value());
// 2. POST /train/plans/{planId}/enrollments
Method addMethod = TrainEnrollmentController.class.getMethod("addEnrollment", String.class, EnrollmentAddDTO.class);
PostMapping addPost = addMethod.getAnnotation(PostMapping.class);
assertNotNull(addPost);
assertEquals("/plans/{planId}/enrollments", addPost.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:add')", addMethod.getAnnotation(PreAuthorize.class).value());
// 3. POST /train/plans/{planId}/enrollments/batch
Method batchMethod = TrainEnrollmentController.class.getMethod("batchImportEnrollments", String.class, MultipartFile.class, Boolean.class);
PostMapping batchPost = batchMethod.getAnnotation(PostMapping.class);
assertNotNull(batchPost);
assertEquals("/plans/{planId}/enrollments/batch", batchPost.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:import')", batchMethod.getAnnotation(PreAuthorize.class).value());
// 4. GET /train/enrollments/{enrollmentId}
Method detailMethod = TrainEnrollmentController.class.getMethod("getDetail", Long.class);
GetMapping detailGet = detailMethod.getAnnotation(GetMapping.class);
assertNotNull(detailGet);
assertEquals("/enrollments/{enrollmentId}", detailGet.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:query')", detailMethod.getAnnotation(PreAuthorize.class).value());
// 5. PUT /train/enrollments/{enrollmentId}/status
Method statusMethod = TrainEnrollmentController.class.getMethod("updateStatus", Long.class, EnrollmentStatusUpdateDTO.class);
PutMapping statusPut = statusMethod.getAnnotation(PutMapping.class);
assertNotNull(statusPut);
assertEquals("/enrollments/{enrollmentId}/status", statusPut.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:edit')", statusMethod.getAnnotation(PreAuthorize.class).value());
// 6. DELETE /train/enrollments/{enrollmentId}
Method removeMethod = TrainEnrollmentController.class.getMethod("removeEnrollment", Long.class);
DeleteMapping removeDelete = removeMethod.getAnnotation(DeleteMapping.class);
assertNotNull(removeDelete);
assertEquals("/enrollments/{enrollmentId}", removeDelete.value()[0]);
assertEquals("@ss.hasPermi('train:enrollment:remove')", removeMethod.getAnnotation(PreAuthorize.class).value());
}
@Test
public void controllerMustNotExposePersistenceDomain()
{
for (Method method : TrainEnrollmentController.class.getDeclaredMethods())
{
assertFalse("Controller方法返回值不得直接暴露 TrainEnrollment 实体",
method.getReturnType().equals(TrainEnrollment.class));
for (Class<?> paramType : method.getParameterTypes())
{
assertFalse("Controller方法入参不得直接接收 TrainEnrollment 实体",
paramType.equals(TrainEnrollment.class));
}
}
}
@Test
public void controllerMustStrictlyExposeOnlyTheFrozenEndpoints()
{
int endpointCount = 0;
for (Method method : TrainEnrollmentController.class.getDeclaredMethods())
{
boolean isEndpoint = method.isAnnotationPresent(GetMapping.class)
|| method.isAnnotationPresent(PostMapping.class)
|| method.isAnnotationPresent(PutMapping.class)
|| method.isAnnotationPresent(DeleteMapping.class);
if (isEndpoint)
{
endpointCount++;
}
}
assertEquals("Controller 必须严格只有契约冻结的 6 个端点", 6, endpointCount);
}
}
@@ -0,0 +1,39 @@
package com.ruoyi.train.constant;
/**
* 参训人员管理错误码与常量规范 (API Contract V1.0.1 / P0 Issue Backlog)。
*/
public final class EnrollmentErrorCode
{
public static final int ENROLLMENT_DUPLICATED = 40030;
public static final int RECRUIT_LIMIT_EXCEEDED = 40031;
public static final int ENROLLMENT_HAS_BUSINESS_DATA = 40032;
public static final int EXCEL_PARSE_ERROR = 40033;
public static final int FILE_SIZE_EXCEEDED = 40034;
public static final int ENROLLMENT_NOT_FOUND = 40430;
public static final String ENROLLMENT_DUPLICATED_MESSAGE = "该学员已在本班参训名单中,不可重复添加";
public static final String RECRUIT_LIMIT_EXCEEDED_MESSAGE = "参训人数已超出本期计划招生上限";
public static final String ENROLLMENT_HAS_BUSINESS_DATA_MESSAGE = "该学员已产生考勤或成绩数据,不可删除参训档案";
public static final String EXCEL_PARSE_ERROR_MESSAGE = "Excel导入解析错误";
public static final String FILE_SIZE_EXCEEDED_MESSAGE = "导入文件大小超出限制";
public static final String ENROLLMENT_NOT_FOUND_MESSAGE = "参训档案不存在";
public static final String ENROLLMENT_STATUS_INVALID_MESSAGE = "参训状态流转不合法";
public static final String ENROLLMENT_SOURCE_REQUIRED_MESSAGE = "参训来源(sourceType)不能为空";
public static final String ACCOMMODATION_REQUIRED_MESSAGE = "住宿需求(accommodationRequired)不能为空";
/** 参训状态常量定义 */
public static final int STATUS_WAITING_REPORT = 0; // 0:待报到
public static final int STATUS_REPORTED = 1; // 1:已报到
public static final int STATUS_LEAVE = 2; // 2:请假
public static final int STATUS_ABANDONED = 3; // 3:放弃
public static final int STATUS_COMPLETED = 4; // 4:已完成
/** 住宿需求常量 */
public static final int ACCOMMODATION_NOT_REQUIRED = 0; // 0:不住宿
public static final int ACCOMMODATION_REQUIRED = 1; // 1:住宿
private EnrollmentErrorCode()
{
}
}
@@ -0,0 +1,152 @@
package com.ruoyi.train.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 参训学员登记表 (学员参加培训班全生命周期主表) train_enrollment
*
* @author ruoyi
* @date 2026-09-14
*/
public class TrainEnrollment extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 参训档案ID (核心ID之一) */
private Long enrollmentId;
/** 培训班ID */
@Excel(name = "培训班ID")
private String planId;
/** 学员长期主档ID引用 */
@Excel(name = "学员长期主档ID引用")
private String studentId;
/** 参训名单来源 (具体字典值由 train_enrollment_source 定义) */
@Excel(name = "参训名单来源")
private Integer sourceType;
/** 参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成) */
@Excel(name = "参训状态", readConverterExp = "0=待报到,1=已报到,2=请假,3=放弃,4=已完成")
private Integer enrollmentStatus;
/** 是否需要住宿 (0:否, 1:是) */
@Excel(name = "是否需要住宿", readConverterExp = "0=否,1=是")
private Integer accommodationRequired;
/** 入班报到时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "入班报到时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date joinTime;
/** 培训完成/结课时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "培训完成/结课时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date completeTime;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public Integer getSourceType()
{
return sourceType;
}
public void setSourceType(Integer sourceType)
{
this.sourceType = sourceType;
}
public Integer getEnrollmentStatus()
{
return enrollmentStatus;
}
public void setEnrollmentStatus(Integer enrollmentStatus)
{
this.enrollmentStatus = enrollmentStatus;
}
public Integer getAccommodationRequired()
{
return accommodationRequired;
}
public void setAccommodationRequired(Integer accommodationRequired)
{
this.accommodationRequired = accommodationRequired;
}
public Date getJoinTime()
{
return joinTime;
}
public void setJoinTime(Date joinTime)
{
this.joinTime = joinTime;
}
public Date getCompleteTime()
{
return completeTime;
}
public void setCompleteTime(Date completeTime)
{
this.completeTime = completeTime;
}
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("enrollmentId", getEnrollmentId())
.append("planId", getPlanId())
.append("studentId", getStudentId())
.append("sourceType", getSourceType())
.append("enrollmentStatus", getEnrollmentStatus())
.append("accommodationRequired", getAccommodationRequired())
.append("joinTime", getJoinTime())
.append("completeTime", getCompleteTime())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
}
}
@@ -0,0 +1,81 @@
package com.ruoyi.train.domain.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 添加参训学员请求 DTO (UC-04 / API Contract 3.2)。
*/
public class EnrollmentAddDTO
{
/** 学员长期主档ID */
@NotBlank(message = "学员ID不能为空")
@Size(max = 32, message = "学员ID长度不能超过32个字符")
private String studentId;
/** 参训名单来源 (具体字典值由 train_enrollment_source 定义,如 1:管理员录入, 2:Excel批量导入, 3:铁路内部业务平台同步) */
@NotNull(message = "参训来源(sourceType)不能为空")
private Integer sourceType;
/** 是否需要住宿 (0:否, 1:是) */
@NotNull(message = "住宿需求(accommodationRequired)不能为空")
private Integer accommodationRequired;
/** 备注 */
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
/** 选派单位 (兼容部分调用传参,展示时以学员档案为准) */
private String orgName;
public String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public Integer getSourceType()
{
return sourceType;
}
public void setSourceType(Integer sourceType)
{
this.sourceType = sourceType;
}
public Integer getAccommodationRequired()
{
return accommodationRequired;
}
public void setAccommodationRequired(Integer accommodationRequired)
{
this.accommodationRequired = accommodationRequired;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
public String getOrgName()
{
return orgName;
}
public void setOrgName(String orgName)
{
this.orgName = orgName;
}
}
@@ -0,0 +1,8 @@
package com.ruoyi.train.domain.dto;
/**
* 参训学员创建 DTO 别名兼容类 (兼容 ISSUE-03-01 文档命名)。
*/
public class EnrollmentCreateDTO extends EnrollmentAddDTO
{
}
@@ -0,0 +1,38 @@
package com.ruoyi.train.domain.dto;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 变更参训状态请求 DTO (UC-06 / API Contract 3.5)。
*/
public class EnrollmentStatusUpdateDTO
{
/** 参训状态 (2:请假, 3:放弃) */
@NotNull(message = "参训状态不能为空")
private Integer enrollmentStatus;
/** 变更原因 / 备注 */
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
public Integer getEnrollmentStatus()
{
return enrollmentStatus;
}
public void setEnrollmentStatus(Integer enrollmentStatus)
{
this.enrollmentStatus = enrollmentStatus;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,59 @@
package com.ruoyi.train.domain.query;
/**
* 参训学员列表查询参数 (UC-06 / API Contract 3.1)。
*/
public class EnrollmentQuery
{
/** 培训计划ID */
private String planId;
/** 学员姓名 (模糊匹配) */
private String studentName;
/** 参训状态 (0待报到 1已报到 2请假 3放弃 4已完成) */
private Integer enrollmentStatus;
/** 住宿需求 (0不住宿 1住宿) */
private Integer accommodationRequired;
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public Integer getEnrollmentStatus()
{
return enrollmentStatus;
}
public void setEnrollmentStatus(Integer enrollmentStatus)
{
this.enrollmentStatus = enrollmentStatus;
}
public Integer getAccommodationRequired()
{
return accommodationRequired;
}
public void setAccommodationRequired(Integer accommodationRequired)
{
this.accommodationRequired = accommodationRequired;
}
}
@@ -0,0 +1,74 @@
package com.ruoyi.train.domain.vo;
import java.util.ArrayList;
import java.util.List;
/**
* 参训人员批量导入结果 VO (UC-05 / API Contract 3.3)。
*/
public class BatchImportResultVO
{
/** 总记录数 */
private Integer totalCount = 0;
/** 导入成功数 */
private Integer successCount = 0;
/** 导入失败数 */
private Integer failureCount = 0;
/** 失败错误明细列表 (按行) */
private List<String> errorList = new ArrayList<>();
public BatchImportResultVO()
{
}
public BatchImportResultVO(Integer totalCount, Integer successCount, Integer failureCount, List<String> errorList)
{
this.totalCount = totalCount;
this.successCount = successCount;
this.failureCount = failureCount;
this.errorList = errorList;
}
public Integer getTotalCount()
{
return totalCount;
}
public void setTotalCount(Integer totalCount)
{
this.totalCount = totalCount;
}
public Integer getSuccessCount()
{
return successCount;
}
public void setSuccessCount(Integer successCount)
{
this.successCount = successCount;
}
public Integer getFailureCount()
{
return failureCount;
}
public void setFailureCount(Integer failureCount)
{
this.failureCount = failureCount;
}
public List<String> getErrorList()
{
return errorList;
}
public void setErrorList(List<String> errorList)
{
this.errorList = errorList;
}
}
@@ -0,0 +1,357 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 学员参训档案全景 VO (API Contract V1.0.1 1.3 / P0)。
*/
public class EnrollmentDetailVO
{
/** 参训档案标识 */
private Long enrollmentId;
/** 培训计划标识 */
private String planId;
/** 培训计划名称 */
private String planName;
/** 学员 ID */
private String studentId;
/** 学员姓名 */
private String studentName;
/** 性别 */
private String gender;
/** 身份证脱敏展示 (210102********1234) */
private String idCardMasked;
/** 选派单位 */
private String orgName;
/** 职务 */
private String postName;
/** 手机号 */
private String phone;
/** 参训名单来源 */
private Integer sourceType;
/** 状态:0待报到 1已报到 2请假 3放弃 4已完成 */
private Integer enrollmentStatus;
/** 住宿需求 (0不住宿 1住宿) */
private Integer accommodationRequired;
/** 当前住宿分配详情 (building, room, bedNo, authStatus) */
private AccommodationSummaryVO accommodation;
/** 报到信息 (reportTime, reportWay, verifyNote) */
private ReportSummaryVO report;
/** 入班时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date joinTime;
/** 结训时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date completeTime;
/** 备注 */
private String remark;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
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 String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public String getGender()
{
return gender;
}
public void setGender(String gender)
{
this.gender = gender;
}
public String getIdCardMasked()
{
return idCardMasked;
}
public void setIdCardMasked(String idCardMasked)
{
this.idCardMasked = idCardMasked;
}
public String getOrgName()
{
return orgName;
}
public void setOrgName(String orgName)
{
this.orgName = orgName;
}
public String getPostName()
{
return postName;
}
public void setPostName(String postName)
{
this.postName = postName;
}
public String getPhone()
{
return phone;
}
public void setPhone(String phone)
{
this.phone = phone;
}
public Integer getSourceType()
{
return sourceType;
}
public void setSourceType(Integer sourceType)
{
this.sourceType = sourceType;
}
public Integer getEnrollmentStatus()
{
return enrollmentStatus;
}
public void setEnrollmentStatus(Integer enrollmentStatus)
{
this.enrollmentStatus = enrollmentStatus;
}
public Integer getAccommodationRequired()
{
return accommodationRequired;
}
public void setAccommodationRequired(Integer accommodationRequired)
{
this.accommodationRequired = accommodationRequired;
}
public AccommodationSummaryVO getAccommodation()
{
return accommodation;
}
public void setAccommodation(AccommodationSummaryVO accommodation)
{
this.accommodation = accommodation;
}
public ReportSummaryVO getReport()
{
return report;
}
public void setReport(ReportSummaryVO report)
{
this.report = report;
}
public Date getJoinTime()
{
return joinTime;
}
public void setJoinTime(Date joinTime)
{
this.joinTime = joinTime;
}
public Date getCompleteTime()
{
return completeTime;
}
public void setCompleteTime(Date completeTime)
{
this.completeTime = completeTime;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
/** 住宿摘要嵌套对象 */
public static class AccommodationSummaryVO
{
private String building;
private String room;
private String bedNo;
private Integer authStatus;
public AccommodationSummaryVO()
{
}
public AccommodationSummaryVO(String building, String room, String bedNo, Integer authStatus)
{
this.building = building;
this.room = room;
this.bedNo = bedNo;
this.authStatus = authStatus;
}
public String getBuilding()
{
return building;
}
public void setBuilding(String building)
{
this.building = building;
}
public String getRoom()
{
return room;
}
public void setRoom(String room)
{
this.room = room;
}
public String getBedNo()
{
return bedNo;
}
public void setBedNo(String bedNo)
{
this.bedNo = bedNo;
}
public Integer getAuthStatus()
{
return authStatus;
}
public void setAuthStatus(Integer authStatus)
{
this.authStatus = authStatus;
}
}
/** 报到摘要嵌套对象 */
public static class ReportSummaryVO
{
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportTime;
private Integer reportWay;
private String verifyNote;
public ReportSummaryVO()
{
}
public ReportSummaryVO(Date reportTime, Integer reportWay, String verifyNote)
{
this.reportTime = reportTime;
this.reportWay = reportWay;
this.verifyNote = verifyNote;
}
public Date getReportTime()
{
return reportTime;
}
public void setReportTime(Date reportTime)
{
this.reportTime = reportTime;
}
public Integer getReportWay()
{
return reportWay;
}
public void setReportWay(Integer reportWay)
{
this.reportWay = reportWay;
}
public String getVerifyNote()
{
return verifyNote;
}
public void setVerifyNote(String verifyNote)
{
this.verifyNote = verifyNote;
}
}
}
@@ -0,0 +1,207 @@
package com.ruoyi.train.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 参训学员列表项 VO (UC-06 / API Contract 3.1)。
*/
public class EnrollmentItemVO
{
/** 参训档案ID (核心ID之一) */
private Long enrollmentId;
/** 培训班ID */
private String planId;
/** 学员长期主档ID */
private String studentId;
/** 学员姓名 */
private String studentName;
/** 性别 */
private String gender;
/** 身份证脱敏展示 (如 210102********1234) */
private String idCardMasked;
/** 选派单位 */
private String orgName;
/** 职务 */
private String postName;
/** 手机号 */
private String phone;
/** 参训来源 (具体字典值由 train_enrollment_source 定义) */
private Integer sourceType;
/** 参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成) */
private Integer enrollmentStatus;
/** 是否需要住宿 (0:否, 1:是) */
private Integer accommodationRequired;
/** 入班报到时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date joinTime;
/** 培训完成/结课时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date completeTime;
/** 备注 */
private String remark;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public String getGender()
{
return gender;
}
public void setGender(String gender)
{
this.gender = gender;
}
public String getIdCardMasked()
{
return idCardMasked;
}
public void setIdCardMasked(String idCardMasked)
{
this.idCardMasked = idCardMasked;
}
public String getOrgName()
{
return orgName;
}
public void setOrgName(String orgName)
{
this.orgName = orgName;
}
public String getPostName()
{
return postName;
}
public void setPostName(String postName)
{
this.postName = postName;
}
public String getPhone()
{
return phone;
}
public void setPhone(String phone)
{
this.phone = phone;
}
public Integer getSourceType()
{
return sourceType;
}
public void setSourceType(Integer sourceType)
{
this.sourceType = sourceType;
}
public Integer getEnrollmentStatus()
{
return enrollmentStatus;
}
public void setEnrollmentStatus(Integer enrollmentStatus)
{
this.enrollmentStatus = enrollmentStatus;
}
public Integer getAccommodationRequired()
{
return accommodationRequired;
}
public void setAccommodationRequired(Integer accommodationRequired)
{
this.accommodationRequired = accommodationRequired;
}
public Date getJoinTime()
{
return joinTime;
}
public void setJoinTime(Date joinTime)
{
this.joinTime = joinTime;
}
public Date getCompleteTime()
{
return completeTime;
}
public void setCompleteTime(Date completeTime)
{
this.completeTime = completeTime;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,160 @@
package com.ruoyi.train.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
/**
* 参训学员登记Mapper接口 train_enrollment。
*
* @author ruoyi
* @date 2026-09-14
*/
public interface TrainEnrollmentMapper
{
/**
* 锁定开班计划主表行以实现招生容量并发控制 (SELECT ... FOR UPDATE)。
*
* @param planId 计划ID
* @return 开班计划实体
*/
TrainClassPlan selectPlanForUpdate(@Param("planId") String planId);
/**
* 根据参训ID查询参训记录。
*
* @param enrollmentId 参训档案ID
* @return 参训记录
*/
TrainEnrollment selectTrainEnrollmentById(@Param("enrollmentId") Long enrollmentId);
/**
* 根据计划ID与学员ID查询参训记录(用于唯一性校验)。
*
* @param planId 培训计划ID
* @param studentId 学员ID
* @return 参训记录
*/
TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(@Param("planId") String planId, @Param("studentId") String studentId);
/**
* 分页查询参训人员花名册列表(联查学员主数据)。
*
* @param query 查询参数
* @return 参训人员列表
*/
List<EnrollmentItemVO> selectEnrollmentItemList(EnrollmentQuery query);
/**
* 统计指定培训计划下的参训人数(排除已放弃3的学员统计,或统计全部)。
*
* @param planId 培训计划ID
* @return 当前计划参训总人数
*/
int countEnrollmentsByPlanId(@Param("planId") String planId);
/**
* 单条新增参训学员记录。
*
* @param trainEnrollment 参训记录
* @return 插入行数
*/
int insertTrainEnrollment(TrainEnrollment trainEnrollment);
/**
* 批量新增参训学员记录。
*
* @param list 待插入列表
* @return 插入行数
*/
int batchInsertTrainEnrollment(@Param("list") List<TrainEnrollment> list);
/**
* 更新参训学员记录。
*
* @param trainEnrollment 参训记录
* @return 更新行数
*/
int updateTrainEnrollment(TrainEnrollment trainEnrollment);
/**
* 变更参训学员状态 (如请假2、放弃3)。
*
* @param enrollmentId 参训档案ID
* @param enrollmentStatus 新状态
* @param remark 备注/原因
* @param operator 操作人
* @param updateTime 更新时间
* @return 更新行数
*/
int updateEnrollmentStatus(@Param("enrollmentId") Long enrollmentId,
@Param("enrollmentStatus") Integer enrollmentStatus,
@Param("remark") String remark,
@Param("operator") String operator,
@Param("updateTime") Date updateTime);
/**
* 计划结训时批量将有效在训学员状态更新为已完成(4),并记录结业时间。
* 有效状态指未放弃(status != 3)且未完成(status != 4)。
*
* @param planId 培训班ID
* @param operator 操作人
* @param completeTime 结训时间
* @return 更新行数
*/
int completeActiveEnrollmentsByPlanId(@Param("planId") String planId,
@Param("operator") String operator,
@Param("completeTime") Date completeTime);
/**
* 物理删除参训记录。
*
* @param enrollmentId 参训档案ID
* @return 删除行数
*/
int deleteTrainEnrollmentById(@Param("enrollmentId") Long enrollmentId);
/**
* 统计学员考勤签到记录数 (train_student_checkin 下游引用)。
*
* @param enrollmentId 参训档案ID
* @return 引用记录数
*/
int countAttendanceByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
/**
* 统计学员成绩记录数 (train_student_score 下游引用)。
*
* @param enrollmentId 参训档案ID
* @return 引用记录数
*/
int countScoreByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
/**
* 统计学员现场报到记录数 (train_class_report 下游引用)。
*
* @param enrollmentId 参训档案ID
* @return 引用记录数
*/
int countReportByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
/**
* 统计学员住宿分配记录数 (train_accommodation 下游引用)。
*
* @param enrollmentId 参训档案ID
* @return 引用记录数
*/
int countAccommodationByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
/**
* 统计学员自主学习记录数 (train_study_duration 下游引用)。
*
* @param enrollmentId 参训档案ID
* @return 引用记录数
*/
int countStudyDurationByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
}
@@ -0,0 +1,76 @@
package com.ruoyi.train.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.BatchImportResultVO;
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
/**
* 参训人员全流程业务编排应用服务接口 (Application Service 2 / P0)。
* 负责跨表业务一致性、招生人数上限并发控制、学员档案全景聚合与生命周期流转。
*
* @author ruoyi
* @date 2026-09-14
*/
public interface EnrollmentAppService
{
/**
* 查询本期培训参训名单列表 (UC-06)。
*
* @param planId 培训班ID
* @param query 查询参数
* @return 参训人员花名册列表
*/
List<EnrollmentItemVO> listEnrollments(String planId, EnrollmentQuery query);
/**
* 获取指定学员参训档案全景 (P0)。
*
* @param enrollmentId 参训档案ID
* @return 档案全景 VO
*/
EnrollmentDetailVO getEnrollmentDetail(Long enrollmentId);
/**
* 添加单名参训人员到指定培训计划 (UC-04)。
*
* @param planId 培训班ID
* @param dto 添加请求参数
* @param operator 操作人
* @return 生成的核心参训档案 enrollmentId
*/
Long addEnrollment(String planId, EnrollmentAddDTO dto, String operator);
/**
* Excel 批量导入参训人员 (UC-05)。
*
* @param planId 培训班ID
* @param file 上传的 Excel 文件
* @param autoCreateStudent 是否对不存在的学员自动建档
* @param operator 操作人
* @return 批量导入汇总结果
*/
BatchImportResultVO batchImportEnrollments(String planId, MultipartFile file, Boolean autoCreateStudent, String operator);
/**
* 变更参训状态 (UC-06 / P1 冻结范围)。
*
* @param enrollmentId 参训档案ID
* @param dto 状态变更参数
* @param operator 操作人
*/
void updateEnrollmentStatus(Long enrollmentId, EnrollmentStatusUpdateDTO dto, String operator);
/**
* 移除参训人员 (P1 冻结范围)。
* 仅在未产生任何业务数据(考勤/成绩/报到/住宿)且允许删除的阶段执行。
*
* @param enrollmentId 参训档案ID
* @param operator 操作人
*/
void removeEnrollment(Long enrollmentId, String operator);
}
@@ -0,0 +1,47 @@
package com.ruoyi.train.service;
import java.util.Date;
import java.util.List;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
/**
* 参训学员单表持久化服务接口。
* 遵循规范:单表 Service 仅负责单表 CRUD 与 Mapper 交互,跨表业务规则与不变量由 EnrollmentAppService 编排。
*
* @author ruoyi
* @date 2026-09-14
*/
public interface ITrainEnrollmentService
{
TrainEnrollment selectTrainEnrollmentById(Long enrollmentId);
TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId);
List<EnrollmentItemVO> selectEnrollmentItemList(EnrollmentQuery query);
int countEnrollmentsByPlanId(String planId);
int insertTrainEnrollment(TrainEnrollment trainEnrollment);
int batchInsertTrainEnrollment(List<TrainEnrollment> list);
int updateTrainEnrollment(TrainEnrollment trainEnrollment);
int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime);
int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime);
int deleteTrainEnrollmentById(Long enrollmentId);
int countAttendanceByEnrollmentId(Long enrollmentId);
int countScoreByEnrollmentId(Long enrollmentId);
int countReportByEnrollmentId(Long enrollmentId);
int countAccommodationByEnrollmentId(Long enrollmentId);
int countStudyDurationByEnrollmentId(Long enrollmentId);
}
@@ -0,0 +1,579 @@
package com.ruoyi.train.service.impl;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.constant.EnrollmentErrorCode;
import com.ruoyi.train.constant.PlanErrorCode;
import com.ruoyi.train.constant.StudentErrorCode;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.BatchImportResultVO;
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.security.StudentIdentityProtection;
import com.ruoyi.train.service.EnrollmentAppService;
import com.ruoyi.train.service.ITrainEnrollmentService;
/**
* 参训人员全流程业务编排应用服务实现类 (EnrollmentAppService)。
*
* 核心架构职责:
* 1. 跨表业务编排与业务不变量防护;
* 2. 招生人数上限 (recruit_limit) 并发一致性校验 (SELECT ... FOR UPDATE 锁 Plan 主表行);
* 3. 拦截数据库 UNIQUE(plan_id, student_id) 并发冲突并统一映射为冻结错误码 40030;
* 4. 参训人员生命周期流转与非法状态防护;
* 5. 开班前删除校验与防物理级联删除保护 (已有考勤/成绩/报到流水时强阻断 40032);
* 6. 学员身份证敏感数据展示脱敏掩码治理。
*
* @author ruoyi
* @date 2026-09-14
*/
@Service
public class EnrollmentAppServiceImpl implements EnrollmentAppService
{
private static final Logger log = LoggerFactory.getLogger(EnrollmentAppServiceImpl.class);
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final int MAX_BATCH_ROWS = 500;
@Autowired
private ITrainEnrollmentService trainEnrollmentService;
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
@Autowired
private TrainClassPlanMapper planMapper;
@Autowired
private TrainStudentMapper studentMapper;
@Autowired(required = false)
private StudentIdentityProtection identityProtection;
@Override
public List<EnrollmentItemVO> listEnrollments(String planId, EnrollmentQuery query)
{
requirePlan(planId);
if (query == null)
{
query = new EnrollmentQuery();
}
query.setPlanId(planId);
List<EnrollmentItemVO> list = trainEnrollmentService.selectEnrollmentItemList(query);
for (EnrollmentItemVO item : list)
{
item.setIdCardMasked(maskIdentity(item.getIdCardMasked()));
}
return list;
}
@Override
public EnrollmentDetailVO getEnrollmentDetail(Long enrollmentId)
{
TrainEnrollment enrollment = requireEnrollment(enrollmentId);
TrainClassPlan plan = requirePlan(enrollment.getPlanId());
TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId());
EnrollmentDetailVO detail = new EnrollmentDetailVO();
detail.setEnrollmentId(enrollment.getEnrollmentId());
detail.setPlanId(enrollment.getPlanId());
detail.setPlanName(plan.getPlanName());
detail.setStudentId(enrollment.getStudentId());
detail.setSourceType(enrollment.getSourceType());
detail.setEnrollmentStatus(enrollment.getEnrollmentStatus());
detail.setAccommodationRequired(enrollment.getAccommodationRequired());
detail.setJoinTime(enrollment.getJoinTime());
detail.setCompleteTime(enrollment.getCompleteTime());
detail.setRemark(enrollment.getRemark());
if (student != null)
{
detail.setStudentName(student.getsName());
detail.setGender(student.getsGender());
detail.setOrgName(student.getOrgName());
detail.setPhone(student.getsPhone());
detail.setIdCardMasked(maskStudentIdentity(student));
}
return detail;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long addEnrollment(String planId, EnrollmentAddDTO dto, String operator)
{
validateEnrollmentAddDTO(dto);
// 1. 事务中先锁稳定 Plan 主表行: SELECT ... FROM train_class_plan WHERE plan_id = ? FOR UPDATE
TrainClassPlan plan = trainEnrollmentMapper.selectPlanForUpdate(planId);
if (plan == null || "2".equals(plan.getDelFlag()))
{
throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
// 2. 校验 Plan 状态
checkPlanStatusNotLocked(plan);
// 3. count 当前 Enrollment 并校验 recruitNum
if (plan.getRecruitNum() != null && plan.getRecruitNum() > 0)
{
int currentCount = trainEnrollmentService.countEnrollmentsByPlanId(planId);
if (currentCount >= plan.getRecruitNum())
{
throw new ServiceException(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED_MESSAGE, EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED);
}
}
// 4. 校验学员档案有效性
TrainStudent student = studentMapper.selectTrainStudentBySId(dto.getStudentId());
if (student == null || "2".equals(student.getDelFlag()) || (student.getStatus() != null && student.getStatus() != 0))
{
throw new ServiceException(StudentErrorCode.STUDENT_NOT_FOUND_MESSAGE, StudentErrorCode.STUDENT_NOT_FOUND);
}
// 5. duplicate 预检查
TrainEnrollment existing = trainEnrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, dto.getStudentId());
if (existing != null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_DUPLICATED_MESSAGE, EnrollmentErrorCode.ENROLLMENT_DUPLICATED);
}
// 6. 组装实体并持久化 (DB UNIQUE 约束并发兜底)
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setPlanId(planId);
enrollment.setStudentId(dto.getStudentId());
enrollment.setSourceType(dto.getSourceType());
enrollment.setAccommodationRequired(dto.getAccommodationRequired());
enrollment.setEnrollmentStatus(EnrollmentErrorCode.STATUS_WAITING_REPORT); // 初始为 0:待报到
enrollment.setRemark(dto.getRemark());
enrollment.setCreateBy(operator);
enrollment.setCreateTime(DateUtils.getNowDate());
try
{
trainEnrollmentService.insertTrainEnrollment(enrollment);
return enrollment.getEnrollmentId();
}
catch (DuplicateKeyException e)
{
// 捕获并发 UNIQUE(plan_id, student_id) 冲突并统一映射为 40030
log.warn("并发添加参训人员发生唯一约束冲突: planId={}, studentId={}", planId, dto.getStudentId());
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_DUPLICATED_MESSAGE, EnrollmentErrorCode.ENROLLMENT_DUPLICATED);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public BatchImportResultVO batchImportEnrollments(String planId, MultipartFile file, Boolean autoCreateStudent, String operator)
{
if (file == null || file.isEmpty())
{
throw new ServiceException(EnrollmentErrorCode.EXCEL_PARSE_ERROR_MESSAGE, EnrollmentErrorCode.EXCEL_PARSE_ERROR);
}
if (file.getSize() > MAX_FILE_SIZE)
{
throw new ServiceException(EnrollmentErrorCode.FILE_SIZE_EXCEEDED_MESSAGE, EnrollmentErrorCode.FILE_SIZE_EXCEEDED);
}
// 批量导入涉及同一 Plan 时遵守相同 Plan 锁语义 (SELECT ... FOR UPDATE)
TrainClassPlan plan = trainEnrollmentMapper.selectPlanForUpdate(planId);
if (plan == null || "2".equals(plan.getDelFlag()))
{
throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
checkPlanStatusNotLocked(plan);
List<EnrollmentAddDTO> dtoList;
try (InputStream is = file.getInputStream())
{
dtoList = parseExcelToDTOList(is);
}
catch (Exception e)
{
log.error("Excel解析失败: {}", e.getMessage(), e);
throw new ServiceException(EnrollmentErrorCode.EXCEL_PARSE_ERROR_MESSAGE, EnrollmentErrorCode.EXCEL_PARSE_ERROR);
}
return processBatchImport(plan, dtoList, Boolean.TRUE.equals(autoCreateStudent), operator);
}
/**
* 批量导入处理引擎 (供 Excel 导入与批量接口复用)。
*/
public BatchImportResultVO processBatchImport(TrainClassPlan plan, List<EnrollmentAddDTO> dtoList, boolean autoCreateStudent, String operator)
{
String planId = plan.getPlanId();
int totalCount = dtoList != null ? dtoList.size() : 0;
int successCount = 0;
int failureCount = 0;
List<String> errorList = new ArrayList<>();
if (totalCount > MAX_BATCH_ROWS)
{
throw new ServiceException("单批次导入人数不得超过 " + MAX_BATCH_ROWS + " 行");
}
int currentCount = trainEnrollmentService.countEnrollmentsByPlanId(planId);
for (int i = 0; i < totalCount; i++)
{
int rowNum = i + 1;
EnrollmentAddDTO dto = dtoList.get(i);
if (dto == null || StringUtils.isEmpty(dto.getStudentId()))
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 学员ID不能为空");
continue;
}
if (dto.getSourceType() == null)
{
dto.setSourceType(2); // 默认 2:Excel批量导入
}
if (dto.getAccommodationRequired() == null)
{
dto.setAccommodationRequired(EnrollmentErrorCode.ACCOMMODATION_NOT_REQUIRED);
}
// 招生上限校验
if (plan.getRecruitNum() != null && plan.getRecruitNum() > 0 && currentCount >= plan.getRecruitNum())
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 计划已达到招生上限(" + plan.getRecruitNum() + "人)");
continue;
}
// 学员存在性校验
TrainStudent student = studentMapper.selectTrainStudentBySId(dto.getStudentId());
if (student == null)
{
if (autoCreateStudent)
{
// 自动建立基础学员档案
student = new TrainStudent();
student.setsId(dto.getStudentId());
student.setsName(StringUtils.isNotEmpty(dto.getRemark()) ? dto.getRemark() : "导入学员_" + dto.getStudentId());
student.setOrgName(dto.getOrgName());
student.setSourceType(2); // 2:Excel批量导入
student.setStatus(0);
student.setDelFlag("0");
student.setCreateBy(operator);
student.setCreateTime(DateUtils.getNowDate());
try
{
studentMapper.insertTrainStudent(student);
}
catch (Exception ex)
{
log.warn("自动建档失败: {}", ex.getMessage());
}
}
else
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 学员档案不存在(" + dto.getStudentId() + ")");
continue;
}
}
// 重复入班校验
TrainEnrollment existing = trainEnrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, dto.getStudentId());
if (existing != null)
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 学员已在参训名单中(" + dto.getStudentId() + ")");
continue;
}
// 入库
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setPlanId(planId);
enrollment.setStudentId(dto.getStudentId());
enrollment.setSourceType(dto.getSourceType());
enrollment.setAccommodationRequired(dto.getAccommodationRequired());
enrollment.setEnrollmentStatus(EnrollmentErrorCode.STATUS_WAITING_REPORT);
enrollment.setRemark(dto.getRemark());
enrollment.setCreateBy(operator);
enrollment.setCreateTime(DateUtils.getNowDate());
try
{
trainEnrollmentService.insertTrainEnrollment(enrollment);
successCount++;
currentCount++;
}
catch (DuplicateKeyException e)
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 并发重复入班(" + dto.getStudentId() + ")");
}
catch (Exception e)
{
failureCount++;
errorList.add("第 " + rowNum + " 行: 插入失败 (" + e.getMessage() + ")");
}
}
return new BatchImportResultVO(totalCount, successCount, failureCount, errorList);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateEnrollmentStatus(Long enrollmentId, EnrollmentStatusUpdateDTO dto, String operator)
{
if (dto == null || dto.getEnrollmentStatus() == null)
{
throw new ServiceException("参训状态不能为空");
}
Integer newStatus = dto.getEnrollmentStatus();
// 严格限制在冻结状态集合: 0待报到 1已报到 2请假 3放弃 4已完成
if (newStatus < EnrollmentErrorCode.STATUS_WAITING_REPORT || newStatus > EnrollmentErrorCode.STATUS_COMPLETED)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_STATUS_INVALID_MESSAGE);
}
TrainEnrollment enrollment = requireEnrollment(enrollmentId);
// 计划已结训或取消时,禁止状态变更
TrainClassPlan plan = requirePlan(enrollment.getPlanId());
checkPlanStatusNotLocked(plan);
// 如果已经结训(4),禁止任意更改状态
if (Integer.valueOf(EnrollmentErrorCode.STATUS_COMPLETED).equals(enrollment.getEnrollmentStatus()))
{
throw new ServiceException("该学员已完成结训归档,不可修改参训状态");
}
trainEnrollmentService.updateEnrollmentStatus(
enrollmentId,
newStatus,
dto.getRemark(),
operator,
DateUtils.getNowDate()
);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removeEnrollment(Long enrollmentId, String operator)
{
TrainEnrollment enrollment = requireEnrollment(enrollmentId);
TrainClassPlan plan = requirePlan(enrollment.getPlanId());
// 阶段校验:已结训(2)或已取消(3)禁止删除
if (Integer.valueOf(2).equals(plan.getPlanStatus()) || Integer.valueOf(3).equals(plan.getPlanStatus()))
{
throw new ServiceException(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
// 业务依赖校验:已产生考勤或成绩等业务数据禁止物理删除,必须提示转为放弃状态
int attendanceCount = trainEnrollmentService.countAttendanceByEnrollmentId(enrollmentId);
int scoreCount = trainEnrollmentService.countScoreByEnrollmentId(enrollmentId);
int reportCount = trainEnrollmentService.countReportByEnrollmentId(enrollmentId);
int accommCount = trainEnrollmentService.countAccommodationByEnrollmentId(enrollmentId);
int studyCount = trainEnrollmentService.countStudyDurationByEnrollmentId(enrollmentId);
if (attendanceCount > 0 || scoreCount > 0 || reportCount > 0 || accommCount > 0 || studyCount > 0)
{
throw new ServiceException(
EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA_MESSAGE,
EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA
);
}
// 严禁 cascade delete,物理删除参训单表行
trainEnrollmentService.deleteTrainEnrollmentById(enrollmentId);
}
private TrainClassPlan requirePlan(String planId)
{
if (StringUtils.isEmpty(planId))
{
throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
TrainClassPlan plan = planMapper.selectTrainClassPlanByPlanId(planId);
if (plan == null || "2".equals(plan.getDelFlag()))
{
throw new ServiceException(PlanErrorCode.PLAN_NOT_FOUND_MESSAGE, PlanErrorCode.PLAN_NOT_FOUND);
}
return plan;
}
private TrainEnrollment requireEnrollment(Long enrollmentId)
{
if (enrollmentId == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
TrainEnrollment enrollment = trainEnrollmentService.selectTrainEnrollmentById(enrollmentId);
if (enrollment == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
return enrollment;
}
private void checkPlanStatusNotLocked(TrainClassPlan plan)
{
if (Integer.valueOf(2).equals(plan.getPlanStatus()) || Integer.valueOf(3).equals(plan.getPlanStatus()))
{
throw new ServiceException(PlanErrorCode.PLAN_STATUS_LOCKED_MESSAGE, PlanErrorCode.PLAN_STATUS_LOCKED);
}
}
private void validateEnrollmentAddDTO(EnrollmentAddDTO dto)
{
if (dto == null)
{
throw new ServiceException("请求参数不能为空");
}
if (StringUtils.isEmpty(dto.getStudentId()))
{
throw new ServiceException("学员ID不能为空");
}
if (dto.getSourceType() == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_SOURCE_REQUIRED_MESSAGE);
}
if (dto.getAccommodationRequired() == null)
{
throw new ServiceException(EnrollmentErrorCode.ACCOMMODATION_REQUIRED_MESSAGE);
}
}
private String maskIdentity(String idCardLast4OrPlain)
{
if (StringUtils.isEmpty(idCardLast4OrPlain))
{
return "";
}
if (idCardLast4OrPlain.length() <= 4)
{
return "**************" + idCardLast4OrPlain;
}
if (idCardLast4OrPlain.length() == 18)
{
return idCardLast4OrPlain.substring(0, 6) + "********" + idCardLast4OrPlain.substring(14);
}
return idCardLast4OrPlain;
}
private String maskStudentIdentity(TrainStudent student)
{
if (student == null)
{
return "";
}
if (identityProtection != null && StringUtils.isNotEmpty(student.getIdCardCiphertext()))
{
try
{
String plain = identityProtection.decrypt(student.getIdCardCiphertext());
return identityProtection.mask(plain);
}
catch (Exception ex)
{
log.debug("解密掩码脱敏异常: {}", ex.getMessage());
}
}
if (StringUtils.isNotEmpty(student.getIdCardLast4()))
{
return "**************" + student.getIdCardLast4();
}
return "";
}
private List<EnrollmentAddDTO> parseExcelToDTOList(InputStream is) throws Exception
{
List<EnrollmentAddDTO> list = new ArrayList<>();
Workbook workbook = WorkbookFactory.create(is);
Sheet sheet = workbook.getSheetAt(0);
int rowCount = sheet.getPhysicalNumberOfRows();
// 假定第 1 行是表头,从第 2 行 (index=1) 开始读
for (int r = 1; r < rowCount; r++)
{
Row row = sheet.getRow(r);
if (row == null)
{
continue;
}
String studentId = getCellValue(row.getCell(0));
if (StringUtils.isEmpty(studentId))
{
continue;
}
String sourceStr = getCellValue(row.getCell(1));
String accommStr = getCellValue(row.getCell(2));
String remark = getCellValue(row.getCell(3));
EnrollmentAddDTO dto = new EnrollmentAddDTO();
dto.setStudentId(studentId);
try
{
dto.setSourceType(StringUtils.isNotEmpty(sourceStr) ? Integer.parseInt(sourceStr) : 2);
}
catch (Exception e)
{
dto.setSourceType(2);
}
try
{
dto.setAccommodationRequired(StringUtils.isNotEmpty(accommStr) ? Integer.parseInt(accommStr) : 0);
}
catch (Exception e)
{
dto.setAccommodationRequired(0);
}
dto.setRemark(remark);
list.add(dto);
}
return list;
}
private String getCellValue(Cell cell)
{
if (cell == null)
{
return "";
}
if (cell.getCellType() == CellType.STRING)
{
return cell.getStringCellValue().trim();
}
else if (cell.getCellType() == CellType.NUMERIC)
{
return String.valueOf((long) cell.getNumericCellValue()).trim();
}
else if (cell.getCellType() == CellType.BOOLEAN)
{
return String.valueOf(cell.getBooleanCellValue());
}
return "";
}
}
@@ -0,0 +1,37 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
/**
* 计划结训联动参训学员归档适配器 (实现 PlanEnrollmentCompletionPort)。
* 严禁 NoOp 桩实现。本适配器与 PlanAppService.completePlan 处于同一本地事务中。
* 业务规则:
* 1. 将该计划仍处于有效参训状态 (0:待报到, 1:已报到) 的学员更新为 4:已完成;
* 2. 写入 complete_time;
* 3. 完整保留考勤、成绩、自主学习时长等历史业务数据,严禁物理删除或级联删除。
*/
@Component
public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletionPort
{
private static final Logger log = LoggerFactory.getLogger(EnrollmentPlanCompletionAdapter.class);
@Autowired
private ITrainEnrollmentService trainEnrollmentService;
@Override
@Transactional(propagation = Propagation.MANDATORY, rollbackFor = Exception.class)
public void completeEnrollments(String planId, String operator, Date completeTime)
{
log.info("执行计划结训学员状态联动归档: planId={}, operator={}, completeTime={}", planId, operator, completeTime);
int updatedRows = trainEnrollmentService.completeActiveEnrollmentsByPlanId(planId, operator, completeTime);
log.info("计划结训学员归档完成: planId={}, 更新记录数={}", planId, updatedRows);
}
}
@@ -0,0 +1,118 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.service.ITrainEnrollmentService;
/**
* 参训学员单表持久化服务实现。
*
* @author ruoyi
* @date 2026-09-14
*/
@Service
public class TrainEnrollmentServiceImpl implements ITrainEnrollmentService
{
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
@Override
public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId)
{
return trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId);
}
@Override
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
{
return trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId);
}
@Override
public List<EnrollmentItemVO> selectEnrollmentItemList(EnrollmentQuery query)
{
return trainEnrollmentMapper.selectEnrollmentItemList(query);
}
@Override
public int countEnrollmentsByPlanId(String planId)
{
return trainEnrollmentMapper.countEnrollmentsByPlanId(planId);
}
@Override
public int insertTrainEnrollment(TrainEnrollment trainEnrollment)
{
return trainEnrollmentMapper.insertTrainEnrollment(trainEnrollment);
}
@Override
public int batchInsertTrainEnrollment(List<TrainEnrollment> list)
{
if (list == null || list.isEmpty())
{
return 0;
}
return trainEnrollmentMapper.batchInsertTrainEnrollment(list);
}
@Override
public int updateTrainEnrollment(TrainEnrollment trainEnrollment)
{
return trainEnrollmentMapper.updateTrainEnrollment(trainEnrollment);
}
@Override
public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime)
{
return trainEnrollmentMapper.updateEnrollmentStatus(enrollmentId, enrollmentStatus, remark, operator, updateTime);
}
@Override
public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime)
{
return trainEnrollmentMapper.completeActiveEnrollmentsByPlanId(planId, operator, completeTime);
}
@Override
public int deleteTrainEnrollmentById(Long enrollmentId)
{
return trainEnrollmentMapper.deleteTrainEnrollmentById(enrollmentId);
}
@Override
public int countAttendanceByEnrollmentId(Long enrollmentId)
{
return trainEnrollmentMapper.countAttendanceByEnrollmentId(enrollmentId);
}
@Override
public int countScoreByEnrollmentId(Long enrollmentId)
{
return trainEnrollmentMapper.countScoreByEnrollmentId(enrollmentId);
}
@Override
public int countReportByEnrollmentId(Long enrollmentId)
{
return trainEnrollmentMapper.countReportByEnrollmentId(enrollmentId);
}
@Override
public int countAccommodationByEnrollmentId(Long enrollmentId)
{
return trainEnrollmentMapper.countAccommodationByEnrollmentId(enrollmentId);
}
@Override
public int countStudyDurationByEnrollmentId(Long enrollmentId)
{
return trainEnrollmentMapper.countStudyDurationByEnrollmentId(enrollmentId);
}
}
@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainEnrollmentMapper">
<resultMap type="com.ruoyi.train.domain.TrainEnrollment" id="TrainEnrollmentResult">
<id property="enrollmentId" column="enrollment_id" />
<result property="planId" column="plan_id" />
<result property="studentId" column="student_id" />
<result property="sourceType" column="source_type" />
<result property="enrollmentStatus" column="enrollment_status" />
<result property="accommodationRequired" column="accommodation_required" />
<result property="joinTime" column="join_time" />
<result property="completeTime" column="complete_time" />
<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="selectTrainEnrollmentDomain">
select enrollment_id, plan_id, student_id, source_type, enrollment_status,
accommodation_required, join_time, complete_time, create_by, create_time,
update_by, update_time, remark
from train_enrollment
</sql>
<select id="selectPlanForUpdate" resultType="com.ruoyi.train.domain.TrainClassPlan">
select plan_id as planId,
plan_name as planName,
plan_status as planStatus,
recruit_num as recruitNum,
start_time as startTime,
end_time as endTime,
del_flag as delFlag
from train_class_plan
where plan_id = #{planId}
for update
</select>
<select id="selectTrainEnrollmentById" parameterType="Long" resultMap="TrainEnrollmentResult">
<include refid="selectTrainEnrollmentDomain" />
where enrollment_id = #{enrollmentId}
</select>
<select id="selectTrainEnrollmentByPlanIdAndStudentId" resultMap="TrainEnrollmentResult">
<include refid="selectTrainEnrollmentDomain" />
where plan_id = #{planId} and student_id = #{studentId}
</select>
<select id="selectEnrollmentItemList" parameterType="com.ruoyi.train.domain.query.EnrollmentQuery"
resultType="com.ruoyi.train.domain.vo.EnrollmentItemVO">
select e.enrollment_id as enrollmentId,
e.plan_id as planId,
e.student_id as studentId,
s.s_name as studentName,
s.s_gender as gender,
s.id_card_last4 as idCardMasked,
s.org_name as orgName,
s.s_phone as phone,
e.source_type as sourceType,
e.enrollment_status as enrollmentStatus,
e.accommodation_required as accommodationRequired,
e.join_time as joinTime,
e.complete_time as completeTime,
e.remark as remark
from train_enrollment e
left join train_student s on e.student_id = s.s_id
<where>
e.plan_id = #{planId}
<if test="studentName != null and studentName != ''">
and s.s_name like concat('%', #{studentName}, '%')
</if>
<if test="enrollmentStatus != null">
and e.enrollment_status = #{enrollmentStatus}
</if>
<if test="accommodationRequired != null">
and e.accommodation_required = #{accommodationRequired}
</if>
</where>
order by e.enrollment_id asc
</select>
<select id="countEnrollmentsByPlanId" parameterType="String" resultType="int">
select count(1) from train_enrollment where plan_id = #{planId}
</select>
<insert id="insertTrainEnrollment" parameterType="com.ruoyi.train.domain.TrainEnrollment"
useGeneratedKeys="true" keyProperty="enrollmentId">
insert into train_enrollment (
plan_id, student_id, source_type, enrollment_status, accommodation_required,
join_time, complete_time, create_by, create_time, update_by, update_time, remark
) values (
#{planId}, #{studentId}, #{sourceType}, #{enrollmentStatus}, #{accommodationRequired},
#{joinTime}, #{completeTime}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}
)
</insert>
<insert id="batchInsertTrainEnrollment">
insert into train_enrollment (
plan_id, student_id, source_type, enrollment_status, accommodation_required,
join_time, complete_time, create_by, create_time, remark
) values
<foreach collection="list" item="item" separator=",">
(
#{item.planId}, #{item.studentId}, #{item.sourceType}, #{item.enrollmentStatus},
#{item.accommodationRequired}, #{item.joinTime}, #{item.completeTime},
#{item.createBy}, #{item.createTime}, #{item.remark}
)
</foreach>
</insert>
<update id="updateTrainEnrollment" parameterType="com.ruoyi.train.domain.TrainEnrollment">
update train_enrollment
<set>
<if test="sourceType != null">source_type = #{sourceType},</if>
<if test="enrollmentStatus != null">enrollment_status = #{enrollmentStatus},</if>
<if test="accommodationRequired != null">accommodation_required = #{accommodationRequired},</if>
<if test="joinTime != null">join_time = #{joinTime},</if>
<if test="completeTime != null">complete_time = #{completeTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</set>
where enrollment_id = #{enrollmentId}
</update>
<update id="updateEnrollmentStatus">
update train_enrollment
set enrollment_status = #{enrollmentStatus},
remark = coalesce(#{remark}, remark),
update_by = #{operator},
update_time = #{updateTime}
where enrollment_id = #{enrollmentId}
</update>
<update id="completeActiveEnrollmentsByPlanId">
update train_enrollment
set enrollment_status = 4,
complete_time = #{completeTime},
update_by = #{operator},
update_time = #{completeTime}
where plan_id = #{planId}
and enrollment_status in (0, 1)
</update>
<delete id="deleteTrainEnrollmentById" parameterType="Long">
delete from train_enrollment where enrollment_id = #{enrollmentId}
</delete>
<select id="countAttendanceByEnrollmentId" parameterType="Long" resultType="int">
select count(1) from train_student_checkin where enrollment_id = #{enrollmentId}
</select>
<select id="countScoreByEnrollmentId" parameterType="Long" resultType="int">
select count(1) from train_student_score where enrollment_id = #{enrollmentId}
</select>
<select id="countReportByEnrollmentId" parameterType="Long" resultType="int">
select count(1) from train_class_report where enrollment_id = #{enrollmentId}
</select>
<select id="countAccommodationByEnrollmentId" parameterType="Long" resultType="int">
select count(1) from train_accommodation where enrollment_id = #{enrollmentId}
</select>
<select id="countStudyDurationByEnrollmentId" parameterType="Long" resultType="int">
select count(1) from train_study_duration where enrollment_id = #{enrollmentId}
</select>
</mapper>
@@ -0,0 +1,381 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.query.PlanQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.PlanItemVO;
import com.ruoyi.train.service.ITrainClassPlanService;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
/**
* AC-03b 专项集成测试:验证 Plan complete -> 真实 Enrollment 联动归档。
* 对应 docs/development/issue-02-01-plan.md 中冻结的 AC-03b 验收门禁。
* 验证:
* 1. 计划 completePlan 真实驱动 EnrollmentPlanCompletionAdapter 执行;
* 2. 计划内 30 名有效状态(0:待报到、1:已报到)学员状态统一更新为 4:已完成;
* 3. 真实写入 complete_time;
* 4. 已放弃(3)的学员状态不受影响;
* 5. 学员历史考勤/成绩/打卡/学时记录完好保留,严禁物理或级联删除;
* 6. 验证 Adapter 事务注解为 MANDATORY,确保纳入同一本地强事务边界。
*/
public class PlanEnrollmentCompletionIntegrationTest
{
private MemoryPlanService planService;
private MemoryEnrollmentService enrollmentService;
private EnrollmentPlanCompletionAdapter realAdapter;
private PlanAppServiceImpl planAppService;
@Before
public void setUp() throws Exception
{
planService = new MemoryPlanService();
enrollmentService = new MemoryEnrollmentService();
// 实例化真实 Adapter 并注入真实单表 service
realAdapter = new EnrollmentPlanCompletionAdapter();
setField(realAdapter, "trainEnrollmentService", enrollmentService);
// 将真实 Adapter 注入 PlanAppService
List<PlanEnrollmentCompletionPort> ports = Collections.singletonList(realAdapter);
planAppService = new PlanAppServiceImpl(planService, ports);
}
@Test
public void testAc03bRealEnrollmentCompletionBatchOf30Students()
{
String planId = "PLAN-2026-SIGNAL";
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(planId);
plan.setPlanName("2026年高速铁路信号综合班");
plan.setPlanStatus(1); // 进行中
planService.planStore.put(planId, plan);
// 创建 30 名有效学员 (20 名已报到 1,10 名待报到 0)
List<Long> activeEnrollmentIds = new ArrayList<>();
for (int i = 1; i <= 30; i++)
{
TrainEnrollment e = new TrainEnrollment();
e.setEnrollmentId((long) i);
e.setPlanId(planId);
e.setStudentId("STU-" + i);
e.setSourceType(1);
e.setAccommodationRequired(1);
e.setEnrollmentStatus(i <= 20 ? 1 : 0); // 1~20:已报到, 21~30:待报到
enrollmentService.enrollmentStore.put(e.getEnrollmentId(), e);
activeEnrollmentIds.add(e.getEnrollmentId());
// 模拟每位学员已有 10 条打卡考勤与 2 门课程成绩
enrollmentService.mockAttendanceCount.put(e.getEnrollmentId(), 10);
enrollmentService.mockScoreCount.put(e.getEnrollmentId(), 2);
}
// 另外创建 2 名非有效学员 (第 31 名请假 2,第 32 名中途放弃 3)
TrainEnrollment leaveStudent = new TrainEnrollment();
leaveStudent.setEnrollmentId(31L);
leaveStudent.setPlanId(planId);
leaveStudent.setStudentId("STU-31");
leaveStudent.setEnrollmentStatus(2); // 请假
enrollmentService.enrollmentStore.put(31L, leaveStudent);
TrainEnrollment abandonStudent = new TrainEnrollment();
abandonStudent.setEnrollmentId(32L);
abandonStudent.setPlanId(planId);
abandonStudent.setStudentId("STU-32");
abandonStudent.setEnrollmentStatus(3); // 放弃
enrollmentService.enrollmentStore.put(32L, abandonStudent);
// WHEN: 执行计划结训 completePlan
planAppService.completePlan(planId, "lead_admin");
// THEN:
// 1. 计划自身状态更新为 2:已结束
assertEquals(Integer.valueOf(2), planService.planStore.get(planId).getPlanStatus());
// 2. 30 名有效参训学员全部被更新为 4:已完成,并且写入了 complete_time
for (Long eid : activeEnrollmentIds)
{
TrainEnrollment record = enrollmentService.enrollmentStore.get(eid);
assertEquals("学员 ID=" + eid + " 参训状态必须置为已完成(4)",
Integer.valueOf(4), record.getEnrollmentStatus());
assertNotNull("学员 ID=" + eid + " 结业时间 complete_time 必须真实写入",
record.getCompleteTime());
assertEquals("lead_admin", record.getUpdateBy());
// 3. 历史数据完整保留,绝无物理或级联删除
assertEquals(10, enrollmentService.countAttendanceByEnrollmentId(eid));
assertEquals(2, enrollmentService.countScoreByEnrollmentId(eid));
}
// 4. 已放弃学员不被误刷
assertEquals(Integer.valueOf(3), enrollmentService.enrollmentStore.get(32L).getEnrollmentStatus());
assertNull("已放弃学员不写入 complete_time", enrollmentService.enrollmentStore.get(32L).getCompleteTime());
}
@Test
public void testTransactionSemanticsPropagation() throws Exception
{
// 验证 Adapter 上具备 @Transactional(propagation = Propagation.MANDATORY)
Transactional tx = realAdapter.getClass()
.getMethod("completeEnrollments", String.class, String.class, Date.class)
.getAnnotation(Transactional.class);
assertNotNull("Adapter 必须具有事务注解", tx);
assertEquals("Adapter 事务传播级别必须是 MANDATORY,强制要求在调用方本地事务内运行",
Propagation.MANDATORY, tx.propagation());
assertEquals("回滚异常类需为 Exception.class", Exception.class, tx.rollbackFor()[0]);
}
@Test
public void testAdapterExceptionRollbackPlanStatus()
{
// 模拟 Adapter 在更新时抛出底层数据库异常
enrollmentService.simulateException = new IllegalStateException("数据库更新参训状态超时");
String planId = "PLAN-FAIL-TX";
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId(planId);
plan.setPlanName("异常回滚班");
plan.setPlanStatus(0);
planService.planStore.put(planId, plan);
try
{
planAppService.completePlan(planId, "admin");
fail("Adapter 异常必须向外层事务传播导致回滚");
}
catch (RuntimeException e)
{
assertTrue(e.getMessage().contains("数据库更新参训状态超时"));
}
}
private static void setField(Object target, String fieldName, Object value)
{
try
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
private static class MemoryPlanService implements ITrainClassPlanService
{
private final Map<String, TrainClassPlan> planStore = new HashMap<>();
@Override
public TrainClassPlan selectTrainClassPlanByPlanId(String planId)
{
return planStore.get(planId);
}
@Override
public TrainClassPlan selectTrainClassPlanByPlanIdForUpdate(String planId)
{
return planStore.get(planId);
}
@Override
public List<PlanItemVO> selectTrainClassPlanList(PlanQuery query)
{
return Collections.emptyList();
}
@Override
public boolean existsByPlanName(String planName, String excludePlanId)
{
return false;
}
@Override
public int insertTrainClassPlan(TrainClassPlan trainClassPlan)
{
planStore.put(trainClassPlan.getPlanId(), trainClassPlan);
return 1;
}
@Override
public int updateTrainClassPlan(TrainClassPlan trainClassPlan)
{
planStore.put(trainClassPlan.getPlanId(), trainClassPlan);
return 1;
}
@Override
public int updateTrainClassPlanStatus(TrainClassPlan trainClassPlan)
{
TrainClassPlan p = planStore.get(trainClassPlan.getPlanId());
if (p != null)
{
p.setPlanStatus(trainClassPlan.getPlanStatus());
p.setUpdateBy(trainClassPlan.getUpdateBy());
p.setUpdateTime(trainClassPlan.getUpdateTime());
}
return 1;
}
}
private static class MemoryEnrollmentService implements ITrainEnrollmentService
{
private final Map<Long, TrainEnrollment> enrollmentStore = new HashMap<>();
private final Map<Long, Integer> mockAttendanceCount = new HashMap<>();
private final Map<Long, Integer> mockScoreCount = new HashMap<>();
private RuntimeException simulateException = null;
@Override
public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId)
{
return enrollmentStore.get(enrollmentId);
}
@Override
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
{
for (TrainEnrollment e : enrollmentStore.values())
{
if (e.getPlanId().equals(planId) && e.getStudentId().equals(studentId))
{
return e;
}
}
return null;
}
@Override
public List<EnrollmentItemVO> selectEnrollmentItemList(EnrollmentQuery query)
{
return Collections.emptyList();
}
@Override
public int countEnrollmentsByPlanId(String planId)
{
int c = 0;
for (TrainEnrollment e : enrollmentStore.values())
{
if (e.getPlanId().equals(planId))
{
c++;
}
}
return c;
}
@Override
public int insertTrainEnrollment(TrainEnrollment trainEnrollment)
{
enrollmentStore.put(trainEnrollment.getEnrollmentId(), trainEnrollment);
return 1;
}
@Override
public int batchInsertTrainEnrollment(List<TrainEnrollment> list)
{
for (TrainEnrollment e : list)
{
enrollmentStore.put(e.getEnrollmentId(), e);
}
return list.size();
}
@Override
public int updateTrainEnrollment(TrainEnrollment trainEnrollment)
{
enrollmentStore.put(trainEnrollment.getEnrollmentId(), trainEnrollment);
return 1;
}
@Override
public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime)
{
TrainEnrollment e = enrollmentStore.get(enrollmentId);
if (e != null)
{
e.setEnrollmentStatus(enrollmentStatus);
return 1;
}
return 0;
}
@Override
public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime)
{
if (simulateException != null)
{
throw simulateException;
}
int updated = 0;
for (TrainEnrollment e : enrollmentStore.values())
{
if (e.getPlanId().equals(planId) && (e.getEnrollmentStatus() == 0 || e.getEnrollmentStatus() == 1))
{
e.setEnrollmentStatus(4);
e.setCompleteTime(completeTime);
e.setUpdateBy(operator);
e.setUpdateTime(completeTime);
updated++;
}
}
return updated;
}
@Override
public int deleteTrainEnrollmentById(Long enrollmentId)
{
return enrollmentStore.remove(enrollmentId) != null ? 1 : 0;
}
@Override
public int countAttendanceByEnrollmentId(Long enrollmentId)
{
return mockAttendanceCount.getOrDefault(enrollmentId, 0);
}
@Override
public int countScoreByEnrollmentId(Long enrollmentId)
{
return mockScoreCount.getOrDefault(enrollmentId, 0);
}
@Override
public int countReportByEnrollmentId(Long enrollmentId)
{
return 0;
}
@Override
public int countAccommodationByEnrollmentId(Long enrollmentId)
{
return 0;
}
@Override
public int countStudyDurationByEnrollmentId(Long enrollmentId)
{
return 0;
}
}
}