新增报到机:实现终端扫码报到核验与受控课堂打卡模拟
This commit is contained in:
1 parent
b1d5a999ca
commit
cecf11790e
16 files changed
+2571
-7
No files matched your search
+18
@@ -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()));
|
||||
}
|
||||
}
|
||||
+77
@@ -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
|
||||
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+317
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
+435
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+632
@@ -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');
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user