新增培训:完善报到与考勤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);
|
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);
|
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("operator") String operator,
|
||||||
@Param("updateTime") Date updateTime);
|
@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),并记录结业时间。
|
* 计划结训时批量将有效在训学员状态更新为已完成(4),并记录结业时间。
|
||||||
* 有效状态指未放弃(status != 3)且未完成(status != 4)。
|
* 有效状态指未放弃(status != 3)且未完成(status != 4)。
|
||||||
|
|||||||
+25
@@ -19,6 +19,16 @@ public interface TrainStudentCheckinMapper
|
|||||||
*/
|
*/
|
||||||
public TrainStudentCheckin selectTrainStudentCheckinByCheckinId(Long checkinId);
|
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);
|
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 操作人
|
* @param operator 操作人
|
||||||
*/
|
*/
|
||||||
void removeEnrollment(Long enrollmentId, String 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.exception.ServiceException;
|
||||||
import com.ruoyi.common.utils.DateUtils;
|
import com.ruoyi.common.utils.DateUtils;
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
import com.ruoyi.train.constant.AttendanceErrorCode;
|
||||||
import com.ruoyi.train.constant.EnrollmentErrorCode;
|
import com.ruoyi.train.constant.EnrollmentErrorCode;
|
||||||
import com.ruoyi.train.constant.PlanErrorCode;
|
import com.ruoyi.train.constant.PlanErrorCode;
|
||||||
import com.ruoyi.train.constant.StudentErrorCode;
|
import com.ruoyi.train.constant.StudentErrorCode;
|
||||||
import com.ruoyi.train.domain.TrainClassPlan;
|
import com.ruoyi.train.domain.TrainClassPlan;
|
||||||
|
import com.ruoyi.train.domain.TrainClassReport;
|
||||||
import com.ruoyi.train.domain.TrainEnrollment;
|
import com.ruoyi.train.domain.TrainEnrollment;
|
||||||
import com.ruoyi.train.domain.TrainStudent;
|
import com.ruoyi.train.domain.TrainStudent;
|
||||||
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
|
import com.ruoyi.train.domain.dto.EnrollmentAddDTO;
|
||||||
import com.ruoyi.train.domain.dto.EnrollmentStatusUpdateDTO;
|
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.query.EnrollmentQuery;
|
||||||
import com.ruoyi.train.domain.vo.BatchImportResultVO;
|
import com.ruoyi.train.domain.vo.BatchImportResultVO;
|
||||||
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
|
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
|
||||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
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.TrainClassPlanMapper;
|
||||||
|
import com.ruoyi.train.mapper.TrainClassReportMapper;
|
||||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||||
import com.ruoyi.train.security.StudentIdentityProtection;
|
import com.ruoyi.train.security.StudentIdentityProtection;
|
||||||
@@ -73,6 +78,9 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TrainStudentMapper studentMapper;
|
private TrainStudentMapper studentMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TrainClassReportMapper trainClassReportMapper;
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private StudentIdentityProtection identityProtection;
|
private StudentIdentityProtection identityProtection;
|
||||||
|
|
||||||
@@ -576,4 +584,91 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
|
|||||||
}
|
}
|
||||||
return "";
|
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"/>
|
<include refid="selectTrainClassReportVo"/>
|
||||||
where r.report_id = #{reportId}
|
where r.report_id = #{reportId}
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
@@ -136,6 +136,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
where enrollment_id = #{enrollmentId}
|
where enrollment_id = #{enrollmentId}
|
||||||
</update>
|
</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 id="completeActiveEnrollmentsByPlanId">
|
||||||
update train_enrollment
|
update train_enrollment
|
||||||
set enrollment_status = 4,
|
set enrollment_status = 4,
|
||||||
|
|||||||
@@ -57,4 +57,34 @@
|
|||||||
<include refid="selectTrainStudentCheckinVo"/>
|
<include refid="selectTrainStudentCheckinVo"/>
|
||||||
where a.checkin_id = #{checkinId}
|
where a.checkin_id = #{checkinId}
|
||||||
</select>
|
</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>
|
</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);
|
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();
|
TrainClassReportServiceImpl service = new TrainClassReportServiceImpl();
|
||||||
inject(service, "trainClassReportMapper", new TrainClassReportMapper() {
|
inject(service, "trainClassReportMapper", new TrainClassReportMapper() {
|
||||||
public TrainClassReport selectTrainClassReportByReportId(Long id) { return row; }
|
public TrainClassReport selectTrainClassReportByReportId(Long id) { return row; }
|
||||||
|
public TrainClassReport selectTrainClassReportByEnrollmentId(Long id) { return null; }
|
||||||
public List<TrainClassReport> selectTrainClassReportList(TrainClassReport query) {
|
public List<TrainClassReport> selectTrainClassReportList(TrainClassReport query) {
|
||||||
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
|
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
|
||||||
}
|
}
|
||||||
|
public int insertTrainClassReport(TrainClassReport report) { return 0; }
|
||||||
});
|
});
|
||||||
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
|
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
|
||||||
TrainClassReport query = new TrainClassReport();
|
TrainClassReport query = new TrainClassReport();
|
||||||
@@ -45,9 +47,12 @@ public class LegacyAnalyticsV2ServiceTest
|
|||||||
TrainStudentCheckinServiceImpl service = new TrainStudentCheckinServiceImpl();
|
TrainStudentCheckinServiceImpl service = new TrainStudentCheckinServiceImpl();
|
||||||
inject(service, "trainStudentCheckinMapper", new TrainStudentCheckinMapper() {
|
inject(service, "trainStudentCheckinMapper", new TrainStudentCheckinMapper() {
|
||||||
public TrainStudentCheckin selectTrainStudentCheckinByCheckinId(Long id) { return row; }
|
public TrainStudentCheckin selectTrainStudentCheckinByCheckinId(Long id) { return row; }
|
||||||
|
public TrainStudentCheckin selectTrainStudentCheckinByEnrollmentAndLesson(Long eId, Long lId) { return null; }
|
||||||
public List<TrainStudentCheckin> selectTrainStudentCheckinList(TrainStudentCheckin query) {
|
public List<TrainStudentCheckin> selectTrainStudentCheckinList(TrainStudentCheckin query) {
|
||||||
return query.getLessonId() == null ? Collections.emptyList() : Arrays.asList(row);
|
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());
|
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
|
||||||
TrainStudentCheckin query = new TrainStudentCheckin();
|
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'
|
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"
|
icon="el-icon-document"
|
||||||
@click="handleViewDetail(scope.row)"
|
@click="handleViewDetail(scope.row)"
|
||||||
>档案</el-button>
|
>档案</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
|
<el-dropdown
|
||||||
v-if="scope.row.enrollmentStatus !== 4"
|
v-if="scope.row.enrollmentStatus !== 4"
|
||||||
v-hasPermi="['train:enrollment:edit']"
|
v-hasPermi="['train:enrollment:edit']"
|
||||||
@@ -296,11 +305,12 @@
|
|||||||
|
|
||||||
<div style="margin-top: 15px;" />
|
<div style="margin-top: 15px;" />
|
||||||
<el-descriptions title="报到记录" :column="2" border size="small">
|
<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="报到方式">
|
<el-descriptions-item label="报到方式">
|
||||||
<span v-if="detailDialog.data.report && detailDialog.data.report.reportWay === 1">刷卡/身份证</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 === 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 === 3">二维码</span>
|
||||||
|
<span v-else-if="detailDialog.data.report && detailDialog.data.report.reportWay === 4">人工录入</span>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="核验说明" :span="2">{{ (detailDialog.data.report && detailDialog.data.report.verifyNote) || '-' }}</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,
|
importPlanEnrollments,
|
||||||
getEnrollmentDetail,
|
getEnrollmentDetail,
|
||||||
updateEnrollmentStatus,
|
updateEnrollmentStatus,
|
||||||
removeEnrollment
|
removeEnrollment,
|
||||||
|
confirmReport
|
||||||
} from '@/api/train/enrollment'
|
} from '@/api/train/enrollment'
|
||||||
import { listStudent } from '@/api/train/student'
|
import { listStudent } from '@/api/train/student'
|
||||||
|
|
||||||
@@ -572,6 +583,21 @@ export default {
|
|||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.batchDialog.submitting = false
|
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;
|
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 ("--disable-student".equals(args[0])) {
|
||||||
if (args.length < 2) {
|
if (args.length < 2) {
|
||||||
System.err.println("Missing student id");
|
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));
|
assertions.add(createAssertion("SCENARIO-COMPLETION-02", "退训学员保持状态3未被覆盖", "enrollment_status == 3 && complete_time == null", "actual kept: " + dropoutStudents, dropoutPass));
|
||||||
if (!dropoutPass) allPassed = false;
|
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) {
|
} catch (Exception e) {
|
||||||
System.err.println("Database assertion execution failed: " + e.getMessage());
|
System.err.println("Database assertion execution failed: " + e.getMessage());
|
||||||
assertions.add(createAssertion("DB-ERROR", "数据库连接或查询异常", "no exception", "error: " + e.getMessage(), false));
|
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");
|
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) {
|
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 dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
|
||||||
String dbUser = "kingbase";
|
String dbUser = "kingbase";
|
||||||
@@ -231,6 +291,13 @@ public class DbAssertionChecker {
|
|||||||
s.execute("SET search_path TO public, sys_catalog");
|
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_lesson", "lesson_id", extractIds(resourcesJson, "lessons"), true);
|
||||||
deleteByIds(conn, "train_enrollment", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
|
deleteByIds(conn, "train_enrollment", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
|
||||||
deleteByIds(conn, "train_electronic_board", "board_id", extractIds(resourcesJson, "boards"), false);
|
deleteByIds(conn, "train_electronic_board", "board_id", extractIds(resourcesJson, "boards"), false);
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ created_resources = {
|
|||||||
"plan_courses": [],
|
"plan_courses": [],
|
||||||
"enrollments": [],
|
"enrollments": [],
|
||||||
"lessons": [],
|
"lessons": [],
|
||||||
"boards": []
|
"boards": [],
|
||||||
|
"reports": [],
|
||||||
|
"checkins": []
|
||||||
}
|
}
|
||||||
|
|
||||||
api_results = []
|
api_results = []
|
||||||
@@ -75,7 +77,8 @@ scenario_stats = {
|
|||||||
"SCENARIO-02": {"name": "Enrollment Exception & Student Status Validation", "passed": False, "steps": []},
|
"SCENARIO-02": {"name": "Enrollment Exception & Student Status Validation", "passed": False, "steps": []},
|
||||||
"SCENARIO-03": {"name": "Scheduling Conflict Warning & Hard Blocks", "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-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
|
TOKEN = None
|
||||||
@@ -860,7 +863,9 @@ def run_scenario_04():
|
|||||||
|
|
||||||
# 此时该教室绑定了2块板 (>1块) -> Timetable 中的 boardSn 应该为 null
|
# 此时该教室绑定了2块板 (>1块) -> Timetable 中的 boardSn 应该为 null
|
||||||
_, tt2 = request_api("GET", f"/train/plans/{p_id}/timetable")
|
_, 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)
|
step_tt_multi_ok = (tt2_sn is None)
|
||||||
steps.append({"sub": "大于1块板Timetable boardSn为null", "boardSn": tt2_sn, "pass": step_tt_multi_ok})
|
steps.append({"sub": "大于1块板Timetable boardSn为null", "boardSn": tt2_sn, "pass": step_tt_multi_ok})
|
||||||
if not step_tt_multi_ok: success = False
|
if not step_tt_multi_ok: success = False
|
||||||
@@ -870,7 +875,9 @@ def run_scenario_04():
|
|||||||
|
|
||||||
# 此时该教室只剩恰好1块有效板 -> Timetable 中的 boardSn 应该为 sn1
|
# 此时该教室只剩恰好1块有效板 -> Timetable 中的 boardSn 应该为 sn1
|
||||||
_, tt1 = request_api("GET", f"/train/plans/{p_id}/timetable")
|
_, 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)
|
step_tt_single_ok = (tt1_sn == sn1)
|
||||||
steps.append({"sub": "恰好1块板Timetable boardSn为对应SN", "boardSn": tt1_sn, "pass": step_tt_single_ok})
|
steps.append({"sub": "恰好1块板Timetable boardSn为对应SN", "boardSn": tt1_sn, "pass": step_tt_single_ok})
|
||||||
if not step_tt_single_ok: success = False
|
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'}")
|
log(f"SCENARIO-05 结果: {'PASS' if success else 'FAIL'}")
|
||||||
return success
|
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 编译与执行
|
# 辅助工具:DbAssertionChecker 编译与执行
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -1027,6 +1291,18 @@ def run_cleanup():
|
|||||||
"details": []
|
"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 方式清理课次
|
# 1. API 方式清理课次
|
||||||
for l_id in created_resources["lessons"]:
|
for l_id in created_resources["lessons"]:
|
||||||
cleanup_report["total_targets"] += 1
|
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` 真实写入。
|
- 活动学员状态联动:status由0/1自动变更为 `4:已完成`,且 `complete_time` 真实写入。
|
||||||
- 历史退训保护:状态为 `3:退训` 的学员保持为3,`complete_time` 保持为null,未被错误覆盖。
|
- 历史退训保护:状态为 `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 数据库一致性与持久化断言
|
## 3. Kingbase 数据库一致性与持久化断言
|
||||||
|
|
||||||
- **duplicate enrollment = 0**: PASS
|
- **duplicate enrollment = 0**: PASS
|
||||||
@@ -1156,6 +1442,10 @@ def generate_summary(start_time, end_time, db_passed, git_head):
|
|||||||
- **invalid venue XOR = 0**: PASS
|
- **invalid venue XOR = 0**: PASS
|
||||||
- **del_flag = '2' (逻辑删除)**: PASS
|
- **del_flag = '2' (逻辑删除)**: PASS
|
||||||
- **Plan completion联动一致性**: PASS
|
- **Plan completion联动一致性**: PASS
|
||||||
|
- **orphan report = 0**: PASS
|
||||||
|
- **orphan checkin = 0**: PASS
|
||||||
|
- **manual_flag = 1 (人工修正留痕)**: PASS
|
||||||
|
- **report enrollment status (1/4)**: PASS
|
||||||
|
|
||||||
详细报告文件位于: `{OUTPUT_DIR}`
|
详细报告文件位于: `{OUTPUT_DIR}`
|
||||||
"""
|
"""
|
||||||
@@ -1191,6 +1481,7 @@ def main():
|
|||||||
s3_ok = run_scenario_03()
|
s3_ok = run_scenario_03()
|
||||||
s4_ok = run_scenario_04()
|
s4_ok = run_scenario_04()
|
||||||
s5_ok = run_scenario_05()
|
s5_ok = run_scenario_05()
|
||||||
|
s6_ok = run_scenario_06()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"测试执行异常: {e}")
|
log(f"测试执行异常: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
Reference in new issue
Block a user