From e340db872c844d0306e93930ace39650d66100a6 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 14 Sep 2026 15:13:40 +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=8F=82=E8=AE=AD=E4=BA=BA=E5=91=98P0?= =?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/TrainEnrollmentController.java | 122 ++ .../controller/train/EnrollmentApiTest.java | 111 ++ .../train/constant/EnrollmentErrorCode.java | 39 + .../ruoyi/train/domain/TrainEnrollment.java | 152 +++ .../train/domain/dto/EnrollmentAddDTO.java | 81 ++ .../train/domain/dto/EnrollmentCreateDTO.java | 8 + .../domain/dto/EnrollmentStatusUpdateDTO.java | 38 + .../train/domain/query/EnrollmentQuery.java | 59 + .../train/domain/vo/BatchImportResultVO.java | 74 ++ .../train/domain/vo/EnrollmentDetailVO.java | 357 ++++++ .../train/domain/vo/EnrollmentItemVO.java | 207 ++++ .../train/mapper/TrainEnrollmentMapper.java | 160 +++ .../train/service/EnrollmentAppService.java | 76 ++ .../service/ITrainEnrollmentService.java | 47 + .../impl/EnrollmentAppServiceImpl.java | 579 ++++++++++ .../impl/EnrollmentPlanCompletionAdapter.java | 37 + .../impl/TrainEnrollmentServiceImpl.java | 118 ++ .../mapper/train/TrainEnrollmentMapper.xml | 173 +++ .../impl/EnrollmentAppServiceImplTest.java | 1021 +++++++++++++++++ ...anEnrollmentCompletionIntegrationTest.java | 381 ++++++ 20 files changed, 3840 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/EnrollmentErrorCode.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainEnrollment.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentCreateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentStatusUpdateDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/BatchImportResultVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java new file mode 100644 index 000000000..d1ea2c419 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java @@ -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 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(); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java new file mode 100644 index 000000000..fb86b9d70 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/EnrollmentApiTest.java @@ -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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/EnrollmentErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/EnrollmentErrorCode.java new file mode 100644 index 000000000..89e8e1502 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/EnrollmentErrorCode.java @@ -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() + { + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainEnrollment.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainEnrollment.java new file mode 100644 index 000000000..0a62118b6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainEnrollment.java @@ -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(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java new file mode 100644 index 000000000..19070eb74 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentAddDTO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentCreateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentCreateDTO.java new file mode 100644 index 000000000..466e229a7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentCreateDTO.java @@ -0,0 +1,8 @@ +package com.ruoyi.train.domain.dto; + +/** + * 参训学员创建 DTO 别名兼容类 (兼容 ISSUE-03-01 文档命名)。 + */ +public class EnrollmentCreateDTO extends EnrollmentAddDTO +{ +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentStatusUpdateDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentStatusUpdateDTO.java new file mode 100644 index 000000000..6cabc11c3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentStatusUpdateDTO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java new file mode 100644 index 000000000..75d68345a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/query/EnrollmentQuery.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/BatchImportResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/BatchImportResultVO.java new file mode 100644 index 000000000..70127d82e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/BatchImportResultVO.java @@ -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 errorList = new ArrayList<>(); + + public BatchImportResultVO() + { + } + + public BatchImportResultVO(Integer totalCount, Integer successCount, Integer failureCount, List 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 getErrorList() + { + return errorList; + } + + public void setErrorList(List errorList) + { + this.errorList = errorList; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java new file mode 100644 index 000000000..592796868 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentDetailVO.java @@ -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; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java new file mode 100644 index 000000000..6074d3402 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentItemVO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java new file mode 100644 index 000000000..e1123a824 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java @@ -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 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 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java new file mode 100644 index 000000000..0016d7772 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/EnrollmentAppService.java @@ -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 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java new file mode 100644 index 000000000..6b4b1f509 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainEnrollmentService.java @@ -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 selectEnrollmentItemList(EnrollmentQuery query); + + int countEnrollmentsByPlanId(String planId); + + int insertTrainEnrollment(TrainEnrollment trainEnrollment); + + int batchInsertTrainEnrollment(List 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java new file mode 100644 index 000000000..0d37a2438 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java @@ -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 listEnrollments(String planId, EnrollmentQuery query) + { + requirePlan(planId); + if (query == null) + { + query = new EnrollmentQuery(); + } + query.setPlanId(planId); + List 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 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 dtoList, boolean autoCreateStudent, String operator) + { + String planId = plan.getPlanId(); + int totalCount = dtoList != null ? dtoList.size() : 0; + int successCount = 0; + int failureCount = 0; + List 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 parseExcelToDTOList(InputStream is) throws Exception + { + List 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 ""; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java new file mode 100644 index 000000000..2ccbef8ee --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java @@ -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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java new file mode 100644 index 000000000..22dec7fdc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainEnrollmentServiceImpl.java @@ -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 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 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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml new file mode 100644 index 000000000..86b54afd0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + 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 into train_enrollment ( + plan_id, student_id, source_type, enrollment_status, accommodation_required, + join_time, complete_time, create_by, create_time, remark + ) values + + ( + #{item.planId}, #{item.studentId}, #{item.sourceType}, #{item.enrollmentStatus}, + #{item.accommodationRequired}, #{item.joinTime}, #{item.completeTime}, + #{item.createBy}, #{item.createTime}, #{item.remark} + ) + + + + + update train_enrollment + + source_type = #{sourceType}, + enrollment_status = #{enrollmentStatus}, + accommodation_required = #{accommodationRequired}, + join_time = #{joinTime}, + complete_time = #{completeTime}, + update_by = #{updateBy}, + update_time = #{updateTime}, + remark = #{remark}, + + where enrollment_id = #{enrollmentId} + + + + update train_enrollment + set enrollment_status = #{enrollmentStatus}, + remark = coalesce(#{remark}, remark), + update_by = #{operator}, + update_time = #{updateTime} + where enrollment_id = #{enrollmentId} + + + + 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) + + + + delete from train_enrollment where enrollment_id = #{enrollmentId} + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java new file mode 100644 index 000000000..dc90d58de --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java @@ -0,0 +1,1021 @@ +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 java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.dao.DuplicateKeyException; +import com.ruoyi.common.exception.ServiceException; +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.query.StudentQuery; +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.service.ITrainEnrollmentService; + +/** + * ISSUE-03-01 参训人员管理核心单元测试。 + * 全面覆盖: + * 1. 正常加入 + * 2. 重复加入 + * 3. 并发 UNIQUE 冲突映射 + * 4. 超出招生人数上限 + * 5. 不存在 Plan + * 6. 计划状态已锁定 + * 7. 不存在 Student + * 8. 列表/查询(脱敏掩码) + * 9. 详情聚合 + * 10. 状态变化 (正常请假/放弃) + * 11. 非法状态变化阻断 + * 12. 允许删除 (开班前无业务数据) + * 13. 禁止删除 (已有考勤/成绩/报到流水时强阻断 40032) + * 14. Plan complete -> active enrollments completed + * 15. complete_time 写入 + * 16. 历史数据不删除 + * 17. 批量导入部分成功与失败行收集 + * 18. recruitNum=1 并发招生容量测试 (只能1条成功,另1条抛40031) + */ +public class EnrollmentAppServiceImplTest +{ + private InMemoryEnrollmentService enrollmentRepository; + private MockPlanMapper planMapper; + private MockStudentMapper studentMapper; + private MockTrainEnrollmentMapper mockEnrollmentMapper; + private EnrollmentAppServiceImpl appService; + + @Before + public void setUp() throws Exception + { + enrollmentRepository = new InMemoryEnrollmentService(); + planMapper = new MockPlanMapper(); + studentMapper = new MockStudentMapper(); + mockEnrollmentMapper = new MockTrainEnrollmentMapper(planMapper.planStore, enrollmentRepository); + appService = new EnrollmentAppServiceImpl(); + + // 反射注入私有属性 + setField(appService, "trainEnrollmentService", enrollmentRepository); + setField(appService, "trainEnrollmentMapper", mockEnrollmentMapper); + setField(appService, "planMapper", planMapper); + setField(appService, "studentMapper", studentMapper); + + // 初始化默认测试数据 + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId("PLAN-001"); + plan.setPlanName("2026年第1期高速铁路信号检修专项培训班"); + plan.setRecruitNum(2L); // 招生上限设为 2 人 + plan.setPlanStatus(0); // 0:未开始 + plan.setDelFlag("0"); + planMapper.planStore.put("PLAN-001", plan); + + TrainStudent s1 = new TrainStudent(); + s1.setsId("S001"); + s1.setsName("张三"); + s1.setsGender("1"); + s1.setOrgName("沈阳电务段"); + s1.setsPhone("13800000001"); + s1.setIdCardLast4("1234"); + s1.setStatus(0); + s1.setDelFlag("0"); + studentMapper.studentStore.put("S001", s1); + + TrainStudent s2 = new TrainStudent(); + s2.setsId("S002"); + s2.setsName("李四"); + s2.setsGender("2"); + s2.setOrgName("大连电务段"); + s2.setsPhone("13800000002"); + s2.setIdCardLast4("5678"); + s2.setStatus(0); + s2.setDelFlag("0"); + studentMapper.studentStore.put("S002", s2); + + TrainStudent s3 = new TrainStudent(); + s3.setsId("S003"); + s3.setsName("王五"); + s3.setsGender("1"); + s3.setOrgName("锦州电务段"); + s3.setsPhone("13800000003"); + s3.setIdCardLast4("9012"); + s3.setStatus(0); + s3.setDelFlag("0"); + studentMapper.studentStore.put("S003", s3); + } + + @After + public void tearDown() + { + if (mockEnrollmentMapper != null) + { + mockEnrollmentMapper.clearLocks(); + } + } + + @Test + public void testAddEnrollmentSuccess() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); // 1:管理员录入 + dto.setAccommodationRequired(1); // 1:住宿 + dto.setRemark("正式入班"); + + Long enrollmentId = appService.addEnrollment("PLAN-001", dto, "admin"); + + assertNotNull("应成功生成 enrollmentId", enrollmentId); + TrainEnrollment saved = enrollmentRepository.selectTrainEnrollmentById(enrollmentId); + assertNotNull(saved); + assertEquals("PLAN-001", saved.getPlanId()); + assertEquals("S001", saved.getStudentId()); + assertEquals(Integer.valueOf(1), saved.getSourceType()); + assertEquals(Integer.valueOf(1), saved.getAccommodationRequired()); + assertEquals(Integer.valueOf(0), saved.getEnrollmentStatus()); // 初始状态必须为 0:待报到 + assertEquals("admin", saved.getCreateBy()); + assertNotNull(saved.getCreateTime()); + } + + @Test + public void testAddEnrollmentDuplicated() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(1); + + appService.addEnrollment("PLAN-001", dto, "admin"); + + // 再次添加相同学员,业务层前置查重拦截 40030 + assertError(EnrollmentErrorCode.ENROLLMENT_DUPLICATED, + () -> appService.addEnrollment("PLAN-001", dto, "admin")); + } + + @Test + public void testAddEnrollmentConcurrentUniqueConflict() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + + // 模拟底层发生并发穿透插入触发数据库 UNIQUE 约束 + enrollmentRepository.simulateDuplicateKey = true; + + assertError(EnrollmentErrorCode.ENROLLMENT_DUPLICATED, + () -> appService.addEnrollment("PLAN-001", dto, "admin")); + } + + @Test + public void testAddEnrollmentRecruitLimitExceeded() + { + EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); + dto1.setStudentId("S001"); + dto1.setSourceType(1); + dto1.setAccommodationRequired(1); + appService.addEnrollment("PLAN-001", dto1, "admin"); + + EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); + dto2.setStudentId("S002"); + dto2.setSourceType(1); + dto2.setAccommodationRequired(0); + appService.addEnrollment("PLAN-001", dto2, "admin"); + + // PLAN-001 上限是 2 人,已有 2 人,加入第 3 人必须抛 40031 + EnrollmentAddDTO dto3 = new EnrollmentAddDTO(); + dto3.setStudentId("S003"); + dto3.setSourceType(1); + dto3.setAccommodationRequired(1); + + assertError(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED, + () -> appService.addEnrollment("PLAN-001", dto3, "admin")); + } + + @Test + public void testAddEnrollmentPlanNotFound() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + + assertError(PlanErrorCode.PLAN_NOT_FOUND, + () -> appService.addEnrollment("PLAN-NON-EXIST", dto, "admin")); + } + + @Test + public void testAddEnrollmentPlanStatusLocked() + { + // 将计划设置为已结束(2) + planMapper.planStore.get("PLAN-001").setPlanStatus(2); + + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + + assertError(PlanErrorCode.PLAN_STATUS_LOCKED, + () -> appService.addEnrollment("PLAN-001", dto, "admin")); + } + + @Test + public void testAddEnrollmentStudentNotFound() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S999_NOT_EXIST"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + + assertError(StudentErrorCode.STUDENT_NOT_FOUND, + () -> appService.addEnrollment("PLAN-001", dto, "admin")); + } + + @Test + public void testListEnrollmentsAndDetailWithMasking() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(1); + Long id = appService.addEnrollment("PLAN-001", dto, "admin"); + + List list = appService.listEnrollments("PLAN-001", new EnrollmentQuery()); + assertEquals(1, list.size()); + assertEquals("张三", list.get(0).getStudentName()); + assertEquals("**************1234", list.get(0).getIdCardMasked()); + + EnrollmentDetailVO detail = appService.getEnrollmentDetail(id); + assertEquals(id, detail.getEnrollmentId()); + assertEquals("PLAN-001", detail.getPlanId()); + assertEquals("2026年第1期高速铁路信号检修专项培训班", detail.getPlanName()); + assertEquals("S001", detail.getStudentId()); + assertEquals("张三", detail.getStudentName()); + assertEquals("**************1234", detail.getIdCardMasked()); + assertEquals(Integer.valueOf(0), detail.getEnrollmentStatus()); + } + + @Test + public void testUpdateEnrollmentStatusSuccess() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(1); + Long id = appService.addEnrollment("PLAN-001", dto, "admin"); + + EnrollmentStatusUpdateDTO update = new EnrollmentStatusUpdateDTO(); + update.setEnrollmentStatus(2); // 2:请假 + update.setRemark("因公请假3天"); + + appService.updateEnrollmentStatus(id, update, "admin"); + + TrainEnrollment saved = enrollmentRepository.selectTrainEnrollmentById(id); + assertEquals(Integer.valueOf(2), saved.getEnrollmentStatus()); + assertEquals("因公请假3天", saved.getRemark()); + } + + @Test + public void testUpdateEnrollmentStatusInvalid() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(1); + Long id = appService.addEnrollment("PLAN-001", dto, "admin"); + + EnrollmentStatusUpdateDTO invalid = new EnrollmentStatusUpdateDTO(); + invalid.setEnrollmentStatus(99); // 非法状态 + + try + { + appService.updateEnrollmentStatus(id, invalid, "admin"); + fail("传入非法状态应当抛出异常"); + } + catch (ServiceException e) + { + assertEquals(EnrollmentErrorCode.ENROLLMENT_STATUS_INVALID_MESSAGE, e.getMessage()); + } + } + + @Test + public void testRemoveEnrollmentAllowedBeforeClassWithoutData() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + Long id = appService.addEnrollment("PLAN-001", dto, "admin"); + + // 开班前无任何考勤、成绩等业务数据,允许删除 + appService.removeEnrollment(id, "admin"); + + assertNull("删除后记录应当不存在", enrollmentRepository.selectTrainEnrollmentById(id)); + } + + @Test + public void testRemoveEnrollmentForbiddenWhenHasBusinessData() + { + EnrollmentAddDTO dto = new EnrollmentAddDTO(); + dto.setStudentId("S001"); + dto.setSourceType(1); + dto.setAccommodationRequired(0); + Long id = appService.addEnrollment("PLAN-001", dto, "admin"); + + // 模拟已有考勤数据 + enrollmentRepository.mockAttendanceCount.put(id, 1); + assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, + () -> appService.removeEnrollment(id, "admin")); + + // 恢复考勤,模拟已有成绩数据 + enrollmentRepository.mockAttendanceCount.put(id, 0); + enrollmentRepository.mockScoreCount.put(id, 1); + assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, + () -> appService.removeEnrollment(id, "admin")); + + // 恢复成绩,模拟已有现场报到数据 + enrollmentRepository.mockScoreCount.put(id, 0); + enrollmentRepository.mockReportCount.put(id, 1); + assertError(EnrollmentErrorCode.ENROLLMENT_HAS_BUSINESS_DATA, + () -> appService.removeEnrollment(id, "admin")); + } + + @Test + public void testPlanCompleteUpdatesActiveEnrollmentsAndKeepsHistory() + { + // 准备 3 名学员:S001(待报到0), S002(已报到1), S003(放弃3) + // 临时调大容量 + planMapper.planStore.get("PLAN-001").setRecruitNum(10L); + + EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); + dto1.setStudentId("S001"); + dto1.setSourceType(1); + dto1.setAccommodationRequired(1); + Long id1 = appService.addEnrollment("PLAN-001", dto1, "admin"); + + EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); + dto2.setStudentId("S002"); + dto2.setSourceType(1); + dto2.setAccommodationRequired(1); + Long id2 = appService.addEnrollment("PLAN-001", dto2, "admin"); + enrollmentRepository.selectTrainEnrollmentById(id2).setEnrollmentStatus(1); // 已报到 + + EnrollmentAddDTO dto3 = new EnrollmentAddDTO(); + dto3.setStudentId("S003"); + dto3.setSourceType(1); + dto3.setAccommodationRequired(0); + Long id3 = appService.addEnrollment("PLAN-001", dto3, "admin"); + enrollmentRepository.selectTrainEnrollmentById(id3).setEnrollmentStatus(3); // 已放弃 + + // 为 S002 记录考勤和成绩历史数据 + enrollmentRepository.mockAttendanceCount.put(id2, 5); + enrollmentRepository.mockScoreCount.put(id2, 3); + + // 执行 Adapter 归档 + EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter(); + setField(adapter, "trainEnrollmentService", enrollmentRepository); + + Date completeTime = new Date(); + adapter.completeEnrollments("PLAN-001", "operator", completeTime); + + // 断言:S001、S002 处于有效参训状态,被批量更新为 4:已完成,且写入 complete_time + TrainEnrollment e1 = enrollmentRepository.selectTrainEnrollmentById(id1); + assertEquals(Integer.valueOf(4), e1.getEnrollmentStatus()); + assertEquals(completeTime, e1.getCompleteTime()); + + TrainEnrollment e2 = enrollmentRepository.selectTrainEnrollmentById(id2); + assertEquals(Integer.valueOf(4), e2.getEnrollmentStatus()); + assertEquals(completeTime, e2.getCompleteTime()); + + // 断言:S003 已放弃,保持为 3:放弃,不被误刷为已完成 + TrainEnrollment e3 = enrollmentRepository.selectTrainEnrollmentById(id3); + assertEquals(Integer.valueOf(3), e3.getEnrollmentStatus()); + assertNull("已放弃学员不写入 complete_time", e3.getCompleteTime()); + + // 断言:历史考勤、成绩记录完好保留,严禁删除 + assertEquals(5, enrollmentRepository.countAttendanceByEnrollmentId(id2)); + assertEquals(3, enrollmentRepository.countScoreByEnrollmentId(id2)); + } + + @Test + public void testBatchImportProcess() + { + TrainClassPlan plan = planMapper.planStore.get("PLAN-001"); + plan.setRecruitNum(2L); // 限制 2 人 + + List batchList = new ArrayList<>(); + + // 第 1 条: 正常学员 S001 + EnrollmentAddDTO d1 = new EnrollmentAddDTO(); + d1.setStudentId("S001"); + d1.setSourceType(2); + d1.setAccommodationRequired(1); + batchList.add(d1); + + // 第 2 条: 学员不存在且 autoCreateStudent=false + EnrollmentAddDTO d2 = new EnrollmentAddDTO(); + d2.setStudentId("S_UNKNOWN"); + d2.setSourceType(2); + d2.setAccommodationRequired(0); + batchList.add(d2); + + // 第 3 条: 重复学员 S001 + EnrollmentAddDTO d3 = new EnrollmentAddDTO(); + d3.setStudentId("S001"); + d3.setSourceType(2); + d3.setAccommodationRequired(1); + batchList.add(d3); + + // 第 4 条: 正常学员 S002 + EnrollmentAddDTO d4 = new EnrollmentAddDTO(); + d4.setStudentId("S002"); + d4.setSourceType(2); + d4.setAccommodationRequired(0); + batchList.add(d4); + + // 第 5 条: 超出人数上限的学员 S003 + EnrollmentAddDTO d5 = new EnrollmentAddDTO(); + d5.setStudentId("S003"); + d5.setSourceType(2); + d5.setAccommodationRequired(1); + batchList.add(d5); + + BatchImportResultVO result = appService.processBatchImport(plan, batchList, false, "admin"); + + assertEquals(Integer.valueOf(5), result.getTotalCount()); + assertEquals(Integer.valueOf(2), result.getSuccessCount()); // S001, S002 成功 + assertEquals(Integer.valueOf(3), result.getFailureCount()); // S_UNKNOWN, 重复S001, 超限S003 失败 + assertEquals(3, result.getErrorList().size()); + assertTrue(result.getErrorList().get(0).contains("学员档案不存在")); + assertTrue(result.getErrorList().get(1).contains("学员已在参训名单中")); + assertTrue(result.getErrorList().get(2).contains("计划已达到招生上限")); + } + + /** + * 并发测试 (ISSUE-03-01 招生容量并发边界): + * recruitNum=1,两个不同 student (S001, S002) 并发加入同一 Plan, + * 最终只能 1 条成功,另一条必须抛出 40031 (RECRUIT_LIMIT_EXCEEDED)。 + */ + @Test + public void testConcurrentEnrollmentRecruitLimit() throws Exception + { + String planId = "PLAN-CONCUR-LIMIT"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanName("2026年并发容量限制测试班"); + plan.setRecruitNum(1L); // 招生上限为 1 人 + plan.setPlanStatus(0); // 0:未开始 + plan.setDelFlag("0"); + planMapper.planStore.put(planId, plan); + + EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); + dto1.setStudentId("S001"); + dto1.setSourceType(1); + dto1.setAccommodationRequired(0); + + EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); + dto2.setStudentId("S002"); + dto2.setSourceType(1); + dto2.setAccommodationRequired(0); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger limitExceededCount = new AtomicInteger(0); + AtomicReference unexpectedError = new AtomicReference<>(); + CountDownLatch finishLatch = new CountDownLatch(2); + + Runnable task1 = () -> { + try + { + barrier.await(); + try + { + appService.addEnrollment(planId, dto1, "admin-1"); + successCount.incrementAndGet(); + } + finally + { + mockEnrollmentMapper.releasePlanLock(planId); + } + } + catch (ServiceException se) + { + if (Integer.valueOf(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED).equals(se.getCode())) + { + limitExceededCount.incrementAndGet(); + } + else + { + unexpectedError.set(se); + } + } + catch (Throwable t) + { + unexpectedError.set(t); + } + finally + { + finishLatch.countDown(); + } + }; + + Runnable task2 = () -> { + try + { + barrier.await(); + try + { + appService.addEnrollment(planId, dto2, "admin-2"); + successCount.incrementAndGet(); + } + finally + { + mockEnrollmentMapper.releasePlanLock(planId); + } + } + catch (ServiceException se) + { + if (Integer.valueOf(EnrollmentErrorCode.RECRUIT_LIMIT_EXCEEDED).equals(se.getCode())) + { + limitExceededCount.incrementAndGet(); + } + else + { + unexpectedError.set(se); + } + } + catch (Throwable t) + { + unexpectedError.set(t); + } + finally + { + finishLatch.countDown(); + } + }; + + executor.submit(task1); + executor.submit(task2); + + boolean finished = finishLatch.await(5, TimeUnit.SECONDS); + executor.shutdown(); + + assertTrue("并发测试应在5秒内执行完毕", finished); + if (unexpectedError.get() != null) + { + fail("并发测试遇到非预期异常: " + unexpectedError.get().getMessage()); + } + + assertEquals("最终只能有且仅有1条记录成功加入", 1, successCount.get()); + assertEquals("另一条并发请求必须被40031拦截", 1, limitExceededCount.get()); + assertEquals("最终该计划参训人数必须为1", 1, enrollmentRepository.countEnrollmentsByPlanId(planId)); + } + + 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 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 InMemoryEnrollmentService implements ITrainEnrollmentService + { + private final AtomicLong idGenerator = new AtomicLong(1000); + private final Map store = new ConcurrentHashMap<>(); + private final Map mockAttendanceCount = new HashMap<>(); + private final Map mockScoreCount = new HashMap<>(); + private final Map mockReportCount = new HashMap<>(); + private final Map mockAccommodationCount = new HashMap<>(); + private final Map mockStudyCount = new HashMap<>(); + private boolean simulateDuplicateKey = false; + + @Override + public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId) + { + return store.get(enrollmentId); + } + + @Override + public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) + { + for (TrainEnrollment e : store.values()) + { + if (e.getPlanId().equals(planId) && e.getStudentId().equals(studentId)) + { + return e; + } + } + return null; + } + + @Override + public List selectEnrollmentItemList(EnrollmentQuery query) + { + List result = new ArrayList<>(); + for (TrainEnrollment e : store.values()) + { + if (query.getPlanId() != null && !e.getPlanId().equals(query.getPlanId())) + { + continue; + } + if (query.getEnrollmentStatus() != null && !e.getEnrollmentStatus().equals(query.getEnrollmentStatus())) + { + continue; + } + EnrollmentItemVO vo = new EnrollmentItemVO(); + vo.setEnrollmentId(e.getEnrollmentId()); + vo.setPlanId(e.getPlanId()); + vo.setStudentId(e.getStudentId()); + vo.setSourceType(e.getSourceType()); + vo.setEnrollmentStatus(e.getEnrollmentStatus()); + vo.setAccommodationRequired(e.getAccommodationRequired()); + vo.setStudentName("张三"); + vo.setIdCardMasked("1234"); + result.add(vo); + } + return result; + } + + @Override + public int countEnrollmentsByPlanId(String planId) + { + int count = 0; + for (TrainEnrollment e : store.values()) + { + if (e.getPlanId().equals(planId)) + { + count++; + } + } + return count; + } + + @Override + public int insertTrainEnrollment(TrainEnrollment trainEnrollment) + { + if (simulateDuplicateKey) + { + throw new DuplicateKeyException("Duplicate entry 'PLAN-001-S001' for key 'uq_enrollment'"); + } + long id = idGenerator.incrementAndGet(); + trainEnrollment.setEnrollmentId(id); + store.put(id, trainEnrollment); + return 1; + } + + @Override + public int batchInsertTrainEnrollment(List list) + { + for (TrainEnrollment e : list) + { + insertTrainEnrollment(e); + } + return list.size(); + } + + @Override + public int updateTrainEnrollment(TrainEnrollment trainEnrollment) + { + store.put(trainEnrollment.getEnrollmentId(), trainEnrollment); + return 1; + } + + @Override + public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime) + { + TrainEnrollment e = store.get(enrollmentId); + if (e != null) + { + e.setEnrollmentStatus(enrollmentStatus); + if (remark != null) + { + e.setRemark(remark); + } + e.setUpdateBy(operator); + e.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime) + { + int rows = 0; + for (TrainEnrollment e : store.values()) + { + if (e.getPlanId().equals(planId) && (e.getEnrollmentStatus() == 0 || e.getEnrollmentStatus() == 1)) + { + e.setEnrollmentStatus(4); + e.setCompleteTime(completeTime); + e.setUpdateBy(operator); + e.setUpdateTime(completeTime); + rows++; + } + } + return rows; + } + + @Override + public int deleteTrainEnrollmentById(Long enrollmentId) + { + return store.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 mockReportCount.getOrDefault(enrollmentId, 0); + } + + @Override + public int countAccommodationByEnrollmentId(Long enrollmentId) + { + return mockAccommodationCount.getOrDefault(enrollmentId, 0); + } + + @Override + public int countStudyDurationByEnrollmentId(Long enrollmentId) + { + return mockStudyCount.getOrDefault(enrollmentId, 0); + } + } + + private static class MockPlanMapper implements TrainClassPlanMapper + { + private final Map 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 selectTrainClassPlanList(com.ruoyi.train.domain.query.PlanQuery query) + { + return Collections.emptyList(); + } + + @Override + public int countTrainClassPlanByName(String planName, String excludePlanId) + { + return 0; + } + + @Override + public int insertTrainClassPlan(TrainClassPlan plan) + { + planStore.put(plan.getPlanId(), plan); + return 1; + } + + @Override + public int updateTrainClassPlan(TrainClassPlan plan) + { + planStore.put(plan.getPlanId(), plan); + return 1; + } + + @Override + public int updateTrainClassPlanStatus(TrainClassPlan plan) + { + TrainClassPlan exist = planStore.get(plan.getPlanId()); + if (exist != null) + { + exist.setPlanStatus(plan.getPlanStatus()); + } + return 1; + } + } + + private static class MockStudentMapper implements TrainStudentMapper + { + private final Map studentStore = new HashMap<>(); + + @Override + public TrainStudent selectTrainStudentBySId(String sId) + { + return studentStore.get(sId); + } + + @Override + public List selectTrainStudentList(StudentQuery query) + { + return new ArrayList<>(studentStore.values()); + } + + @Override + public Integer selectOneByIdCardHash(String idCardHash) + { + return null; + } + + @Override + public int insertTrainStudent(TrainStudent student) + { + studentStore.put(student.getsId(), student); + return 1; + } + } + + private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper + { + private final Map planStore; + private final InMemoryEnrollmentService enrollmentService; + private final Map planLocks = new ConcurrentHashMap<>(); + + public MockTrainEnrollmentMapper(Map planStore, InMemoryEnrollmentService enrollmentService) + { + this.planStore = planStore; + this.enrollmentService = enrollmentService; + } + + @Override + public TrainClassPlan selectPlanForUpdate(String planId) + { + ReentrantLock lock = planLocks.computeIfAbsent(planId, k -> new ReentrantLock()); + lock.lock(); + return planStore.get(planId); + } + + public void releasePlanLock(String planId) + { + ReentrantLock lock = planLocks.get(planId); + if (lock != null && lock.isHeldByCurrentThread()) + { + lock.unlock(); + } + } + + public void clearLocks() + { + planLocks.clear(); + } + + @Override + public TrainEnrollment selectTrainEnrollmentById(Long enrollmentId) + { + return enrollmentService.selectTrainEnrollmentById(enrollmentId); + } + + @Override + public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) + { + return enrollmentService.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId); + } + + @Override + public List selectEnrollmentItemList(EnrollmentQuery query) + { + return enrollmentService.selectEnrollmentItemList(query); + } + + @Override + public int countEnrollmentsByPlanId(String planId) + { + return enrollmentService.countEnrollmentsByPlanId(planId); + } + + @Override + public int insertTrainEnrollment(TrainEnrollment trainEnrollment) + { + return enrollmentService.insertTrainEnrollment(trainEnrollment); + } + + @Override + public int batchInsertTrainEnrollment(List list) + { + return enrollmentService.batchInsertTrainEnrollment(list); + } + + @Override + public int updateTrainEnrollment(TrainEnrollment trainEnrollment) + { + return enrollmentService.updateTrainEnrollment(trainEnrollment); + } + + @Override + public int updateEnrollmentStatus(Long enrollmentId, Integer enrollmentStatus, String remark, String operator, Date updateTime) + { + return enrollmentService.updateEnrollmentStatus(enrollmentId, enrollmentStatus, remark, operator, updateTime); + } + + @Override + public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime) + { + return enrollmentService.completeActiveEnrollmentsByPlanId(planId, operator, completeTime); + } + + @Override + public int deleteTrainEnrollmentById(Long enrollmentId) + { + return enrollmentService.deleteTrainEnrollmentById(enrollmentId); + } + + @Override + public int countAttendanceByEnrollmentId(Long enrollmentId) + { + return enrollmentService.countAttendanceByEnrollmentId(enrollmentId); + } + + @Override + public int countScoreByEnrollmentId(Long enrollmentId) + { + return enrollmentService.countScoreByEnrollmentId(enrollmentId); + } + + @Override + public int countReportByEnrollmentId(Long enrollmentId) + { + return enrollmentService.countReportByEnrollmentId(enrollmentId); + } + + @Override + public int countAccommodationByEnrollmentId(Long enrollmentId) + { + return enrollmentService.countAccommodationByEnrollmentId(enrollmentId); + } + + @Override + public int countStudyDurationByEnrollmentId(Long enrollmentId) + { + return enrollmentService.countStudyDurationByEnrollmentId(enrollmentId); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java new file mode 100644 index 000000000..347304dcc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java @@ -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 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 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 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 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 enrollmentStore = new HashMap<>(); + private final Map mockAttendanceCount = new HashMap<>(); + private final Map 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 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 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; + } + } +}