完善考勤:支持未签到学员人工登记缺勤
名册中无任何打卡记录的已报到学员此前只能"先签到再改成缺勤",会留下伪造的签到事实。 - 新增 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:
1 parent
b7eab13a83
commit
b7a9b89ec5
12 files changed
+1108
-9
No files matched your search
+13
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。
|
||||
*/
|
||||
|
||||
+16
-1
@@ -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()
|
||||
{
|
||||
}
|
||||
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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);
|
||||
|
||||
/**
|
||||
* 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。
|
||||
*
|
||||
|
||||
+164
@@ -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)
|
||||
|
||||
+602
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user