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..cd7f16ccf 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: true
+
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/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..40f08b7d3
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ReportTerminalServiceImpl.java
@@ -0,0 +1,435 @@
+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:true}")
+ private boolean 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("确认报到请求参数不能为空");
+ }
+
+ // 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("模拟设备打卡接口已禁用(仅允许在测试环境开启)");
+ }
+
+ 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/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..d1632d891
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportTerminalServiceTest.java
@@ -0,0 +1,632 @@
+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();
+ }
+
+ 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());
+ }
+
+ @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/terminal/index.vue b/build_ui/src/views/train/terminal/index.vue
new file mode 100644
index 000000000..b84a78915
--- /dev/null
+++ b/build_ui/src/views/train/terminal/index.vue
@@ -0,0 +1,468 @@
+
+
+
+
+
+
+
+
+
+
+
+ 模拟扫码 / 学号查询
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询报到信息
+
+ 重置
+
+
+
+
+
+
+
+
+
+ 【已报到】学员已完成现场报到(报到时间:{{ parseTime(previewData.reportTime) || '已确认' }})
+
+
+
+
+ 【待报到】学员尚未报到,请核对学员基础信息、宿舍安排与课程表后点击下方“确认报到”
+
+
+
+
+
+
+
+
+ 学员信息与培训班级
+
+
+ {{ previewData.studentName || '-' }}
+ {{ previewData.studentId }}
+ {{ formatGender(previewData.gender) }}
+ {{ previewData.phone || '-' }}
+ {{ previewData.orgName || '-' }}
+
+ {{ previewData.planName || previewData.planId }}
+ ({{ previewData.planId }})
+
+
+
+ {{ previewData.enrollmentStatusLabel }}
+
+
+
+
+ {{ previewData.isReported ? '已报到' : '未报到' }}
+
+
+
+
+
+
+
+
+
+
+ 提前住宿分配安排
+
+
+
+ {{ previewData.dormBuilding || '-' }}
+ {{ previewData.dormRoom || '-' }}
+
+ {{ previewData.bedNo || '-' }}
+
+
+
+ {{ previewData.accommodationStatusLabel }}
+
+
+
+
+ 管理员已提前分配宿舍与床位,报到后可直接前往入住。
+
+
+
+
+ 如需住宿,请管理员在【开班计划】参训人员名单中进行住宿安排
+
+
+
+
+
+
+
+
+
+ 本期培训课程表安排
+
+
+
+
+
+
+
+
+ {{ formatTimeRange(scope.row.startTime, scope.row.endTime) }}
+
+
+
+
+
+
+
+ {{ scope.row.venueType === 2 ? '实训场地' : '教室' }}
+
+ {{ scope.row.venueName || scope.row.classRoomName || scope.row.siteName || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 学员已完成报到
+
+
+ 请确认学员信息无误后办理报到
+
+
+
+ {{ previewData.isReported ? '已完成报到' : '一键确认报到' }}
+
+
+
+ 刷新最新状态
+
+
+
+
+
+
+
+
+
+
+
+ 模拟设备课堂考勤刷卡
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 课堂签到 (Sign In)
+ 课堂签退 (Sign Out)
+
+
+
+
+
+
+
+
+ 提交模拟打卡
+ 重置表单
+
+
+
+
+
+ 打卡响应结果
+
+
+ {{ mockResult.checkinId }}
+ {{ mockResult.studentName || mockResult.studentId }}
+ {{ mockResult.planId }}
+ {{ mockResult.courseName || '-' }}
+
+ {{ mockResult.actionType === 'SIGN_IN' ? '签到' : '签退' }}
+
+
+ {{ formatAttendanceStatus(mockResult.attendanceStatus) }}
+
+ {{ parseTime(mockResult.signInTime) || '-' }}
+ {{ parseTime(mockResult.signOutTime) || '-' }}
+
+
+
+
+
+
+
+
+
+
+