新增培训:完善报到与考勤V2业务操作
This commit is contained in:
1 parent
7193f2ac3a
commit
6762189093
30 files changed
+2382
-9
No files matched your search
+73
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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());
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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<TrainClassReport> selectTrainClassReportList(TrainClassReport trainClassReport);
|
||||
|
||||
/**
|
||||
* 新增开班报到事件记录
|
||||
*
|
||||
* @param trainClassReport 开班报到
|
||||
* @return 插入行数
|
||||
*/
|
||||
public int insertTrainClassReport(TrainClassReport trainClassReport);
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
+25
@@ -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<TrainStudentCheckin> selectTrainStudentCheckinList(TrainStudentCheckin trainStudentCheckin);
|
||||
|
||||
/**
|
||||
* 新增学生签到
|
||||
*
|
||||
* @param trainStudentCheckin 学生签到
|
||||
* @return 插入行数
|
||||
*/
|
||||
public int insertTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
|
||||
|
||||
/**
|
||||
* 修改学生签到
|
||||
*
|
||||
* @param trainStudentCheckin 学生签到
|
||||
* @return 更新行数
|
||||
*/
|
||||
public int updateTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,17 @@
|
||||
<include refid="selectTrainClassReportVo"/>
|
||||
where r.report_id = #{reportId}
|
||||
</select>
|
||||
<select id="selectTrainClassReportByEnrollmentId" parameterType="Long" resultMap="TrainClassReportResult">
|
||||
<include refid="selectTrainClassReportVo"/>
|
||||
where r.enrollment_id = #{enrollmentId}
|
||||
</select>
|
||||
<insert id="insertTrainClassReport" parameterType="com.ruoyi.train.domain.TrainClassReport" useGeneratedKeys="true" keyProperty="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}
|
||||
)
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -136,6 +136,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where enrollment_id = #{enrollmentId}
|
||||
</update>
|
||||
|
||||
<update id="confirmEnrollmentReport">
|
||||
update train_enrollment
|
||||
set enrollment_status = 1,
|
||||
join_time = #{reportTime},
|
||||
update_by = #{operator},
|
||||
update_time = #{updateTime}
|
||||
where enrollment_id = #{enrollmentId}
|
||||
</update>
|
||||
|
||||
<update id="completeActiveEnrollmentsByPlanId">
|
||||
update train_enrollment
|
||||
set enrollment_status = 4,
|
||||
|
||||
@@ -57,4 +57,34 @@
|
||||
<include refid="selectTrainStudentCheckinVo"/>
|
||||
where a.checkin_id = #{checkinId}
|
||||
</select>
|
||||
<select id="selectTrainStudentCheckinByEnrollmentAndLesson" resultMap="TrainStudentCheckinResult">
|
||||
<include refid="selectTrainStudentCheckinVo"/>
|
||||
where a.enrollment_id = #{enrollmentId} and a.lesson_id = #{lessonId}
|
||||
</select>
|
||||
<insert id="insertTrainStudentCheckin" parameterType="com.ruoyi.train.domain.TrainStudentCheckin" useGeneratedKeys="true" keyProperty="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}
|
||||
)
|
||||
</insert>
|
||||
<update id="updateTrainStudentCheckin" parameterType="com.ruoyi.train.domain.TrainStudentCheckin">
|
||||
update train_student_checkin
|
||||
<set>
|
||||
<if test="boardId != null">board_id = #{boardId},</if>
|
||||
<if test="checkinWay != null">checkin_way = #{checkinWay},</if>
|
||||
<if test="signInTime != null">sign_in_time = #{signInTime},</if>
|
||||
<if test="signOutTime != null">sign_out_time = #{signOutTime},</if>
|
||||
<if test="attendanceStatus != null">attendance_status = #{attendanceStatus},</if>
|
||||
<if test="manualFlag != null">manual_flag = #{manualFlag},</if>
|
||||
<if test="updateBy != null">update_by = #{updateBy},</if>
|
||||
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
</set>
|
||||
where checkin_id = #{checkinId}
|
||||
</update>
|
||||
</mapper>
|
||||
+438
@@ -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<Long, TrainStudentCheckin> checkinsById = new HashMap<>();
|
||||
private final Map<String, TrainStudentCheckin> checkinsByEnrollLesson = new HashMap<>();
|
||||
private final Map<Long, TrainEnrollment> enrollments = new HashMap<>();
|
||||
private final Map<Long, TrainLesson> lessons = new HashMap<>();
|
||||
private final Map<Long, TrainPlanCourse> planCourses = new HashMap<>();
|
||||
private long checkinSeq = 1L;
|
||||
|
||||
@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> T createProxy(Class<T> interfaceClass, InvocationHandler handler)
|
||||
{
|
||||
return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[]{interfaceClass}, handler);
|
||||
}
|
||||
|
||||
private Object defaultReturn(Method method)
|
||||
{
|
||||
Class<?> returnType = method.getReturnType();
|
||||
if (returnType == int.class || returnType == long.class) return 0;
|
||||
if (returnType == boolean.class) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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<TrainClassReport> 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<TrainStudentCheckin> 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();
|
||||
|
||||
+292
@@ -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<Long, TrainEnrollment> enrollments = new HashMap<>();
|
||||
private final Map<Long, TrainClassReport> reportsByEnrollment = new HashMap<>();
|
||||
private final Map<String, TrainClassPlan> plans = new HashMap<>();
|
||||
private final Map<String, TrainStudent> 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> T createProxy(Class<T> interfaceClass, InvocationHandler handler)
|
||||
{
|
||||
return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(), new Class<?>[]{interfaceClass}, handler);
|
||||
}
|
||||
|
||||
private Object defaultReturn(Method method)
|
||||
{
|
||||
Class<?> returnType = method.getReturnType();
|
||||
if (returnType == int.class || returnType == long.class) return 0;
|
||||
if (returnType == boolean.class) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -98,6 +98,15 @@
|
||||
icon="el-icon-document"
|
||||
@click="handleViewDetail(scope.row)"
|
||||
>档案</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.enrollmentStatus === 0"
|
||||
v-hasPermi="['train:report:confirm']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-check"
|
||||
:disabled="isPlanLocked"
|
||||
@click="handleConfirmReport(scope.row)"
|
||||
>报到</el-button>
|
||||
<el-dropdown
|
||||
v-if="scope.row.enrollmentStatus !== 4"
|
||||
v-hasPermi="['train:enrollment:edit']"
|
||||
@@ -296,11 +305,12 @@
|
||||
|
||||
<div style="margin-top: 15px;" />
|
||||
<el-descriptions title="报到记录" :column="2" border size="small">
|
||||
<el-descriptions-item label="报到时间">{{ (detailDialog.data.report && detailDialog.data.report.reportTime) || '未报到 (报到功能开发中)' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="报到时间">{{ (detailDialog.data.report && detailDialog.data.report.reportTime) || '未报到' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="报到方式">
|
||||
<span v-if="detailDialog.data.report && detailDialog.data.report.reportWay === 1">刷卡/身份证</span>
|
||||
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 2">人工核验</span>
|
||||
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 3">人脸识别</span>
|
||||
<span v-if="detailDialog.data.report && detailDialog.data.report.reportWay === 1">人脸识别</span>
|
||||
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 2">身份证刷卡</span>
|
||||
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 3">二维码</span>
|
||||
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 4">人工录入</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="核验说明" :span="2">{{ (detailDialog.data.report && detailDialog.data.report.verifyNote) || '-' }}</el-descriptions-item>
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user