From b7a9b89ec53422297d812dd32d54f33d1986a461 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Tue, 22 Sep 2026 17:04:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=80=83=E5=8B=A4=EF=BC=9A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=AA=E7=AD=BE=E5=88=B0=E5=AD=A6=E5=91=98?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E7=99=BB=E8=AE=B0=E7=BC=BA=E5=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 名册中无任何打卡记录的已报到学员此前只能"先签到再改成缺勤",会留下伪造的签到事实。 - 新增 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 --- .../train/AttendanceController.java | 13 + ...nceAndReportApiControllerContractTest.java | 17 +- .../train/constant/AttendanceErrorCode.java | 36 ++ .../domain/dto/AttendanceMarkAbsenceDTO.java | 57 ++ .../domain/vo/AttendanceLessonRosterVO.java | 13 + .../train/service/AttendanceAppService.java | 15 +- .../impl/AttendanceAppServiceImpl.java | 164 +++++ .../AttendanceMarkAbsenceContractTest.java | 602 ++++++++++++++++++ build_ui/src/api/train/attendance.js | 11 +- .../views/train/checkin/attendancePolicy.js | 11 +- build_ui/src/views/train/checkin/index.vue | 114 +++- .../tests/unit/attendanceSigninEntry.spec.mjs | 64 ++ 12 files changed, 1108 insertions(+), 9 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java index 48944052b..18f84855e 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java @@ -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); + } + /** * 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java index 5c48e2d65..7d048c5c2 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AttendanceAndReportApiControllerContractTest.java @@ -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); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java index 0d5af1fb0..7ebe13b3c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/constant/AttendanceErrorCode.java @@ -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() { } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java new file mode 100644 index 000000000..8b6d07187 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/AttendanceMarkAbsenceDTO.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/AttendanceLessonRosterVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/AttendanceLessonRosterVO.java index 11f7ce3bf..8a8bd93d5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/AttendanceLessonRosterVO.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/AttendanceLessonRosterVO.java @@ -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; + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/AttendanceAppService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/AttendanceAppService.java index 35866c3a1..39825543a 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/AttendanceAppService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/AttendanceAppService.java @@ -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); + /** * 查询课次学员考勤名册 (纯只读,供培训管理员点名签到)。 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java index 0ad43c2d9..7a9bbe27b 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/AttendanceAppServiceImpl.java @@ -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 getLessonRoster(Long lessonId) { @@ -280,6 +363,9 @@ public class AttendanceAppServiceImpl implements AttendanceAppService } } + // 课次是否已开始:缺勤登记入口的统一前置判定 (开始时间缺失时 fail closed 为未开始) + boolean lessonStarted = isLessonStarted(lesson); + List 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) diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java new file mode 100644 index 000000000..4d4aafaaa --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceMarkAbsenceContractTest.java @@ -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 checkinsById = new HashMap<>(); + private final Map checkinsByEnrollLesson = new HashMap<>(); + private final Map enrollments = new HashMap<>(); + private final Map lessons = new HashMap<>(); + private final Map 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 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 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 createProxy(Class 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 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 future = indexRoster(appService.getLessonRoster(LESSON_FUTURE)); + assertFalse("课次未开始时不得出现标记缺勤入口", + Boolean.TRUE.equals(future.get(311L).getCanMarkAbsence())); + + Map 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 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 indexRoster(List roster) + { + Map map = new HashMap<>(); + for (AttendanceLessonRosterVO vo : roster) + { + map.put(vo.getEnrollmentId(), vo); + } + return map; + } +} diff --git a/build_ui/src/api/train/attendance.js b/build_ui/src/api/train/attendance.js index 98a4d185c..1e7e2dd0c 100644 --- a/build_ui/src/api/train/attendance.js +++ b/build_ui/src/api/train/attendance.js @@ -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`, diff --git a/build_ui/src/views/train/checkin/attendancePolicy.js b/build_ui/src/views/train/checkin/attendancePolicy.js index fe3847be8..f222270b7 100644 --- a/build_ui/src/views/train/checkin/attendancePolicy.js +++ b/build_ui/src/views/train/checkin/attendancePolicy.js @@ -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 }; } /** diff --git a/build_ui/src/views/train/checkin/index.vue b/build_ui/src/views/train/checkin/index.vue index 8adf8508e..cc38fb69e 100644 --- a/build_ui/src/views/train/checkin/index.vue +++ b/build_ui/src/views/train/checkin/index.vue @@ -50,7 +50,7 @@ - +