完善考勤:支持未签到学员人工登记缺勤
名册中无任何打卡记录的已报到学员此前只能"先签到再改成缺勤",会留下伪造的签到事实。 - 新增 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;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,16 @@ export function adjustAttendance(checkinId, data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 3.4 课次学员考勤名册 (纯只读,供培训管理员点名首次签到)
|
||||
// 3.4 课次缺勤登记 (对无任何打卡事实的学员直接登记缺勤,绝不伪造签到时间)
|
||||
export function markAbsenceAttendance(data) {
|
||||
return request({
|
||||
url: '/train/attendance/mark-absence',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 3.5 课次学员考勤名册 (纯只读,供培训管理员点名首次签到)
|
||||
export function getLessonRoster(lessonId) {
|
||||
return request({
|
||||
url: `/train/attendance/lessons/${lessonId}/roster`,
|
||||
|
||||
@@ -96,11 +96,11 @@ export function isEnrollmentEligibleForSignIn(status) {
|
||||
/**
|
||||
* 计算课次考勤名册单行学员的操作按钮可用性
|
||||
* @param {Object} row
|
||||
* @returns {{ canSignIn: boolean, canSignOut: boolean, canAdjust: boolean }}
|
||||
* @returns {{ canSignIn: boolean, canSignOut: boolean, canAdjust: boolean, canMarkAbsence: boolean }}
|
||||
*/
|
||||
export function computeRosterActionEligibility(row) {
|
||||
if (!row) {
|
||||
return { canSignIn: false, canSignOut: false, canAdjust: false };
|
||||
return { canSignIn: false, canSignOut: false, canAdjust: false, canMarkAbsence: false };
|
||||
}
|
||||
|
||||
const hasSignInFact = !!row.signInTime;
|
||||
@@ -115,7 +115,12 @@ export function computeRosterActionEligibility(row) {
|
||||
// 调整:已有打卡记录
|
||||
const canAdjust = row.checkinId !== null && row.checkinId !== undefined && row.canAdjust !== false;
|
||||
|
||||
return { canSignIn, canSignOut, canAdjust };
|
||||
// 标记缺勤:课次开始时间等判定只有后端掌握完整事实,因此严格以后端 canMarkAbsence 为准,
|
||||
// 前端只额外兜底「已有考勤记录时绝不显示该按钮」,避免出现第二条记录的误操作入口。
|
||||
const hasCheckinRecord = row.checkinId !== null && row.checkinId !== undefined;
|
||||
const canMarkAbsence = row.canMarkAbsence === true && !hasCheckinRecord;
|
||||
|
||||
return { canSignIn, canSignOut, canAdjust, canMarkAbsence };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
title="课次应到学员考勤:展示该课次应到参训学员。已报到学员可点击【签到】录入出勤;待报到或已结训学员暂不可签到;已签到学员支持签退与考勤调整。"
|
||||
title="课次应到学员考勤:展示该课次应到参训学员。已报到学员可点击【签到】录入出勤;课次已开始后未到课学员可点击【标记缺勤】填写原因直接登记缺勤(不会产生签到时间);待报到或已结训学员暂不可签到;已签到学员支持签退与考勤调整。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -86,7 +86,7 @@
|
||||
<el-table-column label="签到方式" align="center" width="100">
|
||||
<template slot-scope="scope">{{ labelOf(checkinWays, scope.row.checkinWay) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="190">
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="260">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-if="computeActionEligibility(scope.row).canSignIn"
|
||||
@@ -104,6 +104,14 @@
|
||||
icon="el-icon-time"
|
||||
@click="handleRosterSignOut(scope.row)"
|
||||
>签退</el-button>
|
||||
<el-button
|
||||
v-if="computeActionEligibility(scope.row).canMarkAbsence"
|
||||
v-hasPermi="['train:attendance:edit']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-remove-outline"
|
||||
@click="handleMarkAbsence(scope.row)"
|
||||
>标记缺勤</el-button>
|
||||
<el-button
|
||||
v-if="computeActionEligibility(scope.row).canAdjust"
|
||||
v-hasPermi="['train:attendance:edit']"
|
||||
@@ -246,12 +254,43 @@
|
||||
<el-button @click="cancelAdjust">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 缺勤登记对话框 (直接登记缺勤,不产生任何签到时间) -->
|
||||
<el-dialog title="标记缺勤" :visible.sync="absenceOpen" width="520px" append-to-body>
|
||||
<el-form ref="absenceForm" :model="absenceForm" :rules="absenceRules" label-width="100px">
|
||||
<el-form-item label="学员姓名">
|
||||
<span>{{ absenceRecord.studentName || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="学员ID/工号">
|
||||
<span>{{ absenceRecord.studentId || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="参训状态">
|
||||
<el-tag :type="formatEnrollmentStatus(absenceRecord.enrollmentStatus).type" size="small">
|
||||
{{ formatEnrollmentStatus(absenceRecord.enrollmentStatus).text }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="缺勤原因" prop="remark">
|
||||
<el-input
|
||||
v-model="absenceForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入缺勤原因(必填,如:未到课且未请假,将记入考勤审计留痕)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" :loading="absenceSubmitting" @click="submitMarkAbsence">确 定</el-button>
|
||||
<el-button @click="cancelMarkAbsence">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listCheckin } from '@/api/train/checkin'
|
||||
import { adjustAttendance, signOutAttendance, signInAttendance, getLessonRoster } from '@/api/train/attendance'
|
||||
import { adjustAttendance, signOutAttendance, signInAttendance, markAbsenceAttendance, getLessonRoster } from '@/api/train/attendance'
|
||||
import { listPlan } from '@/api/train/plan'
|
||||
import { getPlanTimetable } from '@/api/train/timetable'
|
||||
import {
|
||||
@@ -261,6 +300,15 @@ import {
|
||||
resolveSignInFeedback
|
||||
} from './attendancePolicy'
|
||||
|
||||
// 缺勤原因必填校验:与后端空白拒绝规则保持一致,纯空格不视为有效原因
|
||||
function validateAbsenceRemark(rule, value, callback) {
|
||||
if (!value || !String(value).trim()) {
|
||||
callback(new Error('请填写缺勤原因'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Checkin',
|
||||
data() {
|
||||
@@ -292,6 +340,21 @@ export default {
|
||||
{ required: true, message: '请选择调整后的考勤状态', trigger: 'change' }
|
||||
]
|
||||
},
|
||||
// 缺勤登记 (标记缺勤) 相关数据
|
||||
absenceOpen: false,
|
||||
absenceSubmitting: false,
|
||||
absenceRecord: {},
|
||||
absenceForm: {
|
||||
enrollmentId: null,
|
||||
lessonId: null,
|
||||
remark: ''
|
||||
},
|
||||
absenceRules: {
|
||||
remark: [
|
||||
{ required: true, message: '请填写缺勤原因', trigger: 'blur' },
|
||||
{ validator: validateAbsenceRemark, trigger: 'blur' }
|
||||
]
|
||||
},
|
||||
// 课次考勤名册 (Roster) 相关数据
|
||||
planOptions: [],
|
||||
lessonOptions: [],
|
||||
@@ -423,6 +486,51 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
handleMarkAbsence(row) {
|
||||
this.absenceRecord = row
|
||||
this.absenceForm = {
|
||||
enrollmentId: row.enrollmentId,
|
||||
lessonId: row.lessonId || this.rosterQuery.lessonId,
|
||||
remark: ''
|
||||
}
|
||||
this.absenceOpen = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.absenceForm) {
|
||||
this.$refs.absenceForm.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
cancelMarkAbsence() {
|
||||
this.absenceOpen = false
|
||||
this.absenceRecord = {}
|
||||
this.absenceForm = {
|
||||
enrollmentId: null,
|
||||
lessonId: null,
|
||||
remark: ''
|
||||
}
|
||||
},
|
||||
submitMarkAbsence() {
|
||||
this.$refs.absenceForm.validate(valid => {
|
||||
if (!valid) return
|
||||
this.absenceSubmitting = true
|
||||
const studentName = this.absenceRecord.studentName
|
||||
markAbsenceAttendance({
|
||||
enrollmentId: this.absenceForm.enrollmentId,
|
||||
lessonId: this.absenceForm.lessonId,
|
||||
remark: this.absenceForm.remark.trim()
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess(`学员【${studentName}】缺勤登记成功并已记录审计留痕`)
|
||||
this.absenceOpen = false
|
||||
this.loadRoster()
|
||||
this.getList()
|
||||
}).catch(err => {
|
||||
const feedback = resolveSignInFeedback(err, studentName)
|
||||
this.$modal.msgError(feedback.message || '缺勤登记失败')
|
||||
}).finally(() => {
|
||||
this.absenceSubmitting = false
|
||||
})
|
||||
})
|
||||
},
|
||||
labelOf(options, value) {
|
||||
const match = options.find(item => item.value === value)
|
||||
return match ? match.label : '-'
|
||||
|
||||
@@ -155,4 +155,68 @@ console.log('=== Testing Real Production Attendance Policy Helper ===');
|
||||
console.log('✔ PASS: Production resolveSignInFeedback faithfully reports errors without false success');
|
||||
}
|
||||
|
||||
// 6. 标记缺勤按钮可用性 (canMarkAbsence 严格以后端判定为准)
|
||||
{
|
||||
// A. 后端判定允许 + 无打卡记录 -> 标记缺勤按钮存在
|
||||
const rowAllowed = {
|
||||
enrollmentId: 200,
|
||||
enrollmentStatus: 1,
|
||||
checkinId: null,
|
||||
attendanceStatus: null,
|
||||
signInTime: null,
|
||||
signOutTime: null,
|
||||
canSignIn: true,
|
||||
canMarkAbsence: true
|
||||
};
|
||||
assert.strictEqual(computeRosterActionEligibility(rowAllowed).canMarkAbsence, true,
|
||||
'后端允许且无打卡记录时标记缺勤按钮必须存在');
|
||||
|
||||
// B. 后端未返回 canMarkAbsence -> 前端绝不自行放开
|
||||
const rowUnknown = {
|
||||
enrollmentId: 201,
|
||||
enrollmentStatus: 1,
|
||||
checkinId: null,
|
||||
attendanceStatus: null,
|
||||
signInTime: null,
|
||||
signOutTime: null,
|
||||
canSignIn: true
|
||||
};
|
||||
assert.strictEqual(computeRosterActionEligibility(rowUnknown).canMarkAbsence, false,
|
||||
'后端未返回 canMarkAbsence 时前端不得自行放开入口');
|
||||
|
||||
// C. 后端判定拒绝 (如课次未开始、请假、待报到) -> 按钮不存在
|
||||
const rowRejected = {
|
||||
enrollmentId: 202,
|
||||
enrollmentStatus: 2,
|
||||
checkinId: null,
|
||||
attendanceStatus: null,
|
||||
signInTime: null,
|
||||
signOutTime: null,
|
||||
canSignIn: true,
|
||||
canMarkAbsence: false
|
||||
};
|
||||
assert.strictEqual(computeRosterActionEligibility(rowRejected).canMarkAbsence, false,
|
||||
'后端拒绝时标记缺勤按钮必须不存在');
|
||||
|
||||
// D. 已有考勤记录 -> 即使后端异常返回 true 也必须兜底屏蔽,改走调整考勤
|
||||
const rowHasRecord = {
|
||||
enrollmentId: 203,
|
||||
enrollmentStatus: 1,
|
||||
checkinId: 7777,
|
||||
attendanceStatus: 0,
|
||||
signInTime: null,
|
||||
signOutTime: null,
|
||||
canAdjust: true,
|
||||
canMarkAbsence: true
|
||||
};
|
||||
const actHasRecord = computeRosterActionEligibility(rowHasRecord);
|
||||
assert.strictEqual(actHasRecord.canMarkAbsence, false, '已有考勤记录时绝不能重复显示标记缺勤按钮');
|
||||
assert.strictEqual(actHasRecord.canAdjust, true, '已登记缺勤后必须可继续调整考勤记录');
|
||||
|
||||
// E. 空行兜底
|
||||
assert.strictEqual(computeRosterActionEligibility(null).canMarkAbsence, false);
|
||||
|
||||
console.log('✔ PASS: Production computeRosterActionEligibility gates mark-absence entry on backend decision');
|
||||
}
|
||||
|
||||
console.log('ALL PRODUCTION ATTENDANCE POLICY TESTS PASSED!');
|
||||
Reference in new issue
Block a user