diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml
index 5c4d05c08..c85f72ed3 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml
@@ -66,7 +66,7 @@
SUM(CASE WHEN c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count
FROM train_student_checkin c
WHERE c.sign_in_time IS NOT NULL
- AND c.sign_in_time >= CURRENT_DATE - INTERVAL '30 days'
+ AND c.sign_in_time >= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY TO_CHAR(c.sign_in_time, 'YYYY-MM-DD')
ORDER BY stat_date ASC
@@ -107,7 +107,7 @@
SELECT
COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name,
count(1) AS usage_count,
- ROUND(SUM(EXTRACT(EPOCH FROM (l.end_time - l.start_time)) / 3600.0)::numeric, 1) AS total_hours
+ ROUND(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0)::numeric, 1) AS total_hours
FROM train_lesson l
LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id
LEFT JOIN train_training_site ts ON ts.site_id = l.site_id
@@ -123,10 +123,10 @@
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE 1 = 1
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -143,10 +143,10 @@
JOIN train_enrollment e ON e.plan_id = pc.plan_id
WHERE l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -162,11 +162,12 @@
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status = 1
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -182,11 +183,12 @@
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status != 1
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -205,11 +207,12 @@
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.sign_in_time IS NOT NULL
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -229,11 +232,12 @@
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE 1 = 1
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -256,11 +260,12 @@
JOIN train_lesson l ON l.plan_course_id = pc.plan_course_id
LEFT JOIN train_student_checkin c ON c.lesson_id = l.lesson_id
WHERE p.del_flag = '0'
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND p.plan_id = #{dto.planId}
@@ -284,11 +289,12 @@
JOIN train_lesson l ON l.plan_course_id = pc.plan_course_id
LEFT JOIN train_student_checkin c ON c.lesson_id = l.lesson_id
WHERE crs.del_flag = '0'
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND pc.plan_id = #{dto.planId}
@@ -319,11 +325,12 @@
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status IN (0, 2, 3, 4)
+ AND l.start_time <= CURRENT_TIMESTAMP
- AND l.start_time >= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time >= TO_DATE(#{dto.startDate}, 'YYYY-MM-DD')
- AND l.start_time <= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
+ AND l.start_time < TO_DATE(#{dto.endDate}, 'YYYY-MM-DD') + INTERVAL '1' DAY
AND p.plan_id = #{dto.planId}
@@ -376,7 +383,9 @@
COUNT(1) AS count_num
FROM train_student_checkin c
JOIN train_enrollment e ON e.enrollment_id = c.enrollment_id
+ JOIN train_lesson l ON l.lesson_id = c.lesson_id
WHERE e.student_id = #{studentId}
+ AND l.start_time <= CURRENT_TIMESTAMP
GROUP BY c.attendance_status
@@ -393,7 +402,9 @@
SELECT
a.accommodation_id AS accommodation_id,
a.dorm_id AS dorm_id,
- d.dorm_name AS room_name,
+ d.dorm_building AS dorm_building,
+ d.dorm_floor AS dorm_floor,
+ d.dorm_room AS dorm_room,
a.bed_no AS bed_no,
a.accommodation_status AS accommodation_status,
auth.auth_status AS auth_status
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java
index dc1b78361..642c6f4d9 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java
@@ -169,12 +169,95 @@ public class TrainAnalysisServiceTest
// 住宿与门禁
assertTrue(vo.getHasAccommodation());
- assertEquals("101", vo.getRoomName());
+ assertEquals("学员1号楼", vo.getDormBuilding());
+ assertEquals("1层", vo.getDormFloor());
+ assertEquals("101", vo.getDormRoom());
+ assertEquals("学员1号楼 / 1层 / 101室", vo.getRoomName());
assertEquals("A床", vo.getBedNo());
assertEquals("已入住", vo.getAccommodationStatusName());
assertEquals("生效中", vo.getAuthStatusName());
}
+ @Test
+ public void testAttendanceDateFilterNormalization_SingleDayMultiDayCrossMonthFuture()
+ {
+ // 1. 空日期 -> 默认最近 30 天
+ AttendanceFilterDto emptyFilter = new AttendanceFilterDto();
+ AttendanceSummaryVo voEmpty = service.getAttendanceSummary(emptyFilter);
+ assertNotNull(voEmpty);
+
+ // 2. 单日 (startDate == endDate)
+ AttendanceFilterDto singleDay = new AttendanceFilterDto();
+ singleDay.setStartDate("2026-09-20");
+ singleDay.setEndDate("2026-09-20");
+ AttendanceSummaryVo voSingle = service.getAttendanceSummary(singleDay);
+ assertNotNull(voSingle);
+
+ // 3. 跨月范围
+ AttendanceFilterDto crossMonth = new AttendanceFilterDto();
+ crossMonth.setStartDate("2026-08-15");
+ crossMonth.setEndDate("2026-09-20");
+ AttendanceSummaryVo voCross = service.getAttendanceSummary(crossMonth);
+ assertNotNull(voCross);
+
+ // 4. 未来日期范围
+ AttendanceFilterDto future = new AttendanceFilterDto();
+ future.setStartDate("2026-10-01");
+ future.setEndDate("2026-10-10");
+ AttendanceSummaryVo voFuture = service.getAttendanceSummary(future);
+ assertNotNull(voFuture);
+ }
+
+ @Test
+ public void testAttendanceExpectedAttendanceCalculation_ExcludesFutureLessons()
+ {
+ // 当筛选未来日期或无开课时,应到人次为 0
+ mockMapper.expectedTotal = 0L;
+ mockMapper.normalTotal = 0L;
+ mockMapper.abnormalTotal = 0L;
+
+ AttendanceFilterDto filter = new AttendanceFilterDto();
+ filter.setStartDate("2026-09-21");
+ filter.setEndDate("2026-09-25");
+
+ AttendanceSummaryVo summary = service.getAttendanceSummary(filter);
+ assertEquals(Long.valueOf(0), summary.getExpectedAttendTotal());
+ assertEquals(Long.valueOf(0), summary.getNormalAttendTotal());
+ assertEquals(Double.valueOf(0.0), summary.getAttendanceRate());
+ }
+
+ @Test
+ public void testNoCheckinAndExplicitAbsenceCalculation_SeparationSafe()
+ {
+ // 验证已开课场景:应到 100,正常签到 73,明确异常流水(迟到/缺勤等)4,无签到 = 100 - (73 + 4) = 23
+ mockMapper.expectedTotal = 100L;
+ mockMapper.normalTotal = 73L;
+ mockMapper.abnormalTotal = 4L;
+
+ AttendanceFilterDto filter = new AttendanceFilterDto();
+ filter.setPlanId("1631c0e1edcc47eea1760584b09f63b5");
+ filter.setStartDate("2026-09-15");
+ filter.setEndDate("2026-09-20");
+
+ AttendanceSummaryVo summary = service.getAttendanceSummary(filter);
+ assertEquals(Long.valueOf(100), summary.getExpectedAttendTotal());
+ assertEquals(Long.valueOf(73), summary.getNormalAttendTotal());
+ // 异常人次合并统计:明确异常流水 (4) + 无签到 (23) = 27
+ assertEquals(Long.valueOf(27), summary.getAbnormalAttendTotal());
+ assertEquals(Double.valueOf(73.0), summary.getAttendanceRate());
+
+ // 验证学生 360 视图中明确缺勤与无签到的分离
+ Student360Vo s360 = service.getStudent360("S2026001");
+ assertNotNull(s360);
+ assertEquals(Long.valueOf(10), s360.getExpectedAttendCount());
+ assertEquals(Long.valueOf(8), s360.getNormalCount());
+ assertEquals(Long.valueOf(1), s360.getAbsentCount()); // 明确缺勤流水
+ assertEquals(Long.valueOf(1), s360.getNoCheckinCount()); // 无签到未打卡
+ // 8 正常 + 1 明确缺勤 + 1 无签到 = 10 应到
+ assertEquals(s360.getExpectedAttendCount().longValue(),
+ s360.getNormalCount() + s360.getAbsentCount() + s360.getNoCheckinCount());
+ }
+
// ==================== Mock Mapper Implementation ====================
private static class MockTrainAnalysisMapper implements TrainAnalysisMapper
{
@@ -384,7 +467,9 @@ public class TrainAnalysisServiceTest
Map map = new HashMap<>();
map.put("accommodation_id", 801L);
map.put("dorm_id", "DORM01");
- map.put("room_name", "101");
+ map.put("dorm_building", "学员1号楼");
+ map.put("dorm_floor", "1层");
+ map.put("dorm_room", "101");
map.put("bed_no", "A床");
map.put("accommodation_status", 1);
map.put("auth_status", "ACTIVE");