diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java index 18f84855e..ae7725b2a 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java @@ -85,6 +85,9 @@ public class AttendanceController extends BaseController return success(checkin); } + @Autowired(required = false) + private com.ruoyi.train.service.ReportTerminalService reportTerminalService; + /** * 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。 */ @@ -94,4 +97,19 @@ public class AttendanceController extends BaseController { return success(attendanceAppService.getLessonRoster(lessonId)); } + + /** + * 模拟电子设备课堂打卡 (P1 Mock 考勤接入)。 + */ + @PreAuthorize("@ss.hasPermi('train:attendance:checkin')") + @Log(title = "模拟设备课堂打卡", businessType = BusinessType.INSERT) + @PostMapping("/mock-swipe") + public AjaxResult mockSwipe(@Validated @RequestBody com.ruoyi.train.domain.dto.MockAttendanceDTO dto) + { + if (reportTerminalService == null) + { + throw new com.ruoyi.common.exception.ServiceException("模拟考勤服务不可用"); + } + return success(reportTerminalService.mockDeviceAttendance(dto, SecurityUtils.getUsername())); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainReportTerminalController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainReportTerminalController.java new file mode 100644 index 000000000..f1f1bbf87 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainReportTerminalController.java @@ -0,0 +1,77 @@ +package com.ruoyi.web.controller.train; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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 com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.dto.MockAttendanceDTO; +import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO; +import com.ruoyi.train.domain.vo.MockAttendanceResultVO; +import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO; +import com.ruoyi.train.service.ReportTerminalService; + +/** + * 模拟报到机与设备考勤接入 Controller (P0/P1 MVP v3)。 + *

+ * 仅供内部授权测试与模拟环境使用,严格要求登录鉴权 (@ss.hasPermi)。 + * + * @author ruoyi + */ +@RestController +@RequestMapping("/train/report-terminal") +public class TrainReportTerminalController extends BaseController +{ + private final ReportTerminalService reportTerminalService; + + @Autowired + public TrainReportTerminalController(ReportTerminalService reportTerminalService) + { + this.reportTerminalService = reportTerminalService; + } + + /** + * 报到机扫码查询学员报到信息 (P0)。 + */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @GetMapping("/lookup") + public AjaxResult lookup(@RequestParam("studentId") String studentId, + @RequestParam(value = "planId", required = false) String planId) + { + ReportTerminalPreviewVO preview = reportTerminalService.lookupTerminalReport(studentId, planId); + return success(preview); + } + + /** + * 报到机核对后一键确认报到 (P0)。 + */ + @PreAuthorize("@ss.hasPermi('train:report:confirm')") + @Log(title = "报到机确认报到", businessType = BusinessType.UPDATE) + @PostMapping("/confirm") + public AjaxResult confirm(@RequestBody ReportTerminalConfirmDTO dto) + { + ReportTerminalPreviewVO result = reportTerminalService.confirmTerminalReport(dto, SecurityUtils.getUsername()); + return success(result); + } + + /** + * 模拟课堂设备打卡 (P1 Mock 考勤接入)。 + */ + @PreAuthorize("@ss.hasPermi('train:attendance:checkin')") + @Log(title = "模拟设备课堂打卡", businessType = BusinessType.INSERT) + @PostMapping("/mock-attendance") + public AjaxResult mockAttendance(@Validated @RequestBody MockAttendanceDTO dto) + { + MockAttendanceResultVO result = reportTerminalService.mockDeviceAttendance(dto, SecurityUtils.getUsername()); + return success(result); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index 547f2e093..00f3ea4a7 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -141,3 +141,11 @@ xss: excludes: /system/notice # 匹配链接 urlPatterns: /system/*,/monitor/*,/tool/* + +# 培训业务配置 +train: + mock: + device: + # 模拟设备打卡接口开关 (生产环境与公共配置必须默认关闭,仅允许在测试环境显式开启) + enabled: false + diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ReportTerminalControllerTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ReportTerminalControllerTest.java new file mode 100644 index 000000000..92e69070e --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ReportTerminalControllerTest.java @@ -0,0 +1,67 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.lang.reflect.Method; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import com.ruoyi.train.domain.dto.MockAttendanceDTO; +import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO; + +/** + * 报到机与设备考勤 Controller 契约与权限注解测试。 + * + * @author ruoyi + */ +public class ReportTerminalControllerTest +{ + @Test + public void testTerminalControllerContract() throws Exception + { + // 1. 报到机查询接口契约 + Method lookupMethod = TrainReportTerminalController.class.getMethod("lookup", String.class, String.class); + assertNotNull(lookupMethod); + GetMapping getMapping = lookupMethod.getAnnotation(GetMapping.class); + assertNotNull(getMapping); + assertEquals("/lookup", getMapping.value()[0]); + PreAuthorize lookupAuth = lookupMethod.getAnnotation(PreAuthorize.class); + assertNotNull(lookupAuth); + assertEquals("@ss.hasPermi('train:report:confirm')", lookupAuth.value()); + + // 2. 报到机确认接口契约 + Method confirmMethod = TrainReportTerminalController.class.getMethod("confirm", ReportTerminalConfirmDTO.class); + assertNotNull(confirmMethod); + PostMapping postMapping = confirmMethod.getAnnotation(PostMapping.class); + assertNotNull(postMapping); + assertEquals("/confirm", postMapping.value()[0]); + PreAuthorize confirmAuth = confirmMethod.getAnnotation(PreAuthorize.class); + assertNotNull(confirmAuth); + assertEquals("@ss.hasPermi('train:report:confirm')", confirmAuth.value()); + + // 3. 模拟打卡接口契约 + Method mockAttendanceMethod = TrainReportTerminalController.class.getMethod("mockAttendance", MockAttendanceDTO.class); + assertNotNull(mockAttendanceMethod); + PostMapping postMock = mockAttendanceMethod.getAnnotation(PostMapping.class); + assertNotNull(postMock); + assertEquals("/mock-attendance", postMock.value()[0]); + PreAuthorize mockAuth = mockAttendanceMethod.getAnnotation(PreAuthorize.class); + assertNotNull(mockAuth); + assertEquals("@ss.hasPermi('train:attendance:checkin')", mockAuth.value()); + } + + @Test + public void testAttendanceControllerMockSwipeContract() throws Exception + { + Method mockSwipeMethod = AttendanceController.class.getMethod("mockSwipe", MockAttendanceDTO.class); + assertNotNull(mockSwipeMethod); + PostMapping postMapping = mockSwipeMethod.getAnnotation(PostMapping.class); + assertNotNull(postMapping); + assertEquals("/mock-swipe", postMapping.value()[0]); + PreAuthorize swipeAuth = mockSwipeMethod.getAnnotation(PreAuthorize.class); + assertNotNull(swipeAuth); + assertEquals("@ss.hasPermi('train:attendance:checkin')", swipeAuth.value()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/MockAttendanceDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/MockAttendanceDTO.java new file mode 100644 index 000000000..62416509e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/MockAttendanceDTO.java @@ -0,0 +1,85 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import java.util.Date; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 模拟设备课堂打卡请求 DTO (P1 Mock 设备考勤适配层)。 + * + * @author ruoyi + */ +public class MockAttendanceDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员学号 (必填) */ + @NotEmpty(message = "学员学号 (studentId) 不能为空") + private String studentId; + + /** 目标课次标识 (必填) */ + @NotNull(message = "目标课次标识 (lessonId) 不能为空") + private Long lessonId; + + /** 打卡动作类型:SIGN_IN (签到) 或 SIGN_OUT (签退),默认 SIGN_IN */ + private String actionType = "SIGN_IN"; + + /** 模拟设备标识 (如 MOCK-BOARD-01) */ + private String deviceSn = "MOCK-BOARD-01"; + + /** 打卡时间 (可选,默认当前时间) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date checkinTime; + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public String getActionType() + { + return actionType; + } + + public void setActionType(String actionType) + { + this.actionType = actionType; + } + + public String getDeviceSn() + { + return deviceSn; + } + + public void setDeviceSn(String deviceSn) + { + this.deviceSn = deviceSn; + } + + public Date getCheckinTime() + { + return checkinTime; + } + + public void setCheckinTime(Date checkinTime) + { + this.checkinTime = checkinTime; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportTerminalConfirmDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportTerminalConfirmDTO.java new file mode 100644 index 000000000..595185929 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/ReportTerminalConfirmDTO.java @@ -0,0 +1,92 @@ +package com.ruoyi.train.domain.dto; + +import java.io.Serializable; +import javax.validation.constraints.NotEmpty; + +/** + * 报到机扫码确认报到请求 DTO (P0 报到机适配层)。 + * + * @author ruoyi + */ +public class ReportTerminalConfirmDTO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 参训档案ID (可选,若不传则需提供 studentId 与 planId) */ + private Long enrollmentId; + + /** 学员学号 */ + private String studentId; + + /** 培训班计划ID */ + private String planId; + + /** 报到终端/设备编号 (默认 TERMINAL-MOCK-01) */ + private String reportDeviceSn = "TERMINAL-MOCK-01"; + + /** 核验备注说明 */ + private String verifyNote = "报到机自助扫码核验"; + + /** 是否强制放行逾期报到 (默认 false) */ + private Boolean forceOverride = false; + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getReportDeviceSn() + { + return reportDeviceSn; + } + + public void setReportDeviceSn(String reportDeviceSn) + { + this.reportDeviceSn = reportDeviceSn; + } + + public String getVerifyNote() + { + return verifyNote; + } + + public void setVerifyNote(String verifyNote) + { + this.verifyNote = verifyNote; + } + + public Boolean getForceOverride() + { + return forceOverride; + } + + public void setForceOverride(Boolean forceOverride) + { + this.forceOverride = forceOverride; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CandidatePlanVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CandidatePlanVO.java new file mode 100644 index 000000000..d7795456f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/CandidatePlanVO.java @@ -0,0 +1,91 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; + +/** + * 学员候选开班计划 VO (供报到机多计划选择)。 + * + * @author ruoyi + */ +public class CandidatePlanVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 培训班计划ID */ + private String planId; + + /** 培训班计划名称 */ + private String planName; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 参训状态:0待报到 1已报到 2请假 3放弃 4已完成 */ + private Integer enrollmentStatus; + + /** 参训状态说明 */ + private String enrollmentStatusLabel; + + public CandidatePlanVO() + { + } + + public CandidatePlanVO(String planId, String planName, Long enrollmentId, Integer enrollmentStatus, String enrollmentStatusLabel) + { + this.planId = planId; + this.planName = planName; + this.enrollmentId = enrollmentId; + this.enrollmentStatus = enrollmentStatus; + this.enrollmentStatusLabel = enrollmentStatusLabel; + } + + 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 Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public Integer getEnrollmentStatus() + { + return enrollmentStatus; + } + + public void setEnrollmentStatus(Integer enrollmentStatus) + { + this.enrollmentStatus = enrollmentStatus; + } + + public String getEnrollmentStatusLabel() + { + return enrollmentStatusLabel; + } + + public void setEnrollmentStatusLabel(String enrollmentStatusLabel) + { + this.enrollmentStatusLabel = enrollmentStatusLabel; + } +} 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 index 6074d3402..172b9a31e 100644 --- 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 @@ -55,6 +55,18 @@ public class EnrollmentItemVO /** 备注 */ private String remark; + /** 宿舍楼栋 (当前分配) */ + private String dormBuilding; + + /** 宿舍房间号 (当前分配) */ + private String dormRoom; + + /** 床位号 (当前分配) */ + private String bedNo; + + /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ + private Integer accommodationStatus; + public Long getEnrollmentId() { return enrollmentId; @@ -204,4 +216,44 @@ public class EnrollmentItemVO { this.remark = remark; } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/MockAttendanceResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/MockAttendanceResultVO.java new file mode 100644 index 000000000..8e9e81532 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/MockAttendanceResultVO.java @@ -0,0 +1,186 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 模拟设备课堂打卡结果 VO (P1 Mock 设备考勤适配层)。 + * + * @author ruoyi + */ +public class MockAttendanceResultVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 打卡记录ID */ + private Long checkinId; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 学员学号 */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 课次ID */ + private Long lessonId; + + /** 课程名称 */ + private String courseName; + + /** 培训计划ID */ + private String planId; + + /** 打卡动作类型:SIGN_IN / SIGN_OUT */ + private String actionType; + + /** 出勤状态 (1:正常出勤, 2:迟到, 3:早退, 4:缺勤, 5:请假) */ + private Integer attendanceStatus; + + /** 签到时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date signInTime; + + /** 签退时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date signOutTime; + + /** 打卡电子班牌/模拟设备编号 */ + private String boardId; + + /** 提示消息 */ + private String message; + + public Long getCheckinId() + { + return checkinId; + } + + public void setCheckinId(Long checkinId) + { + this.checkinId = checkinId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + 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 Long getLessonId() + { + return lessonId; + } + + public void setLessonId(Long lessonId) + { + this.lessonId = lessonId; + } + + public String getCourseName() + { + return courseName; + } + + public void setCourseName(String courseName) + { + this.courseName = courseName; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getActionType() + { + return actionType; + } + + public void setActionType(String actionType) + { + this.actionType = actionType; + } + + public Integer getAttendanceStatus() + { + return attendanceStatus; + } + + public void setAttendanceStatus(Integer attendanceStatus) + { + this.attendanceStatus = attendanceStatus; + } + + public Date getSignInTime() + { + return signInTime; + } + + public void setSignInTime(Date signInTime) + { + this.signInTime = signInTime; + } + + public Date getSignOutTime() + { + return signOutTime; + } + + public void setSignOutTime(Date signOutTime) + { + this.signOutTime = signOutTime; + } + + public String getBoardId() + { + return boardId; + } + + public void setBoardId(String boardId) + { + this.boardId = boardId; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java new file mode 100644 index 000000000..5d5695cf4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/ReportTerminalPreviewVO.java @@ -0,0 +1,317 @@ +package com.ruoyi.train.domain.vo; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 报到机扫码预览及核验结果全景 VO (P0 报到机适配层)。 + * + * @author ruoyi + */ +public class ReportTerminalPreviewVO implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员学号 */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 性别 */ + private String gender; + + /** 选派单位 */ + private String orgName; + + /** 联系电话 */ + private String phone; + + /** 身份证脱敏展示 */ + private String idCardMasked; + + /** 参训档案ID */ + private Long enrollmentId; + + /** 培训计划标识 */ + private String planId; + + /** 培训班名称 */ + private String planName; + + /** 参训状态 (0:待报到, 1:已报到, 2:请假, 3:放弃, 4:已完成) */ + private Integer enrollmentStatus; + + /** 参训状态说明 ("待报到", "已报到"等) */ + private String enrollmentStatusLabel; + + /** 是否已完成报到 */ + private Boolean isReported = false; + + /** 报到时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportTime; + + /** 是否已分配宿舍 (true:已分配, false:未分配) */ + private Boolean hasAccommodation = false; + + /** 宿舍楼栋 */ + private String dormBuilding; + + /** 宿舍房间号 */ + private String dormRoom; + + /** 床位号 */ + private String bedNo; + + /** 住宿状态 (0:预分配, 1:在住, 2:已退宿) */ + private Integer accommodationStatus; + + /** 住宿状态说明 ("预分配", "在住", "未分配"等) */ + private String accommodationStatusLabel; + + /** 当前培训计划完整课程表 */ + private PlanTimetableVO timetable; + + /** 当学员关联多个计划时的候选列表 */ + private List candidatePlans = new ArrayList<>(); + + /** 是否关联了多个计划需要前端指定/选择 */ + private Boolean hasMultiplePlans = false; + + /** 业务提示/操作反馈消息 */ + private String message; + + 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 getOrgName() + { + return orgName; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getIdCardMasked() + { + return idCardMasked; + } + + public void setIdCardMasked(String idCardMasked) + { + this.idCardMasked = idCardMasked; + } + + 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 Integer getEnrollmentStatus() + { + return enrollmentStatus; + } + + public void setEnrollmentStatus(Integer enrollmentStatus) + { + this.enrollmentStatus = enrollmentStatus; + } + + public String getEnrollmentStatusLabel() + { + return enrollmentStatusLabel; + } + + public void setEnrollmentStatusLabel(String enrollmentStatusLabel) + { + this.enrollmentStatusLabel = enrollmentStatusLabel; + } + + public Boolean getIsReported() + { + return isReported; + } + + public void setIsReported(Boolean isReported) + { + this.isReported = isReported; + } + + public Date getReportTime() + { + return reportTime; + } + + public void setReportTime(Date reportTime) + { + this.reportTime = reportTime; + } + + public Boolean getHasAccommodation() + { + return hasAccommodation; + } + + public void setHasAccommodation(Boolean hasAccommodation) + { + this.hasAccommodation = hasAccommodation; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } + + public String getAccommodationStatusLabel() + { + return accommodationStatusLabel; + } + + public void setAccommodationStatusLabel(String accommodationStatusLabel) + { + this.accommodationStatusLabel = accommodationStatusLabel; + } + + public PlanTimetableVO getTimetable() + { + return timetable; + } + + public void setTimetable(PlanTimetableVO timetable) + { + this.timetable = timetable; + } + + public List getCandidatePlans() + { + return candidatePlans; + } + + public void setCandidatePlans(List candidatePlans) + { + this.candidatePlans = candidatePlans; + } + + public Boolean getHasMultiplePlans() + { + return hasMultiplePlans; + } + + public void setHasMultiplePlans(Boolean hasMultiplePlans) + { + this.hasMultiplePlans = hasMultiplePlans; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ReportTerminalService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ReportTerminalService.java new file mode 100644 index 000000000..6e4973832 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ReportTerminalService.java @@ -0,0 +1,44 @@ +package com.ruoyi.train.service; + +import com.ruoyi.train.domain.dto.MockAttendanceDTO; +import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO; +import com.ruoyi.train.domain.vo.MockAttendanceResultVO; +import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO; + +/** + * 报到机终端与设备打卡适配服务接口 (P0/P1 MVP v3 适配层)。 + *

+ * 遵循开闭原则,复用核心 EnrollmentAppService、AttendanceAppService、SchedulingAppService, + * 不推翻既有业务逻辑,不直接修改数据库状态。 + * + * @author ruoyi + */ +public interface ReportTerminalService +{ + /** + * 报到机扫码查询学员报到信息 (预览数据:基础信息、当前计划、宿舍安排、课程表与报到状态)。 + * + * @param studentId 学员学号 + * @param planId 可选培训班计划ID (当学员存在多个有效计划时指定) + * @return 报到机预览全景 VO + */ + ReportTerminalPreviewVO lookupTerminalReport(String studentId, String planId); + + /** + * 报到机学员一键确认报到 (复用原有 confirmReport 业务能力,实现幂等保护与状态自动回显)。 + * + * @param dto 报到机确认参数 + * @param operator 操作人/设备操作身份 + * @return 确认后最新全景 VO + */ + ReportTerminalPreviewVO confirmTerminalReport(ReportTerminalConfirmDTO dto, String operator); + + /** + * 模拟设备课堂打卡 (P1 受控 Mock 打卡适配)。 + * + * @param dto 打卡请求参数 (studentId + lessonId + actionType) + * @param operator 操作人 + * @return 打卡结果 VO + */ + MockAttendanceResultVO mockDeviceAttendance(MockAttendanceDTO dto, String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java new file mode 100644 index 000000000..650440087 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java @@ -0,0 +1,455 @@ +package com.ruoyi.train.service.impl; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainCourse; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.dto.AttendanceSignInDTO; +import com.ruoyi.train.domain.dto.AttendanceSignOutDTO; +import com.ruoyi.train.domain.dto.MockAttendanceDTO; +import com.ruoyi.train.domain.dto.ReportConfirmDTO; +import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO; +import com.ruoyi.train.domain.vo.CandidatePlanVO; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.MockAttendanceResultVO; +import com.ruoyi.train.domain.vo.PlanTimetableVO; +import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainCourseMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainElectronicBoard; +import com.ruoyi.train.mapper.TrainElectronicBoardMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.AttendanceAppService; +import com.ruoyi.train.service.EnrollmentAppService; +import com.ruoyi.train.service.ReportTerminalService; +import com.ruoyi.train.service.SchedulingAppService; + +/** + * 报到机终端与设备打卡适配服务实现 (P0/P1 MVP v3 适配层)。 + * + * @author ruoyi + */ +@Service +public class ReportTerminalServiceImpl implements ReportTerminalService +{ + private static final Logger log = LoggerFactory.getLogger(ReportTerminalServiceImpl.class); + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired + private TrainStudentMapper trainStudentMapper; + + @Autowired + private TrainClassPlanMapper trainClassPlanMapper; + + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + + @Autowired + private TrainClassReportMapper trainClassReportMapper; + + @Autowired + private TrainLessonMapper trainLessonMapper; + + @Autowired + private TrainPlanCourseMapper trainPlanCourseMapper; + + @Autowired + private TrainCourseMapper trainCourseMapper; + + @Autowired + private EnrollmentAppService enrollmentAppService; + + @Autowired + private AttendanceAppService attendanceAppService; + + @Autowired + private SchedulingAppService schedulingAppService; + + @Autowired(required = false) + private TrainElectronicBoardMapper trainElectronicBoardMapper; + + @Value("${train.mock.device.enabled:false}") + private boolean mockDeviceEnabled = false; + + public void setMockDeviceEnabled(boolean mockDeviceEnabled) + { + this.mockDeviceEnabled = mockDeviceEnabled; + } + + public boolean isMockDeviceEnabled() + { + return this.mockDeviceEnabled; + } + + @Override + public ReportTerminalPreviewVO lookupTerminalReport(String studentId, String planId) + { + if (studentId == null || studentId.trim().isEmpty()) + { + throw new ServiceException("学员学号 (studentId) 不能为空"); + } + String sid = studentId.trim(); + + // 1. 查询学员档案 + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sid); + if (student == null) + { + throw new ServiceException("未找到学号为【" + sid + "】的学员档案"); + } + + // 2. 查询学员所有参训名单记录 + List enrollments = trainEnrollmentMapper.selectEnrollmentsByStudentId(sid); + if (enrollments == null || enrollments.isEmpty()) + { + throw new ServiceException("学员【" + student.getsName() + "】暂未加入任何开班计划"); + } + + // 3. 构建候选计划列表 + List candidatePlans = new ArrayList<>(); + for (EnrollmentItemVO e : enrollments) + { + String pName = getPlanName(e.getPlanId()); + candidatePlans.add(new CandidatePlanVO( + e.getPlanId(), + pName, + e.getEnrollmentId(), + e.getEnrollmentStatus(), + formatStatusLabel(e.getEnrollmentStatus()) + )); + } + + // 4. 定位目标参训档案 + EnrollmentItemVO target = null; + if (planId != null && !planId.trim().isEmpty()) + { + String pid = planId.trim(); + for (EnrollmentItemVO e : enrollments) + { + if (pid.equals(e.getPlanId())) + { + target = e; + break; + } + } + if (target == null) + { + throw new ServiceException("学员【" + student.getsName() + "】未加入指定的培训计划【" + pid + "】"); + } + } + else + { + // 若未指定 planId,优先选待报到(0)的有效计划;若无则选第一个 + for (EnrollmentItemVO e : enrollments) + { + if (e.getEnrollmentStatus() != null && e.getEnrollmentStatus() == 0) + { + target = e; + break; + } + } + if (target == null) + { + target = enrollments.get(0); + } + } + + // 5. 组装报到机全景预览 VO + ReportTerminalPreviewVO vo = new ReportTerminalPreviewVO(); + vo.setStudentId(student.getsId()); + vo.setStudentName(student.getsName()); + vo.setGender(student.getsGender()); + vo.setOrgName(student.getOrgName()); + vo.setPhone(student.getsPhone()); + vo.setIdCardMasked(student.getIdCardLast4() != null ? student.getIdCardLast4() : "-"); + + vo.setEnrollmentId(target.getEnrollmentId()); + vo.setPlanId(target.getPlanId()); + vo.setPlanName(getPlanName(target.getPlanId())); + vo.setEnrollmentStatus(target.getEnrollmentStatus()); + vo.setEnrollmentStatusLabel(formatStatusLabel(target.getEnrollmentStatus())); + + vo.setCandidatePlans(candidatePlans); + vo.setHasMultiplePlans(candidatePlans.size() > 1); + + // 5.1 报到状态事实查询 + TrainClassReport report = trainClassReportMapper.selectTrainClassReportByEnrollmentId(target.getEnrollmentId()); + boolean isReported = (target.getEnrollmentStatus() != null && target.getEnrollmentStatus() == 1) || report != null; + vo.setIsReported(isReported); + vo.setReportTime(report != null ? report.getReportTime() : target.getJoinTime()); + + // 5.2 宿舍提前分配情况查询 (实事求是:分配则显示,未分配如实显示未分配) + TrainAccommodation acc = trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(target.getEnrollmentId()); + if (acc != null) + { + vo.setHasAccommodation(true); + vo.setDormBuilding(acc.getDormBuilding()); + vo.setDormRoom(acc.getDormRoom()); + vo.setBedNo(acc.getBedNo()); + vo.setAccommodationStatus(acc.getAccommodationStatus()); + vo.setAccommodationStatusLabel(formatAccommodationStatusLabel(acc.getAccommodationStatus())); + } + else + { + vo.setHasAccommodation(false); + vo.setAccommodationStatusLabel("未分配"); + } + + // 5.3 对应开班计划课程表查询 + try + { + PlanTimetableVO timetable = schedulingAppService.getPlanTimetable(target.getPlanId()); + vo.setTimetable(timetable); + } + catch (Exception ex) + { + log.warn("Lookup timetable for plan {} failed: {}", target.getPlanId(), ex.getMessage()); + } + + if (vo.getHasMultiplePlans()) + { + vo.setMessage("查询成功。该学员关联多个开班计划,当前展示【" + vo.getPlanName() + "】,可切换计划报到"); + } + else + { + vo.setMessage("学员报到信息查询成功"); + } + + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ReportTerminalPreviewVO confirmTerminalReport(ReportTerminalConfirmDTO dto, String operator) + { + if (dto == null) + { + throw new ServiceException("确认报到请求参数不能为空"); + } + if (operator == null || operator.trim().isEmpty() || "anonymousUser".equalsIgnoreCase(operator)) + { + throw new ServiceException("操作员未通过身份认证,拒绝执行报到确认"); + } + + // 1. 定位 enrollmentId + Long enrollmentId = dto.getEnrollmentId(); + if (enrollmentId == null) + { + if (dto.getStudentId() != null && dto.getPlanId() != null) + { + TrainEnrollment en = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId( + dto.getPlanId().trim(), dto.getStudentId().trim()); + if (en == null) + { + throw new ServiceException("未找到参训档案:学员【" + dto.getStudentId() + "】未加入计划【" + dto.getPlanId() + "】"); + } + enrollmentId = en.getEnrollmentId(); + } + else + { + throw new ServiceException("报到确认参数不完整:必须提供 enrollmentId 或 studentId + planId"); + } + } + + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId); + if (enrollment == null) + { + throw new ServiceException("指定参训档案不存在: " + enrollmentId); + } + + // 2. 幂等防重:检查是否此前已确认报到 (保护现有报到事实,重复确认不生成第二条报到记录) + TrainClassReport existingReport = trainClassReportMapper.selectTrainClassReportByEnrollmentId(enrollmentId); + if (existingReport != null || (enrollment.getEnrollmentStatus() != null && enrollment.getEnrollmentStatus() == 1)) + { + ReportTerminalPreviewVO preview = lookupTerminalReport(enrollment.getStudentId(), enrollment.getPlanId()); + preview.setMessage("学员此前已完成报到,无需重复确认 (报到时间: " + + (existingReport != null ? existingReport.getReportTime() : enrollment.getJoinTime()) + ")"); + return preview; + } + + // 3. 构造报到确认参数并复用原有 confirmReport 业务服务 (执行行锁、截止策略校验与门禁联动) + ReportConfirmDTO confirmDto = new ReportConfirmDTO(); + confirmDto.setReportWay(3); // 3: 二维码/扫码核验 + confirmDto.setReportDeviceSn(dto.getReportDeviceSn() != null ? dto.getReportDeviceSn() : "TERMINAL-MOCK-01"); + confirmDto.setVerifyNote(dto.getVerifyNote() != null ? dto.getVerifyNote() : "报到机自助扫码确认"); + confirmDto.setForceOverride(dto.getForceOverride() != null && dto.getForceOverride()); + + enrollmentAppService.confirmReport(enrollmentId, confirmDto, operator); + + // 4. 报到成功后重新查询最新全景数据并返回 + ReportTerminalPreviewVO result = lookupTerminalReport(enrollment.getStudentId(), enrollment.getPlanId()); + result.setMessage("报到确认成功!欢迎参加培训。"); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MockAttendanceResultVO mockDeviceAttendance(MockAttendanceDTO dto, String operator) + { + // 1. 模拟环境开关保护 (公共配置默认关闭,仅允许在独立测试环境开启) + if (!mockDeviceEnabled) + { + throw new ServiceException("模拟设备打卡接口已禁用(仅允许在测试环境开启)"); + } + + // 2. 服务端鉴权:操作人不能未认证或匿名 + if (operator == null || operator.trim().isEmpty() || "anonymousUser".equalsIgnoreCase(operator)) + { + throw new ServiceException("操作员未通过身份认证,拒绝执行模拟打卡"); + } + + if (dto == null || dto.getStudentId() == null || dto.getStudentId().trim().isEmpty() || dto.getLessonId() == null) + { + throw new ServiceException("模拟打卡参数不完整:studentId 和 lessonId 必填"); + } + + String sid = dto.getStudentId().trim(); + Long lessonId = dto.getLessonId(); + + // 2. 定位课次及所属培训计划 + TrainLesson lesson = trainLessonMapper.selectTrainLessonById(lessonId); + if (lesson == null) + { + throw new ServiceException("指定课次不存在: " + lessonId); + } + + TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(lesson.getPlanCourseId()); + if (planCourse == null) + { + throw new ServiceException("课次关联的计划课程不存在: " + lesson.getPlanCourseId()); + } + String planId = planCourse.getPlanId(); + + // 3. 定位学员参训档案 + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, sid); + if (enrollment == null) + { + throw new ServiceException("学员【" + sid + "】未加入本课次对应培训班【" + planId + "】"); + } + + // 4. 获取辅助展示信息 + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sid); + TrainCourse course = trainCourseMapper.selectTrainCourseByCId(planCourse.getCId()); + + String action = dto.getActionType() != null ? dto.getActionType().trim().toUpperCase() : "SIGN_IN"; + Date checkinTime = dto.getCheckinTime() != null ? dto.getCheckinTime() : new Date(); + String deviceSn = dto.getDeviceSn(); + String validBoardId = null; + if (StringUtils.isNotEmpty(deviceSn)) + { + if (trainElectronicBoardMapper != null) + { + TrainElectronicBoard b = trainElectronicBoardMapper.selectTrainElectronicBoardByBoardId(deviceSn); + if (b != null) + { + validBoardId = b.getBoardId(); + } + } + else if ("BP001".equalsIgnoreCase(deviceSn) || "BP002".equalsIgnoreCase(deviceSn)) + { + validBoardId = deviceSn.toUpperCase(); + } + } + + MockAttendanceResultVO result = new MockAttendanceResultVO(); + result.setStudentId(sid); + result.setStudentName(student != null ? student.getsName() : ""); + result.setLessonId(lessonId); + result.setCourseName(course != null ? course.getcName() : ""); + result.setPlanId(planId); + result.setEnrollmentId(enrollment.getEnrollmentId()); + result.setActionType(action); + result.setBoardId(deviceSn != null ? deviceSn : (validBoardId != null ? validBoardId : "MOCK-BOARD-01")); + + // 5. 严格区分入学报到与课堂打卡:复用现有课次考勤服务 + if ("SIGN_OUT".equals(action)) + { + AttendanceSignOutDTO signOutDTO = new AttendanceSignOutDTO(); + signOutDTO.setEnrollmentId(enrollment.getEnrollmentId()); + signOutDTO.setLessonId(lessonId); + signOutDTO.setSignOutTime(checkinTime); + TrainStudentCheckin checkin = attendanceAppService.signOut(signOutDTO, operator); + + result.setCheckinId(checkin.getCheckinId()); + result.setAttendanceStatus(checkin.getAttendanceStatus()); + result.setSignInTime(checkin.getSignInTime()); + result.setSignOutTime(checkin.getSignOutTime()); + result.setMessage("模拟课堂签退成功"); + return result; + } + else + { + // 签到:attendanceAppService.signIn 内部严格校验 enrollment.status == 1 (已报到), + // 未报到(0)学员执行签到将被拒绝,不得绕过报到生命周期! + AttendanceSignInDTO signInDTO = new AttendanceSignInDTO(); + signInDTO.setEnrollmentId(enrollment.getEnrollmentId()); + signInDTO.setLessonId(lessonId); + signInDTO.setCheckinWay(3); // 3: 扫码 + signInDTO.setBoardId(validBoardId); + signInDTO.setSignInTime(checkinTime); + TrainStudentCheckin checkin = attendanceAppService.signIn(signInDTO, operator); + + result.setCheckinId(checkin.getCheckinId()); + result.setAttendanceStatus(checkin.getAttendanceStatus()); + result.setSignInTime(checkin.getSignInTime()); + result.setSignOutTime(checkin.getSignOutTime()); + result.setMessage("模拟课堂签到成功"); + return result; + } + } + + private String getPlanName(String planId) + { + if (planId == null) return "-"; + TrainClassPlan p = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId); + return p != null ? p.getPlanName() : planId; + } + + private String formatStatusLabel(Integer status) + { + if (status == null) return "未知"; + switch (status) + { + case 0: return "待报到"; + case 1: return "已报到"; + case 2: return "请假"; + case 3: return "放弃"; + case 4: return "已完成"; + default: return String.valueOf(status); + } + } + + private String formatAccommodationStatusLabel(Integer status) + { + if (status == null) return "未分配"; + switch (status) + { + case 0: return "预分配"; + case 1: return "在住"; + case 2: return "已退宿"; + default: return String.valueOf(status); + } + } +} 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 index 77e028dea..ed5856306 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml @@ -78,9 +78,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" e.accommodation_required as accommodationRequired, e.join_time as joinTime, e.complete_time as completeTime, - e.remark as remark + e.remark as remark, + d.dorm_building as dormBuilding, + d.dorm_room as dormRoom, + a.bed_no as bedNo, + a.accommodation_status as accommodationStatus from train_enrollment e left join train_student s on e.student_id = s.s_id + left join train_accommodation a on e.enrollment_id = a.enrollment_id and a.accommodation_status in (0, 1) + left join train_dormitory_info d on a.dorm_id = d.dorm_id and d.del_flag = '0' e.plan_id = #{planId} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java new file mode 100644 index 000000000..04782fd6e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/mapper/TrainEnrollmentMapperContractTest.java @@ -0,0 +1,139 @@ +package com.ruoyi.train.mapper; + +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.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.Configuration; +import org.junit.Test; +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 数据映射契约与合规性保护测试 (TrainEnrollmentMapperContractTest) + * 验证住宿关联只使用真实系统表 train_accommodation 与 train_dormitory_info, + * 严禁引用历史不存在的假表,且 MyBatis 映射可成功解析。 + */ +public class TrainEnrollmentMapperContractTest +{ + @Test + public void enrollmentMapperMustOnlyReferenceRealAccommodationTables() throws Exception + { + String xml = loadMapperXml("TrainEnrollmentMapper.xml"); + + // 1. 验证必须引用的真实系统表 + assertContains(xml, + "train_enrollment", + "train_student", + "train_accommodation", + "train_dormitory_info" + ); + + // 2. 验证有效状态与逻辑删除过滤约束 + assertContains(xml, + "a.accommodation_status in (0, 1)", + "d.del_flag = '0'" + ); + + // 3. 严禁引用不存在的临时假表 + assertOmits(xml, + "train_dorm_assignment", + "train_dorm_room", + "train_dorm_bed", + "train_attendance" + ); + + // 4. 严禁使用非通用 PG 专有方言 DISTINCT ON + assertOmits(xml, "distinct on"); + } + + @Test + public void enrollmentMapperXmlParsesValidMyBatisMappedStatements() throws Exception + { + Configuration configuration = new Configuration(); + // 注册必要类型别名以便 XMLMapperBuilder 解析 + configuration.getTypeAliasRegistry().registerAlias("TrainEnrollment", TrainEnrollment.class); + configuration.getTypeAliasRegistry().registerAlias("TrainClassPlan", TrainClassPlan.class); + configuration.getTypeAliasRegistry().registerAlias("EnrollmentQuery", EnrollmentQuery.class); + configuration.getTypeAliasRegistry().registerAlias("EnrollmentItemVO", EnrollmentItemVO.class); + + try (InputStream inputStream = getClass().getResourceAsStream("/mapper/train/TrainEnrollmentMapper.xml")) + { + assertNotNull("TrainEnrollmentMapper.xml must exist in classpath", inputStream); + XMLMapperBuilder mapperParser = new XMLMapperBuilder( + inputStream, + configuration, + "/mapper/train/TrainEnrollmentMapper.xml", + configuration.getSqlFragments() + ); + mapperParser.parse(); + } + + // 验证 selectEnrollmentItemList 语句成功生成 + String statementId = "com.ruoyi.train.mapper.TrainEnrollmentMapper.selectEnrollmentItemList"; + assertTrue("Statement must be registered in configuration", configuration.hasStatement(statementId)); + MappedStatement ms = configuration.getMappedStatement(statementId); + assertNotNull(ms); + assertEquals(EnrollmentItemVO.class, ms.getResultMaps().get(0).getType()); + + // 验证 EnrollmentItemVO 具备映射所需住宿属性 + assertHasProperty(EnrollmentItemVO.class, "getDormBuilding"); + assertHasProperty(EnrollmentItemVO.class, "getDormRoom"); + assertHasProperty(EnrollmentItemVO.class, "getBedNo"); + assertHasProperty(EnrollmentItemVO.class, "getAccommodationStatus"); + } + + private String loadMapperXml(String name) throws Exception + { + try (InputStream input = getClass().getResourceAsStream("/mapper/train/" + name)) + { + assertNotNull("missing mapper " + name, input); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = input.read(buffer)) != -1) + { + output.write(buffer, 0, length); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8).toLowerCase(); + } + } + + private void assertContains(String content, String... tokens) + { + for (String token : tokens) + { + assertTrue("Expected content to contain: " + token, content.contains(token.toLowerCase())); + } + } + + private void assertOmits(String content, String... tokens) + { + for (String token : tokens) + { + assertFalse("Expected content to omit: " + token, content.contains(token.toLowerCase())); + } + } + + private void assertHasProperty(Class clazz, String getterName) + { + try + { + Method method = clazz.getMethod(getterName); + assertNotNull("Getter must exist: " + getterName, method); + } + catch (NoSuchMethodException e) + { + throw new AssertionError("Class " + clazz.getSimpleName() + " missing method: " + getterName, e); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java new file mode 100644 index 000000000..3c99cde82 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java @@ -0,0 +1,757 @@ +package com.ruoyi.train.service.impl; + +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 static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +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 com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.constant.AttendanceErrorCode; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainCourse; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainPlanCourse; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.dto.MockAttendanceDTO; +import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.MockAttendanceResultVO; +import com.ruoyi.train.domain.vo.PlanTimetableVO; +import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO; +import com.ruoyi.train.domain.vo.TimetableDayVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainCourseMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.mapper.TrainStudentCheckinMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.AttendanceAppService; +import com.ruoyi.train.service.EnrollmentAppService; +import com.ruoyi.train.service.SchedulingAppService; + +/** + * 报到机模拟与提前住宿分配核心业务测试 (MVP v3: CASE-1 ~ CASE-7)。 + * + * @author ruoyi + */ +public class ReportTerminalServiceTest +{ + private ReportTerminalServiceImpl terminalService; + private TrainAccommodationServiceImpl accommodationService; + private EnrollmentAppServiceImpl enrollmentAppService; + private AttendanceAppServiceImpl attendanceAppService; + + // In-memory Stores + private final Map enrollments = new HashMap<>(); + private final Map> enrollmentsByStudent = new HashMap<>(); + private final Map students = new HashMap<>(); + private final Map plans = new HashMap<>(); + private final Map accommodations = new HashMap<>(); + private final Map activeAccByEnrollment = new HashMap<>(); + private final Map dormitories = new HashMap<>(); + private final Map reportsByEnrollment = new HashMap<>(); + private final Map lessons = new HashMap<>(); + private final Map planCourses = new HashMap<>(); + private final Map courses = new HashMap<>(); + private final Map checkins = new HashMap<>(); + + private long accSeq = 1L; + private long reportSeq = 1L; + private long checkinSeq = 1L; + + @Before + public void setUp() throws Exception + { + terminalService = new ReportTerminalServiceImpl(); + accommodationService = new TrainAccommodationServiceImpl(); + enrollmentAppService = new EnrollmentAppServiceImpl(); + attendanceAppService = new AttendanceAppServiceImpl(); + + // Clear stores + enrollments.clear(); + enrollmentsByStudent.clear(); + students.clear(); + plans.clear(); + accommodations.clear(); + activeAccByEnrollment.clear(); + dormitories.clear(); + reportsByEnrollment.clear(); + lessons.clear(); + planCourses.clear(); + courses.clear(); + checkins.clear(); + accSeq = 100L; + reportSeq = 200L; + checkinSeq = 300L; + + // Base Data Setup + TrainStudent s1 = new TrainStudent(); + s1.setsId("S101"); + s1.setsName("张三"); + s1.setsGender("男"); + s1.setOrgName("沈阳机务段"); + s1.setsPhone("13800000001"); + s1.setIdCardLast4("1234"); + s1.setStatus(1); + students.put("S101", s1); + + TrainClassPlan p1 = new TrainClassPlan(); + p1.setPlanId("PLAN-2026-01"); + p1.setPlanName("2026年秋季高铁高级研修班"); + p1.setStartTime(new Date(System.currentTimeMillis() - 86400000L)); + p1.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 14)); + plans.put("PLAN-2026-01", p1); + + // Enrollment 1: 待报到 (status 0) + TrainEnrollment e1 = new TrainEnrollment(); + e1.setEnrollmentId(1001L); + e1.setPlanId("PLAN-2026-01"); + e1.setStudentId("S101"); + e1.setEnrollmentStatus(0); // 待报到 + e1.setAccommodationRequired(1); + enrollments.put(1001L, e1); + + EnrollmentItemVO vo1 = new EnrollmentItemVO(); + vo1.setEnrollmentId(1001L); + vo1.setPlanId("PLAN-2026-01"); + vo1.setStudentId("S101"); + vo1.setStudentName("张三"); + vo1.setGender("男"); + vo1.setOrgName("沈阳机务段"); + vo1.setEnrollmentStatus(0); + vo1.setAccommodationRequired(1); + List s1List = new ArrayList<>(); + s1List.add(vo1); + enrollmentsByStudent.put("S101", s1List); + + // Dormitory + TrainDormitoryInfo d1 = new TrainDormitoryInfo(); + d1.setDormId("DORM-01"); + d1.setDormBuilding("青年公寓1号楼"); + d1.setDormRoom("301"); + d1.setCapacity(4); + d1.setDormStatus(1); + dormitories.put("DORM-01", d1); + + // Course & Lesson + TrainCourse c1 = new TrainCourse(); + c1.setcId("C001"); + c1.setcName("高铁列车运行安全规范"); + courses.put("C001", c1); + + TrainPlanCourse pc1 = new TrainPlanCourse(); + pc1.setPlanCourseId(501L); + pc1.setPlanId("PLAN-2026-01"); + pc1.setCId("C001"); + planCourses.put(501L, pc1); + + TrainLesson l1 = new TrainLesson(); + l1.setLessonId(601L); + l1.setPlanCourseId(501L); + l1.setStartTime(new Date()); + l1.setEndTime(new Date(System.currentTimeMillis() + 7200000L)); + lessons.put(601L, l1); + + // Wire Mappers + setupMappers(); + + // 隔离测试环境显式开启模拟打卡开关 + terminalService.setMockDeviceEnabled(true); + } + + private void setupMappers() throws Exception + { + // 1. TrainEnrollmentMapper + TrainEnrollmentMapper enrollmentMapper = createProxy(TrainEnrollmentMapper.class, (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) { + return enrollments.get((Long) args[0]); + } + if ("selectTrainEnrollmentByPlanIdAndStudentId".equals(name)) { + String planId = (String) args[0]; + String studentId = (String) args[1]; + for (TrainEnrollment e : enrollments.values()) { + if (planId.equals(e.getPlanId()) && studentId.equals(e.getStudentId())) return e; + } + return null; + } + if ("selectEnrollmentsByStudentId".equals(name)) { + return enrollmentsByStudent.get((String) args[0]); + } + if ("confirmEnrollmentReport".equals(name)) { + Long id = (Long) args[0]; + TrainEnrollment e = enrollments.get(id); + if (e != null) { + e.setEnrollmentStatus(1); // 已报到 + e.setJoinTime((Date) args[1]); + } + // Update in student list as well + for (List l : enrollmentsByStudent.values()) { + for (EnrollmentItemVO item : l) { + if (id.equals(item.getEnrollmentId())) { + item.setEnrollmentStatus(1); + item.setJoinTime((Date) args[1]); + } + } + } + return 1; + } + return 0; + }); + + // 2. TrainAccommodationMapper + TrainAccommodationMapper accMapper = createProxy(TrainAccommodationMapper.class, (proxy, method, args) -> { + String name = method.getName(); + if ("selectActiveAccommodationByEnrollmentId".equals(name)) { + return activeAccByEnrollment.get((Long) args[0]); + } + if ("insertTrainAccommodation".equals(name)) { + TrainAccommodation acc = (TrainAccommodation) args[0]; + acc.setAccommodationId(accSeq++); + TrainDormitoryInfo d = dormitories.get(acc.getDormId()); + if (d != null) { + acc.setDormBuilding(d.getDormBuilding()); + acc.setDormRoom(d.getDormRoom()); + } + accommodations.put(acc.getAccommodationId(), acc); + activeAccByEnrollment.put(acc.getEnrollmentId(), acc); + return 1; + } + if ("countActiveByDormId".equals(name)) { + return 0; + } + if ("countUnresolvedActiveBedReservations".equals(name)) { + return 0; + } + if ("countOverlappingActiveBedReservations".equals(name)) { + return 0; + } + return null; + }); + + // 3. TrainDormitoryInfoMapper + TrainDormitoryInfoMapper dormMapper = createProxy(TrainDormitoryInfoMapper.class, (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainDormitoryInfoForUpdate".equals(name) || "selectTrainDormitoryInfoByDormId".equals(name)) { + return dormitories.get((String) args[0]); + } + return null; + }); + + // 4. TrainClassReportMapper + TrainClassReportMapper reportMapper = createProxy(TrainClassReportMapper.class, (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainClassReportByEnrollmentId".equals(name)) { + return reportsByEnrollment.get((Long) args[0]); + } + if ("insertTrainClassReport".equals(name)) { + TrainClassReport r = (TrainClassReport) args[0]; + r.setReportId(reportSeq++); + reportsByEnrollment.put(r.getEnrollmentId(), r); + return 1; + } + return 0; + }); + + // 5. TrainStudentMapper + TrainStudentMapper studentMapper = createProxy(TrainStudentMapper.class, (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) { + return students.get((String) args[0]); + } + return null; + }); + + // 6. TrainClassPlanMapper + TrainClassPlanMapper planMapper = createProxy(TrainClassPlanMapper.class, (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName())) { + return plans.get((String) args[0]); + } + return null; + }); + + // 7. TrainLessonMapper & TrainPlanCourseMapper & TrainCourseMapper + TrainLessonMapper lessonMapper = createProxy(TrainLessonMapper.class, (proxy, method, args) -> { + if ("selectTrainLessonById".equals(method.getName())) { + return lessons.get((Long) args[0]); + } + return null; + }); + + TrainPlanCourseMapper planCourseMapper = createProxy(TrainPlanCourseMapper.class, (proxy, method, args) -> { + if ("selectTrainPlanCourseById".equals(method.getName())) { + return planCourses.get((Long) args[0]); + } + return null; + }); + + TrainCourseMapper courseMapper = createProxy(TrainCourseMapper.class, (proxy, method, args) -> { + if ("selectTrainCourseByCId".equals(method.getName())) { + return courses.get((String) args[0]); + } + return null; + }); + + // 8. TrainStudentCheckinMapper + TrainStudentCheckinMapper checkinMapper = createProxy(TrainStudentCheckinMapper.class, (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainStudentCheckinByEnrollmentAndLesson".equals(name)) { + Long eid = (Long) args[0]; + Long lid = (Long) args[1]; + return checkins.get(eid + "_" + lid); + } + if ("selectTrainStudentCheckinByCheckinId".equals(name)) { + Long cid = (Long) args[0]; + for (TrainStudentCheckin c : checkins.values()) { + if (cid != null && cid.equals(c.getCheckinId())) return c; + } + return null; + } + if ("insertTrainStudentCheckin".equals(name)) { + TrainStudentCheckin c = (TrainStudentCheckin) args[0]; + c.setCheckinId(checkinSeq++); + checkins.put(c.getEnrollmentId() + "_" + c.getLessonId(), c); + return 1; + } + if ("updateTrainStudentCheckin".equals(name)) { + TrainStudentCheckin c = (TrainStudentCheckin) args[0]; + checkins.put(c.getEnrollmentId() + "_" + c.getLessonId(), c); + return 1; + } + if ("signOutIfUnsigned".equals(name)) { + Long cid = (Long) args[0]; + Date outTime = (Date) args[1]; + for (TrainStudentCheckin c : checkins.values()) { + if (cid != null && cid.equals(c.getCheckinId())) { + c.setSignOutTime(outTime); + return 1; + } + } + return 0; + } + if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) { + return 0; + } + return null; + }); + + // Mock SchedulingAppService + SchedulingAppService schedulingService = createProxy(SchedulingAppService.class, (proxy, method, args) -> { + if ("getPlanTimetable".equals(method.getName())) { + PlanTimetableVO vo = new PlanTimetableVO(); + vo.setPlanId((String) args[0]); + vo.setPlanName("2026年秋季高铁高级研修班"); + TimetableDayVO day = new TimetableDayVO(); + day.setDate("2026-10-10"); + day.setDayOfWeek(6); + TimetableLessonItemVO item = new TimetableLessonItemVO(); + item.setLessonId(601L); + item.setCourseName("高铁列车运行安全规范"); + item.setTeacherName("李老师"); + item.setVenueName("301智能教室"); + day.getLessons().add(item); + vo.getDays().add(day); + return vo; + } + return null; + }); + + // Wire fields into EnrollmentAppServiceImpl + setPrivateField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper); + setPrivateField(enrollmentAppService, "studentMapper", studentMapper); + setPrivateField(enrollmentAppService, "planMapper", planMapper); + setPrivateField(enrollmentAppService, "trainClassReportMapper", reportMapper); + + // Wire fields into AttendanceAppServiceImpl + setPrivateField(attendanceAppService, "trainEnrollmentMapper", enrollmentMapper); + setPrivateField(attendanceAppService, "trainLessonMapper", lessonMapper); + setPrivateField(attendanceAppService, "trainPlanCourseMapper", planCourseMapper); + setPrivateField(attendanceAppService, "trainStudentCheckinMapper", checkinMapper); + + // Wire fields into TrainAccommodationServiceImpl + setPrivateField(accommodationService, "trainAccommodationMapper", accMapper); + setPrivateField(accommodationService, "trainDormitoryInfoMapper", dormMapper); + setPrivateField(accommodationService, "trainEnrollmentMapper", enrollmentMapper); + setPrivateField(accommodationService, "trainClassPlanMapper", planMapper); + + // Wire fields into ReportTerminalServiceImpl + setPrivateField(terminalService, "trainEnrollmentMapper", enrollmentMapper); + setPrivateField(terminalService, "trainStudentMapper", studentMapper); + setPrivateField(terminalService, "trainClassPlanMapper", planMapper); + setPrivateField(terminalService, "trainAccommodationMapper", accMapper); + setPrivateField(terminalService, "trainClassReportMapper", reportMapper); + setPrivateField(terminalService, "trainLessonMapper", lessonMapper); + setPrivateField(terminalService, "trainPlanCourseMapper", planCourseMapper); + setPrivateField(terminalService, "trainCourseMapper", courseMapper); + setPrivateField(terminalService, "enrollmentAppService", enrollmentAppService); + setPrivateField(terminalService, "attendanceAppService", attendanceAppService); + setPrivateField(terminalService, "schedulingAppService", schedulingService); + setPrivateField(terminalService, "mockDeviceEnabled", true); + } + + /** + * CASE-1:未报到学员提前分配宿舍,分配结果保存。 + */ + @Test + public void testCASE1_PreAssignAccommodationToUnreportedStudent() + { + // 学员状态为 0 (待报到) + TrainEnrollment e = enrollments.get(1001L); + assertEquals("初始状态必须为待报到 (0)", Integer.valueOf(0), e.getEnrollmentStatus()); + + // 管理员提前分配宿舍 + TrainAccommodation draft = new TrainAccommodation(); + draft.setEnrollmentId(1001L); + draft.setDormId("DORM-01"); + draft.setBedNo("2号床"); + draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); // 0: 预分配 + + int rows = accommodationService.insertTrainAccommodation(draft); + assertEquals(1, rows); + + // 验证已保存为有效预分配 + TrainAccommodation active = activeAccByEnrollment.get(1001L); + assertNotNull("提前分配宿舍必须成功持久化", active); + assertEquals("DORM-01", active.getDormId()); + assertEquals("2号床", active.getBedNo()); + assertEquals("状态应为预分配(0)", TrainAccommodation.STATUS_PREALLOCATED, active.getAccommodationStatus()); + } + + /** + * CASE-2:输入 studentId 模拟扫码,正确获得对应学员与开班计划信息。 + */ + @Test + public void testCASE2_LookupByStudentId() + { + ReportTerminalPreviewVO preview = terminalService.lookupTerminalReport("S101", null); + assertNotNull("查询结果不能为 null", preview); + assertEquals("S101", preview.getStudentId()); + assertEquals("张三", preview.getStudentName()); + assertEquals("沈阳机务段", preview.getOrgName()); + assertEquals("PLAN-2026-01", preview.getPlanId()); + assertEquals(Long.valueOf(1001L), preview.getEnrollmentId()); + assertEquals(Integer.valueOf(0), preview.getEnrollmentStatus()); + assertEquals("待报到", preview.getEnrollmentStatusLabel()); + assertFalse("初始报到状态应为 false", preview.getIsReported()); + } + + /** + * CASE-3:报到机预览中正确显示已分配宿舍及真实课程表。 + */ + @Test + public void testCASE3_LookupShowsAssignedDormAndTimetable() + { + // 提前分配宿舍 + testCASE1_PreAssignAccommodationToUnreportedStudent(); + + ReportTerminalPreviewVO preview = terminalService.lookupTerminalReport("S101", "PLAN-2026-01"); + assertTrue("必须显示已分配宿舍", preview.getHasAccommodation()); + assertEquals("青年公寓1号楼", preview.getDormBuilding()); + assertEquals("301", preview.getDormRoom()); + assertEquals("2号床", preview.getBedNo()); + assertEquals("预分配", preview.getAccommodationStatusLabel()); + + // 验证课程表 + assertNotNull("课程表不能为 null", preview.getTimetable()); + assertEquals("PLAN-2026-01", preview.getTimetable().getPlanId()); + assertEquals(1, preview.getTimetable().getDays().size()); + assertEquals("高铁列车运行安全规范", preview.getTimetable().getDays().get(0).getLessons().get(0).getCourseName()); + } + + /** + * CASE-4:点击确认,报到状态更新;再次点击不会产生重复报到。 + */ + @Test + public void testCASE4_ConfirmReportAndDuplicateProtection() + { + // 提前分配宿舍 + testCASE1_PreAssignAccommodationToUnreportedStudent(); + + ReportTerminalConfirmDTO dto = new ReportTerminalConfirmDTO(); + dto.setEnrollmentId(1001L); + dto.setStudentId("S101"); + dto.setPlanId("PLAN-2026-01"); + + // 首次确认报到 + ReportTerminalPreviewVO firstConfirm = terminalService.confirmTerminalReport(dto, "admin"); + assertNotNull(firstConfirm); + assertTrue("确认后报到状态必须为 true", firstConfirm.getIsReported()); + assertEquals("参训状态必须变更为已报到(1)", Integer.valueOf(1), firstConfirm.getEnrollmentStatus()); + assertEquals(1, reportsByEnrollment.size()); + + // 再次点击确认报到 (幂等保护,绝不产生第 2 条非法报到事实) + ReportTerminalPreviewVO secondConfirm = terminalService.confirmTerminalReport(dto, "admin"); + assertNotNull(secondConfirm); + assertTrue("再次确认报到状态仍为 true", secondConfirm.getIsReported()); + assertEquals("报到记录依然仅保留 1 条", 1, reportsByEnrollment.size()); + assertTrue("提示信息应提示无需重复确认", secondConfirm.getMessage().contains("已完成报到")); + } + + /** + * CASE-5:确认后重新查询,返回正确报到状态、宿舍与课表。 + */ + @Test + public void testCASE5_RequeryReturnsUpdatedStatusDormAndTimetable() + { + // 完成报到 + testCASE4_ConfirmReportAndDuplicateProtection(); + + // 模拟报到机重新扫码查询 + ReportTerminalPreviewVO requery = terminalService.lookupTerminalReport("S101", null); + assertNotNull(requery); + assertTrue("重新查询报到状态应为 true", requery.getIsReported()); + assertNotNull("应返回报到时间", requery.getReportTime()); + assertEquals("已报到", requery.getEnrollmentStatusLabel()); + + // 宿舍安排完整保留 + assertTrue(requery.getHasAccommodation()); + assertEquals("青年公寓1号楼", requery.getDormBuilding()); + assertEquals("301", requery.getDormRoom()); + assertEquals("2号床", requery.getBedNo()); + + // 课程表完整保留 + assertNotNull(requery.getTimetable()); + assertEquals(1, requery.getTimetable().getDays().size()); + } + + /** + * CASE-6:模拟课堂打卡能正确定位学员参训记录与课次,在测试库中通过现有业务接口处理。 + */ + @Test + public void testCASE6_MockClassAttendanceSignInAndSignOut() + { + // 先完成入学报到 + testCASE4_ConfirmReportAndDuplicateProtection(); + + // 模拟课堂签到 + MockAttendanceDTO inDto = new MockAttendanceDTO(); + inDto.setStudentId("S101"); + inDto.setLessonId(601L); + inDto.setActionType("SIGN_IN"); + inDto.setDeviceSn("MOCK-BOARD-01"); + + MockAttendanceResultVO inResult = terminalService.mockDeviceAttendance(inDto, "device_scanner"); + assertNotNull(inResult); + assertEquals("S101", inResult.getStudentId()); + assertEquals("张三", inResult.getStudentName()); + assertEquals(Long.valueOf(601L), inResult.getLessonId()); + assertEquals(Long.valueOf(1001L), inResult.getEnrollmentId()); + assertEquals("PLAN-2026-01", inResult.getPlanId()); + assertEquals("高铁列车运行安全规范", inResult.getCourseName()); + assertEquals("SIGN_IN", inResult.getActionType()); + assertEquals("MOCK-BOARD-01", inResult.getBoardId()); + assertEquals(Integer.valueOf(1), inResult.getAttendanceStatus()); + assertNotNull("应有签到时间", inResult.getSignInTime()); + + // 模拟课堂签退 + MockAttendanceDTO outDto = new MockAttendanceDTO(); + outDto.setStudentId("S101"); + outDto.setLessonId(601L); + outDto.setActionType("SIGN_OUT"); + outDto.setDeviceSn("MOCK-BOARD-01"); + + MockAttendanceResultVO outResult = terminalService.mockDeviceAttendance(outDto, "device_scanner"); + assertNotNull(outResult); + assertEquals("SIGN_OUT", outResult.getActionType()); + assertNotNull("应有签退时间", outResult.getSignOutTime()); + } + + /** + * CASE-7:验证打卡不改变入学报到状态,报到也不自动完成课堂签到;未报到学员打卡被拒绝。 + */ + @Test + public void testCASE7_StrictBoundaryBetweenReportAndClassAttendance() + { + // 1. 验证未报到(0)学员打卡将被系统拒绝 (不得用考勤签到绕过报到生命周期) + MockAttendanceDTO unReportedCheckin = new MockAttendanceDTO(); + unReportedCheckin.setStudentId("S101"); + unReportedCheckin.setLessonId(601L); + unReportedCheckin.setActionType("SIGN_IN"); + + try + { + terminalService.mockDeviceAttendance(unReportedCheckin, "device_scanner"); + fail("未报到学员执行课堂打卡必须被拒绝"); + } + catch (ServiceException se) + { + assertEquals("必须返回 40040 拒绝码", + AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRES_REPORTED_ENROLLMENT, + se.getCode().intValue()); + assertTrue(se.getMessage().contains("未完成报到") || se.getMessage().contains("报到")); + } + + // 2. 报到不自动完成课堂签到 + ReportTerminalConfirmDTO reportDto = new ReportTerminalConfirmDTO(); + reportDto.setEnrollmentId(1001L); + reportDto.setStudentId("S101"); + reportDto.setPlanId("PLAN-2026-01"); + terminalService.confirmTerminalReport(reportDto, "admin"); + + // 报到完成,检查签到表:绝不能产生虚假签到记录 + assertEquals("入学报到绝不能自动产生课堂签到记录", 0, checkins.size()); + + // 3. 执行课堂签到,入学报到状态保持不变 + terminalService.mockDeviceAttendance(unReportedCheckin, "device_scanner"); + assertEquals("产生了 1 条课堂签到记录", 1, checkins.size()); + TrainEnrollment currentE = enrollments.get(1001L); + assertEquals("参训状态依然为已报到(1),未被课堂签到篡改", Integer.valueOf(1), currentE.getEnrollmentStatus()); + assertEquals("报到记录依然仅为最初的 1 条", 1, reportsByEnrollment.size()); + } + + /** + * 安全测试 1:当 Mock 开关关闭时(默认关闭状态),模拟打卡接口必须被明确拒绝。 + */ + @Test + public void testSecurityMockDisabledByDefaultThrowsException() + { + // 显式关闭 Mock 开关(模拟公共配置/默认生产环境状态) + terminalService.setMockDeviceEnabled(false); + assertFalse("Mock开关应处于关闭状态", terminalService.isMockDeviceEnabled()); + + MockAttendanceDTO dto = new MockAttendanceDTO(); + dto.setStudentId("S101"); + dto.setLessonId(601L); + dto.setActionType("SIGN_IN"); + + try + { + terminalService.mockDeviceAttendance(dto, "admin"); + fail("Mock 开关关闭时,调用模拟设备打卡接口必须抛出异常"); + } + catch (ServiceException se) + { + assertTrue("应包含接口已禁用提示", se.getMessage().contains("模拟设备打卡接口已禁用")); + } + } + + /** + * 安全测试 2:模拟打卡接口要求服务端操作员认证,匿名或未认证调用必须被拒绝。 + */ + @Test + public void testSecurityUnauthenticatedOperatorRejectedOnMockAttendance() + { + terminalService.setMockDeviceEnabled(true); + MockAttendanceDTO dto = new MockAttendanceDTO(); + dto.setStudentId("S101"); + dto.setLessonId(601L); + dto.setActionType("SIGN_IN"); + + // 匿名操作员测试 + try + { + terminalService.mockDeviceAttendance(dto, "anonymousUser"); + fail("匿名操作员执行打卡必须被拒绝"); + } + catch (ServiceException se) + { + assertTrue(se.getMessage().contains("未通过身份认证")); + } + + // 空操作员测试 + try + { + terminalService.mockDeviceAttendance(dto, null); + fail("未携带操作员凭证必须被拒绝"); + } + catch (ServiceException se) + { + assertTrue(se.getMessage().contains("未通过身份认证")); + } + } + + /** + * 安全测试 3:报到机确认接口要求服务端操作员认证,匿名或空操作员必须被拒绝。 + */ + @Test + public void testSecurityUnauthenticatedOperatorRejectedOnConfirmReport() + { + ReportTerminalConfirmDTO dto = new ReportTerminalConfirmDTO(); + dto.setEnrollmentId(1001L); + dto.setStudentId("S101"); + dto.setPlanId("PLAN-2026-01"); + + try + { + terminalService.confirmTerminalReport(dto, null); + fail("未携带操作员凭据必须被拒绝"); + } + catch (ServiceException se) + { + assertTrue(se.getMessage().contains("未通过身份认证")); + } + + try + { + terminalService.confirmTerminalReport(dto, "anonymousUser"); + fail("匿名操作员必须被拒绝"); + } + catch (ServiceException se) + { + assertTrue(se.getMessage().contains("未通过身份认证")); + } + } + + /** + * 安全测试 4:studentId 仅作为业务检索实体标识,绝不能当作认证凭据。 + * 当外部未提供有效操作员凭据、仅在 DTO 中附带 studentId 时,拒绝操作。 + */ + @Test + public void testSecurityStudentIdIsNotAuthenticationCredential() + { + // 尝试以携带 studentId 作为自认证方式确认报到 + ReportTerminalConfirmDTO dto = new ReportTerminalConfirmDTO(); + dto.setStudentId("S101"); + dto.setPlanId("PLAN-2026-01"); + + try + { + // 操作员未经过服务端鉴权 + terminalService.confirmTerminalReport(dto, ""); + fail("仅有 studentId 而无有效操作员认证时必须拒绝确认报到"); + } + catch (ServiceException se) + { + assertTrue(se.getMessage().contains("未通过身份认证")); + } + + // 携带合法已认证操作员后,正常完成业务流转 + ReportTerminalPreviewVO ok = terminalService.confirmTerminalReport(dto, "terminal_operator_admin"); + assertNotNull(ok); + assertTrue(ok.getIsReported()); + } + + @SuppressWarnings("unchecked") + private static T createProxy(Class clazz, InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz}, handler); + } + + private static void setPrivateField(Object target, String fieldName, Object value) throws Exception + { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql b/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql index 8864e4821..bc3b7a21e 100644 --- a/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql +++ b/RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql @@ -90,27 +90,27 @@ SELECT 105, '教学资源类型', 'train_resource_type', '0', 'admin', CURRENT_T WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_type WHERE dict_type = 'train_resource_type'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 310, 1, '文档', '1', 'train_resource_type', '', 'primary', 'Y', '0', 'admin', CURRENT_TIMESTAMP, 'PDF/Word/PPT等电子课件' +SELECT 710, 1, '文档', '1', 'train_resource_type', '', 'primary', 'Y', '0', 'admin', CURRENT_TIMESTAMP, 'PDF/Word/PPT等电子课件' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '1'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 311, 2, '视频', '2', 'train_resource_type', '', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, 'MP4录播视频' +SELECT 711, 2, '视频', '2', 'train_resource_type', '', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, 'MP4录播视频' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '2'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 312, 3, '3D模型', '3', 'train_resource_type', '', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '3D数字化实物机车/工务模型' +SELECT 712, 3, '3D模型', '3', 'train_resource_type', '', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '3D数字化实物机车/工务模型' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '3'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 313, 4, 'VR资源', '4', 'train_resource_type', '', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '虚拟仿真头盔或交互式实训课件' +SELECT 713, 4, 'VR资源', '4', 'train_resource_type', '', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, '虚拟仿真头盔或交互式实训课件' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '4'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 314, 5, '外部教学链接', '5', 'train_resource_type', '', 'danger', 'N', '0', 'admin', CURRENT_TIMESTAMP, '第三方外部实训/考试网址URL' +SELECT 714, 5, '外部教学链接', '5', 'train_resource_type', '', 'danger', 'N', '0', 'admin', CURRENT_TIMESTAMP, '第三方外部实训/考试网址URL' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '5'); INSERT INTO public.sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 315, 6, '其他', '6', 'train_resource_type', '', 'default', 'N', '0', 'admin', CURRENT_TIMESTAMP, '其他类型教学资料' +SELECT 715, 6, '其他', '6', 'train_resource_type', '', 'default', 'N', '0', 'admin', CURRENT_TIMESTAMP, '其他类型教学资料' WHERE NOT EXISTS (SELECT 1 FROM public.sys_dict_data WHERE dict_type = 'train_resource_type' AND dict_value = '6'); -- ============================================================================= diff --git a/build_ui/src/api/train/terminal.js b/build_ui/src/api/train/terminal.js new file mode 100644 index 000000000..2aec622f9 --- /dev/null +++ b/build_ui/src/api/train/terminal.js @@ -0,0 +1,37 @@ +import request from '@/utils/request' + +// 报到机扫码查询学员报到信息 +export function lookupTerminal(params) { + return request({ + url: '/train/report-terminal/lookup', + method: 'get', + params: params + }) +} + +// 报到机一键确认报到 +export function confirmTerminal(data) { + return request({ + url: '/train/report-terminal/confirm', + method: 'post', + data: data + }) +} + +// 模拟设备课堂打卡 (签到/签退) +export function mockAttendance(data) { + return request({ + url: '/train/report-terminal/mock-attendance', + method: 'post', + data: data + }) +} + +// 模拟刷卡(通过考勤模块端点) +export function mockSwipe(data) { + return request({ + url: '/train/attendance/mock-swipe', + method: 'post', + data: data + }) +} diff --git a/build_ui/src/router/index.js b/build_ui/src/router/index.js index 308b61aab..1ae6762f3 100644 --- a/build_ui/src/router/index.js +++ b/build_ui/src/router/index.js @@ -107,7 +107,7 @@ export const dynamicRoutes = [ component: Layout, hidden: true, redirect: '/train/auth', - permissions: ['train:access:query', 'system:auth:list', 'train:accommodation:list'], + permissions: ['train:access:query', 'system:auth:list', 'train:accommodation:list', 'train:report:confirm'], children: [ { path: 'auth', @@ -122,6 +122,13 @@ export const dynamicRoutes = [ name: 'TrainAccommodation', meta: { title: '住宿管理', icon: 'home' }, permissions: ['train:accommodation:list'] + }, + { + path: 'terminal', + component: () => import('@/views/train/terminal/index'), + name: 'TrainTerminal', + meta: { title: '模拟报到机', icon: 'scan' }, + permissions: ['train:report:confirm'] } ] }, diff --git a/build_ui/src/views/train/checkin/index.vue b/build_ui/src/views/train/checkin/index.vue index cc38fb69e..f8440f81b 100644 --- a/build_ui/src/views/train/checkin/index.vue +++ b/build_ui/src/views/train/checkin/index.vue @@ -389,15 +389,20 @@ export default { }).finally(() => { this.loading = false }) }, loadPlans() { + const queryPlanId = this.$route.query.planId + const queryLessonId = this.$route.query.lessonId listPlan({ pageNum: 1, pageSize: 100 }).then(response => { this.planOptions = response.rows || [] - if (this.planOptions.length > 0 && !this.rosterQuery.planId) { + if (queryPlanId && this.planOptions.some(p => p.planId === queryPlanId)) { + this.rosterQuery.planId = queryPlanId + this.handlePlanChange(queryPlanId, queryLessonId) + } else if (this.planOptions.length > 0 && !this.rosterQuery.planId) { this.rosterQuery.planId = this.planOptions[0].planId this.handlePlanChange(this.rosterQuery.planId) } }).catch(() => {}) }, - handlePlanChange(planId) { + handlePlanChange(planId, targetLessonId) { this.rosterQuery.lessonId = null this.rosterList = [] this.lessonOptions = [] @@ -425,7 +430,9 @@ export default { } this.lessonOptions = list if (list.length > 0) { - this.rosterQuery.lessonId = list[0].lessonId + const parsedTarget = targetLessonId ? Number(targetLessonId) : null + const matched = parsedTarget ? list.find(item => item.lessonId === parsedTarget) : null + this.rosterQuery.lessonId = matched ? matched.lessonId : list[0].lessonId this.loadRoster() } }).finally(() => { diff --git a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue index 3cc470cc1..f9687b055 100644 --- a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue +++ b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue @@ -82,6 +82,31 @@ + + + - +