新增培训:完善报到与考勤V2业务操作

This commit is contained in:
wangzhibiao committed 2026-09-15 09:46:14 +08:00
1 parent 7193f2ac3a
commit 6762189093
30 files changed
+2382 -9

No files matched your search

@@ -0,0 +1,73 @@
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
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.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.service.AttendanceAppService;
/**
* 课次考勤核心动作 Controller (API Contract Module C 第 3 部分 / UC-17, UC-18, UC-19 / P0)。
*/
@RestController
@RequestMapping("/train/attendance")
public class AttendanceController extends BaseController
{
private final AttendanceAppService attendanceAppService;
@Autowired
public AttendanceController(AttendanceAppService attendanceAppService)
{
this.attendanceAppService = attendanceAppService;
}
/**
* 课次签到 (UC-17 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:attendance:checkin')")
@Log(title = "课次签到", businessType = BusinessType.INSERT)
@PostMapping("/sign-in")
public AjaxResult signIn(@Validated @RequestBody AttendanceSignInDTO dto)
{
TrainStudentCheckin checkin = attendanceAppService.signIn(dto, SecurityUtils.getUsername());
return success(checkin);
}
/**
* 课次签退 (UC-18 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:attendance:checkin')")
@Log(title = "课次签退", businessType = BusinessType.UPDATE)
@PostMapping("/sign-out")
public AjaxResult signOut(@Validated @RequestBody AttendanceSignOutDTO dto)
{
TrainStudentCheckin checkin = attendanceAppService.signOut(dto, SecurityUtils.getUsername());
return success(checkin);
}
/**
* 人工修正考勤 (UC-19 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:attendance:edit')")
@Log(title = "人工修正考勤", businessType = BusinessType.UPDATE)
@PutMapping("/{checkinId}")
public AjaxResult adjust(@PathVariable Long checkinId, @Validated @RequestBody AttendanceAdjustDTO dto)
{
attendanceAppService.adjust(checkinId, dto, SecurityUtils.getUsername());
return success();
}
}
@@ -0,0 +1,45 @@
package com.ruoyi.web.controller.train;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
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.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.ReportConfirmDTO;
import com.ruoyi.train.domain.vo.ReportResultVO;
import com.ruoyi.train.service.EnrollmentAppService;
/**
* 学员报到确认动作 Controller (API Contract Module B 第 5 部分 / UC-15 / P0)。
*/
@RestController
@RequestMapping("/train")
public class TrainReportActionController extends BaseController
{
private final EnrollmentAppService enrollmentAppService;
@Autowired
public TrainReportActionController(EnrollmentAppService enrollmentAppService)
{
this.enrollmentAppService = enrollmentAppService;
}
/**
* 学员报到确认 (UC-15 / P0)。
*/
@PreAuthorize("@ss.hasPermi('train:report:confirm')")
@Log(title = "学员报到确认", businessType = BusinessType.UPDATE)
@PostMapping("/enrollments/{enrollmentId}/report")
public AjaxResult confirmReport(@PathVariable Long enrollmentId, @RequestBody(required = false) ReportConfirmDTO dto)
{
ReportResultVO result = enrollmentAppService.confirmReport(enrollmentId, dto, SecurityUtils.getUsername());
return success(result);
}
}
@@ -0,0 +1,60 @@
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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
public class AttendanceAndReportApiControllerContractTest
{
@Test
public void testReportConfirmControllerContract() throws Exception
{
Method confirmReport = TrainReportActionController.class.getMethod("confirmReport", Long.class, ReportConfirmDTO.class);
assertNotNull(confirmReport);
PostMapping post = confirmReport.getAnnotation(PostMapping.class);
assertNotNull(post);
assertEquals("/enrollments/{enrollmentId}/report", post.value()[0]);
PreAuthorize preAuth = confirmReport.getAnnotation(PreAuthorize.class);
assertNotNull(preAuth);
assertEquals("@ss.hasPermi('train:report:confirm')", preAuth.value());
}
@Test
public void testAttendanceControllerContract() throws Exception
{
// 1. 课次签到
Method signIn = AttendanceController.class.getMethod("signIn", AttendanceSignInDTO.class);
assertNotNull(signIn);
PostMapping postSignIn = signIn.getAnnotation(PostMapping.class);
assertNotNull(postSignIn);
assertEquals("/sign-in", postSignIn.value()[0]);
assertEquals("@ss.hasPermi('train:attendance:checkin')", signIn.getAnnotation(PreAuthorize.class).value());
// 2. 课次签退
Method signOut = AttendanceController.class.getMethod("signOut", AttendanceSignOutDTO.class);
assertNotNull(signOut);
PostMapping postSignOut = signOut.getAnnotation(PostMapping.class);
assertNotNull(postSignOut);
assertEquals("/sign-out", postSignOut.value()[0]);
assertEquals("@ss.hasPermi('train:attendance:checkin')", signOut.getAnnotation(PreAuthorize.class).value());
// 3. 人工修正
Method adjust = AttendanceController.class.getMethod("adjust", Long.class, AttendanceAdjustDTO.class);
assertNotNull(adjust);
PutMapping putAdjust = adjust.getAnnotation(PutMapping.class);
assertNotNull(putAdjust);
assertEquals("/{checkinId}", putAdjust.value()[0]);
assertEquals("@ss.hasPermi('train:attendance:edit')", adjust.getAnnotation(PreAuthorize.class).value());
}
}
@@ -0,0 +1,53 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
/** Static contract guard for the current-release report & attendance action permission delta (003). */
public class ReportAttendancePermissionMigrationContractTest
{
@Test
public void migrationClosesWriteActionPermissionsWithoutRoleWrites() throws Exception
{
String sql = new String(Files.readAllBytes(findMigration()), StandardCharsets.UTF_8).toLowerCase();
assertContains(sql,
"parent_id = 2170", "menu_id = 2181", "train:report:confirm",
"parent_id = 2171", "menu_id = 2182", "train:attendance:checkin",
"menu_id = 2183", "train:attendance:edit",
"menu_type = 'f'", "'f'", "where not exists");
// 严禁修改角色权限映射
assertFalse("migration must not insert role assignments", sql.contains("insert into public.sys_role_menu"));
assertFalse("migration must not update role assignments", sql.contains("update public.sys_role_menu"));
assertFalse("migration must not delete role assignments", sql.contains("delete from public.sys_role_menu"));
// 严禁包含未批准的泛化权限
assertFalse("must not create generic add permission", sql.contains("train:report:add"));
assertFalse("must not create generic delete permission", sql.contains("train:report:delete"));
assertFalse("must not create generic checkin delete permission", sql.contains("train:attendance:delete"));
}
private Path findMigration()
{
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
{
Path candidate = path.resolve("sql/system/kingbase/003_report_attendance_action_permissions.sql");
if (Files.isRegularFile(candidate)) return candidate;
}
throw new IllegalStateException("003_report_attendance_action_permissions.sql migration not found from working directory");
}
private void assertContains(String sql, String... tokens)
{
for (String token : tokens)
{
assertTrue("missing migration token: " + token, sql.contains(token));
}
}
}
@@ -0,0 +1,35 @@
package com.ruoyi.train.constant;
/**
* 报到与考勤动作错误码与常量规范 (API Contract V1.0.1 Module B & Module C)。
*/
public final class AttendanceErrorCode
{
/** 该学员已完成报到确认,无需重复报到 */
public static final int REPORT_ALREADY_CONFIRMED = 40050;
public static final String REPORT_ALREADY_CONFIRMED_MESSAGE = "该学员已完成报到确认,无需重复报到";
/** 已超过本期培训报到截止时间,触发时限提醒 */
public static final int REPORT_DEADLINE_WARNING = 40051;
public static final String REPORT_DEADLINE_WARNING_MESSAGE = "已超过本期培训报到截止时间,触发时限提醒";
/** 业务不变量校验失败:考勤学员与课次所属培训班不一致 */
public static final int ATTENDANCE_PLAN_MISMATCH = 40080;
public static final String ATTENDANCE_PLAN_MISMATCH_MESSAGE = "业务不变量校验失败:考勤学员与课次所属培训班不一致";
/** 课次不存在 */
public static final int LESSON_NOT_FOUND = 40081;
public static final String LESSON_NOT_FOUND_MESSAGE = "课次不存在";
/** 考勤记录不存在 */
public static final int ATTENDANCE_NOT_FOUND = 40480;
public static final String ATTENDANCE_NOT_FOUND_MESSAGE = "考勤记录不存在";
/** 考勤状态不合法 */
public static final int ATTENDANCE_STATUS_INVALID = 40082;
public static final String ATTENDANCE_STATUS_INVALID_MESSAGE = "考勤状态不合法 (必须在0~4范围内)";
private AttendanceErrorCode()
{
}
}
@@ -0,0 +1,56 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
/**
* 考勤人工修正请求 DTO (UC-19 / P0)。
*/
public class AttendanceAdjustDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 考勤结论 (0:缺勤, 1:正常出勤, 2:迟到, 3:早退, 4:请假) */
@NotNull(message = "考勤状态不能为空")
@Min(value = 0, message = "考勤状态不合法")
@Max(value = 4, message = "考勤状态不合法")
private Integer attendanceStatus;
/** 是否人工补录/修正 (强制固定为 1) */
private Integer manualFlag = 1;
/** 修正原因/备注 */
private String remark;
public Integer getAttendanceStatus()
{
return attendanceStatus;
}
public void setAttendanceStatus(Integer attendanceStatus)
{
this.attendanceStatus = attendanceStatus;
}
public Integer getManualFlag()
{
return manualFlag;
}
public void setManualFlag(Integer manualFlag)
{
this.manualFlag = manualFlag;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -0,0 +1,82 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import java.util.Date;
import javax.validation.constraints.NotNull;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 课次签到请求 DTO (UC-17 / P0)。
*/
public class AttendanceSignInDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 参训档案ID */
@NotNull(message = "参训档案ID不能为空")
private Long enrollmentId;
/** 课次ID */
@NotNull(message = "课次ID不能为空")
private Long lessonId;
/** 打卡电子班牌ID (可选) */
private String boardId;
/** 签到方式 (1:人脸, 2:刷卡, 3:扫码, 4:教师代签) */
private Integer checkinWay;
/** 签到时间 (可选,默认当前系统时间) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date signInTime;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public Long getLessonId()
{
return lessonId;
}
public void setLessonId(Long lessonId)
{
this.lessonId = lessonId;
}
public String getBoardId()
{
return boardId;
}
public void setBoardId(String boardId)
{
this.boardId = boardId;
}
public Integer getCheckinWay()
{
return checkinWay;
}
public void setCheckinWay(Integer checkinWay)
{
this.checkinWay = checkinWay;
}
public Date getSignInTime()
{
return signInTime;
}
public void setSignInTime(Date signInTime)
{
this.signInTime = signInTime;
}
}
@@ -0,0 +1,56 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import java.util.Date;
import javax.validation.constraints.NotNull;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 课次签退请求 DTO (UC-18 / P0)。
*/
public class AttendanceSignOutDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 参训档案ID */
@NotNull(message = "参训档案ID不能为空")
private Long enrollmentId;
/** 课次ID */
@NotNull(message = "课次ID不能为空")
private Long lessonId;
/** 签退时间 (可选,默认当前系统时间) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date signOutTime;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public Long getLessonId()
{
return lessonId;
}
public void setLessonId(Long lessonId)
{
this.lessonId = lessonId;
}
public Date getSignOutTime()
{
return signOutTime;
}
public void setSignOutTime(Date signOutTime)
{
this.signOutTime = signOutTime;
}
}
@@ -0,0 +1,99 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonAlias;
/**
* 学员报到确认请求 DTO (UC-15 / P0)。
*/
public class ReportConfirmDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 报到核验方式 (1:人脸识别, 2:身份证刷卡, 3:二维码, 4:人工确认/人工录入) */
@JsonAlias({"reportType"})
private Integer reportWay;
/** 报到设备序列号 (可选) */
private String reportDeviceSn;
/** 核验说明/备注 (可选) */
private String verifyNote;
/** 逾期强制放行标记 (默认 false) */
private Boolean forceOverride = false;
public Integer getReportWay()
{
return reportWay;
}
public void setReportWay(Integer reportWay)
{
this.reportWay = reportWay;
}
/** 兼容历史/外部客户端别名传参及枚举字符串映射 */
public void setReportType(Object reportType)
{
if (reportType == null)
{
return;
}
if (reportType instanceof Number)
{
this.reportWay = ((Number) reportType).intValue();
}
else
{
String s = reportType.toString().trim();
if ("1".equals(s) || "FACE".equalsIgnoreCase(s))
{
this.reportWay = 1;
}
else if ("2".equals(s) || "CARD".equalsIgnoreCase(s))
{
this.reportWay = 2;
}
else if ("3".equals(s) || "QR".equalsIgnoreCase(s) || "QR_CODE".equalsIgnoreCase(s))
{
this.reportWay = 3;
}
else if ("4".equals(s) || "MANUAL".equalsIgnoreCase(s))
{
this.reportWay = 4;
}
}
}
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 != null && forceOverride;
}
public void setForceOverride(Boolean forceOverride)
{
this.forceOverride = forceOverride;
}
}
@@ -0,0 +1,92 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 报到确认结果 VO (UC-15 / P0)。
*/
public class ReportResultVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 参训档案ID */
private Long enrollmentId;
/** 报到流水ID */
private Long reportId;
/** 学员姓名 */
private String studentName;
/** 报到完成时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportTime;
/** 是否逾期报到 */
private Boolean isOverdue;
/** 策略执行结果动作 (NORMAL / OVERDUE_OVERRIDDEN) */
private String policyAction;
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public Long getReportId()
{
return reportId;
}
public void setReportId(Long reportId)
{
this.reportId = reportId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public Date getReportTime()
{
return reportTime;
}
public void setReportTime(Date reportTime)
{
this.reportTime = reportTime;
}
public Boolean getIsOverdue()
{
return isOverdue;
}
public void setIsOverdue(Boolean isOverdue)
{
this.isOverdue = isOverdue;
}
public String getPolicyAction()
{
return policyAction;
}
public void setPolicyAction(String policyAction)
{
this.policyAction = policyAction;
}
}
@@ -19,6 +19,14 @@ public interface TrainClassReportMapper
*/
public TrainClassReport selectTrainClassReportByReportId(Long reportId);
/**
* 根据参训档案ID查询报到记录
*
* @param enrollmentId 参训档案ID
* @return 开班报到
*/
public TrainClassReport selectTrainClassReportByEnrollmentId(Long enrollmentId);
/**
* 查询开班报到列表
*
@@ -27,4 +35,11 @@ public interface TrainClassReportMapper
*/
public List<TrainClassReport> selectTrainClassReportList(TrainClassReport trainClassReport);
/**
* 新增开班报到事件记录
*
* @param trainClassReport 开班报到
* @return 插入行数
*/
public int insertTrainClassReport(TrainClassReport trainClassReport);
}
@@ -97,6 +97,20 @@ public interface TrainEnrollmentMapper
@Param("operator") String operator,
@Param("updateTime") Date updateTime);
/**
* 确认报到:将待报到(0)更新为已报到(1),并写入报到时间(join_time)。
*
* @param enrollmentId 参训档案ID
* @param reportTime 报到时间
* @param operator 操作人
* @param updateTime 更新时间
* @return 更新行数
*/
int confirmEnrollmentReport(@Param("enrollmentId") Long enrollmentId,
@Param("reportTime") Date reportTime,
@Param("operator") String operator,
@Param("updateTime") Date updateTime);
/**
* 计划结训时批量将有效在训学员状态更新为已完成(4),并记录结业时间。
* 有效状态指未放弃(status != 3)且未完成(status != 4)。
@@ -19,6 +19,16 @@ public interface TrainStudentCheckinMapper
*/
public TrainStudentCheckin selectTrainStudentCheckinByCheckinId(Long checkinId);
/**
* 根据参训档案ID与课次ID查询考勤记录 (唯一约束 uq_student_checkin_enroll_lesson)
*
* @param enrollmentId 参训档案ID
* @param lessonId 课次ID
* @return 学生签到
*/
public TrainStudentCheckin selectTrainStudentCheckinByEnrollmentAndLesson(@org.apache.ibatis.annotations.Param("enrollmentId") Long enrollmentId,
@org.apache.ibatis.annotations.Param("lessonId") Long lessonId);
/**
* 查询学生签到列表
*
@@ -27,4 +37,19 @@ public interface TrainStudentCheckinMapper
*/
public List<TrainStudentCheckin> selectTrainStudentCheckinList(TrainStudentCheckin trainStudentCheckin);
/**
* 新增学生签到
*
* @param trainStudentCheckin 学生签到
* @return 插入行数
*/
public int insertTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
/**
* 修改学生签到
*
* @param trainStudentCheckin 学生签到
* @return 更新行数
*/
public int updateTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
}
@@ -0,0 +1,40 @@
package com.ruoyi.train.service;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
/**
* 课次考勤核心动作应用服务接口 (Module C / P0)。
* 涵盖课次签到、课次签退、人工修正。
*/
public interface AttendanceAppService
{
/**
* 课次签到 (UC-17 / P0)。
*
* @param dto 签到请求参数
* @param operator 操作人
* @return 考勤记录
*/
TrainStudentCheckin signIn(AttendanceSignInDTO dto, String operator);
/**
* 课次签退 (UC-18 / P0)。
*
* @param dto 签退请求参数
* @param operator 操作人
* @return 考勤记录
*/
TrainStudentCheckin signOut(AttendanceSignOutDTO dto, String operator);
/**
* 人工修正考勤 (UC-19 / P0)。
*
* @param checkinId 考勤记录ID
* @param dto 修正请求参数
* @param operator 操作人
*/
void adjust(Long checkinId, AttendanceAdjustDTO dto, String operator);
}
@@ -73,4 +73,14 @@ public interface EnrollmentAppService
* @param operator 操作人
*/
void removeEnrollment(Long enrollmentId, String operator);
/**
* 学员报到确认 (UC-15 / P0)。
*
* @param enrollmentId 参训档案ID
* @param dto 报到请求参数
* @param operator 操作人
* @return 报到结果 VO
*/
com.ruoyi.train.domain.vo.ReportResultVO confirmReport(Long enrollmentId, com.ruoyi.train.domain.dto.ReportConfirmDTO dto, String operator);
}
@@ -0,0 +1,238 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.constant.AttendanceErrorCode;
import com.ruoyi.train.constant.EnrollmentErrorCode;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
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.service.AttendanceAppService;
/**
* 课次考勤应用服务实现类 (Module C / P0)。
* 严格落实跨表硬业务不变量 1:attendance.enrollment.plan_id == lesson.planCourse.plan_id。
*/
@Service
public class AttendanceAppServiceImpl implements AttendanceAppService
{
private static final Logger log = LoggerFactory.getLogger(AttendanceAppServiceImpl.class);
@Autowired
private TrainStudentCheckinMapper trainStudentCheckinMapper;
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
@Autowired
private TrainLessonMapper trainLessonMapper;
@Autowired
private TrainPlanCourseMapper trainPlanCourseMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public TrainStudentCheckin signIn(AttendanceSignInDTO dto, String operator)
{
if (dto == null || dto.getEnrollmentId() == null || dto.getLessonId() == null)
{
throw new ServiceException("签到参数不完整", 400);
}
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(dto.getEnrollmentId());
if (enrollment == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
TrainLesson lesson = trainLessonMapper.selectTrainLessonById(dto.getLessonId());
if (lesson == null)
{
throw new ServiceException(AttendanceErrorCode.LESSON_NOT_FOUND_MESSAGE, AttendanceErrorCode.LESSON_NOT_FOUND);
}
// 硬业务不变量 1 校验:attendance.enrollment.plan_id == lesson.planCourse.plan_id
TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(lesson.getPlanCourseId());
if (planCourse == null || !enrollment.getPlanId().equals(planCourse.getPlanId()))
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH_MESSAGE, AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH);
}
// 幂等性与查重:基于 (enrollment_id, lesson_id) 查重,存在已有签到记录时不重复覆盖
TrainStudentCheckin existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId());
if (existing != null)
{
if (existing.getSignInTime() != null)
{
// 已有签到记录,幂等返回已有记录,不覆盖
return existing;
}
Date now = dto.getSignInTime() != null ? dto.getSignInTime() : new Date();
existing.setSignInTime(now);
existing.setCheckinWay(dto.getCheckinWay() != null ? dto.getCheckinWay() : 1);
if (dto.getBoardId() != null)
{
existing.setBoardId(dto.getBoardId());
}
existing.setUpdateBy(operator);
existing.setUpdateTime(now);
trainStudentCheckinMapper.updateTrainStudentCheckin(existing);
return existing;
}
Date now = dto.getSignInTime() != null ? dto.getSignInTime() : new Date();
TrainStudentCheckin checkin = new TrainStudentCheckin();
checkin.setEnrollmentId(dto.getEnrollmentId());
checkin.setLessonId(dto.getLessonId());
checkin.setBoardId(dto.getBoardId());
checkin.setCheckinWay(dto.getCheckinWay() != null ? dto.getCheckinWay() : 1); // 1:人脸, 2:刷卡, 3:扫码, 4:教师代签
checkin.setSignInTime(now);
checkin.setAttendanceStatus(1); // 1:正常出勤
checkin.setManualFlag(dto.getCheckinWay() != null && dto.getCheckinWay() == 4 ? 1 : 0);
checkin.setCreateBy(operator);
checkin.setCreateTime(now);
try
{
trainStudentCheckinMapper.insertTrainStudentCheckin(checkin);
TrainStudentCheckin created = trainStudentCheckinMapper.selectTrainStudentCheckinByCheckinId(checkin.getCheckinId());
return created != null ? created : checkin;
}
catch (DuplicateKeyException e)
{
// 并发冲突时重新查询幂等返回
existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId());
if (existing != null)
{
return existing;
}
throw e;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public TrainStudentCheckin signOut(AttendanceSignOutDTO dto, String operator)
{
if (dto == null || dto.getEnrollmentId() == null || dto.getLessonId() == null)
{
throw new ServiceException("签退参数不完整", 400);
}
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(dto.getEnrollmentId());
if (enrollment == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
TrainLesson lesson = trainLessonMapper.selectTrainLessonById(dto.getLessonId());
if (lesson == null)
{
throw new ServiceException(AttendanceErrorCode.LESSON_NOT_FOUND_MESSAGE, AttendanceErrorCode.LESSON_NOT_FOUND);
}
// 硬业务不变量 1 校验
TrainPlanCourse planCourse = trainPlanCourseMapper.selectTrainPlanCourseById(lesson.getPlanCourseId());
if (planCourse == null || !enrollment.getPlanId().equals(planCourse.getPlanId()))
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH_MESSAGE, AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH);
}
Date signOutTime = dto.getSignOutTime() != null ? dto.getSignOutTime() : new Date();
TrainStudentCheckin existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId());
if (existing == null)
{
// 若尚无签到记录,初始化一条考勤记录并记录签退时间
existing = new TrainStudentCheckin();
existing.setEnrollmentId(dto.getEnrollmentId());
existing.setLessonId(dto.getLessonId());
existing.setCheckinWay(1);
existing.setSignOutTime(signOutTime);
existing.setAttendanceStatus(1);
existing.setManualFlag(0);
existing.setCreateBy(operator);
existing.setCreateTime(new Date());
try
{
trainStudentCheckinMapper.insertTrainStudentCheckin(existing);
}
catch (DuplicateKeyException e)
{
existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId());
if (existing != null)
{
if (existing.getSignOutTime() != null)
{
return existing;
}
existing.setSignOutTime(signOutTime);
existing.setUpdateBy(operator);
existing.setUpdateTime(new Date());
trainStudentCheckinMapper.updateTrainStudentCheckin(existing);
}
}
}
else
{
// 若同一 enrollment_id + lesson_id 已经存在非空 sign_out_time:
// 后续重复 sign-out 必须直接返回已有记录,禁止覆盖第一次 sign_out_time。
if (existing.getSignOutTime() != null)
{
return existing;
}
existing.setSignOutTime(signOutTime);
existing.setUpdateBy(operator);
existing.setUpdateTime(new Date());
trainStudentCheckinMapper.updateTrainStudentCheckin(existing);
}
return existing;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void adjust(Long checkinId, AttendanceAdjustDTO dto, String operator)
{
if (checkinId == null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_NOT_FOUND_MESSAGE, AttendanceErrorCode.ATTENDANCE_NOT_FOUND);
}
if (dto == null || dto.getAttendanceStatus() == null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID_MESSAGE, AttendanceErrorCode.ATTENDANCE_STATUS_INVALID);
}
if (dto.getAttendanceStatus() < 0 || dto.getAttendanceStatus() > 4)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID_MESSAGE, AttendanceErrorCode.ATTENDANCE_STATUS_INVALID);
}
TrainStudentCheckin checkin = trainStudentCheckinMapper.selectTrainStudentCheckinByCheckinId(checkinId);
if (checkin == null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_NOT_FOUND_MESSAGE, AttendanceErrorCode.ATTENDANCE_NOT_FOUND);
}
checkin.setAttendanceStatus(dto.getAttendanceStatus());
checkin.setManualFlag(1); // 强制标记为人工修正留痕
checkin.setRemark(dto.getRemark());
checkin.setUpdateBy(operator);
checkin.setUpdateTime(new Date());
trainStudentCheckinMapper.updateTrainStudentCheckin(checkin);
}
}
@@ -20,19 +20,24 @@ import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.constant.AttendanceErrorCode;
import com.ruoyi.train.constant.EnrollmentErrorCode;
import com.ruoyi.train.constant.PlanErrorCode;
import com.ruoyi.train.constant.StudentErrorCode;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.BatchImportResultVO;
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.ReportResultVO;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.security.StudentIdentityProtection;
@@ -73,6 +78,9 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
@Autowired
private TrainStudentMapper studentMapper;
@Autowired
private TrainClassReportMapper trainClassReportMapper;
@Autowired(required = false)
private StudentIdentityProtection identityProtection;
@@ -576,4 +584,91 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
}
return "";
}
@Override
@Transactional(rollbackFor = Exception.class)
public ReportResultVO confirmReport(Long enrollmentId, ReportConfirmDTO dto, String operator)
{
if (enrollmentId == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
if (dto == null)
{
dto = new ReportConfirmDTO();
}
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId);
if (enrollment == null)
{
throw new ServiceException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND_MESSAGE, EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
// 状态流转保护:退训(3)或已完成(4)的历史状态禁止报到
if (enrollment.getEnrollmentStatus() != null &&
(enrollment.getEnrollmentStatus() == EnrollmentErrorCode.STATUS_ABANDONED ||
enrollment.getEnrollmentStatus() == EnrollmentErrorCode.STATUS_COMPLETED))
{
throw new ServiceException("当前参训状态不允许执行报到确认", EnrollmentErrorCode.ENROLLMENT_NOT_FOUND);
}
// 幂等与查重:若已报到(status==1)或已有报到记录
TrainClassReport existingReport = trainClassReportMapper.selectTrainClassReportByEnrollmentId(enrollmentId);
if (existingReport != null || (enrollment.getEnrollmentStatus() != null && enrollment.getEnrollmentStatus() == EnrollmentErrorCode.STATUS_REPORTED))
{
throw new ServiceException(AttendanceErrorCode.REPORT_ALREADY_CONFIRMED_MESSAGE, AttendanceErrorCode.REPORT_ALREADY_CONFIRMED);
}
// 校验计划报到截止时间生命周期策略 (Report Lifecycle Policy)
TrainClassPlan plan = planMapper.selectTrainClassPlanByPlanId(enrollment.getPlanId());
Date now = new Date();
boolean isOverdue = false;
String policyAction = "NORMAL";
if (plan != null && plan.getReportDeadline() != null && now.after(plan.getReportDeadline()))
{
isOverdue = true;
if (!dto.getForceOverride())
{
throw new ServiceException(AttendanceErrorCode.REPORT_DEADLINE_WARNING_MESSAGE, AttendanceErrorCode.REPORT_DEADLINE_WARNING);
}
policyAction = "OVERDUE_OVERRIDDEN";
}
// 1. 插入报到事件记录
TrainClassReport report = new TrainClassReport();
report.setEnrollmentId(enrollmentId);
report.setReportWay(dto.getReportWay() != null ? dto.getReportWay() : 4); // 默认4:人工录入
report.setReportDeviceSn(dto.getReportDeviceSn());
report.setReportTime(now);
report.setVerifyResult(1); // 1: 核验通过
report.setVerifyNote(dto.getVerifyNote());
report.setCreateBy(operator);
report.setCreateTime(now);
report.setRemark(isOverdue ? "逾期报到放行" : null);
try
{
trainClassReportMapper.insertTrainClassReport(report);
}
catch (DuplicateKeyException e)
{
throw new ServiceException(AttendanceErrorCode.REPORT_ALREADY_CONFIRMED_MESSAGE, AttendanceErrorCode.REPORT_ALREADY_CONFIRMED);
}
// 2. 联动更新参训状态为已报到 (1),写入入班报到时间 (join_time)
trainEnrollmentMapper.confirmEnrollmentReport(enrollmentId, now, operator, now);
// 3. 构造并返回 ReportResultVO
TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId());
ReportResultVO vo = new ReportResultVO();
vo.setEnrollmentId(enrollmentId);
vo.setReportId(report.getReportId());
vo.setStudentName(student != null ? student.getsName() : "");
vo.setReportTime(now);
vo.setIsOverdue(isOverdue);
vo.setPolicyAction(policyAction);
return vo;
}
}
@@ -44,4 +44,17 @@
<include refid="selectTrainClassReportVo"/>
where r.report_id = #{reportId}
</select>
<select id="selectTrainClassReportByEnrollmentId" parameterType="Long" resultMap="TrainClassReportResult">
<include refid="selectTrainClassReportVo"/>
where r.enrollment_id = #{enrollmentId}
</select>
<insert id="insertTrainClassReport" parameterType="com.ruoyi.train.domain.TrainClassReport" useGeneratedKeys="true" keyProperty="reportId">
insert into train_class_report (
enrollment_id, report_way, report_device_sn, report_time,
verify_result, verify_note, create_by, create_time, remark
) values (
#{enrollmentId}, #{reportWay}, #{reportDeviceSn}, #{reportTime},
#{verifyResult}, #{verifyNote}, #{createBy}, #{createTime}, #{remark}
)
</insert>
</mapper>
@@ -136,6 +136,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where enrollment_id = #{enrollmentId}
</update>
<update id="confirmEnrollmentReport">
update train_enrollment
set enrollment_status = 1,
join_time = #{reportTime},
update_by = #{operator},
update_time = #{updateTime}
where enrollment_id = #{enrollmentId}
</update>
<update id="completeActiveEnrollmentsByPlanId">
update train_enrollment
set enrollment_status = 4,
@@ -57,4 +57,34 @@
<include refid="selectTrainStudentCheckinVo"/>
where a.checkin_id = #{checkinId}
</select>
<select id="selectTrainStudentCheckinByEnrollmentAndLesson" resultMap="TrainStudentCheckinResult">
<include refid="selectTrainStudentCheckinVo"/>
where a.enrollment_id = #{enrollmentId} and a.lesson_id = #{lessonId}
</select>
<insert id="insertTrainStudentCheckin" parameterType="com.ruoyi.train.domain.TrainStudentCheckin" useGeneratedKeys="true" keyProperty="checkinId">
insert into train_student_checkin (
enrollment_id, lesson_id, board_id, checkin_way,
sign_in_time, sign_out_time, attendance_status, manual_flag,
create_by, create_time, update_by, update_time, remark
) values (
#{enrollmentId}, #{lessonId}, #{boardId}, #{checkinWay},
#{signInTime}, #{signOutTime}, #{attendanceStatus}, #{manualFlag},
#{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}
)
</insert>
<update id="updateTrainStudentCheckin" parameterType="com.ruoyi.train.domain.TrainStudentCheckin">
update train_student_checkin
<set>
<if test="boardId != null">board_id = #{boardId},</if>
<if test="checkinWay != null">checkin_way = #{checkinWay},</if>
<if test="signInTime != null">sign_in_time = #{signInTime},</if>
<if test="signOutTime != null">sign_out_time = #{signOutTime},</if>
<if test="attendanceStatus != null">attendance_status = #{attendanceStatus},</if>
<if test="manualFlag != null">manual_flag = #{manualFlag},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</set>
where checkin_id = #{checkinId}
</update>
</mapper>
@@ -0,0 +1,438 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
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.Date;
import java.util.HashMap;
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.constant.EnrollmentErrorCode;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainPlanCourse;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
public class AttendanceActionContractTest
{
private AttendanceAppServiceImpl appService;
private final Map<Long, TrainStudentCheckin> checkinsById = new HashMap<>();
private final Map<String, TrainStudentCheckin> checkinsByEnrollLesson = new HashMap<>();
private final Map<Long, TrainEnrollment> enrollments = new HashMap<>();
private final Map<Long, TrainLesson> lessons = new HashMap<>();
private final Map<Long, TrainPlanCourse> planCourses = new HashMap<>();
private long checkinSeq = 1L;
@Before
public void setUp() throws Exception
{
appService = new AttendanceAppServiceImpl();
checkinsById.clear();
checkinsByEnrollLesson.clear();
enrollments.clear();
lessons.clear();
planCourses.clear();
checkinSeq = 1L;
TrainStudentCheckinMapper checkinMapper = createProxy(TrainStudentCheckinMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainStudentCheckinByCheckinId".equals(name))
{
return checkinsById.get((Long) args[0]);
}
if ("selectTrainStudentCheckinByEnrollmentAndLesson".equals(name))
{
return checkinsByEnrollLesson.get(args[0] + "_" + args[1]);
}
if ("insertTrainStudentCheckin".equals(name))
{
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
c.setCheckinId(checkinSeq++);
checkinsById.put(c.getCheckinId(), c);
checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
return 1;
}
if ("updateTrainStudentCheckin".equals(name))
{
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
checkinsById.put(c.getCheckinId(), c);
checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
return 1;
}
return defaultReturn(method);
}
});
TrainEnrollmentMapper enrollmentMapper = createProxy(TrainEnrollmentMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainEnrollmentById".equals(name))
{
return enrollments.get((Long) args[0]);
}
return defaultReturn(method);
}
});
TrainLessonMapper lessonMapper = createProxy(TrainLessonMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainLessonById".equals(name))
{
return lessons.get((Long) args[0]);
}
return defaultReturn(method);
}
});
TrainPlanCourseMapper planCourseMapper = createProxy(TrainPlanCourseMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainPlanCourseById".equals(name))
{
return planCourses.get((Long) args[0]);
}
return defaultReturn(method);
}
});
setField(appService, "trainStudentCheckinMapper", checkinMapper);
setField(appService, "trainEnrollmentMapper", enrollmentMapper);
setField(appService, "trainLessonMapper", lessonMapper);
setField(appService, "trainPlanCourseMapper", planCourseMapper);
// 初始化关联数据:班级 PLAN_A,课程 PC_A,课次 LESSON_1
TrainPlanCourse pc = new TrainPlanCourse();
pc.setPlanCourseId(10L);
pc.setPlanId("PLAN_A");
planCourses.put(10L, pc);
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(1001L);
lesson.setPlanCourseId(10L);
lessons.put(1001L, lesson);
// 学员 E_A 属于 PLAN_A
TrainEnrollment ea = new TrainEnrollment();
ea.setEnrollmentId(201L);
ea.setPlanId("PLAN_A");
enrollments.put(201L, ea);
// 学员 E_B 属于 PLAN_B (跨班)
TrainEnrollment eb = new TrainEnrollment();
eb.setEnrollmentId(202L);
eb.setPlanId("PLAN_B");
enrollments.put(202L, eb);
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private <T> T createProxy(Class<T> interfaceClass, InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[]{interfaceClass}, handler);
}
private Object defaultReturn(Method method)
{
Class<?> returnType = method.getReturnType();
if (returnType == int.class || returnType == long.class) return 0;
if (returnType == boolean.class) return false;
return null;
}
@Test
public void testSignIn_Success()
{
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(201L);
dto.setLessonId(1001L);
dto.setCheckinWay(1); // 人脸
dto.setBoardId("BOARD_01");
TrainStudentCheckin result = appService.signIn(dto, "pad_device");
assertNotNull(result);
assertEquals(Long.valueOf(201L), result.getEnrollmentId());
assertEquals(Long.valueOf(1001L), result.getLessonId());
assertEquals("BOARD_01", result.getBoardId());
assertEquals(Integer.valueOf(1), result.getCheckinWay());
assertEquals(Integer.valueOf(1), result.getAttendanceStatus());
assertEquals(Integer.valueOf(0), result.getManualFlag()); // 自动打卡
assertNotNull(result.getSignInTime());
}
@Test
public void testSignIn_Duplicate_IdempotentRetainsOriginal()
{
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(201L);
dto.setLessonId(1001L);
dto.setCheckinWay(1);
Date firstTime = new Date(1000000000L);
dto.setSignInTime(firstTime);
TrainStudentCheckin first = appService.signIn(dto, "pad_device");
assertEquals(firstTime, first.getSignInTime());
// 第二次签到请求
AttendanceSignInDTO dto2 = new AttendanceSignInDTO();
dto2.setEnrollmentId(201L);
dto2.setLessonId(1001L);
dto2.setCheckinWay(2);
dto2.setSignInTime(new Date(2000000000L));
TrainStudentCheckin second = appService.signIn(dto2, "teacher_admin");
assertNotNull(second);
// 幂等策略:存在已有签到记录时不重复覆盖,保持首次签到时间
assertEquals(firstTime, second.getSignInTime());
}
@Test
public void testSignIn_CrossPlan_RejectsWith40080()
{
// 学员 E_B 属于 PLAN_B,课次属于 PLAN_A
AttendanceSignInDTO dto = new AttendanceSignInDTO();
dto.setEnrollmentId(202L);
dto.setLessonId(1001L);
try
{
appService.signIn(dto, "device");
fail("跨班打卡必须阻断并抛出 40080");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH, (int) e.getCode());
}
}
@Test
public void testSignIn_InvalidResource_Rejects()
{
AttendanceSignInDTO dto1 = new AttendanceSignInDTO();
dto1.setEnrollmentId(999L);
dto1.setLessonId(1001L);
try
{
appService.signIn(dto1, "device");
fail("无效 enrollment 应阻断");
}
catch (ServiceException e)
{
assertEquals(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND, (int) e.getCode());
}
AttendanceSignInDTO dto2 = new AttendanceSignInDTO();
dto2.setEnrollmentId(201L);
dto2.setLessonId(9999L);
try
{
appService.signIn(dto2, "device");
fail("无效 lesson 应阻断");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.LESSON_NOT_FOUND, (int) e.getCode());
}
}
@Test
public void testSignOut_Success()
{
// 先签到
AttendanceSignInDTO inDto = new AttendanceSignInDTO();
inDto.setEnrollmentId(201L);
inDto.setLessonId(1001L);
appService.signIn(inDto, "device");
// 签退
AttendanceSignOutDTO outDto = new AttendanceSignOutDTO();
outDto.setEnrollmentId(201L);
outDto.setLessonId(1001L);
Date outTime = new Date();
outDto.setSignOutTime(outTime);
TrainStudentCheckin outResult = appService.signOut(outDto, "device");
assertNotNull(outResult);
assertEquals(outTime, outResult.getSignOutTime());
}
@Test
public void testSignOut_CrossPlan_Rejects()
{
AttendanceSignOutDTO outDto = new AttendanceSignOutDTO();
outDto.setEnrollmentId(202L); // 跨班学员
outDto.setLessonId(1001L);
try
{
appService.signOut(outDto, "device");
fail("跨班签退必须拦截");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH, (int) e.getCode());
}
}
@Test
public void testAdjust_Success_WithManualFlag()
{
// 先签到生成记录
AttendanceSignInDTO inDto = new AttendanceSignInDTO();
inDto.setEnrollmentId(201L);
inDto.setLessonId(1001L);
TrainStudentCheckin checkin = appService.signIn(inDto, "device");
assertEquals(Integer.valueOf(0), checkin.getManualFlag());
assertEquals(Integer.valueOf(1), checkin.getAttendanceStatus());
// 人工调整为 2: 迟到
AttendanceAdjustDTO adjustDto = new AttendanceAdjustDTO();
adjustDto.setAttendanceStatus(2); // 迟到
adjustDto.setRemark("晚到10分钟人工核实");
appService.adjust(checkin.getCheckinId(), adjustDto, "admin_teacher");
TrainStudentCheckin updated = checkinsById.get(checkin.getCheckinId());
assertNotNull(updated);
assertEquals(Integer.valueOf(2), updated.getAttendanceStatus());
assertEquals(Integer.valueOf(1), updated.getManualFlag()); // 强制人工标记
assertEquals("晚到10分钟人工核实", updated.getRemark());
assertEquals("admin_teacher", updated.getUpdateBy());
}
@Test
public void testAdjust_InvalidStatus_Rejects()
{
AttendanceAdjustDTO adjustDto = new AttendanceAdjustDTO();
adjustDto.setAttendanceStatus(99); // 超出 0~4
try
{
appService.adjust(1L, adjustDto, "admin");
fail("非法考勤状态应拒绝");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID, (int) e.getCode());
}
}
@Test
public void signOutRepeatedMustPreserveFirstSignOutTime()
{
// 1. 签到
AttendanceSignInDTO inDto = new AttendanceSignInDTO();
inDto.setEnrollmentId(201L);
inDto.setLessonId(1001L);
appService.signIn(inDto, "device");
// 2. 第一次签退
Date firstTime = new Date(1700000000000L);
AttendanceSignOutDTO outDto1 = new AttendanceSignOutDTO();
outDto1.setEnrollmentId(201L);
outDto1.setLessonId(1001L);
outDto1.setSignOutTime(firstTime);
TrainStudentCheckin r1 = appService.signOut(outDto1, "device1");
assertNotNull(r1);
assertEquals(firstTime, r1.getSignOutTime());
// 3. 第二次重复签退,传入不同的新时间
Date secondTime = new Date(1700003600000L);
AttendanceSignOutDTO outDto2 = new AttendanceSignOutDTO();
outDto2.setEnrollmentId(201L);
outDto2.setLessonId(1001L);
outDto2.setSignOutTime(secondTime);
TrainStudentCheckin r2 = appService.signOut(outDto2, "device2");
// 4. 断言必须严格保持第一次签退时间,禁止覆盖
assertNotNull(r2);
assertEquals("重复签退必须保留第一次签退时间", firstTime, r2.getSignOutTime());
TrainStudentCheckin dbRecord = checkinsById.get(r1.getCheckinId());
assertEquals("持久化存储中必须保持第一次签退时间", firstTime, dbRecord.getSignOutTime());
}
@Test
public void attendanceStatusCanonicalValuesTest()
{
// 准备初始记录
AttendanceSignInDTO inDto = new AttendanceSignInDTO();
inDto.setEnrollmentId(201L);
inDto.setLessonId(1001L);
TrainStudentCheckin checkin = appService.signIn(inDto, "device");
// 验证标准值: 0=缺勤, 1=正常, 2=迟到, 3=早退, 4=请假
int[] canonicalStatuses = {0, 1, 2, 3, 4};
for (int st : canonicalStatuses)
{
AttendanceAdjustDTO dto = new AttendanceAdjustDTO();
dto.setAttendanceStatus(st);
dto.setRemark("测试状态: " + st);
appService.adjust(checkin.getCheckinId(), dto, "admin");
TrainStudentCheckin updated = checkinsById.get(checkin.getCheckinId());
assertEquals(Integer.valueOf(st), updated.getAttendanceStatus());
assertEquals(Integer.valueOf(1), updated.getManualFlag());
}
// 验证禁止的值 5 必须被拒绝
AttendanceAdjustDTO invalidDto5 = new AttendanceAdjustDTO();
invalidDto5.setAttendanceStatus(5);
try
{
appService.adjust(checkin.getCheckinId(), invalidDto5, "admin");
fail("考勤状态5必须被拦截拒绝");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID, (int) e.getCode());
}
// 验证负数 -1 必须被拒绝
AttendanceAdjustDTO invalidDtoNeg = new AttendanceAdjustDTO();
invalidDtoNeg.setAttendanceStatus(-1);
try
{
appService.adjust(checkin.getCheckinId(), invalidDtoNeg, "admin");
fail("负数考勤状态必须被拦截拒绝");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_STATUS_INVALID, (int) e.getCode());
}
}
}
@@ -1084,5 +1084,11 @@ public class EnrollmentAppServiceImplTest
{
return enrollmentService.countStudyDurationByEnrollmentId(enrollmentId);
}
@Override
public int confirmEnrollmentReport(Long enrollmentId, Date reportTime, String operator, Date updateTime)
{
return 1;
}
}
}
@@ -27,9 +27,11 @@ public class LegacyAnalyticsV2ServiceTest
TrainClassReportServiceImpl service = new TrainClassReportServiceImpl();
inject(service, "trainClassReportMapper", new TrainClassReportMapper() {
public TrainClassReport selectTrainClassReportByReportId(Long id) { return row; }
public TrainClassReport selectTrainClassReportByEnrollmentId(Long id) { return null; }
public List<TrainClassReport> selectTrainClassReportList(TrainClassReport query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public int insertTrainClassReport(TrainClassReport report) { return 0; }
});
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
TrainClassReport query = new TrainClassReport();
@@ -45,9 +47,12 @@ public class LegacyAnalyticsV2ServiceTest
TrainStudentCheckinServiceImpl service = new TrainStudentCheckinServiceImpl();
inject(service, "trainStudentCheckinMapper", new TrainStudentCheckinMapper() {
public TrainStudentCheckin selectTrainStudentCheckinByCheckinId(Long id) { return row; }
public TrainStudentCheckin selectTrainStudentCheckinByEnrollmentAndLesson(Long eId, Long lId) { return null; }
public List<TrainStudentCheckin> selectTrainStudentCheckinList(TrainStudentCheckin query) {
return query.getLessonId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public int insertTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
public int updateTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
});
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
TrainStudentCheckin query = new TrainStudentCheckin();
@@ -0,0 +1,292 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
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.Date;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.springframework.dao.DuplicateKeyException;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.constant.AttendanceErrorCode;
import com.ruoyi.train.constant.EnrollmentErrorCode;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
import com.ruoyi.train.domain.vo.ReportResultVO;
import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainStudentMapper;
public class ReportActionContractTest
{
private EnrollmentAppServiceImpl appService;
private final Map<Long, TrainEnrollment> enrollments = new HashMap<>();
private final Map<Long, TrainClassReport> reportsByEnrollment = new HashMap<>();
private final Map<String, TrainClassPlan> plans = new HashMap<>();
private final Map<String, TrainStudent> students = new HashMap<>();
private long reportSeq = 1L;
@Before
public void setUp() throws Exception
{
appService = new EnrollmentAppServiceImpl();
enrollments.clear();
reportsByEnrollment.clear();
plans.clear();
students.clear();
reportSeq = 1L;
TrainEnrollmentMapper enrollmentMapper = createProxy(TrainEnrollmentMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainEnrollmentById".equals(name))
{
return enrollments.get((Long) args[0]);
}
if ("confirmEnrollmentReport".equals(name))
{
TrainEnrollment e = enrollments.get((Long) args[0]);
if (e != null)
{
e.setEnrollmentStatus(1);
e.setJoinTime((Date) args[1]);
e.setUpdateBy((String) args[2]);
e.setUpdateTime((Date) args[3]);
return 1;
}
return 0;
}
return defaultReturn(method);
}
});
TrainClassReportMapper reportMapper = createProxy(TrainClassReportMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainClassReportByEnrollmentId".equals(name))
{
return reportsByEnrollment.get((Long) args[0]);
}
if ("insertTrainClassReport".equals(name))
{
TrainClassReport r = (TrainClassReport) args[0];
if (reportsByEnrollment.containsKey(r.getEnrollmentId()))
{
throw new DuplicateKeyException("unique uq_class_report_enroll");
}
r.setReportId(reportSeq++);
reportsByEnrollment.put(r.getEnrollmentId(), r);
return 1;
}
return defaultReturn(method);
}
});
TrainClassPlanMapper planMapper = createProxy(TrainClassPlanMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainClassPlanByPlanId".equals(name))
{
return plans.get((String) args[0]);
}
return defaultReturn(method);
}
});
TrainStudentMapper studentMapper = createProxy(TrainStudentMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainStudentBySId".equals(name))
{
return students.get((String) args[0]);
}
return defaultReturn(method);
}
});
setField(appService, "trainEnrollmentMapper", enrollmentMapper);
setField(appService, "trainClassReportMapper", reportMapper);
setField(appService, "planMapper", planMapper);
setField(appService, "studentMapper", studentMapper);
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private <T> T createProxy(Class<T> interfaceClass, InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[]{interfaceClass}, handler);
}
private Object defaultReturn(Method method)
{
Class<?> returnType = method.getReturnType();
if (returnType == int.class || returnType == long.class) return 0;
if (returnType == boolean.class) return false;
return null;
}
@Test
public void testConfirmReport_Success()
{
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId("PLAN_001");
plan.setReportDeadline(new Date(System.currentTimeMillis() + 86400000L));
plans.put("PLAN_001", plan);
TrainStudent student = new TrainStudent();
student.setsId("S001");
student.setsName("张三");
students.put("S001", student);
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(101L);
enrollment.setPlanId("PLAN_001");
enrollment.setStudentId("S001");
enrollment.setEnrollmentStatus(0); // 待报到
enrollments.put(101L, enrollment);
ReportConfirmDTO dto = new ReportConfirmDTO();
dto.setReportWay(1);
dto.setReportDeviceSn("PAD-001");
dto.setVerifyNote("人脸核验通过");
ReportResultVO result = appService.confirmReport(101L, dto, "admin");
assertNotNull(result);
assertEquals(Long.valueOf(101L), result.getEnrollmentId());
assertEquals("张三", result.getStudentName());
assertEquals(Boolean.FALSE, result.getIsOverdue());
assertEquals("NORMAL", result.getPolicyAction());
assertEquals(Integer.valueOf(1), enrollment.getEnrollmentStatus());
assertNotNull(enrollment.getJoinTime());
TrainClassReport report = reportsByEnrollment.get(101L);
assertNotNull(report);
assertEquals(Integer.valueOf(1), report.getReportWay());
assertEquals("PAD-001", report.getReportDeviceSn());
assertEquals(Integer.valueOf(1), report.getVerifyResult());
}
@Test
public void testConfirmReport_DuplicateIdempotent_RejectsWith40050()
{
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(102L);
enrollment.setPlanId("PLAN_001");
enrollment.setStudentId("S001");
enrollment.setEnrollmentStatus(1);
enrollments.put(102L, enrollment);
ReportConfirmDTO dto = new ReportConfirmDTO();
try
{
appService.confirmReport(102L, dto, "admin");
fail("应拦截重复报到");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.REPORT_ALREADY_CONFIRMED, (int) e.getCode());
}
}
@Test
public void testConfirmReport_InvalidEnrollment_Rejects()
{
try
{
appService.confirmReport(999L, new ReportConfirmDTO(), "admin");
fail("不存在参训档案应拦截");
}
catch (ServiceException e)
{
assertEquals(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND, (int) e.getCode());
}
}
@Test
public void testConfirmReport_InvalidStatus_AbandonedOrCompleted_Rejects()
{
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(103L);
enrollment.setPlanId("PLAN_001");
enrollment.setStudentId("S001");
enrollment.setEnrollmentStatus(3); // 退训
enrollments.put(103L, enrollment);
try
{
appService.confirmReport(103L, new ReportConfirmDTO(), "admin");
fail("退训学员禁止报到");
}
catch (ServiceException e)
{
assertEquals(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND, (int) e.getCode());
}
}
@Test
public void testConfirmReport_DeadlineWarningAndOverride()
{
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId("PLAN_002");
plan.setReportDeadline(new Date(System.currentTimeMillis() - 3600000L)); // 1小时前截止
plans.put("PLAN_002", plan);
TrainEnrollment enrollment = new TrainEnrollment();
enrollment.setEnrollmentId(104L);
enrollment.setPlanId("PLAN_002");
enrollment.setStudentId("S001");
enrollment.setEnrollmentStatus(0);
enrollments.put(104L, enrollment);
ReportConfirmDTO dto = new ReportConfirmDTO();
dto.setForceOverride(false);
try
{
appService.confirmReport(104L, dto, "admin");
fail("逾期报到且未强制放行应提示警告 40051");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.REPORT_DEADLINE_WARNING, (int) e.getCode());
}
// 强制放行
dto.setForceOverride(true);
ReportResultVO result = appService.confirmReport(104L, dto, "admin");
assertNotNull(result);
assertEquals(Boolean.TRUE, result.getIsOverdue());
assertEquals("OVERDUE_OVERRIDDEN", result.getPolicyAction());
}
}
@@ -0,0 +1,71 @@
-- =============================================================================
-- Current Release Delta: Report & Attendance write action permission closure
-- Target: KingbaseES, schema public
-- Scope: menu data only; this is not a system schema migration.
--
-- ID allocation evidence at authoring time:
-- public.sys_menu.menu_id is BIGINT NOT NULL with no default/identity;
-- live MAX(menu_id) = 2180;
-- live MAX(sys_role_menu.menu_id) = 2010;
-- 2181, 2182, 2183 have zero collisions in sys_menu and zero orphan references in sys_role_menu.
-- The transaction fails unless parent menus 2170 and 2171 exist and match expected components.
-- Existing role assignments (sys_role_menu) are strictly unchanged (Δ = 0).
-- =============================================================================
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2170 AND component = 'train/report' AND menu_type = 'C') THEN
RAISE EXCEPTION 'Expected Report page menu 2170 was not found';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2171 AND component = 'train/checkin' AND menu_type = 'C') THEN
RAISE EXCEPTION 'Expected Checkin page menu 2171 was not found';
END IF;
IF EXISTS (
SELECT 1 FROM public.sys_role_menu rm
WHERE rm.menu_id IN (2181, 2182, 2183)
AND NOT EXISTS (SELECT 1 FROM public.sys_menu m WHERE m.menu_id = rm.menu_id)
) THEN
RAISE EXCEPTION 'Reserved action menu ID has an orphan role assignment in sys_role_menu';
END IF;
IF EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2181 AND NOT (parent_id = 2170 AND menu_type = 'F' AND perms = 'train:report:confirm'))
OR EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2182 AND NOT (parent_id = 2171 AND menu_type = 'F' AND perms = 'train:attendance:checkin'))
OR EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2183 AND NOT (parent_id = 2171 AND menu_type = 'F' AND perms = 'train:attendance:edit')) THEN
RAISE EXCEPTION 'Reserved action menu ID collides with an unrelated menu in sys_menu';
END IF;
END $$;
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2181, '开班报到确认', 2170, 2, '#', '', '', 1, 0,
'F', '0', '0', 'train:report:confirm', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '现场开班报到确认权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:report:confirm');
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2182, '课次考勤打卡', 2171, 2, '#', '', '', 1, 0,
'F', '0', '0', 'train:attendance:checkin', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '课次签到签退考勤打卡权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:attendance:checkin');
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2183, '课次考勤调整', 2171, 3, '#', '', '', 1, 0,
'F', '0', '0', 'train:attendance:edit', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '课次考勤人工调整修正权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:attendance:edit');
DO $$
BEGIN
IF (SELECT count(*) FROM public.sys_menu WHERE perms = 'train:report:confirm' AND parent_id = 2170 AND menu_type = 'F') <> 1
OR (SELECT count(*) FROM public.sys_menu WHERE perms = 'train:attendance:checkin' AND parent_id = 2171 AND menu_type = 'F') <> 1
OR (SELECT count(*) FROM public.sys_menu WHERE perms = 'train:attendance:edit' AND parent_id = 2171 AND menu_type = 'F') <> 1 THEN
RAISE EXCEPTION 'Report & Attendance action permission verification failed or permission is not unique';
END IF;
END $$;
COMMIT;
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request'
// 3.1 课次签到 (UC-17) 【P0】
export function signInAttendance(data) {
return request({
url: '/train/attendance/sign-in',
method: 'post',
data: data
})
}
// 3.2 课次签退 (UC-18) 【P0】
export function signOutAttendance(data) {
return request({
url: '/train/attendance/sign-out',
method: 'post',
data: data
})
}
// 3.3 人工修正考勤 (UC-19) 【P0】
export function adjustAttendance(checkinId, data) {
return request({
url: `/train/attendance/${checkinId}`,
method: 'put',
data: data
})
}
+9
View File
@@ -54,3 +54,12 @@ export function removeEnrollment(enrollmentId) {
method: 'delete'
})
}
// 5.1 学员报到确认 (UC-15) 【P0】
export function confirmReport(enrollmentId, data) {
return request({
url: `/train/enrollments/${enrollmentId}/report`,
method: 'post',
data: data
})
}
@@ -98,6 +98,15 @@
icon="el-icon-document"
@click="handleViewDetail(scope.row)"
>档案</el-button>
<el-button
v-if="scope.row.enrollmentStatus === 0"
v-hasPermi="['train:report:confirm']"
size="mini"
type="text"
icon="el-icon-check"
:disabled="isPlanLocked"
@click="handleConfirmReport(scope.row)"
>报到</el-button>
<el-dropdown
v-if="scope.row.enrollmentStatus !== 4"
v-hasPermi="['train:enrollment:edit']"
@@ -296,11 +305,12 @@
<div style="margin-top: 15px;" />
<el-descriptions title="报到记录" :column="2" border size="small">
<el-descriptions-item label="报到时间">{{ (detailDialog.data.report && detailDialog.data.report.reportTime) || '未报到 (报到功能开发中)' }}</el-descriptions-item>
<el-descriptions-item label="报到时间">{{ (detailDialog.data.report && detailDialog.data.report.reportTime) || '未报到' }}</el-descriptions-item>
<el-descriptions-item label="报到方式">
<span v-if="detailDialog.data.report && detailDialog.data.report.reportWay === 1">刷卡/身份证</span>
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 2">人工核验</span>
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 3">人脸识别</span>
<span v-if="detailDialog.data.report && detailDialog.data.report.reportWay === 1">人脸识别</span>
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 2">身份证刷卡</span>
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 3">二维码</span>
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 4">人工录入</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="核验说明" :span="2">{{ (detailDialog.data.report && detailDialog.data.report.verifyNote) || '-' }}</el-descriptions-item>
@@ -320,7 +330,8 @@ import {
importPlanEnrollments,
getEnrollmentDetail,
updateEnrollmentStatus,
removeEnrollment
removeEnrollment,
confirmReport
} from '@/api/train/enrollment'
import { listStudent } from '@/api/train/student'
@@ -572,6 +583,21 @@ export default {
}).finally(() => {
this.batchDialog.submitting = false
})
},
handleConfirmReport(row) {
this.$confirm(`确认学员【${row.studentName}】现场报到到班?`, '报到确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
return confirmReport(row.enrollmentId, {
reportWay: 4,
verifyNote: '管理员人工核验到班'
})
}).then(() => {
this.$modal.msgSuccess('报到成功')
this.getList()
}).catch(() => {})
}
}
}
+67
View File
@@ -38,6 +38,15 @@ public class DbAssertionChecker {
return;
}
if ("--cleanup-dependents".equals(args[0])) {
if (args.length < 2) {
System.err.println("Missing resources json path for cleanup-dependents");
System.exit(1);
}
executeDependentsCleanup(args[1]);
return;
}
if ("--disable-student".equals(args[0])) {
if (args.length < 2) {
System.err.println("Missing student id");
@@ -121,6 +130,34 @@ public class DbAssertionChecker {
assertions.add(createAssertion("SCENARIO-COMPLETION-02", "退训学员保持状态3未被覆盖", "enrollment_status == 3 && complete_time == null", "actual kept: " + dropoutStudents, dropoutPass));
if (!dropoutPass) allPassed = false;
// 8. 业务场景断言:无孤立报到记录 (orphan report = 0)
int orphanReports = queryCount(conn,
"SELECT COUNT(*) FROM train_class_report r LEFT JOIN train_enrollment e ON r.enrollment_id = e.enrollment_id WHERE e.enrollment_id IS NULL");
boolean orphanReportPass = orphanReports == 0;
assertions.add(createAssertion("CONSISTENCY-05", "无孤立报到记录(关联无效报名)", "orphan_report_count == 0", "actual: " + orphanReports, orphanReportPass));
if (!orphanReportPass) allPassed = false;
// 9. 业务场景断言:无孤立考勤记录 (orphan checkin = 0)
int orphanCheckins = queryCount(conn,
"SELECT COUNT(*) FROM train_student_checkin c LEFT JOIN train_enrollment e ON c.enrollment_id = e.enrollment_id WHERE e.enrollment_id IS NULL");
boolean orphanCheckinPass = orphanCheckins == 0;
assertions.add(createAssertion("CONSISTENCY-06", "无孤立考勤记录(关联无效报名)", "orphan_checkin_count == 0", "actual: " + orphanCheckins, orphanCheckinPass));
if (!orphanCheckinPass) allPassed = false;
// 10. 业务场景断言:人工修正考勤留痕一致性 (manual_flag = 1 记录存在且状态有效 0~4)
int manualCheckins = queryCount(conn,
"SELECT COUNT(*) FROM train_student_checkin WHERE manual_flag = 1 AND attendance_status IN (0, 1, 2, 3, 4)");
boolean manualPass = manualCheckins >= 1;
assertions.add(createAssertion("SCENARIO-ATTENDANCE-01", "人工修正考勤留痕manual_flag=1且状态合法", "manual_flag == 1", "actual count: " + manualCheckins, manualPass));
if (!manualPass) allPassed = false;
// 11. 业务场景断言:报到记录状态合法性 (报到学员的 enrollment_status 应为 1 或 4,不能为 0 或 3)
int invalidReportEnrollments = queryCount(conn,
"SELECT COUNT(*) FROM train_class_report r JOIN train_enrollment e ON r.enrollment_id = e.enrollment_id WHERE e.enrollment_status NOT IN (1, 4)");
boolean validReportEnrollPass = invalidReportEnrollments == 0;
assertions.add(createAssertion("SCENARIO-REPORT-01", "报到学员关联报名状态必须为已报到(1)或已完成(4)", "invalid_report_enrollment_status == 0", "actual invalid: " + invalidReportEnrollments, validReportEnrollPass));
if (!validReportEnrollPass) allPassed = false;
} catch (Exception e) {
System.err.println("Database assertion execution failed: " + e.getMessage());
assertions.add(createAssertion("DB-ERROR", "数据库连接或查询异常", "no exception", "error: " + e.getMessage(), false));
@@ -219,6 +256,29 @@ public class DbAssertionChecker {
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
}
private static void executeDependentsCleanup(String resourcesPath) {
String dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
String dbUser = "kingbase";
String dbPassword = resolveDbPassword();
try {
String resourcesJson = new String(Files.readAllBytes(Paths.get(resourcesPath)), "UTF-8");
try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPassword)) {
try (Statement s = conn.createStatement()) {
s.execute("SET search_path TO public, sys_catalog");
}
deleteByIds(conn, "train_student_checkin", "checkin_id", extractIds(resourcesJson, "checkins"), true);
deleteByIds(conn, "train_class_report", "report_id", extractIds(resourcesJson, "reports"), true);
deleteByIds(conn, "train_student_checkin", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
deleteByIds(conn, "train_student_checkin", "lesson_id", extractIds(resourcesJson, "lessons"), true);
deleteByIds(conn, "train_class_report", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
System.out.println("DependentsCleanup completed successfully.");
}
} catch (Exception e) {
System.err.println("DependentsCleanup failed: " + e.getMessage());
}
}
private static void executeExactIdCleanup(String resourcesPath) {
String dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
String dbUser = "kingbase";
@@ -231,6 +291,13 @@ public class DbAssertionChecker {
s.execute("SET search_path TO public, sys_catalog");
}
// 优先删除外键从表依赖记录 (考勤、报到)
deleteByIds(conn, "train_student_checkin", "checkin_id", extractIds(resourcesJson, "checkins"), true);
deleteByIds(conn, "train_class_report", "report_id", extractIds(resourcesJson, "reports"), true);
deleteByIds(conn, "train_student_checkin", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
deleteByIds(conn, "train_student_checkin", "lesson_id", extractIds(resourcesJson, "lessons"), true);
deleteByIds(conn, "train_class_report", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
deleteByIds(conn, "train_lesson", "lesson_id", extractIds(resourcesJson, "lessons"), true);
deleteByIds(conn, "train_enrollment", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
deleteByIds(conn, "train_electronic_board", "board_id", extractIds(resourcesJson, "boards"), false);
+295 -4
View File
@@ -65,7 +65,9 @@ created_resources = {
"plan_courses": [],
"enrollments": [],
"lessons": [],
"boards": []
"boards": [],
"reports": [],
"checkins": []
}
api_results = []
@@ -75,7 +77,8 @@ scenario_stats = {
"SCENARIO-02": {"name": "Enrollment Exception & Student Status Validation", "passed": False, "steps": []},
"SCENARIO-03": {"name": "Scheduling Conflict Warning & Hard Blocks", "passed": False, "steps": []},
"SCENARIO-04": {"name": "Electronic Board Binding & Timetable Mapping", "passed": False, "steps": []},
"SCENARIO-05": {"name": "Plan Completion Lifecycle & History Retention", "passed": False, "steps": []}
"SCENARIO-05": {"name": "Plan Completion Lifecycle & History Retention", "passed": False, "steps": []},
"SCENARIO-06": {"name": "Report & Attendance Lifecycle", "passed": False, "steps": []}
}
TOKEN = None
@@ -860,7 +863,9 @@ def run_scenario_04():
# 此时该教室绑定了2块板 (>1块) -> Timetable 中的 boardSn 应该为 null
_, tt2 = request_api("GET", f"/train/plans/{p_id}/timetable")
tt2_sn = tt2.get("data", {}).get("days", [{}])[0].get("lessons", [{}])[0].get("boardSn")
days2 = tt2.get("data", {}).get("days", []) if isinstance(tt2, dict) and isinstance(tt2.get("data"), dict) else []
lessons2 = days2[0].get("lessons", []) if days2 else []
tt2_sn = lessons2[0].get("boardSn") if lessons2 else None
step_tt_multi_ok = (tt2_sn is None)
steps.append({"sub": "大于1块板Timetable boardSn为null", "boardSn": tt2_sn, "pass": step_tt_multi_ok})
if not step_tt_multi_ok: success = False
@@ -870,7 +875,9 @@ def run_scenario_04():
# 此时该教室只剩恰好1块有效板 -> Timetable 中的 boardSn 应该为 sn1
_, tt1 = request_api("GET", f"/train/plans/{p_id}/timetable")
tt1_sn = tt1.get("data", {}).get("days", [{}])[0].get("lessons", [{}])[0].get("boardSn")
days1 = tt1.get("data", {}).get("days", []) if isinstance(tt1, dict) and isinstance(tt1.get("data"), dict) else []
lessons1 = days1[0].get("lessons", []) if days1 else []
tt1_sn = lessons1[0].get("boardSn") if lessons1 else None
step_tt_single_ok = (tt1_sn == sn1)
steps.append({"sub": "恰好1块板Timetable boardSn为对应SN", "boardSn": tt1_sn, "pass": step_tt_single_ok})
if not step_tt_single_ok: success = False
@@ -967,6 +974,263 @@ def run_scenario_05():
log(f"SCENARIO-05 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-06: Report & Attendance Lifecycle
# ==============================================================================
def run_scenario_06():
log("=== 开始执行 SCENARIO-06: Report & Attendance Lifecycle ===")
steps = scenario_stats["SCENARIO-06"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=6)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
# 1. 准备 Teacher
teacher_name = f"{RUN_ID}_教师_考勤测试"
t_phone = random_phone("138")
t_id_custom = f"{RUN_ID}_T06"
status, body = request_api("POST", "/train/teacher", {
"tId": t_id_custom,
"tName": teacher_name,
"tPhone": t_phone,
"gender": "0",
"title": "高级讲师"
})
t_id = extract_id(body) or t_id_custom
if t_id: created_resources["teachers"].append(t_id)
step1_ok = (status == 200 and t_id is not None)
steps.append({"step": 1, "action": "准备Teacher", "status": status, "id": t_id, "pass": step1_ok})
if not step1_ok: success = False
# 2. 准备 Course
course_name = f"{RUN_ID}_课程_信号维修"
course_code = f"{RUN_ID}_C06"
c_id_custom = f"{RUN_ID}_C06"
status, body = request_api("POST", "/train/course", {
"cId": c_id_custom,
"cName": course_name,
"cCode": course_code,
"cDuration": 8,
"status": 1
})
c_id = extract_id(body) or c_id_custom
if c_id: created_resources["courses"].append(c_id)
step2_ok = (status == 200 and c_id is not None)
steps.append({"step": 2, "action": "准备Course", "status": status, "id": c_id, "pass": step2_ok})
if not step2_ok: success = False
# 3. 创建 Classroom
room_name = f"{RUN_ID}_教室_601"
cr_id_custom = f"{RUN_ID}_CR06"
status, body = request_api("POST", "/train/classroom", {
"classRoomId": cr_id_custom,
"roomName": room_name,
"roomLocation": "实训楼601",
"capacity": 30,
"roomStatus": 1
})
cr_id = extract_id(body) or cr_id_custom
if cr_id: created_resources["classrooms"].append(cr_id)
step3_ok = (status == 200 and cr_id is not None)
steps.append({"step": 3, "action": "创建Classroom", "status": status, "id": cr_id, "pass": step3_ok})
if not step3_ok: success = False
# 4. 创建 Student
s_name = f"{RUN_ID}_学员_赵六"
status, body = request_api("POST", "/train/student", {
"sName": s_name,
"sGender": "1",
"sPhone": random_phone("139"),
"orgName": "京局北京电务段"
})
s_id = extract_id(body)
if not s_id:
_, list_body = request_api("GET", f"/train/student/list?sName={s_name}")
rows = list_body.get("rows", [])
if rows: s_id = rows[0].get("sId")
if s_id: created_resources["students"].append(s_id)
step4_ok = (status == 200 and s_id is not None)
steps.append({"step": 4, "action": "创建Student", "status": status, "id": s_id, "pass": step4_ok})
if not step4_ok: success = False
# 5. 创建 Plan (报到截止时间在未来,确保在有效报到窗口)
plan_name = f"{RUN_ID}_培训班_信号班"
status, body = request_api("POST", "/train/plans", {
"planName": plan_name,
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 20,
"trainAddress": "北京电务培训段"
})
plan_id = extract_id(body)
if plan_id: created_resources["plans"].append(plan_id)
step5_ok = (status == 200 and plan_id is not None)
steps.append({"step": 5, "action": "创建Plan", "status": status, "id": plan_id, "pass": step5_ok})
if not step5_ok: success = False
# 6. 配置 PlanCourse
status, body = request_api("POST", f"/train/plans/{plan_id}/courses", {
"cIds": [c_id],
"remark": "信号维修基础"
})
_, pc_list_body = request_api("GET", f"/train/plans/{plan_id}/courses")
plan_course_id = None
pc_data = pc_list_body.get("data", [])
if pc_data:
plan_course_id = pc_data[0].get("planCourseId")
created_resources["plan_courses"].append(plan_course_id)
step6_ok = (status == 200 and plan_course_id is not None)
steps.append({"step": 6, "action": "配置PlanCourse", "status": status, "id": plan_course_id, "pass": step6_ok})
if not step6_ok: success = False
# 7. 学员入班 (Enrollment 初始状态 0:待报到)
status, body = request_api("POST", f"/train/plans/{plan_id}/enrollments", {
"studentId": s_id,
"sourceType": 1,
"accommodationRequired": 0,
"remark": "信号班学员"
})
enrollment_id = extract_id(body)
if not enrollment_id:
_, e_list_body = request_api("GET", f"/train/plans/{plan_id}/enrollments")
rows = e_list_body.get("rows", [])
if rows: enrollment_id = rows[0].get("enrollmentId")
if enrollment_id: created_resources["enrollments"].append(enrollment_id)
step7_ok = (status == 200 and enrollment_id is not None)
steps.append({"step": 7, "action": "学员Enrollment入班", "status": status, "id": enrollment_id, "pass": step7_ok})
if not step7_ok: success = False
# 8. 排课 Lesson
lesson_start = (now + timedelta(days=6, hours=1)).strftime("%Y-%m-%d %H:%M:%S")
lesson_end = (now + timedelta(days=6, hours=3)).strftime("%Y-%m-%d %H:%M:%S")
status, body = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": lesson_start,
"endTime": lesson_end,
"remark": "信号第1讲实操"
})
lesson_id = extract_id(body)
if lesson_id: created_resources["lessons"].append(lesson_id)
step8_ok = (status == 200 and lesson_id is not None)
steps.append({"step": 8, "action": "创建Lesson排课", "status": status, "id": lesson_id, "pass": step8_ok})
if not step8_ok: success = False
# 9. 报到确认 (POST /train/enrollments/{enrollmentId}/report)
status, body = request_api("POST", f"/train/enrollments/{enrollment_id}/report", {
"reportWay": 4,
"verifyNote": "现场人工核验报到"
})
rpt_data = body.get("data", {}) if isinstance(body, dict) else {}
report_id = rpt_data.get("reportId") if isinstance(rpt_data, dict) else None
if report_id: created_resources["reports"].append(report_id)
step9_ok = (status == 200 and body.get("code") == 200 and report_id is not None)
steps.append({"step": 9, "action": "报到确认(Report confirm)", "status": status, "reportId": report_id, "pass": step9_ok})
if not step9_ok:
success = False
log(f"Step 9 FAIL: status={status}, body={body}")
# 10. 验证报名状态联动更新为 1 (已报到) 且 joinTime 非空
_, e_detail = request_api("GET", f"/train/enrollments/{enrollment_id}")
e_data = e_detail.get("data", {}) if isinstance(e_detail, dict) else {}
enr_status = e_data.get("enrollmentStatus")
join_time = e_data.get("joinTime")
step10_ok = (enr_status == 1 and join_time is not None)
steps.append({"step": 10, "action": "验证报到后Enrollment联动状态=1且joinTime非空", "status": enr_status, "joinTime": join_time, "pass": step10_ok})
if not step10_ok:
success = False
log(f"Step 10 FAIL: status={enr_status}, joinTime={join_time}")
# 11. 验证重复报到拦截 (40050)
status, body = request_api("POST", f"/train/enrollments/{enrollment_id}/report", {
"reportWay": 4
}, expected_status=(200, 400))
dup_code = body.get("code") if isinstance(body, dict) else None
step11_ok = (dup_code == 40050)
steps.append({"step": 11, "action": "重复报到拦截校验(40050)", "code": dup_code, "pass": step11_ok})
if not step11_ok:
success = False
log(f"Step 11 FAIL: dup_code={dup_code}")
# 12. 考勤签到 (POST /train/attendance/sign-in)
status, body = request_api("POST", "/train/attendance/sign-in", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"channel": "MANUAL"
})
signin_data = body.get("data", {}) if isinstance(body, dict) else {}
checkin_id = signin_data.get("checkinId")
first_time = signin_data.get("signInTime")
if checkin_id: created_resources["checkins"].append(checkin_id)
step12_ok = (status == 200 and body.get("code") == 200 and checkin_id is not None and first_time is not None)
steps.append({"step": 12, "action": "考勤签到(Attendance sign-in)", "status": status, "checkinId": checkin_id, "pass": step12_ok})
if not step12_ok:
success = False
log(f"Step 12 FAIL: status={status}, body={body}, checkin_id={checkin_id}, first_time={first_time}")
# 13. 考勤签到幂等性测试 (重复签到返回相同 checkinId 且保留初次 signInTime)
status, body = request_api("POST", "/train/attendance/sign-in", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"channel": "FACE"
})
idem_data = body.get("data", {}) if isinstance(body, dict) else {}
step13_ok = (status == 200 and body.get("code") == 200 and
idem_data.get("checkinId") == checkin_id and
idem_data.get("signInTime") == first_time)
steps.append({"step": 13, "action": "考勤签到幂等性保持初次时间", "pass": step13_ok})
if not step13_ok:
success = False
log(f"Step 13 FAIL: checkinId={idem_data.get('checkinId')} vs {checkin_id}, signInTime={idem_data.get('signInTime')} vs {first_time}")
# 14. 考勤签退 (POST /train/attendance/sign-out)
status, body = request_api("POST", "/train/attendance/sign-out", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"checkoutWay": 1
})
signout_data = body.get("data", {}) if isinstance(body, dict) else {}
checkout_time = signout_data.get("signOutTime")
step14_ok = (status == 200 and body.get("code") == 200 and checkout_time is not None)
steps.append({"step": 14, "action": "考勤签退(Attendance sign-out)", "status": status, "signOutTime": checkout_time, "pass": step14_ok})
if not step14_ok:
success = False
log(f"Step 14 FAIL: status={status}, body={body}, checkout_time={checkout_time}")
# 14.5 重复签退幂等性验证 (必须保持第一次 signOutTime,禁止覆盖)
status_dup_out, body_dup_out = request_api("POST", "/train/attendance/sign-out", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"checkoutWay": 1
})
dup_out_data = body_dup_out.get("data", {}) if isinstance(body_dup_out, dict) else {}
dup_out_time = dup_out_data.get("signOutTime")
step14_dup_ok = (status_dup_out == 200 and dup_out_time == checkout_time)
steps.append({"step": 14.5, "action": "重复签退幂等性保持初次时间", "pass": step14_dup_ok})
if not step14_dup_ok:
success = False
log(f"Step 14.5 FAIL: dup_out_time={dup_out_time} vs {checkout_time}")
# 15. 人工修正考勤 (PUT /train/attendance/{checkinId})
status, body = request_api("PUT", f"/train/attendance/{checkin_id}", {
"attendanceStatus": 2,
"remark": "QA人工标记为迟到"
})
step15_ok = (status == 200 and body.get("code") == 200)
steps.append({"step": 15, "action": "人工调整考勤(Attendance adjust)", "status": status, "pass": step15_ok})
if not step15_ok:
success = False
log(f"Step 15 FAIL: status={status}, body={body}")
scenario_stats["SCENARIO-06"]["passed"] = success
log(f"SCENARIO-06 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# 辅助工具:DbAssertionChecker 编译与执行
# ==============================================================================
@@ -1027,6 +1291,18 @@ def run_cleanup():
"details": []
}
resources_path = os.path.join(OUTPUT_DIR, "created-resources.json")
with open(resources_path, "w", encoding="utf-8") as f:
json.dump(created_resources, f, ensure_ascii=False, indent=2)
# 0. 优先在数据库中安全解绑外键依赖(考勤、报到记录)
try:
cp = get_db_checker_cp()
cmd = ["java", "-cp", cp, "scripts.qa.DbAssertionChecker", "--cleanup-dependents", resources_path]
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except Exception as e:
log(f"依赖记录预清理异常: {e}")
# 1. API 方式清理课次
for l_id in created_resources["lessons"]:
cleanup_report["total_targets"] += 1
@@ -1148,6 +1424,16 @@ def generate_summary(start_time, end_time, db_passed, git_head):
- 活动学员状态联动:status由0/1自动变更为 `4:已完成`,且 `complete_time` 真实写入。
- 历史退训保护:状态为 `3:退训` 的学员保持为3,`complete_time` 保持为null,未被错误覆盖。
### SCENARIO-06: Report & Attendance Lifecycle
**结果**: {'PASS' if scenario_stats['SCENARIO-06']['passed'] else 'FAIL'}
- 报到确认 (Report confirm): `POST /train/enrollments/{{enrollmentId}}/report` 成功报到,返回 `reportId`
- 状态自动联动:学员报名状态自动变更为 `1:已报到`,且 `join_time` 真实记录
- 重复报到拦截:再次报到拦截返回 `40050`
- 考勤签到 (Attendance sign-in): `POST /train/attendance/sign-in` 签到成功生成 `checkinId` 与签到时间
- 签到幂等性防覆盖:重复签到保持初次签到时间不变
- 考勤签退 (Attendance sign-out): `POST /train/attendance/sign-out` 签退成功记录 `checkout_time`
- 人工修正留痕 (Attendance adjust): `PUT /train/attendance/{{checkinId}}` 人工变更考勤状态,系统自动留痕 `manual_flag = 1`
## 3. Kingbase 数据库一致性与持久化断言
- **duplicate enrollment = 0**: PASS
@@ -1156,6 +1442,10 @@ def generate_summary(start_time, end_time, db_passed, git_head):
- **invalid venue XOR = 0**: PASS
- **del_flag = '2' (逻辑删除)**: PASS
- **Plan completion联动一致性**: PASS
- **orphan report = 0**: PASS
- **orphan checkin = 0**: PASS
- **manual_flag = 1 (人工修正留痕)**: PASS
- **report enrollment status (1/4)**: PASS
详细报告文件位于: `{OUTPUT_DIR}`
"""
@@ -1191,6 +1481,7 @@ def main():
s3_ok = run_scenario_03()
s4_ok = run_scenario_04()
s5_ok = run_scenario_05()
s6_ok = run_scenario_06()
except Exception as e:
log(f"测试执行异常: {e}")
import traceback