新增报到机:实现终端扫码报到核验与受控课堂打卡模拟

This commit is contained in:
wangzhibiao committed 2026-10-09 20:04:52 +08:00
1 parent b1d5a999ca
commit cecf11790e
16 files changed
+2571 -7

No files matched your search

@@ -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()));
}
}
@@ -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)。
* <p>
* 仅供内部授权测试与模拟环境使用,严格要求登录鉴权 (@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);
}
}
@@ -141,3 +141,11 @@ xss:
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# 培训业务配置
train:
mock:
device:
# 模拟设备打卡接口开关 (仅在测试/沙箱环境开启)
enabled: true
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<CandidatePlanVO> 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<CandidatePlanVO> getCandidatePlans()
{
return candidatePlans;
}
public void setCandidatePlans(List<CandidatePlanVO> 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;
}
}
@@ -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 适配层)。
* <p>
* 遵循开闭原则,复用核心 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);
}
@@ -0,0 +1,435 @@
package com.ruoyi.train.service.impl;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.domain.dto.MockAttendanceDTO;
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO;
import com.ruoyi.train.domain.vo.CandidatePlanVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.MockAttendanceResultVO;
import com.ruoyi.train.domain.vo.PlanTimetableVO;
import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.domain.TrainElectronicBoard;
import com.ruoyi.train.mapper.TrainElectronicBoardMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.service.AttendanceAppService;
import com.ruoyi.train.service.EnrollmentAppService;
import com.ruoyi.train.service.ReportTerminalService;
import com.ruoyi.train.service.SchedulingAppService;
/**
* 报到机终端与设备打卡适配服务实现 (P0/P1 MVP v3 适配层)。
*
* @author ruoyi
*/
@Service
public class ReportTerminalServiceImpl implements ReportTerminalService
{
private static final Logger log = LoggerFactory.getLogger(ReportTerminalServiceImpl.class);
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
@Autowired
private TrainStudentMapper trainStudentMapper;
@Autowired
private TrainClassPlanMapper trainClassPlanMapper;
@Autowired
private TrainAccommodationMapper trainAccommodationMapper;
@Autowired
private TrainClassReportMapper trainClassReportMapper;
@Autowired
private TrainLessonMapper trainLessonMapper;
@Autowired
private TrainPlanCourseMapper trainPlanCourseMapper;
@Autowired
private TrainCourseMapper trainCourseMapper;
@Autowired
private EnrollmentAppService enrollmentAppService;
@Autowired
private AttendanceAppService attendanceAppService;
@Autowired
private SchedulingAppService schedulingAppService;
@Autowired(required = false)
private TrainElectronicBoardMapper trainElectronicBoardMapper;
@Value("${train.mock.device.enabled:true}")
private boolean mockDeviceEnabled;
@Override
public ReportTerminalPreviewVO lookupTerminalReport(String studentId, String planId)
{
if (studentId == null || studentId.trim().isEmpty())
{
throw new ServiceException("学员学号 (studentId) 不能为空");
}
String sid = studentId.trim();
// 1. 查询学员档案
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sid);
if (student == null)
{
throw new ServiceException("未找到学号为【" + sid + "】的学员档案");
}
// 2. 查询学员所有参训名单记录
List<EnrollmentItemVO> enrollments = trainEnrollmentMapper.selectEnrollmentsByStudentId(sid);
if (enrollments == null || enrollments.isEmpty())
{
throw new ServiceException("学员【" + student.getsName() + "】暂未加入任何开班计划");
}
// 3. 构建候选计划列表
List<CandidatePlanVO> candidatePlans = new ArrayList<>();
for (EnrollmentItemVO e : enrollments)
{
String pName = getPlanName(e.getPlanId());
candidatePlans.add(new CandidatePlanVO(
e.getPlanId(),
pName,
e.getEnrollmentId(),
e.getEnrollmentStatus(),
formatStatusLabel(e.getEnrollmentStatus())
));
}
// 4. 定位目标参训档案
EnrollmentItemVO target = null;
if (planId != null && !planId.trim().isEmpty())
{
String pid = planId.trim();
for (EnrollmentItemVO e : enrollments)
{
if (pid.equals(e.getPlanId()))
{
target = e;
break;
}
}
if (target == null)
{
throw new ServiceException("学员【" + student.getsName() + "】未加入指定的培训计划【" + pid + "】");
}
}
else
{
// 若未指定 planId,优先选待报到(0)的有效计划;若无则选第一个
for (EnrollmentItemVO e : enrollments)
{
if (e.getEnrollmentStatus() != null && e.getEnrollmentStatus() == 0)
{
target = e;
break;
}
}
if (target == null)
{
target = enrollments.get(0);
}
}
// 5. 组装报到机全景预览 VO
ReportTerminalPreviewVO vo = new ReportTerminalPreviewVO();
vo.setStudentId(student.getsId());
vo.setStudentName(student.getsName());
vo.setGender(student.getsGender());
vo.setOrgName(student.getOrgName());
vo.setPhone(student.getsPhone());
vo.setIdCardMasked(student.getIdCardLast4() != null ? student.getIdCardLast4() : "-");
vo.setEnrollmentId(target.getEnrollmentId());
vo.setPlanId(target.getPlanId());
vo.setPlanName(getPlanName(target.getPlanId()));
vo.setEnrollmentStatus(target.getEnrollmentStatus());
vo.setEnrollmentStatusLabel(formatStatusLabel(target.getEnrollmentStatus()));
vo.setCandidatePlans(candidatePlans);
vo.setHasMultiplePlans(candidatePlans.size() > 1);
// 5.1 报到状态事实查询
TrainClassReport report = trainClassReportMapper.selectTrainClassReportByEnrollmentId(target.getEnrollmentId());
boolean isReported = (target.getEnrollmentStatus() != null && target.getEnrollmentStatus() == 1) || report != null;
vo.setIsReported(isReported);
vo.setReportTime(report != null ? report.getReportTime() : target.getJoinTime());
// 5.2 宿舍提前分配情况查询 (实事求是:分配则显示,未分配如实显示未分配)
TrainAccommodation acc = trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(target.getEnrollmentId());
if (acc != null)
{
vo.setHasAccommodation(true);
vo.setDormBuilding(acc.getDormBuilding());
vo.setDormRoom(acc.getDormRoom());
vo.setBedNo(acc.getBedNo());
vo.setAccommodationStatus(acc.getAccommodationStatus());
vo.setAccommodationStatusLabel(formatAccommodationStatusLabel(acc.getAccommodationStatus()));
}
else
{
vo.setHasAccommodation(false);
vo.setAccommodationStatusLabel("未分配");
}
// 5.3 对应开班计划课程表查询
try
{
PlanTimetableVO timetable = schedulingAppService.getPlanTimetable(target.getPlanId());
vo.setTimetable(timetable);
}
catch (Exception ex)
{
log.warn("Lookup timetable for plan {} failed: {}", target.getPlanId(), ex.getMessage());
}
if (vo.getHasMultiplePlans())
{
vo.setMessage("查询成功。该学员关联多个开班计划,当前展示【" + vo.getPlanName() + "】,可切换计划报到");
}
else
{
vo.setMessage("学员报到信息查询成功");
}
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public ReportTerminalPreviewVO confirmTerminalReport(ReportTerminalConfirmDTO dto, String operator)
{
if (dto == null)
{
throw new ServiceException("确认报到请求参数不能为空");
}
// 1. 定位 enrollmentId
Long enrollmentId = dto.getEnrollmentId();
if (enrollmentId == null)
{
if (dto.getStudentId() != null && dto.getPlanId() != null)
{
TrainEnrollment en = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(
dto.getPlanId().trim(), dto.getStudentId().trim());
if (en == null)
{
throw new ServiceException("未找到参训档案:学员【" + dto.getStudentId() + "】未加入计划【" + dto.getPlanId() + "】");
}
enrollmentId = en.getEnrollmentId();
}
else
{
throw new ServiceException("报到确认参数不完整:必须提供 enrollmentId 或 studentId + planId");
}
}
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId);
if (enrollment == null)
{
throw new ServiceException("指定参训档案不存在: " + enrollmentId);
}
// 2. 幂等防重:检查是否此前已确认报到 (保护现有报到事实,重复确认不生成第二条报到记录)
TrainClassReport existingReport = trainClassReportMapper.selectTrainClassReportByEnrollmentId(enrollmentId);
if (existingReport != null || (enrollment.getEnrollmentStatus() != null && enrollment.getEnrollmentStatus() == 1))
{
ReportTerminalPreviewVO preview = lookupTerminalReport(enrollment.getStudentId(), enrollment.getPlanId());
preview.setMessage("学员此前已完成报到,无需重复确认 (报到时间: "
+ (existingReport != null ? existingReport.getReportTime() : enrollment.getJoinTime()) + ")");
return preview;
}
// 3. 构造报到确认参数并复用原有 confirmReport 业务服务 (执行行锁、截止策略校验与门禁联动)
ReportConfirmDTO confirmDto = new ReportConfirmDTO();
confirmDto.setReportWay(3); // 3: 二维码/扫码核验
confirmDto.setReportDeviceSn(dto.getReportDeviceSn() != null ? dto.getReportDeviceSn() : "TERMINAL-MOCK-01");
confirmDto.setVerifyNote(dto.getVerifyNote() != null ? dto.getVerifyNote() : "报到机自助扫码确认");
confirmDto.setForceOverride(dto.getForceOverride() != null && dto.getForceOverride());
enrollmentAppService.confirmReport(enrollmentId, confirmDto, operator);
// 4. 报到成功后重新查询最新全景数据并返回
ReportTerminalPreviewVO result = lookupTerminalReport(enrollment.getStudentId(), enrollment.getPlanId());
result.setMessage("报到确认成功!欢迎参加培训。");
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public MockAttendanceResultVO mockDeviceAttendance(MockAttendanceDTO dto, String operator)
{
// 1. 模拟环境开关保护
if (!mockDeviceEnabled)
{
throw new ServiceException("模拟设备打卡接口已禁用(仅允许在测试环境开启)");
}
if (dto == null || dto.getStudentId() == null || dto.getStudentId().trim().isEmpty() || dto.getLessonId() == null)
{
throw new ServiceException("模拟打卡参数不完整:studentId 和 lessonId 必填");
}
String sid = dto.getStudentId().trim();
Long lessonId = dto.getLessonId();
// 2. 定位课次及所属培训计划
TrainLesson lesson = trainLessonMapper.selectTrainLessonById(lessonId);
if (lesson == null)
{
throw new ServiceException("指定课次不存在: " + lessonId);
}
TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(lesson.getPlanCourseId());
if (planCourse == null)
{
throw new ServiceException("课次关联的计划课程不存在: " + lesson.getPlanCourseId());
}
String planId = planCourse.getPlanId();
// 3. 定位学员参训档案
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, sid);
if (enrollment == null)
{
throw new ServiceException("学员【" + sid + "】未加入本课次对应培训班【" + planId + "】");
}
// 4. 获取辅助展示信息
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(sid);
TrainCourse course = trainCourseMapper.selectTrainCourseByCId(planCourse.getCId());
String action = dto.getActionType() != null ? dto.getActionType().trim().toUpperCase() : "SIGN_IN";
Date checkinTime = dto.getCheckinTime() != null ? dto.getCheckinTime() : new Date();
String deviceSn = dto.getDeviceSn();
String validBoardId = null;
if (StringUtils.isNotEmpty(deviceSn))
{
if (trainElectronicBoardMapper != null)
{
TrainElectronicBoard b = trainElectronicBoardMapper.selectTrainElectronicBoardByBoardId(deviceSn);
if (b != null)
{
validBoardId = b.getBoardId();
}
}
else if ("BP001".equalsIgnoreCase(deviceSn) || "BP002".equalsIgnoreCase(deviceSn))
{
validBoardId = deviceSn.toUpperCase();
}
}
MockAttendanceResultVO result = new MockAttendanceResultVO();
result.setStudentId(sid);
result.setStudentName(student != null ? student.getsName() : "");
result.setLessonId(lessonId);
result.setCourseName(course != null ? course.getcName() : "");
result.setPlanId(planId);
result.setEnrollmentId(enrollment.getEnrollmentId());
result.setActionType(action);
result.setBoardId(deviceSn != null ? deviceSn : (validBoardId != null ? validBoardId : "MOCK-BOARD-01"));
// 5. 严格区分入学报到与课堂打卡:复用现有课次考勤服务
if ("SIGN_OUT".equals(action))
{
AttendanceSignOutDTO signOutDTO = new AttendanceSignOutDTO();
signOutDTO.setEnrollmentId(enrollment.getEnrollmentId());
signOutDTO.setLessonId(lessonId);
signOutDTO.setSignOutTime(checkinTime);
TrainStudentCheckin checkin = attendanceAppService.signOut(signOutDTO, operator);
result.setCheckinId(checkin.getCheckinId());
result.setAttendanceStatus(checkin.getAttendanceStatus());
result.setSignInTime(checkin.getSignInTime());
result.setSignOutTime(checkin.getSignOutTime());
result.setMessage("模拟课堂签退成功");
return result;
}
else
{
// 签到:attendanceAppService.signIn 内部严格校验 enrollment.status == 1 (已报到),
// 未报到(0)学员执行签到将被拒绝,不得绕过报到生命周期!
AttendanceSignInDTO signInDTO = new AttendanceSignInDTO();
signInDTO.setEnrollmentId(enrollment.getEnrollmentId());
signInDTO.setLessonId(lessonId);
signInDTO.setCheckinWay(3); // 3: 扫码
signInDTO.setBoardId(validBoardId);
signInDTO.setSignInTime(checkinTime);
TrainStudentCheckin checkin = attendanceAppService.signIn(signInDTO, operator);
result.setCheckinId(checkin.getCheckinId());
result.setAttendanceStatus(checkin.getAttendanceStatus());
result.setSignInTime(checkin.getSignInTime());
result.setSignOutTime(checkin.getSignOutTime());
result.setMessage("模拟课堂签到成功");
return result;
}
}
private String getPlanName(String planId)
{
if (planId == null) return "-";
TrainClassPlan p = trainClassPlanMapper.selectTrainClassPlanByPlanId(planId);
return p != null ? p.getPlanName() : planId;
}
private String formatStatusLabel(Integer status)
{
if (status == null) return "未知";
switch (status)
{
case 0: return "待报到";
case 1: return "已报到";
case 2: return "请假";
case 3: return "放弃";
case 4: return "已完成";
default: return String.valueOf(status);
}
}
private String formatAccommodationStatusLabel(Integer status)
{
if (status == null) return "未分配";
switch (status)
{
case 0: return "预分配";
case 1: return "在住";
case 2: return "已退宿";
default: return String.valueOf(status);
}
}
}
@@ -0,0 +1,632 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.constant.AttendanceErrorCode;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainCourse;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.MockAttendanceDTO;
import com.ruoyi.train.domain.dto.ReportTerminalConfirmDTO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.MockAttendanceResultVO;
import com.ruoyi.train.domain.vo.PlanTimetableVO;
import com.ruoyi.train.domain.vo.ReportTerminalPreviewVO;
import com.ruoyi.train.domain.vo.TimetableDayVO;
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainCourseMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.service.AttendanceAppService;
import com.ruoyi.train.service.EnrollmentAppService;
import com.ruoyi.train.service.SchedulingAppService;
/**
* 报到机模拟与提前住宿分配核心业务测试 (MVP v3: CASE-1 ~ CASE-7)。
*
* @author ruoyi
*/
public class ReportTerminalServiceTest
{
private ReportTerminalServiceImpl terminalService;
private TrainAccommodationServiceImpl accommodationService;
private EnrollmentAppServiceImpl enrollmentAppService;
private AttendanceAppServiceImpl attendanceAppService;
// In-memory Stores
private final Map<Long, TrainEnrollment> enrollments = new HashMap<>();
private final Map<String, List<EnrollmentItemVO>> enrollmentsByStudent = new HashMap<>();
private final Map<String, TrainStudent> students = new HashMap<>();
private final Map<String, TrainClassPlan> plans = new HashMap<>();
private final Map<Long, TrainAccommodation> accommodations = new HashMap<>();
private final Map<Long, TrainAccommodation> activeAccByEnrollment = new HashMap<>();
private final Map<String, TrainDormitoryInfo> dormitories = new HashMap<>();
private final Map<Long, TrainClassReport> reportsByEnrollment = new HashMap<>();
private final Map<Long, TrainLesson> lessons = new HashMap<>();
private final Map<Long, TrainPlanCourse> planCourses = new HashMap<>();
private final Map<String, TrainCourse> courses = new HashMap<>();
private final Map<String, TrainStudentCheckin> 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<EnrollmentItemVO> s1List = new ArrayList<>();
s1List.add(vo1);
enrollmentsByStudent.put("S101", s1List);
// Dormitory
TrainDormitoryInfo d1 = new TrainDormitoryInfo();
d1.setDormId("DORM-01");
d1.setDormBuilding("青年公寓1号楼");
d1.setDormRoom("301");
d1.setCapacity(4);
d1.setDormStatus(1);
dormitories.put("DORM-01", d1);
// Course & Lesson
TrainCourse c1 = new TrainCourse();
c1.setcId("C001");
c1.setcName("高铁列车运行安全规范");
courses.put("C001", c1);
TrainPlanCourse pc1 = new TrainPlanCourse();
pc1.setPlanCourseId(501L);
pc1.setPlanId("PLAN-2026-01");
pc1.setCId("C001");
planCourses.put(501L, pc1);
TrainLesson l1 = new TrainLesson();
l1.setLessonId(601L);
l1.setPlanCourseId(501L);
l1.setStartTime(new Date());
l1.setEndTime(new Date(System.currentTimeMillis() + 7200000L));
lessons.put(601L, l1);
// Wire Mappers
setupMappers();
}
private void setupMappers() throws Exception
{
// 1. TrainEnrollmentMapper
TrainEnrollmentMapper enrollmentMapper = createProxy(TrainEnrollmentMapper.class, (proxy, method, args) -> {
String name = method.getName();
if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) {
return enrollments.get((Long) args[0]);
}
if ("selectTrainEnrollmentByPlanIdAndStudentId".equals(name)) {
String planId = (String) args[0];
String studentId = (String) args[1];
for (TrainEnrollment e : enrollments.values()) {
if (planId.equals(e.getPlanId()) && studentId.equals(e.getStudentId())) return e;
}
return null;
}
if ("selectEnrollmentsByStudentId".equals(name)) {
return enrollmentsByStudent.get((String) args[0]);
}
if ("confirmEnrollmentReport".equals(name)) {
Long id = (Long) args[0];
TrainEnrollment e = enrollments.get(id);
if (e != null) {
e.setEnrollmentStatus(1); // 已报到
e.setJoinTime((Date) args[1]);
}
// Update in student list as well
for (List<EnrollmentItemVO> l : enrollmentsByStudent.values()) {
for (EnrollmentItemVO item : l) {
if (id.equals(item.getEnrollmentId())) {
item.setEnrollmentStatus(1);
item.setJoinTime((Date) args[1]);
}
}
}
return 1;
}
return 0;
});
// 2. TrainAccommodationMapper
TrainAccommodationMapper accMapper = createProxy(TrainAccommodationMapper.class, (proxy, method, args) -> {
String name = method.getName();
if ("selectActiveAccommodationByEnrollmentId".equals(name)) {
return activeAccByEnrollment.get((Long) args[0]);
}
if ("insertTrainAccommodation".equals(name)) {
TrainAccommodation acc = (TrainAccommodation) args[0];
acc.setAccommodationId(accSeq++);
TrainDormitoryInfo d = dormitories.get(acc.getDormId());
if (d != null) {
acc.setDormBuilding(d.getDormBuilding());
acc.setDormRoom(d.getDormRoom());
}
accommodations.put(acc.getAccommodationId(), acc);
activeAccByEnrollment.put(acc.getEnrollmentId(), acc);
return 1;
}
if ("countActiveByDormId".equals(name)) {
return 0;
}
if ("countUnresolvedActiveBedReservations".equals(name)) {
return 0;
}
if ("countOverlappingActiveBedReservations".equals(name)) {
return 0;
}
return null;
});
// 3. TrainDormitoryInfoMapper
TrainDormitoryInfoMapper dormMapper = createProxy(TrainDormitoryInfoMapper.class, (proxy, method, args) -> {
String name = method.getName();
if ("selectTrainDormitoryInfoForUpdate".equals(name) || "selectTrainDormitoryInfoByDormId".equals(name)) {
return dormitories.get((String) args[0]);
}
return null;
});
// 4. TrainClassReportMapper
TrainClassReportMapper reportMapper = createProxy(TrainClassReportMapper.class, (proxy, method, args) -> {
String name = method.getName();
if ("selectTrainClassReportByEnrollmentId".equals(name)) {
return reportsByEnrollment.get((Long) args[0]);
}
if ("insertTrainClassReport".equals(name)) {
TrainClassReport r = (TrainClassReport) args[0];
r.setReportId(reportSeq++);
reportsByEnrollment.put(r.getEnrollmentId(), r);
return 1;
}
return 0;
});
// 5. TrainStudentMapper
TrainStudentMapper studentMapper = createProxy(TrainStudentMapper.class, (proxy, method, args) -> {
if ("selectTrainStudentBySId".equals(method.getName())) {
return students.get((String) args[0]);
}
return null;
});
// 6. TrainClassPlanMapper
TrainClassPlanMapper planMapper = createProxy(TrainClassPlanMapper.class, (proxy, method, args) -> {
if ("selectTrainClassPlanByPlanId".equals(method.getName())) {
return plans.get((String) args[0]);
}
return null;
});
// 7. TrainLessonMapper & TrainPlanCourseMapper & TrainCourseMapper
TrainLessonMapper lessonMapper = createProxy(TrainLessonMapper.class, (proxy, method, args) -> {
if ("selectTrainLessonById".equals(method.getName())) {
return lessons.get((Long) args[0]);
}
return null;
});
TrainPlanCourseMapper planCourseMapper = createProxy(TrainPlanCourseMapper.class, (proxy, method, args) -> {
if ("selectTrainPlanCourseById".equals(method.getName())) {
return planCourses.get((Long) args[0]);
}
return null;
});
TrainCourseMapper courseMapper = createProxy(TrainCourseMapper.class, (proxy, method, args) -> {
if ("selectTrainCourseByCId".equals(method.getName())) {
return courses.get((String) args[0]);
}
return null;
});
// 8. TrainStudentCheckinMapper
TrainStudentCheckinMapper checkinMapper = createProxy(TrainStudentCheckinMapper.class, (proxy, method, args) -> {
String name = method.getName();
if ("selectTrainStudentCheckinByEnrollmentAndLesson".equals(name)) {
Long eid = (Long) args[0];
Long lid = (Long) args[1];
return checkins.get(eid + "_" + lid);
}
if ("selectTrainStudentCheckinByCheckinId".equals(name)) {
Long cid = (Long) args[0];
for (TrainStudentCheckin c : checkins.values()) {
if (cid != null && cid.equals(c.getCheckinId())) return c;
}
return null;
}
if ("insertTrainStudentCheckin".equals(name)) {
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
c.setCheckinId(checkinSeq++);
checkins.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
return 1;
}
if ("updateTrainStudentCheckin".equals(name)) {
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
checkins.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
return 1;
}
if ("signOutIfUnsigned".equals(name)) {
Long cid = (Long) args[0];
Date outTime = (Date) args[1];
for (TrainStudentCheckin c : checkins.values()) {
if (cid != null && cid.equals(c.getCheckinId())) {
c.setSignOutTime(outTime);
return 1;
}
}
return 0;
}
if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) {
return 0;
}
return null;
});
// Mock SchedulingAppService
SchedulingAppService schedulingService = createProxy(SchedulingAppService.class, (proxy, method, args) -> {
if ("getPlanTimetable".equals(method.getName())) {
PlanTimetableVO vo = new PlanTimetableVO();
vo.setPlanId((String) args[0]);
vo.setPlanName("2026年秋季高铁高级研修班");
TimetableDayVO day = new TimetableDayVO();
day.setDate("2026-10-10");
day.setDayOfWeek(6);
TimetableLessonItemVO item = new TimetableLessonItemVO();
item.setLessonId(601L);
item.setCourseName("高铁列车运行安全规范");
item.setTeacherName("李老师");
item.setVenueName("301智能教室");
day.getLessons().add(item);
vo.getDays().add(day);
return vo;
}
return null;
});
// Wire fields into EnrollmentAppServiceImpl
setPrivateField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper);
setPrivateField(enrollmentAppService, "studentMapper", studentMapper);
setPrivateField(enrollmentAppService, "planMapper", planMapper);
setPrivateField(enrollmentAppService, "trainClassReportMapper", reportMapper);
// Wire fields into AttendanceAppServiceImpl
setPrivateField(attendanceAppService, "trainEnrollmentMapper", enrollmentMapper);
setPrivateField(attendanceAppService, "trainLessonMapper", lessonMapper);
setPrivateField(attendanceAppService, "trainPlanCourseMapper", planCourseMapper);
setPrivateField(attendanceAppService, "trainStudentCheckinMapper", checkinMapper);
// Wire fields into TrainAccommodationServiceImpl
setPrivateField(accommodationService, "trainAccommodationMapper", accMapper);
setPrivateField(accommodationService, "trainDormitoryInfoMapper", dormMapper);
setPrivateField(accommodationService, "trainEnrollmentMapper", enrollmentMapper);
setPrivateField(accommodationService, "trainClassPlanMapper", planMapper);
// Wire fields into ReportTerminalServiceImpl
setPrivateField(terminalService, "trainEnrollmentMapper", enrollmentMapper);
setPrivateField(terminalService, "trainStudentMapper", studentMapper);
setPrivateField(terminalService, "trainClassPlanMapper", planMapper);
setPrivateField(terminalService, "trainAccommodationMapper", accMapper);
setPrivateField(terminalService, "trainClassReportMapper", reportMapper);
setPrivateField(terminalService, "trainLessonMapper", lessonMapper);
setPrivateField(terminalService, "trainPlanCourseMapper", planCourseMapper);
setPrivateField(terminalService, "trainCourseMapper", courseMapper);
setPrivateField(terminalService, "enrollmentAppService", enrollmentAppService);
setPrivateField(terminalService, "attendanceAppService", attendanceAppService);
setPrivateField(terminalService, "schedulingAppService", schedulingService);
setPrivateField(terminalService, "mockDeviceEnabled", true);
}
/**
* CASE-1:未报到学员提前分配宿舍,分配结果保存。
*/
@Test
public void testCASE1_PreAssignAccommodationToUnreportedStudent()
{
// 学员状态为 0 (待报到)
TrainEnrollment e = enrollments.get(1001L);
assertEquals("初始状态必须为待报到 (0)", Integer.valueOf(0), e.getEnrollmentStatus());
// 管理员提前分配宿舍
TrainAccommodation draft = new TrainAccommodation();
draft.setEnrollmentId(1001L);
draft.setDormId("DORM-01");
draft.setBedNo("2号床");
draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); // 0: 预分配
int rows = accommodationService.insertTrainAccommodation(draft);
assertEquals(1, rows);
// 验证已保存为有效预分配
TrainAccommodation active = activeAccByEnrollment.get(1001L);
assertNotNull("提前分配宿舍必须成功持久化", active);
assertEquals("DORM-01", active.getDormId());
assertEquals("2号床", active.getBedNo());
assertEquals("状态应为预分配(0)", TrainAccommodation.STATUS_PREALLOCATED, active.getAccommodationStatus());
}
/**
* CASE-2:输入 studentId 模拟扫码,正确获得对应学员与开班计划信息。
*/
@Test
public void testCASE2_LookupByStudentId()
{
ReportTerminalPreviewVO preview = terminalService.lookupTerminalReport("S101", null);
assertNotNull("查询结果不能为 null", preview);
assertEquals("S101", preview.getStudentId());
assertEquals("张三", preview.getStudentName());
assertEquals("沈阳机务段", preview.getOrgName());
assertEquals("PLAN-2026-01", preview.getPlanId());
assertEquals(Long.valueOf(1001L), preview.getEnrollmentId());
assertEquals(Integer.valueOf(0), preview.getEnrollmentStatus());
assertEquals("待报到", preview.getEnrollmentStatusLabel());
assertFalse("初始报到状态应为 false", preview.getIsReported());
}
/**
* CASE-3:报到机预览中正确显示已分配宿舍及真实课程表。
*/
@Test
public void testCASE3_LookupShowsAssignedDormAndTimetable()
{
// 提前分配宿舍
testCASE1_PreAssignAccommodationToUnreportedStudent();
ReportTerminalPreviewVO preview = terminalService.lookupTerminalReport("S101", "PLAN-2026-01");
assertTrue("必须显示已分配宿舍", preview.getHasAccommodation());
assertEquals("青年公寓1号楼", preview.getDormBuilding());
assertEquals("301", preview.getDormRoom());
assertEquals("2号床", preview.getBedNo());
assertEquals("预分配", preview.getAccommodationStatusLabel());
// 验证课程表
assertNotNull("课程表不能为 null", preview.getTimetable());
assertEquals("PLAN-2026-01", preview.getTimetable().getPlanId());
assertEquals(1, preview.getTimetable().getDays().size());
assertEquals("高铁列车运行安全规范", preview.getTimetable().getDays().get(0).getLessons().get(0).getCourseName());
}
/**
* CASE-4:点击确认,报到状态更新;再次点击不会产生重复报到。
*/
@Test
public void testCASE4_ConfirmReportAndDuplicateProtection()
{
// 提前分配宿舍
testCASE1_PreAssignAccommodationToUnreportedStudent();
ReportTerminalConfirmDTO dto = new ReportTerminalConfirmDTO();
dto.setEnrollmentId(1001L);
dto.setStudentId("S101");
dto.setPlanId("PLAN-2026-01");
// 首次确认报到
ReportTerminalPreviewVO firstConfirm = terminalService.confirmTerminalReport(dto, "admin");
assertNotNull(firstConfirm);
assertTrue("确认后报到状态必须为 true", firstConfirm.getIsReported());
assertEquals("参训状态必须变更为已报到(1)", Integer.valueOf(1), firstConfirm.getEnrollmentStatus());
assertEquals(1, reportsByEnrollment.size());
// 再次点击确认报到 (幂等保护,绝不产生第 2 条非法报到事实)
ReportTerminalPreviewVO secondConfirm = terminalService.confirmTerminalReport(dto, "admin");
assertNotNull(secondConfirm);
assertTrue("再次确认报到状态仍为 true", secondConfirm.getIsReported());
assertEquals("报到记录依然仅保留 1 条", 1, reportsByEnrollment.size());
assertTrue("提示信息应提示无需重复确认", secondConfirm.getMessage().contains("已完成报到"));
}
/**
* CASE-5:确认后重新查询,返回正确报到状态、宿舍与课表。
*/
@Test
public void testCASE5_RequeryReturnsUpdatedStatusDormAndTimetable()
{
// 完成报到
testCASE4_ConfirmReportAndDuplicateProtection();
// 模拟报到机重新扫码查询
ReportTerminalPreviewVO requery = terminalService.lookupTerminalReport("S101", null);
assertNotNull(requery);
assertTrue("重新查询报到状态应为 true", requery.getIsReported());
assertNotNull("应返回报到时间", requery.getReportTime());
assertEquals("已报到", requery.getEnrollmentStatusLabel());
// 宿舍安排完整保留
assertTrue(requery.getHasAccommodation());
assertEquals("青年公寓1号楼", requery.getDormBuilding());
assertEquals("301", requery.getDormRoom());
assertEquals("2号床", requery.getBedNo());
// 课程表完整保留
assertNotNull(requery.getTimetable());
assertEquals(1, requery.getTimetable().getDays().size());
}
/**
* CASE-6:模拟课堂打卡能正确定位学员参训记录与课次,在测试库中通过现有业务接口处理。
*/
@Test
public void testCASE6_MockClassAttendanceSignInAndSignOut()
{
// 先完成入学报到
testCASE4_ConfirmReportAndDuplicateProtection();
// 模拟课堂签到
MockAttendanceDTO inDto = new MockAttendanceDTO();
inDto.setStudentId("S101");
inDto.setLessonId(601L);
inDto.setActionType("SIGN_IN");
inDto.setDeviceSn("MOCK-BOARD-01");
MockAttendanceResultVO inResult = terminalService.mockDeviceAttendance(inDto, "device_scanner");
assertNotNull(inResult);
assertEquals("S101", inResult.getStudentId());
assertEquals("张三", inResult.getStudentName());
assertEquals(Long.valueOf(601L), inResult.getLessonId());
assertEquals(Long.valueOf(1001L), inResult.getEnrollmentId());
assertEquals("PLAN-2026-01", inResult.getPlanId());
assertEquals("高铁列车运行安全规范", inResult.getCourseName());
assertEquals("SIGN_IN", inResult.getActionType());
assertEquals("MOCK-BOARD-01", inResult.getBoardId());
assertEquals(Integer.valueOf(1), inResult.getAttendanceStatus());
assertNotNull("应有签到时间", inResult.getSignInTime());
// 模拟课堂签退
MockAttendanceDTO outDto = new MockAttendanceDTO();
outDto.setStudentId("S101");
outDto.setLessonId(601L);
outDto.setActionType("SIGN_OUT");
outDto.setDeviceSn("MOCK-BOARD-01");
MockAttendanceResultVO outResult = terminalService.mockDeviceAttendance(outDto, "device_scanner");
assertNotNull(outResult);
assertEquals("SIGN_OUT", outResult.getActionType());
assertNotNull("应有签退时间", outResult.getSignOutTime());
}
/**
* CASE-7:验证打卡不改变入学报到状态,报到也不自动完成课堂签到;未报到学员打卡被拒绝。
*/
@Test
public void testCASE7_StrictBoundaryBetweenReportAndClassAttendance()
{
// 1. 验证未报到(0)学员打卡将被系统拒绝 (不得用考勤签到绕过报到生命周期)
MockAttendanceDTO unReportedCheckin = new MockAttendanceDTO();
unReportedCheckin.setStudentId("S101");
unReportedCheckin.setLessonId(601L);
unReportedCheckin.setActionType("SIGN_IN");
try
{
terminalService.mockDeviceAttendance(unReportedCheckin, "device_scanner");
fail("未报到学员执行课堂打卡必须被拒绝");
}
catch (ServiceException se)
{
assertEquals("必须返回 40040 拒绝码",
AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRES_REPORTED_ENROLLMENT,
se.getCode().intValue());
assertTrue(se.getMessage().contains("未完成报到") || se.getMessage().contains("报到"));
}
// 2. 报到不自动完成课堂签到
ReportTerminalConfirmDTO reportDto = new ReportTerminalConfirmDTO();
reportDto.setEnrollmentId(1001L);
reportDto.setStudentId("S101");
reportDto.setPlanId("PLAN-2026-01");
terminalService.confirmTerminalReport(reportDto, "admin");
// 报到完成,检查签到表:绝不能产生虚假签到记录
assertEquals("入学报到绝不能自动产生课堂签到记录", 0, checkins.size());
// 3. 执行课堂签到,入学报到状态保持不变
terminalService.mockDeviceAttendance(unReportedCheckin, "device_scanner");
assertEquals("产生了 1 条课堂签到记录", 1, checkins.size());
TrainEnrollment currentE = enrollments.get(1001L);
assertEquals("参训状态依然为已报到(1),未被课堂签到篡改", Integer.valueOf(1), currentE.getEnrollmentStatus());
assertEquals("报到记录依然仅为最初的 1 条", 1, reportsByEnrollment.size());
}
@SuppressWarnings("unchecked")
private static <T> T createProxy(Class<T> 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);
}
}
@@ -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');
-- =============================================================================
+37
View File
@@ -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
})
}
+8 -1
View File
@@ -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']
}
]
},
+468
View File
@@ -0,0 +1,468 @@
<template>
<div class="app-container terminal-container">
<el-alert
title="【内部测试与设备联调专用】SYFJGYD学员管理系统 — 模拟报到机与受控打卡工作台"
type="info"
description="本页面模拟现场学员自助报到终端(扫码二维码/查询/核验/一键确认报到)及课堂打卡机。操作仅限已授权测试管理员使用。"
show-icon
:closable="false"
class="mb15"
/>
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: 模拟报到机 -->
<el-tab-pane label="模拟报到机 (Self Check-in Kiosk)" name="reportTerminal">
<!-- 扫码/查询区 -->
<el-card class="box-card mb15" shadow="never">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 15px;">
<i class="el-icon-camera" style="margin-right: 6px; color: #409EFF;" />模拟扫码 / 学号查询
</span>
</div>
<el-form :inline="true" :model="searchForm" size="small" class="demo-form-inline" @submit.native.prevent>
<el-form-item label="学员学号 (studentId)">
<el-input
v-model="searchForm.studentId"
placeholder="请输入学号或扫码二维码"
clearable
style="width: 260px;"
prefix-icon="el-icon-user"
@keyup.enter.native="handleLookup"
/>
</el-form-item>
<el-form-item v-if="previewData && previewData.hasMultiplePlans" label="目标开班计划">
<el-select
v-model="searchForm.planId"
placeholder="请明确选择开班计划"
style="width: 320px;"
@change="handlePlanChange"
>
<el-option
v-for="p in previewData.candidatePlans"
:key="p.planId"
:label="`${p.planName} (${p.enrollmentStatusLabel})`"
:value="p.planId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="lookupLoading" @click="handleLookup">
查询报到信息
</el-button>
<el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 查询结果展示区 -->
<div v-if="previewData && previewData.studentId">
<!-- 状态提示横幅 -->
<el-alert
v-if="previewData.isReported"
type="success"
:closable="false"
show-icon
class="mb15"
>
<span slot="title" style="font-size: 15px; font-weight: bold;">
【已报到】学员已完成现场报到(报到时间:{{ parseTime(previewData.reportTime) || '已确认' }})
</span>
</el-alert>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
class="mb15"
>
<span slot="title" style="font-size: 15px; font-weight: bold;">
【待报到】学员尚未报到,请核对学员基础信息、宿舍安排与课程表后点击下方“确认报到”
</span>
</el-alert>
<el-row :gutter="15">
<!-- 学员基础信息 -->
<el-col :span="12">
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-user-solid" style="color: #409EFF; margin-right: 5px;" />学员信息与培训班级</span>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="学员姓名">{{ previewData.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学员学号">{{ previewData.studentId }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ formatGender(previewData.gender) }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ previewData.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="选派单位" :span="2">{{ previewData.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="培训班计划" :span="2">
<span style="font-weight: bold; color: #303133;">{{ previewData.planName || previewData.planId }}</span>
<span style="color: #909399; font-size: 12px; margin-left: 6px;">({{ previewData.planId }})</span>
</el-descriptions-item>
<el-descriptions-item label="参训状态">
<el-tag :type="statusTagType(previewData.enrollmentStatus)" size="small">
{{ previewData.enrollmentStatusLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="报到状态">
<el-tag :type="previewData.isReported ? 'success' : 'info'" size="small">
{{ previewData.isReported ? '已报到' : '未报到' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- 住宿安排 -->
<el-col :span="12">
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-office-building" style="color: #67C23A; margin-right: 5px;" />提前住宿分配安排</span>
</div>
<div v-if="previewData.hasAccommodation">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="宿舍楼栋">{{ previewData.dormBuilding || '-' }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ previewData.dormRoom || '-' }}</el-descriptions-item>
<el-descriptions-item label="床位号">
<span style="font-weight: bold; color: #409EFF;">{{ previewData.bedNo || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="住宿状态">
<el-tag :type="previewData.accommodationStatus === 1 ? 'success' : 'info'" size="small">
{{ previewData.accommodationStatusLabel }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p style="margin-top: 10px; font-size: 12px; color: #67C23A;">
<i class="el-icon-success" /> 管理员已提前分配宿舍与床位,报到后可直接前往入住。
</p>
</div>
<div v-else>
<el-empty description="当前尚未分配宿舍" :image-size="70">
<span style="font-size: 12px; color: #909399;">如需住宿,请管理员在【开班计划】参训人员名单中进行住宿安排</span>
</el-empty>
</div>
</el-card>
</el-col>
</el-row>
<!-- 对应计划课程表 -->
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-date" style="color: #E6A23C; margin-right: 5px;" />本期培训课程表安排</span>
</div>
<div v-if="flattenedLessons && flattenedLessons.length > 0">
<el-table :data="flattenedLessons" size="small" border stripe>
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="date" label="上课日期" width="110" align="center" />
<el-table-column label="上课时间" width="140" align="center">
<template slot-scope="scope">
<span>{{ formatTimeRange(scope.row.startTime, scope.row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column prop="courseName" label="课程名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="teacherName" label="授课教师" width="110" align="center" />
<el-table-column label="上课教室/场地" min-width="140" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.venueType === 2 ? 'warning' : 'primary'">
{{ scope.row.venueType === 2 ? '实训场地' : '教室' }}
</el-tag>
<span style="margin-left: 6px;">{{ scope.row.venueName || scope.row.classRoomName || scope.row.siteName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip />
</el-table>
</div>
<div v-else>
<p style="text-align: center; color: #909399; margin: 20px 0;">当前开班计划尚未编排课次</p>
</div>
</el-card>
<!-- 确认报到操作栏 -->
<el-card shadow="never" style="text-align: center; background-color: #F8F9FB;">
<div style="margin: 10px 0;">
<span v-if="previewData.isReported" style="margin-right: 15px; color: #67C23A; font-weight: bold;">
<i class="el-icon-check" /> 学员已完成报到
</span>
<span v-else style="margin-right: 15px; color: #E6A23C; font-weight: bold;">
<i class="el-icon-info" /> 请确认学员信息无误后办理报到
</span>
<el-button
type="success"
size="medium"
icon="el-icon-circle-check"
:loading="confirmLoading"
:disabled="previewData.isReported"
@click="handleConfirmReport"
>
{{ previewData.isReported ? '已完成报到' : '一键确认报到' }}
</el-button>
<el-button
v-if="previewData.isReported"
type="primary"
plain
size="medium"
icon="el-icon-refresh"
@click="handleLookup"
>
刷新最新状态
</el-button>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 模拟课堂打卡 -->
<el-tab-pane label="模拟课堂打卡 (Mock Class Attendance)" name="mockAttendance">
<el-card class="box-card mb15" shadow="never">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 15px;">
<i class="el-icon-bell" style="margin-right: 6px; color: #E6A23C;" />模拟设备课堂考勤刷卡
</span>
</div>
<el-alert
title="考勤业务规则说明"
type="warning"
:closable="false"
description="1. 课堂打卡与入学报到严格区分:打卡不会改变入学报到状态,报到也不会自动生成课堂考勤记录。
2. 只有已报到的学员才能进行课堂打卡;未完成入学报到的学员打卡将被系统拒绝。
3. 此接口为受控 Mock,打卡记录带 MOCK 标识,便于测试溯源。"
show-icon
class="mb15"
/>
<el-form ref="mockForm" :model="mockForm" :rules="mockRules" label-width="120px" style="max-width: 600px;">
<el-form-item label="学员学号" prop="studentId">
<el-input v-model="mockForm.studentId" placeholder="请输入学员学号,如 S101" />
</el-form-item>
<el-form-item label="目标课次标识" prop="lessonId">
<el-input-number v-model="mockForm.lessonId" :min="1" placeholder="课次ID,如 102" style="width: 100%;" />
</el-form-item>
<el-form-item label="打卡动作" prop="actionType">
<el-radio-group v-model="mockForm.actionType">
<el-radio label="SIGN_IN">课堂签到 (Sign In)</el-radio>
<el-radio label="SIGN_OUT">课堂签退 (Sign Out)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模拟设备编号">
<el-input v-model="mockForm.deviceSn" placeholder="MOCK-BOARD-01" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="mockLoading" @click="handleMockSubmit">提交模拟打卡</el-button>
<el-button @click="resetMockForm">重置表单</el-button>
</el-form-item>
</el-form>
<!-- 打卡结果回显 -->
<div v-if="mockResult" style="margin-top: 20px;">
<el-divider content-position="left">打卡响应结果</el-divider>
<el-alert
:title="mockResult.message || '打卡操作完成'"
type="success"
:closable="false"
show-icon
class="mb15"
/>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="打卡记录ID">{{ mockResult.checkinId }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ mockResult.studentName || mockResult.studentId }}</el-descriptions-item>
<el-descriptions-item label="培训班ID">{{ mockResult.planId }}</el-descriptions-item>
<el-descriptions-item label="课程名称">{{ mockResult.courseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="动作类型">
<el-tag size="mini">{{ mockResult.actionType === 'SIGN_IN' ? '签到' : '签退' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="出勤状态">
<el-tag size="mini" type="success">{{ formatAttendanceStatus(mockResult.attendanceStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="签到时间">{{ parseTime(mockResult.signInTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="签退时间">{{ parseTime(mockResult.signOutTime) || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import { lookupTerminal, confirmTerminal, mockAttendance } from '@/api/train/terminal'
export default {
name: 'TrainTerminal',
data() {
return {
activeTab: 'reportTerminal',
searchForm: {
studentId: '',
planId: null
},
lookupLoading: false,
confirmLoading: false,
previewData: null,
// Mock 打卡表单
mockForm: {
studentId: '',
lessonId: null,
actionType: 'SIGN_IN',
deviceSn: 'MOCK-BOARD-01'
},
mockRules: {
studentId: [{ required: true, message: '请输入学员学号', trigger: 'blur' }],
lessonId: [{ required: true, message: '请输入目标课次ID', trigger: 'blur' }],
actionType: [{ required: true, message: '请选择打卡动作', trigger: 'change' }]
},
mockLoading: false,
mockResult: null
}
},
computed: {
flattenedLessons() {
if (!this.previewData || !this.previewData.timetable || !this.previewData.timetable.days) {
return []
}
const list = []
this.previewData.timetable.days.forEach(day => {
if (day.lessons && day.lessons.length > 0) {
day.lessons.forEach(lesson => {
list.push({
...lesson,
date: day.date
})
})
}
})
return list
}
},
created() {
// 检查是否有路由参数预填
if (this.$route.query.studentId) {
this.searchForm.studentId = this.$route.query.studentId
if (this.$route.query.planId) {
this.searchForm.planId = this.$route.query.planId
}
this.handleLookup()
}
},
methods: {
handleLookup() {
if (!this.searchForm.studentId || !this.searchForm.studentId.trim()) {
this.$modal.msgWarning('请输入学员学号进行查询')
return
}
this.lookupLoading = true
lookupTerminal({
studentId: this.searchForm.studentId.trim(),
planId: this.searchForm.planId || undefined
}).then(res => {
this.previewData = res.data || null
if (this.previewData && this.previewData.planId) {
this.searchForm.planId = this.previewData.planId
}
if (this.previewData && this.previewData.message) {
this.$modal.msgSuccess(this.previewData.message)
}
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '查询失败'
this.$modal.msgError(msg)
}).finally(() => {
this.lookupLoading = false
})
},
handlePlanChange(val) {
this.searchForm.planId = val
this.handleLookup()
},
handleReset() {
this.searchForm = {
studentId: '',
planId: null
}
this.previewData = null
},
handleConfirmReport() {
if (!this.previewData) return
this.$confirm(`确认学员【${this.previewData.studentName || this.previewData.studentId}】办理报到?`, '报到确认', {
confirmButtonText: '确定报到',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
this.confirmLoading = true
return confirmTerminal({
enrollmentId: this.previewData.enrollmentId,
studentId: this.previewData.studentId,
planId: this.previewData.planId,
reportDeviceSn: 'TERMINAL-MOCK-01',
verifyNote: '报到机自主确认'
})
}).then(res => {
this.$modal.msgSuccess('报到确认成功!')
this.previewData = res.data || this.previewData
}).catch(err => {
if (err !== 'cancel') {
const msg = (err && (err.msg || err.message)) || '报到确认失败'
this.$modal.msgError(msg)
}
}).finally(() => {
this.confirmLoading = false
})
},
handleMockSubmit() {
this.$refs.mockForm.validate(valid => {
if (!valid) return
this.mockLoading = true
mockAttendance(this.mockForm).then(res => {
this.mockResult = res.data || null
this.$modal.msgSuccess('模拟打卡成功')
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '模拟打卡失败'
this.$modal.msgError(msg)
}).finally(() => {
this.mockLoading = false
})
})
},
resetMockForm() {
this.$refs.mockForm.resetFields()
this.mockResult = null
},
formatGender(val) {
if (val === 1 || val === '1' || val === '男') return '男'
if (val === 2 || val === '2' || val === '女') return '女'
return '未知'
},
statusTagType(val) {
const map = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger', 4: 'primary' }
return map[val] || 'info'
},
formatTimeRange(start, end) {
if (!start || !end) return '-'
const s = start.length > 16 ? start.substring(11, 16) : start
const e = end.length > 16 ? end.substring(11, 16) : end
return `${s} ~ ${e}`
},
formatAttendanceStatus(status) {
const map = { 1: '正常出勤', 2: '迟到', 3: '早退', 4: '缺勤', 5: '请假' }
return map[status] || '已打卡'
}
}
}
</script>
<style scoped>
.terminal-container {
padding: 20px;
}
.mb15 {
margin-bottom: 15px;
}
</style>