完善考勤:支持未签到学员人工登记缺勤

名册中无任何打卡记录的已报到学员此前只能"先签到再改成缺勤",会留下伪造的签到事实。

- 新增 POST /train/attendance/mark-absence,权限 train:attendance:edit,带操作日志
- 写入 attendance_status=0、sign_in_time 与 sign_out_time 保持 NULL、manual_flag=1,
  缺勤原因必填,绝不伪造签到时间
- 前置条件全部 fail closed:课次已开始、与课次同属一个开班计划、
  参训状态必须恰好为已报到;请假不得被自动改写为缺勤
- 已有考勤记录时拒绝新建并引导改用调整考勤记录,
  并发命中唯一索引转为同一业务错误,不泄露数据库异常
- 名册新增 canMarkAbsence,前端直接采用后端判定,登记后可继续调整考勤记录
- 未改动出勤率统计口径与 Analytics 业务策略

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wangzhibiaoandCursor committed 2026-09-22 17:04:41 +08:00
1 parent b7eab13a83
commit b7a9b89ec5
12 files changed
+1108 -9

No files matched your search

@@ -17,6 +17,7 @@ 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.AttendanceMarkAbsenceDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.service.AttendanceAppService;
@@ -72,6 +73,18 @@ public class AttendanceController extends BaseController
return success();
}
/**
* 课次缺勤登记 (对名册中无任何打卡事实的学员直接登记缺勤)。
*/
@PreAuthorize("@ss.hasPermi('train:attendance:edit')")
@Log(title = "课次缺勤登记", businessType = BusinessType.INSERT)
@PostMapping("/mark-absence")
public AjaxResult markAbsence(@Validated @RequestBody AttendanceMarkAbsenceDTO dto)
{
TrainStudentCheckin checkin = attendanceAppService.markAbsence(dto, SecurityUtils.getUsername());
return success(checkin);
}
/**
* 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。
*/
@@ -9,7 +9,10 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceMarkAbsenceDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
@@ -58,7 +61,19 @@ public class AttendanceAndReportApiControllerContractTest
assertEquals("/{checkinId}", putAdjust.value()[0]);
assertEquals("@ss.hasPermi('train:attendance:edit')", adjust.getAnnotation(PreAuthorize.class).value());
// 4. 课次学员考勤名册 (纯只读,供管理员点名签到)
// 4. 课次缺勤登记 (对无任何打卡事实的学员直接登记缺勤)
Method markAbsence = AttendanceController.class.getMethod("markAbsence", AttendanceMarkAbsenceDTO.class);
assertNotNull(markAbsence);
PostMapping postMarkAbsence = markAbsence.getAnnotation(PostMapping.class);
assertNotNull(postMarkAbsence);
assertEquals("/mark-absence", postMarkAbsence.value()[0]);
assertEquals("@ss.hasPermi('train:attendance:edit')", markAbsence.getAnnotation(PreAuthorize.class).value());
Log markAbsenceLog = markAbsence.getAnnotation(Log.class);
assertNotNull(markAbsenceLog);
assertEquals("课次缺勤登记", markAbsenceLog.title());
assertEquals(BusinessType.INSERT, markAbsenceLog.businessType());
// 5. 课次学员考勤名册 (纯只读,供管理员点名签到)
Method getRoster = AttendanceController.class.getMethod("getLessonRoster", Long.class);
assertNotNull(getRoster);
GetMapping getRosterAnnotation = getRoster.getAnnotation(GetMapping.class);
@@ -53,6 +53,42 @@ public final class AttendanceErrorCode
public static final int ATTENDANCE_SIGN_IN_ENROLLMENT_STATUS_INVALID = 40088;
public static final String ATTENDANCE_SIGN_IN_ENROLLMENT_STATUS_INVALID_MESSAGE = "学员参训生命周期状态不合法,禁止录入课次考勤";
/** 标记缺勤:课次尚未开始,禁止提前登记缺勤 */
public static final int ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED = 40089;
public static final String ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED_MESSAGE = "课次尚未开始,禁止提前登记缺勤";
/** 标记缺勤:课次开始时间缺失,无法判定课次是否已开始 (fail closed 拒绝) */
public static final int ATTENDANCE_MARK_ABSENCE_LESSON_TIME_UNKNOWN = 40090;
public static final String ATTENDANCE_MARK_ABSENCE_LESSON_TIME_UNKNOWN_MESSAGE = "课次开始时间缺失,无法判定课次是否已开始,禁止登记缺勤";
/** 标记缺勤:学员尚未完成开班报到 */
public static final int ATTENDANCE_MARK_ABSENCE_REQUIRES_REPORTED_ENROLLMENT = 40091;
public static final String ATTENDANCE_MARK_ABSENCE_REQUIRES_REPORTED_ENROLLMENT_MESSAGE = "学员尚未完成开班报到,禁止登记缺勤";
/** 标记缺勤:学员当前为请假状态,不得自动转为缺勤 */
public static final int ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ON_LEAVE = 40092;
public static final String ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ON_LEAVE_MESSAGE = "学员当前为请假状态,禁止直接登记缺勤,请先处理请假流程";
/** 标记缺勤:学员参训状态已放弃 */
public static final int ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ABANDONED = 40093;
public static final String ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ABANDONED_MESSAGE = "学员参训状态已放弃,禁止登记缺勤";
/** 标记缺勤:学员参训档案已结训完成 */
public static final int ATTENDANCE_MARK_ABSENCE_ENROLLMENT_COMPLETED = 40094;
public static final String ATTENDANCE_MARK_ABSENCE_ENROLLMENT_COMPLETED_MESSAGE = "学员参训档案已结训完成,禁止登记缺勤";
/** 标记缺勤:学员参训生命周期状态不合法 (fail closed 拒绝) */
public static final int ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID = 40095;
public static final String ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID_MESSAGE = "学员参训生命周期状态不合法,禁止登记缺勤";
/** 标记缺勤:缺勤登记原因必填 */
public static final int ATTENDANCE_MARK_ABSENCE_REMARK_REQUIRED = 40096;
public static final String ATTENDANCE_MARK_ABSENCE_REMARK_REQUIRED_MESSAGE = "缺勤登记原因不能为空,请填写缺勤原因";
/** 标记缺勤:该课次该学员已存在考勤记录,禁止重复新建 */
public static final int ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS = 40097;
public static final String ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS_MESSAGE = "该学员本课次已存在考勤记录,请改用「调整考勤记录」修改考勤结论";
private AttendanceErrorCode()
{
}
@@ -0,0 +1,57 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 课次缺勤登记请求 DTO。
* 用于对名册中尚未产生任何打卡事实 (NO_CHECKIN) 的学员直接登记缺勤,
* 避免出现「先伪造签到再改成缺勤」的失真操作。
*/
public class AttendanceMarkAbsenceDTO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 参训档案ID */
@NotNull(message = "参训档案ID不能为空")
private Long enrollmentId;
/** 课次ID */
@NotNull(message = "课次ID不能为空")
private Long lessonId;
/** 缺勤登记原因 (必填,写入 remark 留痕) */
@NotBlank(message = "缺勤登记原因不能为空")
private String remark;
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 getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -65,6 +65,9 @@ public class AttendanceLessonRosterVO implements Serializable
/** 是否允许调整 (已有考勤记录) */
private Boolean canAdjust;
/** 是否允许登记缺勤 (无任何打卡记录 + 参训状态为1已报到 + 课次已开始) */
private Boolean canMarkAbsence;
public Long getEnrollmentId()
{
return enrollmentId;
@@ -224,4 +227,14 @@ public class AttendanceLessonRosterVO implements Serializable
{
this.canAdjust = canAdjust;
}
public Boolean getCanMarkAbsence()
{
return canMarkAbsence;
}
public void setCanMarkAbsence(Boolean canMarkAbsence)
{
this.canMarkAbsence = canMarkAbsence;
}
}
@@ -2,12 +2,13 @@ package com.ruoyi.train.service;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.domain.dto.AttendanceMarkAbsenceDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
/**
* 课次考勤核心动作应用服务接口 (Module C / P0)。
* 涵盖课次签到、课次签退、人工修正。
* 涵盖课次签到、课次签退、人工修正、缺勤登记。
*/
public interface AttendanceAppService
{
@@ -38,6 +39,18 @@ public interface AttendanceAppService
*/
void adjust(Long checkinId, AttendanceAdjustDTO dto, String operator);
/**
* 课次缺勤登记 (对尚无任何打卡事实的学员直接登记缺勤)。
*
* 写入事实严格约束:attendance_status=0、sign_in_time 与 sign_out_time 均为 NULL、
* manual_flag=1,绝不伪造签到时间。
*
* @param dto 缺勤登记请求参数
* @param operator 操作人
* @return 新建的考勤记录
*/
TrainStudentCheckin markAbsence(AttendanceMarkAbsenceDTO dto, String operator);
/**
* 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。
*
@@ -19,6 +19,7 @@ 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.AttendanceMarkAbsenceDTO;
import com.ruoyi.train.domain.dto.AttendanceSignInDTO;
import com.ruoyi.train.domain.dto.AttendanceSignOutDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
@@ -243,6 +244,88 @@ public class AttendanceAppServiceImpl implements AttendanceAppService
trainStudentCheckinMapper.updateTrainStudentCheckin(checkin);
}
@Override
@Transactional(rollbackFor = Exception.class)
public TrainStudentCheckin markAbsence(AttendanceMarkAbsenceDTO dto, String operator)
{
if (dto == null || dto.getEnrollmentId() == null || dto.getLessonId() == null)
{
throw new ServiceException("缺勤登记参数不完整", 400);
}
// 缺勤登记原因必填:缺勤是负向考勤事实,必须留下可追溯的人工登记依据
if (dto.getRemark() == null || dto.getRemark().trim().isEmpty())
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REMARK_REQUIRED_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REMARK_REQUIRED);
}
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() == null || !enrollment.getPlanId().equals(planCourse.getPlanId()))
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH_MESSAGE, AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH);
}
// 课次必须已经开始:未开始的课次不存在「缺勤」这个既成事实
assertLessonStarted(lesson);
// 参训生命周期守卫:仅允许对状态恰好为 1 (已报到) 的在训学员登记缺勤
assertEnrollmentEligibleForMarkAbsence(enrollment);
// 唯一性守卫:已有任何考勤记录时禁止再建第二条,引导用户改走调整考勤
TrainStudentCheckin existing = trainStudentCheckinMapper.selectTrainStudentCheckinByEnrollmentAndLesson(dto.getEnrollmentId(), dto.getLessonId());
if (existing != null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS);
}
Date now = new Date();
TrainStudentCheckin checkin = new TrainStudentCheckin();
checkin.setEnrollmentId(dto.getEnrollmentId());
checkin.setLessonId(dto.getLessonId());
checkin.setBoardId(null);
// DDL 约束 train_student_checkin.checkin_way NOT NULL,无法留空;
// 缺勤登记是管理端人工录入渠道,因此复用既有合法值 4 (教师/管理代签) 表示人工录入来源。
// 真实出勤事实完全由 attendance_status=0 与 sign_in_time=NULL 表达,不受该渠道值影响。
checkin.setCheckinWay(4);
checkin.setSignInTime(null); // 绝不伪造签到时间
checkin.setSignOutTime(null);
checkin.setAttendanceStatus(0); // 0:缺勤
checkin.setManualFlag(1); // 人工登记留痕
checkin.setRemark(dto.getRemark().trim());
checkin.setCreateBy(operator);
checkin.setCreateTime(now);
try
{
trainStudentCheckinMapper.insertTrainStudentCheckin(checkin);
}
catch (DuplicateKeyException e)
{
// 并发下命中唯一索引 uq_student_checkin_enroll_lesson:转成同一个业务错误,不泄露数据库异常
log.warn("缺勤登记命中考勤唯一约束,enrollmentId={}, lessonId={}", dto.getEnrollmentId(), dto.getLessonId());
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS);
}
TrainStudentCheckin created = trainStudentCheckinMapper.selectTrainStudentCheckinByCheckinId(checkin.getCheckinId());
return created != null ? created : checkin;
}
@Override
public List<AttendanceLessonRosterVO> getLessonRoster(Long lessonId)
{
@@ -280,6 +363,9 @@ public class AttendanceAppServiceImpl implements AttendanceAppService
}
}
// 课次是否已开始:缺勤登记入口的统一前置判定 (开始时间缺失时 fail closed 为未开始)
boolean lessonStarted = isLessonStarted(lesson);
List<AttendanceLessonRosterVO> roster = new ArrayList<>();
if (enrollments != null)
{
@@ -315,6 +401,8 @@ public class AttendanceAppServiceImpl implements AttendanceAppService
vo.setCanSignIn(c.getSignInTime() == null && eligibleForSignIn);
vo.setCanSignOut(c.getSignInTime() != null && c.getSignOutTime() == null);
vo.setCanAdjust(true);
// 已有考勤记录一律禁止再次登记缺勤,必须走「调整考勤记录」
vo.setCanMarkAbsence(false);
}
else
{
@@ -327,6 +415,10 @@ public class AttendanceAppServiceImpl implements AttendanceAppService
vo.setCanSignIn(eligibleForSignIn);
vo.setCanSignOut(false);
vo.setCanAdjust(false);
// 缺勤登记:无任何打卡事实 + 参训状态恰好为 1 (已报到) + 课次已开始
vo.setCanMarkAbsence(lessonStarted
&& item.getEnrollmentStatus() != null
&& item.getEnrollmentStatus() == 1);
}
roster.add(vo);
}
@@ -386,6 +478,78 @@ public class AttendanceAppServiceImpl implements AttendanceAppService
return status == 1 || status == 2;
}
/**
* 判定课次是否已经开始 (开始时间 <= 当前时间)。
* 开始时间缺失或无法解析时 fail closed 返回 false,视为未开始。
*/
private boolean isLessonStarted(TrainLesson lesson)
{
if (lesson == null || lesson.getStartTime() == null)
{
return false;
}
return !lesson.getStartTime().after(new Date());
}
/**
* 缺勤登记的课次时间守卫:课次开始时间缺失或课次尚未开始时一律拒绝。
*/
private void assertLessonStarted(TrainLesson lesson)
{
if (lesson == null || lesson.getStartTime() == null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_TIME_UNKNOWN_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_TIME_UNKNOWN);
}
if (lesson.getStartTime().after(new Date()))
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED);
}
}
/**
* 校验参训学员生命周期状态是否允许登记缺勤。
*
* 冻结业务规则 (比签到更严格,必须恰好为 1):
* 0 待报到 -> 拒绝 (未报到不构成缺课事实,应先走报到流程)
* 1 已报到 -> 允许
* 2 请假 -> 拒绝 (请假是独立考勤结论,不得被自动改写为缺勤)
* 3 放弃 -> 拒绝
* 4 已完成 -> 拒绝
* 未知/null -> fail closed 拒绝
*/
private void assertEnrollmentEligibleForMarkAbsence(TrainEnrollment enrollment)
{
if (enrollment == null || enrollment.getEnrollmentStatus() == null)
{
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID);
}
int status = enrollment.getEnrollmentStatus();
switch (status)
{
case 1: // 1:已报到
return;
case 0: // 0:待报到
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REQUIRES_REPORTED_ENROLLMENT_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REQUIRES_REPORTED_ENROLLMENT);
case 2: // 2:请假
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ON_LEAVE_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ON_LEAVE);
case 3: // 3:放弃
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ABANDONED_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ABANDONED);
case 4: // 4:已完成
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_COMPLETED_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_COMPLETED);
default:
throw new ServiceException(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID_MESSAGE,
AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID);
}
}
private String maskPhone(String phone)
{
if (phone == null || phone.length() < 7)
@@ -0,0 +1,602 @@
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.assertNull;
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 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.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.AttendanceMarkAbsenceDTO;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.AttendanceLessonRosterVO;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
/**
* 课次缺勤登记 (markAbsence) 契约测试。
*
* 冻结核心事实:
* 1. 成功登记时 attendance_status=0、sign_in_time/sign_out_time 必须为 NULL、manual_flag=1、remark 原样保留;
* 2. 课次未开始、开始时间缺失、参训状态非 1、remark 空白、已有考勤记录、跨班均 fail closed 拒绝;
* 3. 唯一索引冲突必须转成可读业务错误,不得泄露 DuplicateKeyException。
*
* 沿用仓库既有手写 Mock Mapper 风格 (无 Mockito)。
*/
public class AttendanceMarkAbsenceContractTest
{
/** 课次 LESSON_STARTED:一小时前已开始 */
private static final Long LESSON_STARTED = 1001L;
/** 课次 LESSON_FUTURE:一天后才开始 */
private static final Long LESSON_FUTURE = 1002L;
/** 课次 LESSON_NO_TIME:开始时间缺失 */
private static final Long LESSON_NO_TIME = 1003L;
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;
/** 为 true 时模拟并发场景:insert 直接抛唯一索引冲突 */
private boolean forceDuplicateKeyOnInsert = false;
@Before
public void setUp() throws Exception
{
appService = new AttendanceAppServiceImpl();
checkinsById.clear();
checkinsByEnrollLesson.clear();
enrollments.clear();
lessons.clear();
planCourses.clear();
checkinSeq = 1L;
forceDuplicateKeyOnInsert = false;
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))
{
if (forceDuplicateKeyOnInsert)
{
throw new DuplicateKeyException("duplicate key value violates unique constraint \"uq_student_checkin_enroll_lesson\"");
}
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;
}
if ("selectCheckinsByLessonId".equals(name))
{
Long lid = (Long) args[0];
List<TrainStudentCheckin> list = new ArrayList<>();
for (TrainStudentCheckin c : checkinsById.values())
{
if (lid.equals(c.getLessonId()))
{
list.add(c);
}
}
return list;
}
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) || "selectTrainEnrollmentByIdForUpdate".equals(name))
{
return enrollments.get((Long) args[0]);
}
if ("selectEnrollmentItemList".equals(name))
{
EnrollmentQuery q = (EnrollmentQuery) args[0];
List<EnrollmentItemVO> list = new ArrayList<>();
for (TrainEnrollment e : enrollments.values())
{
if (q.getPlanId() != null && q.getPlanId().equals(e.getPlanId()))
{
EnrollmentItemVO vo = new EnrollmentItemVO();
vo.setEnrollmentId(e.getEnrollmentId());
vo.setPlanId(e.getPlanId());
vo.setStudentId("S_" + e.getEnrollmentId());
vo.setStudentName("学员_" + e.getEnrollmentId());
vo.setGender("男");
vo.setOrgName("电务段");
vo.setPhone("13812345678");
vo.setEnrollmentStatus(e.getEnrollmentStatus());
list.add(vo);
}
}
return list;
}
return defaultReturn(method);
}
});
TrainLessonMapper lessonMapper = createProxy(TrainLessonMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
if ("selectTrainLessonById".equals(method.getName()))
{
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
{
if ("selectTrainPlanCourseById".equals(method.getName()))
{
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 -> 课程实例 10
TrainPlanCourse pc = new TrainPlanCourse();
pc.setPlanCourseId(10L);
pc.setPlanId("PLAN_A");
planCourses.put(10L, pc);
long now = System.currentTimeMillis();
TrainLesson started = new TrainLesson();
started.setLessonId(LESSON_STARTED);
started.setPlanCourseId(10L);
started.setStartTime(new Date(now - 3600_000L));
started.setEndTime(new Date(now + 3600_000L));
lessons.put(LESSON_STARTED, started);
TrainLesson future = new TrainLesson();
future.setLessonId(LESSON_FUTURE);
future.setPlanCourseId(10L);
future.setStartTime(new Date(now + 86_400_000L));
future.setEndTime(new Date(now + 90_000_000L));
lessons.put(LESSON_FUTURE, future);
TrainLesson noTime = new TrainLesson();
noTime.setLessonId(LESSON_NO_TIME);
noTime.setPlanCourseId(10L);
noTime.setStartTime(null);
lessons.put(LESSON_NO_TIME, noTime);
// 学员 201:PLAN_A,1 已报到
enrollments.put(201L, buildEnrollment(201L, "PLAN_A", 1));
// 学员 202:PLAN_B 跨班,1 已报到
enrollments.put(202L, buildEnrollment(202L, "PLAN_B", 1));
}
private TrainEnrollment buildEnrollment(Long id, String planId, Integer status)
{
TrainEnrollment e = new TrainEnrollment();
e.setEnrollmentId(id);
e.setPlanId(planId);
e.setEnrollmentStatus(status);
return e;
}
private AttendanceMarkAbsenceDTO buildDto(Long enrollmentId, Long lessonId, String remark)
{
AttendanceMarkAbsenceDTO dto = new AttendanceMarkAbsenceDTO();
dto.setEnrollmentId(enrollmentId);
dto.setLessonId(lessonId);
dto.setRemark(remark);
return dto;
}
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 markAbsence_ReportedStudentStartedLessonNoRecord_CreatesPureAbsenceFact()
{
TrainStudentCheckin created = appService.markAbsence(
buildDto(201L, LESSON_STARTED, "未到课且未请假"), "train_admin");
assertNotNull(created);
assertEquals(Long.valueOf(201L), created.getEnrollmentId());
assertEquals(Long.valueOf(LESSON_STARTED), created.getLessonId());
assertEquals("考勤结论必须为 0 缺勤", Integer.valueOf(0), created.getAttendanceStatus());
assertNull("绝不得伪造签到时间", created.getSignInTime());
assertNull("绝不得伪造签退时间", created.getSignOutTime());
assertEquals("必须标记为人工登记留痕", Integer.valueOf(1), created.getManualFlag());
assertEquals("缺勤原因必须原样保留", "未到课且未请假", created.getRemark());
assertEquals("train_admin", created.getCreateBy());
// 持久化侧同样断言,防止只在返回对象上做样子
TrainStudentCheckin stored = checkinsByEnrollLesson.get("201_" + LESSON_STARTED);
assertNotNull(stored);
assertEquals(Integer.valueOf(0), stored.getAttendanceStatus());
assertNull(stored.getSignInTime());
assertNull(stored.getSignOutTime());
assertEquals(Integer.valueOf(1), stored.getManualFlag());
}
@Test
public void markAbsence_TrimsRemarkWhitespace()
{
TrainStudentCheckin created = appService.markAbsence(
buildDto(201L, LESSON_STARTED, " 旷课未报备 "), "train_admin");
assertEquals("旷课未报备", created.getRemark());
}
@Test
public void markAbsence_LessonNotStarted_Rejected()
{
try
{
appService.markAbsence(buildDto(201L, LESSON_FUTURE, "未到课"), "train_admin");
fail("课次尚未开始必须拒绝登记缺勤");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED, (int) e.getCode());
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_NOT_STARTED_MESSAGE, e.getMessage());
}
assertNull("拒绝路径不得产生任何写入", checkinsByEnrollLesson.get("201_" + LESSON_FUTURE));
}
@Test
public void markAbsence_LessonStartTimeMissing_FailClosed()
{
try
{
appService.markAbsence(buildDto(201L, LESSON_NO_TIME, "未到课"), "train_admin");
fail("课次开始时间缺失必须 fail closed 拒绝");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_LESSON_TIME_UNKNOWN, (int) e.getCode());
}
assertNull(checkinsByEnrollLesson.get("201_" + LESSON_NO_TIME));
}
@Test
public void markAbsence_EnrollmentStatus0Pending_Rejected()
{
enrollments.put(300L, buildEnrollment(300L, "PLAN_A", 0));
assertRejected(300L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REQUIRES_REPORTED_ENROLLMENT,
"待报到学员必须拒绝登记缺勤");
}
@Test
public void markAbsence_EnrollmentStatus2OnLeave_Rejected()
{
// 请假是独立考勤结论,禁止被自动改写成缺勤
enrollments.put(302L, buildEnrollment(302L, "PLAN_A", 2));
assertRejected(302L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ON_LEAVE,
"请假学员必须拒绝登记缺勤");
}
@Test
public void markAbsence_EnrollmentStatus3Abandoned_Rejected()
{
enrollments.put(303L, buildEnrollment(303L, "PLAN_A", 3));
assertRejected(303L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_ABANDONED,
"已放弃学员必须拒绝登记缺勤");
}
@Test
public void markAbsence_EnrollmentStatus4Completed_Rejected()
{
enrollments.put(304L, buildEnrollment(304L, "PLAN_A", 4));
assertRejected(304L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_COMPLETED,
"已结训学员必须拒绝登记缺勤");
}
@Test
public void markAbsence_EnrollmentStatusNullOrUnknown_FailClosed()
{
enrollments.put(305L, buildEnrollment(305L, "PLAN_A", null));
assertRejected(305L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID,
"null 参训状态必须 fail closed 拒绝");
enrollments.put(306L, buildEnrollment(306L, "PLAN_A", 99));
assertRejected(306L, AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ENROLLMENT_STATUS_INVALID,
"未知参训状态必须 fail closed 拒绝");
}
private void assertRejected(Long enrollmentId, int expectedCode, String failHint)
{
try
{
appService.markAbsence(buildDto(enrollmentId, LESSON_STARTED, "未到课"), "train_admin");
fail(failHint);
}
catch (ServiceException e)
{
assertEquals(expectedCode, (int) e.getCode());
}
assertNull("拒绝路径不得产生任何写入", checkinsByEnrollLesson.get(enrollmentId + "_" + LESSON_STARTED));
}
@Test
public void markAbsence_BlankRemark_Rejected()
{
String[] blanks = {null, "", " "};
for (String blank : blanks)
{
try
{
appService.markAbsence(buildDto(201L, LESSON_STARTED, blank), "train_admin");
fail("空白缺勤原因必须拒绝, 输入=" + blank);
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_REMARK_REQUIRED, (int) e.getCode());
}
}
assertNull(checkinsByEnrollLesson.get("201_" + LESSON_STARTED));
}
@Test
public void markAbsence_ExistingCheckin_RejectsAndGuidesToAdjust()
{
TrainStudentCheckin existing = new TrainStudentCheckin();
existing.setCheckinId(9001L);
existing.setEnrollmentId(201L);
existing.setLessonId(LESSON_STARTED);
existing.setAttendanceStatus(1);
existing.setSignInTime(new Date());
checkinsById.put(9001L, existing);
checkinsByEnrollLesson.put("201_" + LESSON_STARTED, existing);
try
{
appService.markAbsence(buildDto(201L, LESSON_STARTED, "重复登记"), "train_admin");
fail("已有考勤记录必须拒绝新建第二条");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS, (int) e.getCode());
assertTrue("错误文案必须引导用户改用调整考勤记录", e.getMessage().contains("调整考勤记录"));
}
// 原有记录必须原样保留,不得被覆盖
TrainStudentCheckin stored = checkinsById.get(9001L);
assertEquals(Integer.valueOf(1), stored.getAttendanceStatus());
assertNotNull(stored.getSignInTime());
}
@Test
public void markAbsence_CrossPlan_Rejected()
{
try
{
appService.markAbsence(buildDto(202L, LESSON_STARTED, "未到课"), "train_admin");
fail("跨培训班登记缺勤必须拦截");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH, (int) e.getCode());
}
}
@Test
public void markAbsence_InvalidResource_Rejected()
{
try
{
appService.markAbsence(buildDto(999L, LESSON_STARTED, "未到课"), "train_admin");
fail("不存在的参训档案必须拦截");
}
catch (ServiceException e)
{
assertEquals(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND, (int) e.getCode());
}
try
{
appService.markAbsence(buildDto(201L, 99999L, "未到课"), "train_admin");
fail("不存在的课次必须拦截");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.LESSON_NOT_FOUND, (int) e.getCode());
}
}
@Test
public void markAbsence_DuplicateKeyException_TranslatedToBusinessError()
{
// 模拟并发:查重通过但 insert 命中唯一索引 uq_student_checkin_enroll_lesson
forceDuplicateKeyOnInsert = true;
try
{
appService.markAbsence(buildDto(201L, LESSON_STARTED, "未到课"), "train_admin");
fail("唯一索引冲突必须转换为业务错误");
}
catch (DuplicateKeyException e)
{
fail("禁止向调用方泄露数据库异常 DuplicateKeyException");
}
catch (ServiceException e)
{
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS, (int) e.getCode());
assertEquals(AttendanceErrorCode.ATTENDANCE_MARK_ABSENCE_ALREADY_EXISTS_MESSAGE, e.getMessage());
}
}
@Test
public void markAbsence_NullParams_Rejected()
{
try
{
appService.markAbsence(null, "train_admin");
fail("空参数必须拒绝");
}
catch (ServiceException e)
{
assertEquals(400, (int) e.getCode());
}
try
{
appService.markAbsence(buildDto(null, LESSON_STARTED, "未到课"), "train_admin");
fail("缺少 enrollmentId 必须拒绝");
}
catch (ServiceException e)
{
assertEquals(400, (int) e.getCode());
}
}
@Test
public void roster_CanMarkAbsence_OnlyForReportedStudentWithoutRecordOnStartedLesson()
{
enrollments.clear();
enrollments.put(310L, buildEnrollment(310L, "PLAN_A", 0)); // 待报到
enrollments.put(311L, buildEnrollment(311L, "PLAN_A", 1)); // 已报到,无记录
enrollments.put(312L, buildEnrollment(312L, "PLAN_A", 2)); // 请假
enrollments.put(314L, buildEnrollment(314L, "PLAN_A", 4)); // 已完成
enrollments.put(315L, buildEnrollment(315L, "PLAN_A", 1)); // 已报到,但已有考勤记录
TrainStudentCheckin existing = new TrainStudentCheckin();
existing.setCheckinId(9100L);
existing.setEnrollmentId(315L);
existing.setLessonId(LESSON_STARTED);
existing.setAttendanceStatus(1);
existing.setSignInTime(new Date());
checkinsById.put(9100L, existing);
checkinsByEnrollLesson.put("315_" + LESSON_STARTED, existing);
Map<Long, AttendanceLessonRosterVO> byEnrollment = indexRoster(appService.getLessonRoster(LESSON_STARTED));
assertFalse("status=0 待报到不得出现标记缺勤入口",
Boolean.TRUE.equals(byEnrollment.get(310L).getCanMarkAbsence()));
assertTrue("status=1 已报到且无记录且课次已开始必须允许标记缺勤",
Boolean.TRUE.equals(byEnrollment.get(311L).getCanMarkAbsence()));
assertFalse("status=2 请假不得出现标记缺勤入口",
Boolean.TRUE.equals(byEnrollment.get(312L).getCanMarkAbsence()));
assertFalse("status=4 已完成不得出现标记缺勤入口",
Boolean.TRUE.equals(byEnrollment.get(314L).getCanMarkAbsence()));
assertFalse("已有考勤记录一律不得出现标记缺勤入口",
Boolean.TRUE.equals(byEnrollment.get(315L).getCanMarkAbsence()));
}
@Test
public void roster_CanMarkAbsence_FalseWhenLessonNotStartedOrTimeMissing()
{
enrollments.clear();
enrollments.put(311L, buildEnrollment(311L, "PLAN_A", 1));
Map<Long, AttendanceLessonRosterVO> future = indexRoster(appService.getLessonRoster(LESSON_FUTURE));
assertFalse("课次未开始时不得出现标记缺勤入口",
Boolean.TRUE.equals(future.get(311L).getCanMarkAbsence()));
Map<Long, AttendanceLessonRosterVO> noTime = indexRoster(appService.getLessonRoster(LESSON_NO_TIME));
assertFalse("课次开始时间缺失时 fail closed 不得出现标记缺勤入口",
Boolean.TRUE.equals(noTime.get(311L).getCanMarkAbsence()));
}
@Test
public void roster_AfterMarkAbsence_ShowsAbsentAndAllowsAdjust()
{
appService.markAbsence(buildDto(201L, LESSON_STARTED, "未到课且未请假"), "train_admin");
Map<Long, AttendanceLessonRosterVO> byEnrollment = indexRoster(appService.getLessonRoster(LESSON_STARTED));
AttendanceLessonRosterVO row = byEnrollment.get(201L);
assertNotNull(row);
assertEquals("名册必须展示为 0 缺勤", Integer.valueOf(0), row.getAttendanceStatus());
assertNull(row.getSignInTime());
assertTrue("登记缺勤后必须可继续调整考勤记录", Boolean.TRUE.equals(row.getCanAdjust()));
assertFalse("已产生考勤记录后不得重复出现标记缺勤入口", Boolean.TRUE.equals(row.getCanMarkAbsence()));
// 后续调整为 4 请假必须仍然可用,验证与既有 adjust 行为衔接
AttendanceAdjustDTO adjustDto = new AttendanceAdjustDTO();
adjustDto.setAttendanceStatus(4);
adjustDto.setRemark("事后补交请假单");
appService.adjust(row.getCheckinId(), adjustDto, "train_admin");
assertEquals(Integer.valueOf(4), checkinsById.get(row.getCheckinId()).getAttendanceStatus());
}
private Map<Long, AttendanceLessonRosterVO> indexRoster(List<AttendanceLessonRosterVO> roster)
{
Map<Long, AttendanceLessonRosterVO> map = new HashMap<>();
for (AttendanceLessonRosterVO vo : roster)
{
map.put(vo.getEnrollmentId(), vo);
}
return map;
}
}