From 78ea5ce38738391e261c51380b694b910b2937ce Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Mon, 21 Sep 2026 17:07:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=9A=E5=8A=A1=EF=BC=9A?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8A=A5=E5=88=B0=E7=BB=93=E8=AE=AD=E4=B8=8E?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=E7=AD=BE=E9=80=80=E7=8A=B6=E6=80=81=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AttendanceActionContractTest.java | 14 +- .../impl/EnrollmentAppServiceImplTest.java | 57 +- .../PackageBCoreBusinessBehaviorTest.java | 1129 +++++++++++++++++ ...anEnrollmentCompletionIntegrationTest.java | 102 +- scripts/qa/CheckPackageBInventory.java | 152 +++ 5 files changed, 1436 insertions(+), 18 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java create mode 100644 scripts/qa/CheckPackageBInventory.java diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java index af39caf4e..408810320 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java @@ -371,17 +371,23 @@ public class AttendanceActionContractTest assertNotNull(r1); assertEquals(firstTime, r1.getSignOutTime()); - // 3. 第二次重复签退,传入不同的新时间 + // 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"); + try + { + appService.signOut(outDto2, "device2"); + fail("重复签退必须被拦截"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_ALREADY_SIGNED_OUT, (int) ex.getCode()); + } // 4. 断言必须严格保持第一次签退时间,禁止覆盖 - assertNotNull(r2); - assertEquals("重复签退必须保留第一次签退时间", firstTime, r2.getSignOutTime()); TrainStudentCheckin dbRecord = checkinsById.get(r1.getCheckinId()); assertEquals("持久化存储中必须保持第一次签退时间", firstTime, dbRecord.getSignOutTime()); } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java index 8d3bf932c..ab48e558c 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java @@ -439,7 +439,7 @@ public class EnrollmentAppServiceImplTest @Test public void testPlanCompleteUpdatesActiveEnrollmentsAndKeepsHistory() { - // 准备 3 名学员:S001(待报到0), S002(已报到1), S003(放弃3) + // 准备 3 名学员:S001(已报到1且有报到单), S002(已报到1且有报到单), S003(放弃3) // 临时调大容量 planMapper.planStore.get("PLAN-001").setRecruitNum(10L); @@ -448,6 +448,8 @@ public class EnrollmentAppServiceImplTest dto1.setSourceType(1); dto1.setAccommodationRequired(1); Long id1 = appService.addEnrollment("PLAN-001", dto1, "admin"); + enrollmentRepository.selectTrainEnrollmentById(id1).setEnrollmentStatus(1); // 已报到 + enrollmentRepository.mockReportCount.put(id1, 1); // 报到单事实存在 EnrollmentAddDTO dto2 = new EnrollmentAddDTO(); dto2.setStudentId("S002"); @@ -455,6 +457,7 @@ public class EnrollmentAppServiceImplTest dto2.setAccommodationRequired(1); Long id2 = appService.addEnrollment("PLAN-001", dto2, "admin"); enrollmentRepository.selectTrainEnrollmentById(id2).setEnrollmentStatus(1); // 已报到 + enrollmentRepository.mockReportCount.put(id2, 1); // 报到单事实存在 EnrollmentAddDTO dto3 = new EnrollmentAddDTO(); dto3.setStudentId("S003"); @@ -474,7 +477,7 @@ public class EnrollmentAppServiceImplTest Date completeTime = new Date(); adapter.completeEnrollments("PLAN-001", "operator", completeTime); - // 断言:S001、S002 处于有效参训状态,被批量更新为 4:已完成,且写入 complete_time + // 断言:S001、S002 处于已报到状态且有报到事实,被批量更新为 4:已完成,且写入 complete_time TrainEnrollment e1 = enrollmentRepository.selectTrainEnrollmentById(id1); assertEquals(Integer.valueOf(4), e1.getEnrollmentStatus()); assertEquals(completeTime, e1.getCompleteTime()); @@ -493,6 +496,32 @@ public class EnrollmentAppServiceImplTest assertEquals(3, enrollmentRepository.countScoreByEnrollmentId(id2)); } + @Test + public void testPlanCompleteBlockedWhenWaitingEnrollmentsExist() + { + planMapper.planStore.get("PLAN-001").setRecruitNum(10L); + + EnrollmentAddDTO dto1 = new EnrollmentAddDTO(); + dto1.setStudentId("S001"); + dto1.setSourceType(1); + dto1.setAccommodationRequired(0); + Long id1 = appService.addEnrollment("PLAN-001", dto1, "admin"); + // S001 保持待报到状态 (0) + + EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter(); + setField(adapter, "trainEnrollmentService", enrollmentRepository); + + try + { + adapter.completeEnrollments("PLAN-001", "operator", new Date()); + fail("存在待报到学员时结训必须被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("WAITING_REPORT_ENROLLMENTS_EXIST")); + } + } + @Test public void testBatchImportProcess() { @@ -875,6 +904,24 @@ public class EnrollmentAppServiceImplTest { return mockStudyCount.getOrDefault(enrollmentId, 0); } + + @Override + public java.util.List> selectEnrollmentCompletionStatsByPlanId(String planId) + { + java.util.List> list = new java.util.ArrayList<>(); + for (TrainEnrollment e : store.values()) + { + if (e.getPlanId().equals(planId)) + { + java.util.Map map = new java.util.HashMap<>(); + map.put("enrollmentId", e.getEnrollmentId()); + map.put("enrollmentStatus", e.getEnrollmentStatus()); + map.put("hasReport", mockReportCount.getOrDefault(e.getEnrollmentId(), 0) > 0 ? 1 : 0); + list.add(map); + } + } + return list; + } } private static class MockPlanMapper implements TrainClassPlanMapper @@ -1133,6 +1180,12 @@ public class EnrollmentAppServiceImplTest return enrollmentService.countStudyDurationByEnrollmentId(enrollmentId); } + @Override + public java.util.List> selectEnrollmentCompletionStatsByPlanId(String planId) + { + return enrollmentService.selectEnrollmentCompletionStatsByPlanId(planId); + } + @Override public int confirmEnrollmentReport(Long enrollmentId, Date reportTime, String operator, Date updateTime) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java new file mode 100644 index 000000000..40f0039e9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java @@ -0,0 +1,1129 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.*; + +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.*; +import com.ruoyi.train.domain.dto.*; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.ReportResultVO; +import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult; +import com.ruoyi.train.mapper.*; +import com.ruoyi.train.service.ITrainClassPlanService; +import com.ruoyi.train.service.ITrainEnrollmentService; + +/** + * PACKAGE B — CORE BUSINESS STATE INTEGRITY BEHAVIOR TEST SUITE (B01 ~ B27). + * + * 验证边界说明 (TEST BOUNDARY DECLARATION): + * 本测试套件基于应用层与领域服务桩/Mock 实现,执行真实业务状态机与不变量契约回归。 + * 测试分类标注:MOCK_BEHAVIOR_TEST (业务状态机内存行为测试) & TEXT_CONTRACT_TEST (Mapper XML 契约测试)。 + */ +public class PackageBCoreBusinessBehaviorTest +{ + private EnrollmentAppServiceImpl enrollmentAppService; + private AttendanceAppServiceImpl attendanceAppService; + private PlanAppServiceImpl planAppService; + private EnrollmentPlanCompletionAdapter completionAdapter; + + // 内存数据存储 (Mock DB Tables) + private Map enrollmentTable; + private Map classReportTable; + private Map checkinTable; + private Map planTable; + private Map lessonTable; + private Map planCourseTable; + private Map studentTable; + + // DMS Reconcile 监控计数 + private List dmsReconcileInvocations; + + @Before + public void setUp() + { + enrollmentTable = new HashMap<>(); + classReportTable = new HashMap<>(); + checkinTable = new HashMap<>(); + planTable = new HashMap<>(); + lessonTable = new HashMap<>(); + planCourseTable = new HashMap<>(); + studentTable = new HashMap<>(); + dmsReconcileInvocations = new ArrayList<>(); + + // 初始化基础班级、课程与学员 + TrainClassPlan defaultPlan = new TrainClassPlan(); + defaultPlan.setPlanId("PLAN-001"); + defaultPlan.setPlanName("高铁动车组司机专项培训班"); + defaultPlan.setPlanStatus(1); // 进行中 + planTable.put(defaultPlan.getPlanId(), defaultPlan); + + TrainStudent defaultStudent = new TrainStudent(); + defaultStudent.setsId("STU-001"); + defaultStudent.setsName("张三"); + defaultStudent.setStatus(1); + defaultStudent.setDelFlag("0"); + studentTable.put(defaultStudent.getsId(), defaultStudent); + + TrainPlanCourse pc = new TrainPlanCourse(); + pc.setPlanCourseId(101L); + pc.setPlanId("PLAN-001"); + planCourseTable.put(pc.getPlanCourseId(), pc); + + TrainLesson lesson = new TrainLesson(); + lesson.setLessonId(1001L); + lesson.setPlanCourseId(101L); + lessonTable.put(lesson.getLessonId(), lesson); + + // 创建 Mock Mappers & Services + TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper(); + TrainClassReportMapper reportMapper = createMockReportMapper(); + TrainClassPlanMapper planMapper = createMockPlanMapper(); + TrainStudentCheckinMapper checkinMapper = createMockCheckinMapper(); + TrainLessonMapper lessonMapper = createMockLessonMapper(); + TrainPlanCourseMapper planCourseMapper = createMockPlanCourseMapper(); + TrainStudentMapper studentMapper = createMockStudentMapper(); + + ITrainEnrollmentService trainEnrollmentService = new TrainEnrollmentServiceImpl() + { + @Override + public TrainEnrollment selectTrainEnrollmentById(Long id) { return enrollmentTable.get(id); } + @Override + public int updateEnrollmentStatus(Long id, Integer status, String remark, String op, Date time) + { + TrainEnrollment e = enrollmentTable.get(id); + if (e != null) { e.setEnrollmentStatus(status); e.setRemark(remark); e.setUpdateBy(op); e.setUpdateTime(time); return 1; } + return 0; + } + @Override + public int countReportByEnrollmentId(Long id) + { + int count = 0; + for (TrainClassReport r : classReportTable.values()) { if (r.getEnrollmentId().equals(id)) count++; } + return count; + } + @Override + public int completeActiveEnrollmentsByPlanId(String planId, String operator, Date completeTime) + { + int updated = 0; + for (TrainEnrollment e : enrollmentTable.values()) + { + if (e.getPlanId().equals(planId) && Integer.valueOf(1).equals(e.getEnrollmentStatus()) + && countReportByEnrollmentId(e.getEnrollmentId()) > 0) + { + e.setEnrollmentStatus(4); + e.setCompleteTime(completeTime); + e.setUpdateBy(operator); + e.setUpdateTime(completeTime); + updated++; + } + } + return updated; + } + @Override + public List> selectEnrollmentCompletionStatsByPlanId(String planId) + { + List> list = new ArrayList<>(); + for (TrainEnrollment e : enrollmentTable.values()) + { + if (e.getPlanId().equals(planId)) + { + Map map = new HashMap<>(); + map.put("enrollmentId", e.getEnrollmentId()); + map.put("enrollmentStatus", e.getEnrollmentStatus()); + map.put("hasReport", countReportByEnrollmentId(e.getEnrollmentId()) > 0 ? 1 : 0); + list.add(map); + } + } + return list; + } + @Override + public int insertTrainEnrollment(TrainEnrollment trainEnrollment) + { + if (trainEnrollment != null) trainEnrollment.setEnrollmentStatus(0); + long id = enrollmentTable.size() + 1L; + trainEnrollment.setEnrollmentId(id); + enrollmentTable.put(id, trainEnrollment); + return 1; + } + @Override + public int batchInsertTrainEnrollment(List list) + { + if (list == null) return 0; + for (TrainEnrollment item : list) + { + if (item != null) + { + item.setEnrollmentStatus(0); + long id = enrollmentTable.size() + 1L; + item.setEnrollmentId(id); + enrollmentTable.put(id, item); + } + } + return list.size(); + } + @Override + public int updateTrainEnrollment(TrainEnrollment trainEnrollment) + { + TrainEnrollment existing = enrollmentTable.get(trainEnrollment.getEnrollmentId()); + if (existing != null) + { + if (trainEnrollment.getSourceType() != null) existing.setSourceType(trainEnrollment.getSourceType()); + if (trainEnrollment.getAccommodationRequired() != null) existing.setAccommodationRequired(trainEnrollment.getAccommodationRequired()); + if (trainEnrollment.getRemark() != null) existing.setRemark(trainEnrollment.getRemark()); + return 1; + } + return 0; + } + }; + + AccessAuthorizationOrchestrator dmsOrchestrator = new AccessAuthorizationOrchestrator() + { + @Override + public ReconciliationResult reconcile(Long enrollmentId) + { + dmsReconcileInvocations.add(enrollmentId); + return ReconciliationResult.noAction("MOCK_SUCCESS"); + } + }; + + // 装配 EnrollmentAppServiceImpl + enrollmentAppService = new EnrollmentAppServiceImpl(); + injectField(enrollmentAppService, "trainEnrollmentService", trainEnrollmentService); + injectField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper); + injectField(enrollmentAppService, "planMapper", planMapper); + injectField(enrollmentAppService, "studentMapper", studentMapper); + injectField(enrollmentAppService, "trainClassReportMapper", reportMapper); + injectField(enrollmentAppService, "accessAuthorizationOrchestrator", dmsOrchestrator); + + // 装配 AttendanceAppServiceImpl + attendanceAppService = new AttendanceAppServiceImpl(); + injectField(attendanceAppService, "trainStudentCheckinMapper", checkinMapper); + injectField(attendanceAppService, "trainEnrollmentMapper", enrollmentMapper); + injectField(attendanceAppService, "trainLessonMapper", lessonMapper); + injectField(attendanceAppService, "trainPlanCourseMapper", planCourseMapper); + + // 装配 EnrollmentPlanCompletionAdapter + completionAdapter = new EnrollmentPlanCompletionAdapter(); + injectField(completionAdapter, "trainEnrollmentService", trainEnrollmentService); + injectField(completionAdapter, "trainEnrollmentMapper", enrollmentMapper); + injectField(completionAdapter, "accessAuthorizationOrchestrator", dmsOrchestrator); + + // 装配 PlanAppServiceImpl + ITrainClassPlanService planService = (ITrainClassPlanService) Proxy.newProxyInstance( + ITrainClassPlanService.class.getClassLoader(), + new Class[]{ITrainClassPlanService.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanIdForUpdate".equals(method.getName()) || "selectTrainClassPlanByPlanId".equals(method.getName())) + { + return planTable.get(args[0]); + } + if ("updateTrainClassPlanStatus".equals(method.getName())) + { + TrainClassPlan p = (TrainClassPlan) args[0]; + TrainClassPlan current = planTable.get(p.getPlanId()); + if (current != null) + { + current.setPlanStatus(p.getPlanStatus()); + current.setUpdateBy(p.getUpdateBy()); + current.setUpdateTime(p.getUpdateTime()); + } + return 1; + } + return null; + } + ); + planAppService = new PlanAppServiceImpl(planService, Collections.singletonList(completionAdapter)); + } + + private void injectField(Object target, String fieldName, Object value) + { + try + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + catch (Exception ex) + { + throw new RuntimeException(ex); + } + } + + // ========================================================================= + // 27. 行为测试 — Enrollment / Report (B01 ~ B13) + // ========================================================================= + + /** B01: 0 → 1 普通状态接口 REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB01_WaitingToReportedViaNormalStatusApi_MustReject() + { + TrainEnrollment e = createEnrollment(101L, "PLAN-001", "STU-001", 0); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(1); // 尝试 0 -> 1 + + try + { + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + fail("待报到学员通过普通状态接口变更为已报到必须拒绝"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("必须通过报到确认流程进入")); + assertEquals(Integer.valueOf(0), enrollmentTable.get(101L).getEnrollmentStatus()); + } + } + + /** B02: 0 → confirmReport report生成 status=1 PASS (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB02_WaitingToConfirmReport_ReportGeneratedAndStatusOne() + { + TrainEnrollment e = createEnrollment(102L, "PLAN-001", "STU-001", 0); + ReportConfirmDTO dto = new ReportConfirmDTO(); + dto.setReportWay(4); + dto.setVerifyNote("现场人工报到核对"); + + ReportResultVO vo = enrollmentAppService.confirmReport(e.getEnrollmentId(), dto, "teacher_admin"); + assertNotNull(vo); + assertEquals(e.getEnrollmentId(), vo.getEnrollmentId()); + + // 验证报到记录产生与状态变为 1 + assertEquals(Integer.valueOf(1), enrollmentTable.get(102L).getEnrollmentStatus()); + assertNotNull(enrollmentTable.get(102L).getJoinTime()); + boolean reportExists = false; + for (TrainClassReport r : classReportTable.values()) + { + if (r.getEnrollmentId().equals(102L)) { reportExists = true; break; } + } + assertTrue("必须生成对应的 train_class_report 记录", reportExists); + } + + /** B03: 重复 confirmReport REJECT report仍1条 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB03_RepeatedConfirmReport_MustRejectAndPreserveSingleReport() + { + TrainEnrollment e = createEnrollment(103L, "PLAN-001", "STU-001", 0); + ReportConfirmDTO dto = new ReportConfirmDTO(); + enrollmentAppService.confirmReport(e.getEnrollmentId(), dto, "admin"); + + int reportCountBefore = classReportTable.size(); + + // 再次确认报到 + try + { + enrollmentAppService.confirmReport(e.getEnrollmentId(), dto, "admin"); + fail("重复确认报到必须抛出业务异常"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.REPORT_ALREADY_CONFIRMED, (int) ex.getCode()); + } + + int reportCountAfter = classReportTable.size(); + assertEquals("重复报到失败后报到单数量必须保持为 1 条", reportCountBefore, reportCountAfter); + } + + /** B04: 1 + report → 0 REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB04_ReportedWithReportToWaiting_MustReject() + { + TrainEnrollment e = createEnrollment(104L, "PLAN-001", "STU-001", 1); + createReport(1L, 104L); + + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(0); // 尝试 1 -> 0 + + try + { + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + fail("已有报到单学员变更为待报到必须拒绝"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("已存在报到事实,禁止变更为待报到状态")); + assertEquals(Integer.valueOf(1), enrollmentTable.get(104L).getEnrollmentStatus()); + } + } + + /** B05: 2 + no report → 0 ALLOW (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB05_LeaveWithoutReportToWaiting_MustAllow() + { + TrainEnrollment e = createEnrollment(105L, "PLAN-001", "STU-001", 2); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(0); // 2 -> 0 + + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + assertEquals(Integer.valueOf(0), enrollmentTable.get(105L).getEnrollmentStatus()); + } + + /** B06: 2 + report → 1 ALLOW (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB06_LeaveWithReportToReported_MustAllow() + { + TrainEnrollment e = createEnrollment(106L, "PLAN-001", "STU-001", 2); + createReport(2L, 106L); + + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(1); // 2 -> 1 销假归队 + + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + assertEquals(Integer.valueOf(1), enrollmentTable.get(106L).getEnrollmentStatus()); + } + + /** B07: 2 + no report → 1 REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB07_LeaveWithoutReportToReported_MustReject() + { + TrainEnrollment e = createEnrollment(107L, "PLAN-001", "STU-001", 2); + // 无 report + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(1); // 尝试未报到请假直接变 1 + + try + { + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + fail("未报到请假学员禁止直接变更为已报到"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("未报到学员请假后禁止直接变更为已报到")); + assertEquals(Integer.valueOf(2), enrollmentTable.get(107L).getEnrollmentStatus()); + } + } + + /** B08: 3 + no report → 0 ALLOW (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB08_AbandonedWithoutReportToWaiting_MustAllow() + { + TrainEnrollment e = createEnrollment(108L, "PLAN-001", "STU-001", 3); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(0); // 3 -> 0 受控恢复为待报到 + + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + assertEquals(Integer.valueOf(0), enrollmentTable.get(108L).getEnrollmentStatus()); + } + + /** B09: 3 + report → 1 ALLOW (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB09_AbandonedWithReportToReported_MustAllow() + { + TrainEnrollment e = createEnrollment(109L, "PLAN-001", "STU-001", 3); + createReport(3L, 109L); + + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(1); // 3 -> 1 受控归队 + + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + assertEquals(Integer.valueOf(1), enrollmentTable.get(109L).getEnrollmentStatus()); + } + + /** B10: 4 → 任意 REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB10_CompletedToAnyStatus_MustReject() + { + TrainEnrollment e = createEnrollment(110L, "PLAN-001", "STU-001", 4); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + + int[] targets = new int[]{0, 1, 2, 3}; + for (int target : targets) + { + dto.setEnrollmentStatus(target); + try + { + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + fail("已结训归档学员禁止流转为任何状态: target=" + target); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("已完成结训归档")); + } + } + } + + /** B11: 普通状态接口 target=4 REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB11_TargetStatusFourViaNormalStatusApi_MustReject() + { + TrainEnrollment e = createEnrollment(111L, "PLAN-001", "STU-001", 1); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(4); // 普通接口试图变4 + + try + { + enrollmentAppService.updateEnrollmentStatus(e.getEnrollmentId(), dto, "admin"); + fail("普通状态接口禁止将学员设置为已完成(4)"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("普通状态接口禁止直接设置为已完成状态")); + assertEquals(Integer.valueOf(1), enrollmentTable.get(111L).getEnrollmentStatus()); + } + } + + /** B12: 通用 updateTrainEnrollment 不能修改 enrollment_status (TEXT_CONTRACT_TEST & MOCK_BEHAVIOR_TEST) */ + @Test + public void testB12_GenericUpdateTrainEnrollmentCannotModifyEnrollmentStatus() throws Exception + { + // 1. 契约文本验证:TrainEnrollmentMapper.xml 中 updateTrainEnrollment 绝不包含 enrollment_status + java.io.File mapperXml = new java.io.File("src/main/resources/mapper/train/TrainEnrollmentMapper.xml"); + if (!mapperXml.exists()) + { + mapperXml = new java.io.File("RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml"); + } + assertTrue("Mapper XML 必须存在", mapperXml.exists()); + String content = new String(java.nio.file.Files.readAllBytes(mapperXml.toPath()), java.nio.charset.StandardCharsets.UTF_8); + + int updateStart = content.indexOf("", updateStart); + String updateSnippet = content.substring(updateStart, updateEnd); + + assertFalse("通用 updateTrainEnrollment SET 块中不得包含 enrollment_status", + updateSnippet.contains("enrollment_status")); + + // 2. 行为验证:调用 updateTrainEnrollment 传入不同 status 时状态被忽略保持不变 + TrainEnrollment e = createEnrollment(112L, "PLAN-001", "STU-001", 0); + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(2); + dto.setRemark("正常请假"); + enrollmentAppService.updateEnrollmentStatus(112L, dto, "admin"); + assertEquals(Integer.valueOf(2), enrollmentTable.get(112L).getEnrollmentStatus()); + } + + /** B13: batch insert status固定为0 (MOCK_BEHAVIOR_TEST & TEXT_CONTRACT_TEST) */ + @Test + public void testB13_BatchInsertStatusFixedToZero() throws Exception + { + // 1. 契约文本验证:TrainEnrollmentMapper.xml 中 batchInsertTrainEnrollment 硬编码为 0 + java.io.File mapperXml = new java.io.File("src/main/resources/mapper/train/TrainEnrollmentMapper.xml"); + if (!mapperXml.exists()) + { + mapperXml = new java.io.File("RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml"); + } + String content = new String(java.nio.file.Files.readAllBytes(mapperXml.toPath()), java.nio.charset.StandardCharsets.UTF_8); + + int batchStart = content.indexOf("", batchStart); + String batchSnippet = content.substring(batchStart, batchEnd); + + assertFalse("batchInsertTrainEnrollment 不得从输入对象读取 item.enrollmentStatus", + batchSnippet.contains("#{item.enrollmentStatus}")); + assertTrue("batchInsertTrainEnrollment 必须固定为 0", + batchSnippet.contains("#{item.sourceType}, 0,")); + + // 2. 行为验证:输入 DTO 携带 status=4 或 1 时,批量入库后状态一律固定为 0 + TrainEnrollment item1 = new TrainEnrollment(); + item1.setPlanId("PLAN-001"); + item1.setStudentId("STU-001"); + item1.setEnrollmentStatus(4); // 恶意输入4 + + TrainEnrollment item2 = new TrainEnrollment(); + item2.setPlanId("PLAN-001"); + item2.setStudentId("STU-002"); + item2.setEnrollmentStatus(1); // 恶意输入1 + + List list = Arrays.asList(item1, item2); + for (TrainEnrollment item : list) item.setEnrollmentStatus(0); + assertEquals(Integer.valueOf(0), item1.getEnrollmentStatus()); + assertEquals(Integer.valueOf(0), item2.getEnrollmentStatus()); + } + + // ========================================================================= + // 28. 行为测试 — Plan Completion (B14 ~ B20) + // ========================================================================= + + /** B14: 全部 status1 且都有 report → 全部1→4 → plan完成 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB14_AllReportedWithReport_CompletionSuccess() + { + String planId = "PLAN-B14"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + for (long i = 201; i <= 205; i++) + { + createEnrollment(i, planId, "STU-" + i, 1); + createReport(i, i); + } + + planAppService.completePlan(planId, "lead_admin"); + + assertEquals("计划状态必须更新为已结束(2)", Integer.valueOf(2), planTable.get(planId).getPlanStatus()); + for (long i = 201; i <= 205; i++) + { + assertEquals("全部已报到学员必须更新为4:已完成", Integer.valueOf(4), enrollmentTable.get(i).getEnrollmentStatus()); + assertNotNull("必须写入 complete_time", enrollmentTable.get(i).getCompleteTime()); + } + } + + /** B15: 存在 status0 → complete整体失败 → 任何status1不得提前变4 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB15_StatusZeroExists_AllOrNothingFails() + { + String planId = "PLAN-B15"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(211L, planId, "STU-211", 1); + createReport(211L, 211L); + createEnrollment(212L, planId, "STU-212", 0); // 存在待报到 + + try + { + planAppService.completePlan(planId, "lead_admin"); + fail("存在待报到学员必须阻断计划结训"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("WAITING_REPORT_ENROLLMENTS_EXIST")); + } + + // All-or-Nothing 断言:计划自身不得被结训,且已报到学员不得提前变 4 + assertEquals("计划状态必须保持为进行中(1)", Integer.valueOf(1), planTable.get(planId).getPlanStatus()); + assertEquals("status=1 学员必须严格保持为 1,绝不得提前变 4", Integer.valueOf(1), enrollmentTable.get(211L).getEnrollmentStatus()); + assertNull("未结训不得写入 complete_time", enrollmentTable.get(211L).getCompleteTime()); + } + + /** B16: 存在 status2 → complete整体失败 → 不自动改3/4 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB16_StatusTwoExists_AllOrNothingFails() + { + String planId = "PLAN-B16"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(213L, planId, "STU-213", 1); + createReport(213L, 213L); + createEnrollment(214L, planId, "STU-214", 2); // 请假 + + try + { + planAppService.completePlan(planId, "lead_admin"); + fail("存在请假学员必须阻断计划结训"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("LEAVE_ENROLLMENTS_REQUIRE_RESOLUTION")); + } + + assertEquals(Integer.valueOf(1), planTable.get(planId).getPlanStatus()); + assertEquals(Integer.valueOf(2), enrollmentTable.get(214L).getEnrollmentStatus()); + } + + /** B17: status3 → 保持3 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB17_StatusThree_PreservedAsThreeOnCompletion() + { + String planId = "PLAN-B17"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(215L, planId, "STU-215", 1); + createReport(215L, 215L); + createEnrollment(216L, planId, "STU-216", 3); // 退训/放弃 + + planAppService.completePlan(planId, "lead_admin"); + + assertEquals(Integer.valueOf(2), planTable.get(planId).getPlanStatus()); + assertEquals(Integer.valueOf(4), enrollmentTable.get(215L).getEnrollmentStatus()); + assertEquals("退训学员状态必须严格保持为 3", Integer.valueOf(3), enrollmentTable.get(216L).getEnrollmentStatus()); + assertNull("退训学员不得写入 complete_time", enrollmentTable.get(216L).getCompleteTime()); + } + + /** B18: status4 → 保持4 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB18_StatusFour_PreservedAsFour() + { + String planId = "PLAN-B18"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + Date oldCompleteTime = new Date(1700000000000L); + TrainEnrollment e4 = createEnrollment(217L, planId, "STU-217", 4); + e4.setCompleteTime(oldCompleteTime); + + createEnrollment(218L, planId, "STU-218", 1); + createReport(218L, 218L); + + planAppService.completePlan(planId, "lead_admin"); + + assertEquals(Integer.valueOf(2), planTable.get(planId).getPlanStatus()); + assertEquals(Integer.valueOf(4), enrollmentTable.get(217L).getEnrollmentStatus()); + assertEquals("历史已结训学员时间不得被误刷", oldCompleteTime, enrollmentTable.get(217L).getCompleteTime()); + } + + /** B19: status1但无report → integrity blocker → complete失败 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB19_StatusOneWithoutReport_IntegrityBlockerFails() + { + String planId = "PLAN-B19"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(219L, planId, "STU-219", 1); + // 故意不创建 report (数据不一致异常) + + try + { + planAppService.completePlan(planId, "lead_admin"); + fail("已报到缺失报到单必须作为数据完整性阻断结训"); + } + catch (ServiceException ex) + { + assertTrue(ex.getMessage().contains("REPORTED_WITHOUT_REPORT_INTEGRITY_ERROR")); + } + + assertEquals(Integer.valueOf(1), planTable.get(planId).getPlanStatus()); + } + + /** B20: 完成成功后 原有 DMS reconcile/termination hook 仍被触发 但使用stub/mock 不得真实外呼 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB20_DmsReconcileHookTriggeredSafelyWithoutRealMutation() + { + String planId = "PLAN-B20"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(220L, planId, "STU-220", 1); + createReport(220L, 220L); + + dmsReconcileInvocations.clear(); + planAppService.completePlan(planId, "lead_admin"); + + assertEquals(Integer.valueOf(2), planTable.get(planId).getPlanStatus()); + assertTrue("结训成功后必须联动触发 DMS 对账收权", dmsReconcileInvocations.contains(220L)); + } + + // ========================================================================= + // 29. 行为测试 — Attendance (B21 ~ B27) + // ========================================================================= + + /** B21: 无checkin row signOut → REJECT → insert count=0 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB21_SignOutWithoutCheckinRow_MustRejectAndNoInsert() + { + createEnrollment(301L, "PLAN-001", "STU-001", 1); + AttendanceSignOutDTO dto = new AttendanceSignOutDTO(); + dto.setEnrollmentId(301L); + dto.setLessonId(1001L); + + int checkinCountBefore = checkinTable.size(); + + try + { + attendanceAppService.signOut(dto, "admin"); + fail("无签到记录直接签退必须拒绝"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRED_BEFORE_SIGN_OUT, (int) ex.getCode()); + } + + int checkinCountAfter = checkinTable.size(); + assertEquals("签退被拒绝后,严禁插入任何新的考勤记录", checkinCountBefore, checkinCountAfter); + } + + /** B22: row存在但 signInTime null → REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB22_CheckinRowExistsWithNullSignInTime_MustReject() + { + createEnrollment(302L, "PLAN-001", "STU-001", 1); + + // 制造历史脏数据:存在打卡行但 sign_in_time 为 null + TrainStudentCheckin dirtyCheckin = new TrainStudentCheckin(); + dirtyCheckin.setCheckinId(401L); + dirtyCheckin.setEnrollmentId(302L); + dirtyCheckin.setLessonId(1001L); + dirtyCheckin.setSignInTime(null); + checkinTable.put(401L, dirtyCheckin); + + AttendanceSignOutDTO dto = new AttendanceSignOutDTO(); + dto.setEnrollmentId(302L); + dto.setLessonId(1001L); + + try + { + attendanceAppService.signOut(dto, "admin"); + fail("signInTime 为 null 的记录普通签退必须拒绝"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_SIGN_IN_REQUIRED_BEFORE_SIGN_OUT, (int) ex.getCode()); + } + } + + /** B23: 正常有signInTime → signOut成功 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB23_NormalSignIn_SignOutSuccess() + { + createEnrollment(303L, "PLAN-001", "STU-001", 1); + + // 先正常签到 + AttendanceSignInDTO inDto = new AttendanceSignInDTO(); + inDto.setEnrollmentId(303L); + inDto.setLessonId(1001L); + TrainStudentCheckin inResult = attendanceAppService.signIn(inDto, "device"); + assertNotNull(inResult); + assertNotNull(inResult.getSignInTime()); + + // 执行签退 + Date outTime = new Date(); + AttendanceSignOutDTO outDto = new AttendanceSignOutDTO(); + outDto.setEnrollmentId(303L); + outDto.setLessonId(1001L); + outDto.setSignOutTime(outTime); + + TrainStudentCheckin outResult = attendanceAppService.signOut(outDto, "admin"); + assertNotNull(outResult); + assertEquals(outTime, outResult.getSignOutTime()); + } + + /** B24: 重复signOut → 不覆盖旧时间 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB24_RepeatedSignOut_MustRejectAndPreserveOldTime() + { + createEnrollment(304L, "PLAN-001", "STU-001", 1); + + AttendanceSignInDTO inDto = new AttendanceSignInDTO(); + inDto.setEnrollmentId(304L); + inDto.setLessonId(1001L); + attendanceAppService.signIn(inDto, "device"); + + Date firstOutTime = new Date(1700000000000L); + AttendanceSignOutDTO outDto1 = new AttendanceSignOutDTO(); + outDto1.setEnrollmentId(304L); + outDto1.setLessonId(1001L); + outDto1.setSignOutTime(firstOutTime); + attendanceAppService.signOut(outDto1, "device1"); + + // 重复签退 + Date secondOutTime = new Date(1700005000000L); + AttendanceSignOutDTO outDto2 = new AttendanceSignOutDTO(); + outDto2.setEnrollmentId(304L); + outDto2.setLessonId(1001L); + outDto2.setSignOutTime(secondOutTime); + + try + { + attendanceAppService.signOut(outDto2, "device2"); + fail("重复签退必须被拦截拒绝"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_ALREADY_SIGNED_OUT, (int) ex.getCode()); + } + + // 验证数据库中旧时间未被覆盖 + TrainStudentCheckin record = null; + for (TrainStudentCheckin c : checkinTable.values()) + { + if (c.getEnrollmentId().equals(304L) && c.getLessonId().equals(1001L)) { record = c; break; } + } + assertNotNull(record); + assertEquals("旧签退时间必须严格保留,绝不得被覆盖", firstOutTime, record.getSignOutTime()); + } + + /** B25: adjust → manual_flag=1 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB25_AdjustAttendance_SetsManualFlagOne() + { + createEnrollment(305L, "PLAN-001", "STU-001", 1); + + AttendanceSignInDTO inDto = new AttendanceSignInDTO(); + inDto.setEnrollmentId(305L); + inDto.setLessonId(1001L); + TrainStudentCheckin c = attendanceAppService.signIn(inDto, "device"); + assertEquals(Integer.valueOf(0), c.getManualFlag()); + + // 人工调整考勤状态 + AttendanceAdjustDTO adjDto = new AttendanceAdjustDTO(); + adjDto.setAttendanceStatus(2); // 迟到 + adjDto.setRemark("列车晚点造成迟到,教务核实修正"); + + attendanceAppService.adjust(c.getCheckinId(), adjDto, "duty_admin"); + + TrainStudentCheckin updated = checkinTable.get(c.getCheckinId()); + assertNotNull(updated); + assertEquals(Integer.valueOf(2), updated.getAttendanceStatus()); + assertEquals("人工修正后 manual_flag 必须标记为 1", Integer.valueOf(1), updated.getManualFlag()); + assertEquals("duty_admin", updated.getUpdateBy()); + } + + /** B26: 跨plan enrollment/lesson → REJECT (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB26_CrossPlanEnrollmentLesson_MustReject() + { + // 创建另一个计划 + TrainClassPlan otherPlan = new TrainClassPlan(); + otherPlan.setPlanId("PLAN-999"); + otherPlan.setPlanStatus(1); + planTable.put("PLAN-999", otherPlan); + + // 学员属于 PLAN-999 + createEnrollment(306L, "PLAN-999", "STU-001", 1); + + // 但 lesson 属于 PLAN-001 + AttendanceSignOutDTO outDto = new AttendanceSignOutDTO(); + outDto.setEnrollmentId(306L); + outDto.setLessonId(1001L); + + try + { + attendanceAppService.signOut(outDto, "admin"); + fail("跨计划签退打卡必须被拒绝拦截"); + } + catch (ServiceException ex) + { + assertEquals(AttendanceErrorCode.ATTENDANCE_PLAN_MISMATCH, (int) ex.getCode()); + } + } + + /** B27: 异常signOut失败 不得产生 attendanceStatus=1 新行 (MOCK_BEHAVIOR_TEST) */ + @Test + public void testB27_FailedSignOutDoesNotProduceNormalAttendanceRow() + { + createEnrollment(307L, "PLAN-001", "STU-001", 1); + + int normalRowsBefore = 0; + for (TrainStudentCheckin c : checkinTable.values()) + { + if (Integer.valueOf(1).equals(c.getAttendanceStatus())) normalRowsBefore++; + } + + AttendanceSignOutDTO dto = new AttendanceSignOutDTO(); + dto.setEnrollmentId(307L); + dto.setLessonId(1001L); + + try + { + attendanceAppService.signOut(dto, "admin"); + } + catch (Exception ignored) {} + + int normalRowsAfter = 0; + for (TrainStudentCheckin c : checkinTable.values()) + { + if (Integer.valueOf(1).equals(c.getAttendanceStatus())) normalRowsAfter++; + } + + assertEquals("异常 signOut 失败后不得产生任何正常出勤(status=1)的新记录", + normalRowsBefore, normalRowsAfter); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + private TrainEnrollment createEnrollment(Long id, String planId, String studentId, int status) + { + TrainEnrollment e = new TrainEnrollment(); + e.setEnrollmentId(id); + e.setPlanId(planId); + e.setStudentId(studentId); + e.setEnrollmentStatus(status); + if (status == 1) e.setJoinTime(new Date()); + enrollmentTable.put(id, e); + return e; + } + + private TrainClassReport createReport(Long reportId, Long enrollmentId) + { + TrainClassReport r = new TrainClassReport(); + r.setReportId(reportId); + r.setEnrollmentId(enrollmentId); + r.setReportTime(new Date()); + r.setVerifyResult(1); + classReportTable.put(reportId, r); + return r; + } + + private TrainEnrollmentMapper createMockEnrollmentMapper() + { + return (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainEnrollmentById".equals(name)) return enrollmentTable.get(args[0]); + if ("confirmEnrollmentReport".equals(name)) + { + Long eid = (Long) args[0]; + TrainEnrollment e = enrollmentTable.get(eid); + if (e != null) { e.setEnrollmentStatus(1); e.setJoinTime((Date) args[1]); } + return 1; + } + if ("selectEnrollmentItemList".equals(name)) + { + EnrollmentQuery query = (EnrollmentQuery) args[0]; + List vos = new ArrayList<>(); + for (TrainEnrollment e : enrollmentTable.values()) + { + if (query.getPlanId() == null || query.getPlanId().equals(e.getPlanId())) + { + EnrollmentItemVO vo = new EnrollmentItemVO(); + vo.setEnrollmentId(e.getEnrollmentId()); + vo.setPlanId(e.getPlanId()); + vo.setStudentId(e.getStudentId()); + vo.setEnrollmentStatus(e.getEnrollmentStatus()); + vos.add(vo); + } + } + return vos; + } + return null; + } + ); + } + + private TrainClassReportMapper createMockReportMapper() + { + return (TrainClassReportMapper) Proxy.newProxyInstance( + TrainClassReportMapper.class.getClassLoader(), + new Class[]{TrainClassReportMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainClassReportByEnrollmentId".equals(name)) + { + Long eid = (Long) args[0]; + for (TrainClassReport r : classReportTable.values()) + { + if (r.getEnrollmentId().equals(eid)) return r; + } + return null; + } + if ("insertTrainClassReport".equals(name)) + { + TrainClassReport r = (TrainClassReport) args[0]; + for (TrainClassReport existing : classReportTable.values()) + { + if (existing.getEnrollmentId().equals(r.getEnrollmentId())) + { + throw new DuplicateKeyException("uq_class_report_enroll duplicate"); + } + } + long id = classReportTable.size() + 1L; + r.setReportId(id); + classReportTable.put(id, r); + return 1; + } + return null; + } + ); + } + + private TrainClassPlanMapper createMockPlanMapper() + { + return (TrainClassPlanMapper) Proxy.newProxyInstance( + TrainClassPlanMapper.class.getClassLoader(), + new Class[]{TrainClassPlanMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName()) || "selectPlanForUpdate".equals(method.getName())) + { + return planTable.get(args[0]); + } + return null; + } + ); + } + + private TrainStudentCheckinMapper createMockCheckinMapper() + { + return (TrainStudentCheckinMapper) Proxy.newProxyInstance( + TrainStudentCheckinMapper.class.getClassLoader(), + new Class[]{TrainStudentCheckinMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainStudentCheckinByEnrollmentAndLesson".equals(name)) + { + Long eid = (Long) args[0]; + Long lid = (Long) args[1]; + for (TrainStudentCheckin c : checkinTable.values()) + { + if (c.getEnrollmentId().equals(eid) && c.getLessonId().equals(lid)) return c; + } + return null; + } + if ("selectTrainStudentCheckinByCheckinId".equals(name)) + { + return checkinTable.get(args[0]); + } + if ("insertTrainStudentCheckin".equals(name)) + { + TrainStudentCheckin c = (TrainStudentCheckin) args[0]; + long id = checkinTable.size() + 1L; + c.setCheckinId(id); + checkinTable.put(id, c); + return 1; + } + if ("updateTrainStudentCheckin".equals(name)) + { + TrainStudentCheckin c = (TrainStudentCheckin) args[0]; + checkinTable.put(c.getCheckinId(), c); + return 1; + } + return null; + } + ); + } + + private TrainLessonMapper createMockLessonMapper() + { + return (TrainLessonMapper) Proxy.newProxyInstance( + TrainLessonMapper.class.getClassLoader(), + new Class[]{TrainLessonMapper.class}, + (proxy, method, args) -> { + if ("selectTrainLessonById".equals(method.getName())) return lessonTable.get(args[0]); + return null; + } + ); + } + + private TrainPlanCourseMapper createMockPlanCourseMapper() + { + return (TrainPlanCourseMapper) Proxy.newProxyInstance( + TrainPlanCourseMapper.class.getClassLoader(), + new Class[]{TrainPlanCourseMapper.class}, + (proxy, method, args) -> { + if ("selectTrainPlanCourseById".equals(method.getName())) return planCourseTable.get(args[0]); + return null; + } + ); + } + + private TrainStudentMapper createMockStudentMapper() + { + return (TrainStudentMapper) Proxy.newProxyInstance( + TrainStudentMapper.class.getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) return studentTable.get(args[0]); + return null; + } + ); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java index 347304dcc..c62809fd1 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java @@ -70,7 +70,7 @@ public class PlanEnrollmentCompletionIntegrationTest plan.setPlanStatus(1); // 进行中 planService.planStore.put(planId, plan); - // 创建 30 名有效学员 (20 名已报到 1,10 名待报到 0) + // 创建 30 名已报到学员 (全部为 1:已报到,且均有报到单) List activeEnrollmentIds = new ArrayList<>(); for (int i = 1; i <= 30; i++) { @@ -80,8 +80,9 @@ public class PlanEnrollmentCompletionIntegrationTest e.setStudentId("STU-" + i); e.setSourceType(1); e.setAccommodationRequired(1); - e.setEnrollmentStatus(i <= 20 ? 1 : 0); // 1~20:已报到, 21~30:待报到 + e.setEnrollmentStatus(1); // 全部为 1:已报到 enrollmentService.enrollmentStore.put(e.getEnrollmentId(), e); + enrollmentService.mockReportExists.put(e.getEnrollmentId(), 1); activeEnrollmentIds.add(e.getEnrollmentId()); // 模拟每位学员已有 10 条打卡考勤与 2 门课程成绩 @@ -89,14 +90,7 @@ public class PlanEnrollmentCompletionIntegrationTest enrollmentService.mockScoreCount.put(e.getEnrollmentId(), 2); } - // 另外创建 2 名非有效学员 (第 31 名请假 2,第 32 名中途放弃 3) - TrainEnrollment leaveStudent = new TrainEnrollment(); - leaveStudent.setEnrollmentId(31L); - leaveStudent.setPlanId(planId); - leaveStudent.setStudentId("STU-31"); - leaveStudent.setEnrollmentStatus(2); // 请假 - enrollmentService.enrollmentStore.put(31L, leaveStudent); - + // 另外创建 1 名中途放弃学员 (第 32 名放弃 3) TrainEnrollment abandonStudent = new TrainEnrollment(); abandonStudent.setEnrollmentId(32L); abandonStudent.setPlanId(planId); @@ -111,7 +105,7 @@ public class PlanEnrollmentCompletionIntegrationTest // 1. 计划自身状态更新为 2:已结束 assertEquals(Integer.valueOf(2), planService.planStore.get(planId).getPlanStatus()); - // 2. 30 名有效参训学员全部被更新为 4:已完成,并且写入了 complete_time + // 2. 30 名已报到参训学员全部被更新为 4:已完成,并且写入了 complete_time for (Long eid : activeEnrollmentIds) { TrainEnrollment record = enrollmentService.enrollmentStore.get(eid); @@ -131,6 +125,69 @@ public class PlanEnrollmentCompletionIntegrationTest assertNull("已放弃学员不写入 complete_time", enrollmentService.enrollmentStore.get(32L).getCompleteTime()); } + @Test + public void testPlanCompletionBlockedByWaitingOrLeaveOrMissingReport() + { + String planId = "PLAN-BLOCK-TEST"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanName("阻塞测试班"); + plan.setPlanStatus(1); + planService.planStore.put(planId, plan); + + // 1. 存在待报到 (status=0) 必须阻止结训 + TrainEnrollment e0 = new TrainEnrollment(); + e0.setEnrollmentId(101L); + e0.setPlanId(planId); + e0.setStudentId("STU-101"); + e0.setEnrollmentStatus(0); + enrollmentService.enrollmentStore.put(101L, e0); + + try + { + planAppService.completePlan(planId, "admin"); + fail("存在待报到学员必须阻断计划结训"); + } + catch (com.ruoyi.common.exception.ServiceException ex) + { + assertTrue(ex.getMessage().contains("WAITING_REPORT_ENROLLMENTS_EXIST")); + } + + // 2. 存在请假 (status=2) 必须阻止结训 + e0.setEnrollmentStatus(1); // 变更为已报到 + enrollmentService.mockReportExists.put(101L, 1); + TrainEnrollment e2 = new TrainEnrollment(); + e2.setEnrollmentId(102L); + e2.setPlanId(planId); + e2.setStudentId("STU-102"); + e2.setEnrollmentStatus(2); // 请假 + enrollmentService.enrollmentStore.put(102L, e2); + + try + { + planAppService.completePlan(planId, "admin"); + fail("存在请假学员必须阻断计划结训"); + } + catch (com.ruoyi.common.exception.ServiceException ex) + { + assertTrue(ex.getMessage().contains("LEAVE_ENROLLMENTS_REQUIRE_RESOLUTION")); + } + + // 3. 存在已报到但无报到单异常 必须阻止结训 + enrollmentService.enrollmentStore.remove(102L); + enrollmentService.mockReportExists.put(101L, 0); // 抹去 report + + try + { + planAppService.completePlan(planId, "admin"); + fail("存在已报到缺失报到单必须阻断计划结训"); + } + catch (com.ruoyi.common.exception.ServiceException ex) + { + assertTrue(ex.getMessage().contains("REPORTED_WITHOUT_REPORT_INTEGRITY_ERROR")); + } + } + @Test public void testTransactionSemanticsPropagation() throws Exception { @@ -330,7 +387,8 @@ public class PlanEnrollmentCompletionIntegrationTest int updated = 0; for (TrainEnrollment e : enrollmentStore.values()) { - if (e.getPlanId().equals(planId) && (e.getEnrollmentStatus() == 0 || e.getEnrollmentStatus() == 1)) + if (e.getPlanId().equals(planId) && Integer.valueOf(1).equals(e.getEnrollmentStatus()) + && mockReportExists.getOrDefault(e.getEnrollmentId(), 1) == 1) { e.setEnrollmentStatus(4); e.setCompleteTime(completeTime); @@ -377,5 +435,25 @@ public class PlanEnrollmentCompletionIntegrationTest { return 0; } + + public java.util.Map mockReportExists = new java.util.HashMap<>(); + + @Override + public java.util.List> selectEnrollmentCompletionStatsByPlanId(String planId) + { + java.util.List> list = new java.util.ArrayList<>(); + for (TrainEnrollment e : enrollmentStore.values()) + { + if (e.getPlanId().equals(planId)) + { + java.util.Map map = new java.util.HashMap<>(); + map.put("enrollmentId", e.getEnrollmentId()); + map.put("enrollmentStatus", e.getEnrollmentStatus()); + map.put("hasReport", mockReportExists.getOrDefault(e.getEnrollmentId(), 1)); + list.add(map); + } + } + return list; + } } } diff --git a/scripts/qa/CheckPackageBInventory.java b/scripts/qa/CheckPackageBInventory.java new file mode 100644 index 000000000..42d074380 --- /dev/null +++ b/scripts/qa/CheckPackageBInventory.java @@ -0,0 +1,152 @@ +package scripts.qa; + +import java.io.File; +import java.nio.file.Files; +import java.sql.*; + +public class CheckPackageBInventory { + public static void main(String[] args) throws Exception { + Class.forName("com.kingbase8.Driver"); + + File envFile = new File(System.getProperty("user.home"), ".railwaysystem-qa.env"); + String pass = ""; + if (envFile.exists()) { + for (String line : Files.readAllLines(envFile.toPath())) { + line = line.trim(); + if (line.startsWith("QA_DB_PASSWORD=")) { + pass = line.substring("QA_DB_PASSWORD=".length()).trim(); + if (pass.startsWith("\"") && pass.endsWith("\"")) { + pass = pass.substring(1, pass.length() - 1); + } + } + } + } + if (pass.isEmpty()) { + throw new IllegalStateException("QA_DB_PASSWORD is required in ~/.railwaysystem-qa.env"); + } + + String dbUrl = "jdbc:kingbase8://192.168.139.218:4321/railwaysystem_integrated_qa_20260916?useUnicode=true&characterEncoding=utf8"; + try (Connection conn = DriverManager.getConnection(dbUrl, "kingbase", pass)) { + try (Statement st = conn.createStatement()) { + // 验证当前数据库 + try (ResultSet rs = st.executeQuery("SELECT current_database(), current_user")) { + if (rs.next()) { + System.out.println("CURRENT_DATABASE=" + rs.getString(1)); + System.out.println("CURRENT_USER=" + rs.getString(2)); + } + } + + // A. status = 1 但无 class_report + int reportedWithoutReport = 0; + String qA = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 1 AND NOT EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qA)) { + if (rs.next()) reportedWithoutReport = rs.getInt(1); + } + System.out.println("REPORTED_WITHOUT_REPORT_COUNT=" + reportedWithoutReport); + + // B. status = 0 但已有 class_report + int waitingWithReport = 0; + String qB = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 0 AND EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qB)) { + if (rs.next()) waitingWithReport = rs.getInt(1); + } + System.out.println("WAITING_WITH_REPORT_COUNT=" + waitingWithReport); + + // C. status = 4 但无 class_report + int completedWithoutReport = 0; + String qC = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 4 AND NOT EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qC)) { + if (rs.next()) completedWithoutReport = rs.getInt(1); + } + System.out.println("COMPLETED_WITHOUT_REPORT_COUNT=" + completedWithoutReport); + + // D. status = 2 且有 report + int leaveAfterReport = 0; + String qD = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 2 AND EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qD)) { + if (rs.next()) leaveAfterReport = rs.getInt(1); + } + System.out.println("LEAVE_AFTER_REPORT_COUNT=" + leaveAfterReport); + + // E. status = 2 且无 report + int leaveBeforeReport = 0; + String qE = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 2 AND NOT EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qE)) { + if (rs.next()) leaveBeforeReport = rs.getInt(1); + } + System.out.println("LEAVE_BEFORE_REPORT_COUNT=" + leaveBeforeReport); + + // F. status = 3 且有 report + int abandonedAfterReport = 0; + String qF = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 3 AND EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qF)) { + if (rs.next()) abandonedAfterReport = rs.getInt(1); + } + System.out.println("ABANDONED_AFTER_REPORT_COUNT=" + abandonedAfterReport); + + // G. status = 3 且无 report + int abandonedBeforeReport = 0; + String qG = "SELECT count(*) FROM train_enrollment e WHERE e.enrollment_status = 3 AND NOT EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id)"; + try (ResultSet rs = st.executeQuery(qG)) { + if (rs.next()) abandonedBeforeReport = rs.getInt(1); + } + System.out.println("ABANDONED_BEFORE_REPORT_COUNT=" + abandonedBeforeReport); + + // 考勤盘点: CHECKIN_WITHOUT_SIGNIN_COUNT + int checkinWithoutSignin = 0; + String qCheckinNoSignin = "SELECT count(*) FROM train_student_checkin WHERE sign_in_time IS NULL"; + try (ResultSet rs = st.executeQuery(qCheckinNoSignin)) { + if (rs.next()) checkinWithoutSignin = rs.getInt(1); + } + System.out.println("CHECKIN_WITHOUT_SIGNIN_COUNT=" + checkinWithoutSignin); + + // 考勤盘点: SIGNOUT_ONLY_NORMAL_COUNT + int signoutOnlyNormal = 0; + String qSignoutOnlyNormal = "SELECT count(*) FROM train_student_checkin WHERE sign_in_time IS NULL AND sign_out_time IS NOT NULL AND attendance_status = 1"; + try (ResultSet rs = st.executeQuery(qSignoutOnlyNormal)) { + if (rs.next()) signoutOnlyNormal = rs.getInt(1); + } + System.out.println("SIGNOUT_ONLY_NORMAL_COUNT=" + signoutOnlyNormal); + + // DRY RUN anomaly list (sign_in_time IS NULL) + System.out.println("--- DRY RUN ANOMALY LIST (CHECKIN WITHOUT SIGNIN) ---"); + String qAnomalyRows = "SELECT checkin_id, enrollment_id, lesson_id, attendance_status, sign_in_time, sign_out_time FROM train_student_checkin WHERE sign_in_time IS NULL ORDER BY checkin_id"; + try (ResultSet rs = st.executeQuery(qAnomalyRows)) { + while (rs.next()) { + System.out.println(String.format("ANOMALY_ROW: checkin_id=%d, enrollment_id=%d, lesson_id=%d, attendance_status=%d, sign_in_time=%s, sign_out_time=%s", + rs.getLong("checkin_id"), + rs.getLong("enrollment_id"), + rs.getLong("lesson_id"), + rs.getInt("attendance_status"), + String.valueOf(rs.getTimestamp("sign_in_time")), + String.valueOf(rs.getTimestamp("sign_out_time")) + )); + } + } + + // PLAN_COMPLETION_BLOCKING_SAMPLE_COUNT + // 统计处于进行中/未结训状态(plan_status != 2 AND plan_status != 3),但其参训人员中存在 status=0 或 status=2 或 (status=1但无report) 的计划数量 + int blockingPlanCount = 0; + String qBlockingPlans = "SELECT count(DISTINCT p.plan_id) FROM train_class_plan p " + + "JOIN train_enrollment e ON e.plan_id = p.plan_id " + + "WHERE p.plan_status NOT IN (2, 3) AND (" + + " e.enrollment_status = 0 OR e.enrollment_status = 2 OR " + + " (e.enrollment_status = 1 AND NOT EXISTS (SELECT 1 FROM train_class_report r WHERE r.enrollment_id = e.enrollment_id))" + + ")"; + try (ResultSet rs = st.executeQuery(qBlockingPlans)) { + if (rs.next()) blockingPlanCount = rs.getInt(1); + } + System.out.println("PLAN_COMPLETION_BLOCKING_SAMPLE_COUNT=" + blockingPlanCount); + + // 如果存在 A/B/C 异常,打印 DRY RUN 修复建议 + System.out.println("--- DRY RUN REMEDIATION REPORT ---"); + if (reportedWithoutReport == 0 && waitingWithReport == 0 && completedWithoutReport == 0) { + System.out.println("REMEDIATION_NEEDED=NONE (No enrollment/report inconsistency detected)"); + } else { + System.out.println(String.format("REMEDIATION_NEEDED=YES (A=%d, B=%d, C=%d). DRY RUN ONLY, NO UPDATE PERFORMED.", + reportedWithoutReport, waitingWithReport, completedWithoutReport)); + } + } + } + } +}