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 @@
+
+
+
+ {{ scope.row.dormBuilding || '' }} {{ scope.row.dormRoom || '' }} ({{ scope.row.bedNo || '-' }})
+
+ {{ scope.row.accommodationStatus === 1 ? '在住' : (scope.row.accommodationStatus === 0 ? '预分配' : '已退宿') }}
+
+
+
+ 未分配
+ 去分配
+
+ 无需住宿
+
+
diff --git a/build_ui/src/views/train/plan/components/TimetablePanel.vue b/build_ui/src/views/train/plan/components/TimetablePanel.vue
index 34467955a..e5d472de3 100644
--- a/build_ui/src/views/train/plan/components/TimetablePanel.vue
+++ b/build_ui/src/views/train/plan/components/TimetablePanel.vue
@@ -11,6 +11,7 @@
+ 学校周课表
按日日程
课次明细
@@ -30,6 +31,119 @@
+
+
+
+ 考勤
-
+
+ 考勤
取 消
+
+
+
+
+
+
+ {{ attendanceDialog.lesson.courseName || '-' }}
+ {{ attendanceDialog.lesson.teacherName || '-' }}
+ {{ attendanceDialog.lesson.venueName || attendanceDialog.lesson.classRoomName || attendanceDialog.lesson.siteName || '-' }}
+
+ {{ attendanceDialog.lesson.startTime }} ~ {{ attendanceDialog.lesson.endTime }}
+
+ {{ attendanceDialog.lesson.boardSn || '无' }}
+
+
+
+
+ 应到学员考勤名册
+ 共 {{ attendanceDialog.rosterList.length }} 人
+
+
+ 刷新名册
+ 前往考勤中心
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatEnrollmentStatusTag(scope.row.enrollmentStatus).text }}
+
+
+
+
+
+
+ {{ formatAttendanceStatusTag(scope.row.attendanceStatus).text }}
+
+
+
+
+ {{ scope.row.signInTime || '-' }}
+
+
+ {{ scope.row.signOutTime || '-' }}
+
+
+
+ 签到
+ 缺勤
+ 签退
+ 调整
+
+
+
+
+
+
+
@@ -360,6 +594,13 @@ import {
getSchedulingOptions
} from '@/api/train/timetable'
import { listPlanCourses } from '@/api/train/planCourse'
+import {
+ getLessonRoster,
+ signInAttendance,
+ signOutAttendance,
+ adjustAttendance,
+ markAbsenceAttendance
+} from '@/api/train/attendance'
export default {
name: 'TimetablePanel',
@@ -376,7 +617,13 @@ export default {
data() {
return {
loading: false,
- viewMode: 'timeline', // 'timeline' 或 'table'
+ viewMode: 'week', // 默认优先展示学校周课表视图,亦可切换 'timeline' 或 'table'
+ currentWeekAnchor: new Date(), // 当前周所锚定的日期对象
+ periodList: [
+ { key: 'morning', label: '上午', timeRange: '08:00 - 12:00' },
+ { key: 'afternoon', label: '下午', timeRange: '14:00 - 18:00' },
+ { key: 'evening', label: '晚上', timeRange: '18:30 - 21:30' }
+ ],
timetableData: {
planId: null,
planName: '',
@@ -418,7 +665,14 @@ export default {
},
// 冲突检测状态与结果
conflictChecking: false,
- conflictResult: null
+ conflictResult: null,
+ // 课次考勤名册对话框
+ attendanceDialog: {
+ open: false,
+ loading: false,
+ lesson: {},
+ rosterList: []
+ }
}
},
computed: {
@@ -434,6 +688,47 @@ export default {
}
})
return list
+ },
+ // 当前锚定周的周一到周日 7 天日期清单
+ currentWeekDays() {
+ const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
+ // JS中 getDay() 0为周日,1为周一...6为周六
+ const day = anchor.getDay()
+ const diffToMonday = day === 0 ? -6 : 1 - day
+ const monday = new Date(anchor)
+ monday.setDate(anchor.getDate() + diffToMonday)
+
+ const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ const days = []
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(monday)
+ d.setDate(monday.getDate() + i)
+ const year = d.getFullYear()
+ const month = String(d.getMonth() + 1).padStart(2, '0')
+ const date = String(d.getDate()).padStart(2, '0')
+ const dateStr = `${year}-${month}-${date}`
+ days.push({
+ dateStr,
+ displayDate: `${month}-${date}`,
+ label: labels[i],
+ dayOfWeek: i + 1
+ })
+ }
+ return days
+ },
+ currentWeekDisplayTitle() {
+ if (!this.currentWeekDays || this.currentWeekDays.length === 0) return ''
+ const first = this.currentWeekDays[0].dateStr
+ const last = this.currentWeekDays[6].dateStr
+ return `${first} 至 ${last}`
+ },
+ currentWeekLessonCount() {
+ if (!this.currentWeekDays || !this.allLessons) return 0
+ const dates = new Set(this.currentWeekDays.map(d => d.dateStr))
+ return this.allLessons.filter(l => {
+ const d = (l.startTime || '').substring(0, 10)
+ return dates.has(d)
+ }).length
}
},
watch: {
@@ -699,6 +994,190 @@ export default {
this.$modal.msgSuccess('删除成功')
this.loadTimetable()
}).catch(() => {})
+ },
+ // ---- 学校周课表方法 (UC-11) ----
+ handlePrevWeek() {
+ const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
+ anchor.setDate(anchor.getDate() - 7)
+ this.currentWeekAnchor = anchor
+ },
+ handleNextWeek() {
+ const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
+ anchor.setDate(anchor.getDate() + 7)
+ this.currentWeekAnchor = anchor
+ },
+ handleCurrentOrFirstWeek() {
+ if (this.timetableData.startDate) {
+ const parsed = new Date(this.timetableData.startDate.replace(/-/g, '/'))
+ if (!isNaN(parsed.getTime())) {
+ this.currentWeekAnchor = parsed
+ return
+ }
+ }
+ this.currentWeekAnchor = new Date()
+ },
+ isToday(dateStr) {
+ if (!dateStr) return false
+ const now = new Date()
+ const y = now.getFullYear()
+ const m = String(now.getMonth() + 1).padStart(2, '0')
+ const d = String(now.getDate()).padStart(2, '0')
+ return dateStr === `${y}-${m}-${d}`
+ },
+ getLessonsInCell(dateStr, periodKey) {
+ if (!this.allLessons || !dateStr) return []
+ return this.allLessons.filter(lesson => {
+ const lessonDate = (lesson.startTime || '').substring(0, 10)
+ if (lessonDate !== dateStr) return false
+ const timePart = (lesson.startTime || '').split(' ')[1] || ''
+ const hour = parseInt(timePart.split(':')[0], 10)
+ if (isNaN(hour)) return false
+ if (periodKey === 'morning') return hour < 13
+ if (periodKey === 'afternoon') return hour >= 13 && hour < 18
+ if (periodKey === 'evening') return hour >= 18
+ return false
+ })
+ },
+ handleQuickAddLesson(dateStr, periodKey) {
+ this.dialog.isEdit = false
+ this.dialog.title = `新增课次排课 (${dateStr})`
+ this.resetLessonForm()
+ let startH = '09:00:00'
+ let endH = '11:30:00'
+ if (periodKey === 'afternoon') {
+ startH = '14:00:00'
+ endH = '16:30:00'
+ } else if (periodKey === 'evening') {
+ startH = '19:00:00'
+ endH = '21:00:00'
+ }
+ this.lessonForm.startTime = `${dateStr} ${startH}`
+ this.lessonForm.endTime = `${dateStr} ${endH}`
+ this.dialog.open = true
+ this.conflictResult = null
+ },
+ // 支持从“本期课程”外部直接调用排课并预选课程
+ openAddLessonForCourse(planCourseId) {
+ this.dialog.isEdit = false
+ this.dialog.title = '新增课次排课'
+ this.resetLessonForm()
+ this.lessonForm.planCourseId = planCourseId
+ this.dialog.open = true
+ this.conflictResult = null
+ },
+ // ---- 课次学员考勤名册与签到/缺勤 (第四优先级) ----
+ handleOpenAttendance(lesson) {
+ this.attendanceDialog.lesson = lesson || {}
+ this.attendanceDialog.open = true
+ this.refreshAttendanceRoster()
+ },
+ refreshAttendanceRoster() {
+ if (!this.attendanceDialog.lesson || !this.attendanceDialog.lesson.lessonId) return
+ this.attendanceDialog.loading = true
+ getLessonRoster(this.attendanceDialog.lesson.lessonId)
+ .then(res => {
+ this.attendanceDialog.rosterList = res.data || []
+ })
+ .finally(() => {
+ this.attendanceDialog.loading = false
+ })
+ },
+ goToFullCheckinPage() {
+ this.attendanceDialog.open = false
+ this.$router.push({
+ path: '/train/checkin',
+ query: {
+ planId: this.planId,
+ lessonId: this.attendanceDialog.lesson.lessonId
+ }
+ })
+ },
+ formatEnrollmentStatusTag(status) {
+ const map = {
+ 0: { text: '待报到', type: 'info' },
+ 1: { text: '已报到', type: 'success' },
+ 2: { text: '请假', type: 'warning' },
+ 3: { text: '放弃', type: 'danger' },
+ 4: { text: '已完成', type: 'primary' }
+ }
+ return map[status] || { text: '未知', type: 'info' }
+ },
+ formatAttendanceStatusTag(status) {
+ const map = {
+ 0: { text: '缺勤', type: 'danger' },
+ 1: { text: '正常', type: 'success' },
+ 2: { text: '迟到', type: 'warning' },
+ 3: { text: '早退', type: 'warning' },
+ 4: { text: '请假', type: 'info' }
+ }
+ if (status == null) {
+ return { text: '未签到', type: 'info' }
+ }
+ return map[status] || { text: '已记录', type: 'info' }
+ },
+ canRosterSignIn(row) {
+ return row.enrollmentStatus === 1 && (row.attendanceStatus == null || row.attendanceStatus === 0)
+ },
+ canRosterMarkAbsence(row) {
+ return row.attendanceStatus !== 0
+ },
+ canRosterSignOut(row) {
+ return (row.attendanceStatus === 1 || row.attendanceStatus === 2) && !row.signOutTime
+ },
+ canRosterAdjust(row) {
+ return row.checkinId != null
+ },
+ handleRosterSignIn(row) {
+ signInAttendance({
+ lessonId: this.attendanceDialog.lesson.lessonId,
+ enrollmentId: row.enrollmentId,
+ checkinWay: 4
+ }).then(() => {
+ this.$modal.msgSuccess(`学员【${row.studentName}】人工签到成功`)
+ this.refreshAttendanceRoster()
+ })
+ },
+ handleRosterSignOut(row) {
+ signOutAttendance({
+ lessonId: this.attendanceDialog.lesson.lessonId,
+ enrollmentId: row.enrollmentId
+ }).then(() => {
+ this.$modal.msgSuccess(`学员【${row.studentName}】签退成功`)
+ this.refreshAttendanceRoster()
+ })
+ },
+ handleRosterMarkAbsence(row) {
+ this.$prompt(`请输入学员【${row.studentName}】的缺勤原因:`, '课次缺勤登记', {
+ confirmButtonText: '确定登记',
+ cancelButtonText: '取消',
+ inputPattern: /\S+/,
+ inputErrorMessage: '缺勤原因不能为空'
+ }).then(({ value }) => {
+ return markAbsenceAttendance({
+ lessonId: this.attendanceDialog.lesson.lessonId,
+ enrollmentId: row.enrollmentId,
+ remark: value
+ })
+ }).then(() => {
+ this.$modal.msgSuccess(`已登记学员【${row.studentName}】缺勤`)
+ this.refreshAttendanceRoster()
+ }).catch(() => {})
+ },
+ handleRosterAdjust(row) {
+ this.$prompt(`请输入调整后的考勤状态(1:正常 2:迟到 3:早退 4:请假 0:缺勤):`, '调整考勤状态', {
+ confirmButtonText: '确定',
+ cancelButtonText: '取消',
+ inputPattern: /^[0-4]$/,
+ inputErrorMessage: '请输入0-4之间的数字状态码'
+ }).then(({ value }) => {
+ return adjustAttendance(row.checkinId, {
+ attendanceStatus: parseInt(value, 10),
+ remark: '管理员人工调整'
+ })
+ }).then(() => {
+ this.$modal.msgSuccess('考勤调整成功')
+ this.refreshAttendanceRoster()
+ }).catch(() => {})
}
}
}
@@ -711,6 +1190,159 @@ export default {
.empty-tip {
padding: 40px 0;
}
+.school-timetable-view {
+ margin-top: 5px;
+}
+.week-nav-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ background: #fdfdfd;
+ padding: 8px 12px;
+ border: 1px solid #ebeef5;
+ border-radius: 4px;
+}
+.week-title-display {
+ font-weight: bold;
+ font-size: 14px;
+ color: #303133;
+}
+.school-grid-container {
+ overflow-x: auto;
+ border: 1px solid #ebeef5;
+ border-radius: 6px;
+ background: #fff;
+}
+.school-grid-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+.school-grid-table th,
+.school-grid-table td {
+ border: 1px solid #ebeef5;
+ padding: 6px;
+ vertical-align: top;
+}
+.time-col-header {
+ width: 100px;
+ background-color: #fafafa;
+ text-align: center;
+ font-size: 13px;
+ color: #606266;
+ font-weight: 600;
+}
+.day-col-header {
+ text-align: center;
+ background-color: #fafafa;
+ color: #303133;
+ padding: 8px 4px;
+}
+.day-col-header.is-today {
+ background-color: #ecf5ff;
+ border-top: 3px solid #409eff;
+}
+.header-day-label {
+ font-weight: bold;
+ font-size: 14px;
+ margin-bottom: 2px;
+}
+.header-date-label {
+ font-size: 12px;
+ color: #909399;
+}
+.period-title-cell {
+ background-color: #fafafa;
+ text-align: center;
+ vertical-align: middle !important;
+}
+.period-name {
+ font-weight: bold;
+ font-size: 14px;
+ color: #303133;
+ margin-bottom: 4px;
+}
+.period-time-range {
+ font-size: 11px;
+ color: #909399;
+}
+.timetable-cell {
+ min-height: 120px;
+ height: 135px;
+ position: relative;
+ background: #fff;
+}
+.school-lesson-card {
+ background: linear-gradient(135deg, #f0f7ff 0%, #e6f1fc 100%);
+ border: 1px solid #b3d8ff;
+ border-left: 4px solid #409eff;
+ border-radius: 4px;
+ padding: 6px 8px;
+ margin-bottom: 6px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ transition: all 0.2s;
+}
+.school-lesson-card:hover {
+ box-shadow: 0 2px 8px rgba(64, 158, 255, 0.25);
+ transform: translateY(-1px);
+}
+.slc-time-tag {
+ font-size: 11px;
+ color: #409eff;
+ font-weight: 600;
+ margin-bottom: 3px;
+}
+.slc-course-title {
+ font-weight: bold;
+ font-size: 13px;
+ color: #1f2f3d;
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.slc-meta {
+ font-size: 11px;
+ color: #606266;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.meta-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.slc-card-actions {
+ margin-top: 5px;
+ padding-top: 3px;
+ border-top: 1px dashed #dcdfe6;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.empty-cell-placeholder {
+ display: none;
+ height: 100%;
+ min-height: 80px;
+ align-items: center;
+ justify-content: center;
+ color: #c0c4cc;
+ font-size: 12px;
+ cursor: pointer;
+ border-radius: 4px;
+ border: 1px dashed #dcdfe6;
+ margin: 4px;
+}
+.timetable-cell:hover .empty-cell-placeholder {
+ display: flex;
+ background: #f9f9f9;
+ color: #409eff;
+}
.day-cards {
display: flex;
flex-direction: column;
@@ -734,7 +1366,7 @@ export default {
align-items: center;
padding: 10px 14px;
background-color: #f8f9fa;
- border-left: 4px solid #409EFF;
+ border-left: 4px solid #409eff;
border-radius: 4px;
}
.lesson-time {
diff --git a/build_ui/src/views/train/plan/index.vue b/build_ui/src/views/train/plan/index.vue
index 97ed716db..f6f501fae 100644
--- a/build_ui/src/views/train/plan/index.vue
+++ b/build_ui/src/views/train/plan/index.vue
@@ -77,7 +77,7 @@
>{{ scope.row.enrolledCount || 0 }}
-
+
课程配置
+ 考勤
-
+
+ 去排课
@@ -625,6 +641,22 @@ export default {
this.courseDialog.planName = row.planName
this.courseDialog.planStatus = row.planStatus
this.loadPlanCourses(row.planId)
+ },
+ // 跳转考勤管理中心并预选指定培训班
+ handleGoCheckin(row) {
+ this.$router.push({
+ path: '/train/checkin',
+ query: { planId: row.planId }
+ })
+ },
+ // 从本期课程列表中快捷前往课程表Tab并直接打开排课对话框
+ handleGoScheduleCourse(courseRow) {
+ this.planDetailDialog.activeTab = 'timetable'
+ this.$nextTick(() => {
+ if (this.$refs.timetablePanel && this.$refs.timetablePanel.openAddLessonForCourse) {
+ this.$refs.timetablePanel.openAddLessonForCourse(courseRow.planCourseId)
+ }
+ })
}
}
}
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) || '-' }}
+
+
+
+
+
+
+
+
+
+
+