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