diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAnalysisController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAnalysisController.java new file mode 100644 index 000000000..df419781d --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAnalysisController.java @@ -0,0 +1,83 @@ +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.train.domain.analysis.AttendanceChartsVo; +import com.ruoyi.train.domain.analysis.AttendanceFilterDto; +import com.ruoyi.train.domain.analysis.AttendanceSummaryVo; +import com.ruoyi.train.domain.analysis.DashboardChartsVo; +import com.ruoyi.train.domain.analysis.DashboardOverviewVo; +import com.ruoyi.train.domain.analysis.Student360Vo; +import com.ruoyi.train.service.ITrainAnalysisService; + +/** + * 数据分析与可视化 Controller + */ +@RestController +@RequestMapping("/train/analysis") +public class TrainAnalysisController extends BaseController +{ + @Autowired + private ITrainAnalysisService analysisService; + + /** + * 获取管理驾驶舱宏观概览与业务提醒 + */ + @PreAuthorize("@ss.hasPermi('train:analysis:dashboard')") + @GetMapping("/dashboard/overview") + public AjaxResult getDashboardOverview() + { + DashboardOverviewVo vo = analysisService.getDashboardOverview(); + return success(vo); + } + + /** + * 获取管理驾驶舱图表数据 + */ + @PreAuthorize("@ss.hasPermi('train:analysis:dashboard')") + @GetMapping("/dashboard/charts") + public AjaxResult getDashboardCharts() + { + DashboardChartsVo vo = analysisService.getDashboardCharts(); + return success(vo); + } + + /** + * 获取考勤专题分析汇总 KPI + */ + @PreAuthorize("@ss.hasPermi('train:analysis:attendance')") + @GetMapping("/attendance/summary") + public AjaxResult getAttendanceSummary(AttendanceFilterDto filter) + { + AttendanceSummaryVo vo = analysisService.getAttendanceSummary(filter); + return success(vo); + } + + /** + * 获取考勤专题分析图表集合 + */ + @PreAuthorize("@ss.hasPermi('train:analysis:attendance')") + @GetMapping("/attendance/charts") + public AjaxResult getAttendanceCharts(AttendanceFilterDto filter) + { + AttendanceChartsVo vo = analysisService.getAttendanceCharts(filter); + return success(vo); + } + + /** + * 获取指定学员 360 客观全景画像 (复用现有 train:portrait:query 权限) + */ + @PreAuthorize("@ss.hasPermi('train:portrait:query')") + @GetMapping("/portrait/student-360/{studentId}") + public AjaxResult getStudent360(@PathVariable("studentId") String studentId) + { + Student360Vo vo = analysisService.getStudent360(studentId); + return success(vo); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAnalysisControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAnalysisControllerContractTest.java new file mode 100644 index 000000000..348beecc9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAnalysisControllerContractTest.java @@ -0,0 +1,55 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.*; + +import java.lang.reflect.Method; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import com.ruoyi.train.domain.analysis.AttendanceFilterDto; + +/** + * 数据分析与可视化 Controller 契约与权限边界测试 + */ +public class TrainAnalysisControllerContractTest +{ + @Test + public void shouldExposeAllFiveAnalyticsEndpointsAndCorrectPermissions() throws Exception + { + // 1. Controller 根路径 + RequestMapping root = TrainAnalysisController.class.getAnnotation(RequestMapping.class); + assertNotNull(root); + assertEquals("/train/analysis", root.value()[0]); + + // 2. 驾驶舱概览 + Method overview = TrainAnalysisController.class.getMethod("getDashboardOverview"); + assertNotNull(overview.getAnnotation(GetMapping.class)); + assertEquals("/dashboard/overview", overview.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:analysis:dashboard')", overview.getAnnotation(PreAuthorize.class).value()); + + // 3. 驾驶舱图表 + Method dashCharts = TrainAnalysisController.class.getMethod("getDashboardCharts"); + assertNotNull(dashCharts.getAnnotation(GetMapping.class)); + assertEquals("/dashboard/charts", dashCharts.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:analysis:dashboard')", dashCharts.getAnnotation(PreAuthorize.class).value()); + + // 4. 考勤汇总 KPI + Method attSummary = TrainAnalysisController.class.getMethod("getAttendanceSummary", AttendanceFilterDto.class); + assertNotNull(attSummary.getAnnotation(GetMapping.class)); + assertEquals("/attendance/summary", attSummary.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:analysis:attendance')", attSummary.getAnnotation(PreAuthorize.class).value()); + + // 5. 考勤图表集合 + Method attCharts = TrainAnalysisController.class.getMethod("getAttendanceCharts", AttendanceFilterDto.class); + assertNotNull(attCharts.getAnnotation(GetMapping.class)); + assertEquals("/attendance/charts", attCharts.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:analysis:attendance')", attCharts.getAnnotation(PreAuthorize.class).value()); + + // 6. 学员 360 画像 (复用现有 train:portrait:query) + Method s360 = TrainAnalysisController.class.getMethod("getStudent360", String.class); + assertNotNull(s360.getAnnotation(GetMapping.class)); + assertEquals("/portrait/student-360/{studentId}", s360.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:portrait:query')", s360.getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AbnormalStudentDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AbnormalStudentDto.java new file mode 100644 index 000000000..36d4798be --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AbnormalStudentDto.java @@ -0,0 +1,52 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 考勤异常学员明细 DTO + */ +public class AbnormalStudentDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String studentId; + private String studentName; + private String planId; + private String planName; + private Long absentCount; + private Long lateCount; + private Long earlyLeaveCount; + private Long leaveCount; + private Long noCheckinCount; + private Long totalAbnormalCount; + + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public Long getAbsentCount() { return absentCount; } + public void setAbsentCount(Long absentCount) { this.absentCount = absentCount; } + + public Long getLateCount() { return lateCount; } + public void setLateCount(Long lateCount) { this.lateCount = lateCount; } + + public Long getEarlyLeaveCount() { return earlyLeaveCount; } + public void setEarlyLeaveCount(Long earlyLeaveCount) { this.earlyLeaveCount = earlyLeaveCount; } + + public Long getLeaveCount() { return leaveCount; } + public void setLeaveCount(Long leaveCount) { this.leaveCount = leaveCount; } + + public Long getNoCheckinCount() { return noCheckinCount; } + public void setNoCheckinCount(Long noCheckinCount) { this.noCheckinCount = noCheckinCount; } + + public Long getTotalAbnormalCount() { return totalAbnormalCount; } + public void setTotalAbnormalCount(Long totalAbnormalCount) { this.totalAbnormalCount = totalAbnormalCount; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceChartsVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceChartsVo.java new file mode 100644 index 000000000..12d330e44 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceChartsVo.java @@ -0,0 +1,42 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.List; + +/** + * 考勤专题分析图表集合 VO + */ +public class AttendanceChartsVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 1. 每日考勤趋势折线图 */ + private List dailyTrend; + + /** 2. 考勤状态环形分布 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录) */ + private List statusDistribution; + + /** 3. 培训计划出勤对比 (横向柱状图 Top 10) */ + private List planComparison; + + /** 4. 课程考勤对比 (横向柱状图 Top 10) */ + private List courseComparison; + + /** 5. 异常学员 Top 10 列表与柱图 */ + private List abnormalStudentTop10; + + public List getDailyTrend() { return dailyTrend; } + public void setDailyTrend(List dailyTrend) { this.dailyTrend = dailyTrend; } + + public List getStatusDistribution() { return statusDistribution; } + public void setStatusDistribution(List statusDistribution) { this.statusDistribution = statusDistribution; } + + public List getPlanComparison() { return planComparison; } + public void setPlanComparison(List planComparison) { this.planComparison = planComparison; } + + public List getCourseComparison() { return courseComparison; } + public void setCourseComparison(List courseComparison) { this.courseComparison = courseComparison; } + + public List getAbnormalStudentTop10() { return abnormalStudentTop10; } + public void setAbnormalStudentTop10(List abnormalStudentTop10) { this.abnormalStudentTop10 = abnormalStudentTop10; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceFilterDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceFilterDto.java new file mode 100644 index 000000000..8c2af60a4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceFilterDto.java @@ -0,0 +1,35 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 考勤专题分析通用查询入参 DTO + */ +public class AttendanceFilterDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 开始日期 (格式: yyyy-MM-dd) */ + private String startDate; + + /** 结束日期 (格式: yyyy-MM-dd) */ + private String endDate; + + /** 培训计划ID */ + private String planId; + + /** 课程ID */ + private String courseId; + + public String getStartDate() { return startDate; } + public void setStartDate(String startDate) { this.startDate = startDate; } + + public String getEndDate() { return endDate; } + public void setEndDate(String endDate) { this.endDate = endDate; } + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getCourseId() { return courseId; } + public void setCourseId(String courseId) { this.courseId = courseId; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceSummaryVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceSummaryVo.java new file mode 100644 index 000000000..58870e5c8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/AttendanceSummaryVo.java @@ -0,0 +1,47 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 考勤专题分析汇总 KPI VO + */ +public class AttendanceSummaryVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 累计已开课次 */ + private Long lessonTotal; + + /** 应到总人次 */ + private Long expectedAttendTotal; + + /** 正常出勤人次 */ + private Long normalAttendTotal; + + /** 异常考勤人次 (迟到/早退/请假/缺勤/无签到记录) */ + private Long abnormalAttendTotal; + + /** 综合出勤率 (百分比,如 92.5) */ + private Double attendanceRate; + + /** 业务口径状态警示提示 */ + private String metricDefinitionNotice = "BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION: 当前出勤率分母采用开课所属班级参训学员应到人次,业务最终标准待甲方确认"; + + public Long getLessonTotal() { return lessonTotal; } + public void setLessonTotal(Long lessonTotal) { this.lessonTotal = lessonTotal; } + + public Long getExpectedAttendTotal() { return expectedAttendTotal; } + public void setExpectedAttendTotal(Long expectedAttendTotal) { this.expectedAttendTotal = expectedAttendTotal; } + + public Long getNormalAttendTotal() { return normalAttendTotal; } + public void setNormalAttendTotal(Long normalAttendTotal) { this.normalAttendTotal = normalAttendTotal; } + + public Long getAbnormalAttendTotal() { return abnormalAttendTotal; } + public void setAbnormalAttendTotal(Long abnormalAttendTotal) { this.abnormalAttendTotal = abnormalAttendTotal; } + + public Double getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; } + + public String getMetricDefinitionNotice() { return metricDefinitionNotice; } + public void setMetricDefinitionNotice(String metricDefinitionNotice) { this.metricDefinitionNotice = metricDefinitionNotice; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/CompareItemDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/CompareItemDto.java new file mode 100644 index 000000000..5f6d0f661 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/CompareItemDto.java @@ -0,0 +1,48 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 计划或课程横向对比项 DTO + */ +public class CompareItemDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String id; + private String name; + private Long expectedCount; + private Long normalCount; + private Long abnormalCount; + private Double attendanceRate; + + public CompareItemDto() {} + + public CompareItemDto(String id, String name, Long expectedCount, Long normalCount, Long abnormalCount, Double attendanceRate) + { + this.id = id; + this.name = name; + this.expectedCount = expectedCount; + this.normalCount = normalCount; + this.abnormalCount = abnormalCount; + this.attendanceRate = attendanceRate; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Long getExpectedCount() { return expectedCount; } + public void setExpectedCount(Long expectedCount) { this.expectedCount = expectedCount; } + + public Long getNormalCount() { return normalCount; } + public void setNormalCount(Long normalCount) { this.normalCount = normalCount; } + + public Long getAbnormalCount() { return abnormalCount; } + public void setAbnormalCount(Long abnormalCount) { this.abnormalCount = abnormalCount; } + + public Double getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DailyTrendDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DailyTrendDto.java new file mode 100644 index 000000000..b2e4f0677 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DailyTrendDto.java @@ -0,0 +1,38 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 每日考勤趋势 DTO + */ +public class DailyTrendDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String date; + private Long normalCount; + private Long abnormalCount; + private Double attendanceRate; + + public DailyTrendDto() {} + + public DailyTrendDto(String date, Long normalCount, Long abnormalCount, Double attendanceRate) + { + this.date = date; + this.normalCount = normalCount; + this.abnormalCount = abnormalCount; + this.attendanceRate = attendanceRate; + } + + public String getDate() { return date; } + public void setDate(String date) { this.date = date; } + + public Long getNormalCount() { return normalCount; } + public void setNormalCount(Long normalCount) { this.normalCount = normalCount; } + + public Long getAbnormalCount() { return abnormalCount; } + public void setAbnormalCount(Long abnormalCount) { this.abnormalCount = abnormalCount; } + + public Double getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardChartsVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardChartsVo.java new file mode 100644 index 000000000..da77a5052 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardChartsVo.java @@ -0,0 +1,60 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.List; + +/** + * 管理驾驶舱图表数据集合 VO + */ +public class DashboardChartsVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 1. 近30天考勤走势:日期横坐标 */ + private List trendDates; + + /** 近30天考勤走势:正常签到人次 */ + private List trendNormalCounts; + + /** 近30天考勤走势:异常考勤人次 */ + private List trendAbnormalCounts; + + /** 2. 培训计划参训/报到对比:计划名称列表 (Top 10) */ + private List planComparisonNames; + + /** 培训计划参训人数 */ + private List planEnrolledCounts; + + /** 培训计划报到人数 */ + private List planReportedCounts; + + /** 3. 考勤状态全局构成 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录) */ + private List attendanceStatusDistribution; + + /** 4. 教学场地使用频次与课时 Top 5 */ + private List venueUsageTop5; + + public List getTrendDates() { return trendDates; } + public void setTrendDates(List trendDates) { this.trendDates = trendDates; } + + public List getTrendNormalCounts() { return trendNormalCounts; } + public void setTrendNormalCounts(List trendNormalCounts) { this.trendNormalCounts = trendNormalCounts; } + + public List getTrendAbnormalCounts() { return trendAbnormalCounts; } + public void setTrendAbnormalCounts(List trendAbnormalCounts) { this.trendAbnormalCounts = trendAbnormalCounts; } + + public List getPlanComparisonNames() { return planComparisonNames; } + public void setPlanComparisonNames(List planComparisonNames) { this.planComparisonNames = planComparisonNames; } + + public List getPlanEnrolledCounts() { return planEnrolledCounts; } + public void setPlanEnrolledCounts(List planEnrolledCounts) { this.planEnrolledCounts = planEnrolledCounts; } + + public List getPlanReportedCounts() { return planReportedCounts; } + public void setPlanReportedCounts(List planReportedCounts) { this.planReportedCounts = planReportedCounts; } + + public List getAttendanceStatusDistribution() { return attendanceStatusDistribution; } + public void setAttendanceStatusDistribution(List attendanceStatusDistribution) { this.attendanceStatusDistribution = attendanceStatusDistribution; } + + public List getVenueUsageTop5() { return venueUsageTop5; } + public void setVenueUsageTop5(List venueUsageTop5) { this.venueUsageTop5 = venueUsageTop5; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardOverviewVo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardOverviewVo.java new file mode 100644 index 000000000..21131c3c4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/DashboardOverviewVo.java @@ -0,0 +1,90 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.Map; + +/** + * 管理驾驶舱宏观概览 VO + */ +public class DashboardOverviewVo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 学员总数 */ + private Long studentTotal; + + /** 教师总数 */ + private Long teacherTotal; + + /** 课程总数 */ + private Long courseTotal; + + /** 培训计划总数 */ + private Long planTotal; + + /** 累计排课课次 */ + private Long lessonTotal; + + /** 累计参训人数 */ + private Long enrollmentTotal; + + /** 累计报到人数 */ + private Long reportTotal; + + /** 当前在宿人数 */ + private Long accommodationCurrent; + + /** 培训计划状态分布 (未开始, 进行中, 已结束, 已取消) */ + private Map planStatusCounts; + + /** 待办提醒:异常考勤人次 */ + private Long abnormalAttendanceCount; + + /** 待办提醒:待报到人数 */ + private Long pendingReportCount; + + /** 待办提醒:住宿预分配人数 */ + private Long preallocatedAccommodationCount; + + /** 待办提醒:门禁异常授权数 */ + private Long abnormalAuthCount; + + public Long getStudentTotal() { return studentTotal; } + public void setStudentTotal(Long studentTotal) { this.studentTotal = studentTotal; } + + public Long getTeacherTotal() { return teacherTotal; } + public void setTeacherTotal(Long teacherTotal) { this.teacherTotal = teacherTotal; } + + public Long getCourseTotal() { return courseTotal; } + public void setCourseTotal(Long courseTotal) { this.courseTotal = courseTotal; } + + public Long getPlanTotal() { return planTotal; } + public void setPlanTotal(Long planTotal) { this.planTotal = planTotal; } + + public Long getLessonTotal() { return lessonTotal; } + public void setLessonTotal(Long lessonTotal) { this.lessonTotal = lessonTotal; } + + public Long getEnrollmentTotal() { return enrollmentTotal; } + public void setEnrollmentTotal(Long enrollmentTotal) { this.enrollmentTotal = enrollmentTotal; } + + public Long getReportTotal() { return reportTotal; } + public void setReportTotal(Long reportTotal) { this.reportTotal = reportTotal; } + + public Long getAccommodationCurrent() { return accommodationCurrent; } + public void setAccommodationCurrent(Long accommodationCurrent) { this.accommodationCurrent = accommodationCurrent; } + + public Map getPlanStatusCounts() { return planStatusCounts; } + public void setPlanStatusCounts(Map planStatusCounts) { this.planStatusCounts = planStatusCounts; } + + public Long getAbnormalAttendanceCount() { return abnormalAttendanceCount; } + public void setAbnormalAttendanceCount(Long abnormalAttendanceCount) { this.abnormalAttendanceCount = abnormalAttendanceCount; } + + public Long getPendingReportCount() { return pendingReportCount; } + public void setPendingReportCount(Long pendingReportCount) { this.pendingReportCount = pendingReportCount; } + + public Long getPreallocatedAccommodationCount() { return preallocatedAccommodationCount; } + public void setPreallocatedAccommodationCount(Long preallocatedAccommodationCount) { this.preallocatedAccommodationCount = preallocatedAccommodationCount; } + + public Long getAbnormalAuthCount() { return abnormalAuthCount; } + public void setAbnormalAuthCount(Long abnormalAuthCount) { this.abnormalAuthCount = abnormalAuthCount; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/NameValueDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/NameValueDto.java new file mode 100644 index 000000000..c8a9b4f2e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/NameValueDto.java @@ -0,0 +1,28 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 通用名称数值键值对 DTO + */ +public class NameValueDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String name; + private Long value; + + public NameValueDto() {} + + public NameValueDto(String name, Long value) + { + this.name = name; + this.value = value; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Long getValue() { return value; } + public void setValue(Long value) { this.value = value; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java new file mode 100644 index 000000000..d66aec34e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/Student360Vo.java @@ -0,0 +1,161 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 学员 360 客观全景画像 VO + */ +public class Student360Vo implements Serializable +{ + private static final long serialVersionUID = 1L; + + /* ========== 1. 基础主档 (敏感信息脱敏) ========== */ + private String studentId; + private String studentName; + private String gender; + private String genderName; + + @JsonFormat(pattern = "yyyy-MM-dd") + private Date birth; + + private String phone; + private String email; + private String orgName; + private String idCardMasked; + private String userBindingStatus; + private Integer status; + + /* ========== 2. 培训履历时间轴 ========== */ + private List enrollmentTimeline; + + /* ========== 3. 考勤客观纪律画像 ========== */ + private Long expectedAttendCount; + private Long normalCount; + private Long lateCount; + private Long earlyCount; + private Long leaveCount; + private Long absentCount; + private Long noCheckinCount; + private Double attendanceRate; + private String metricDefinitionNotice = "BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION: 个人出勤率采用学员所属班级已开课次应到数为分母,待甲方最终确认"; + + /* ========== 4. 成绩状态 (严格不造假) ========== */ + private Boolean hasScoreData = false; + private String scoreStatusDesc = "暂无成绩数据 (V2成绩表待考务录入)"; + + /* ========== 5. 自主学习状态 (严格不造假) ========== */ + private Boolean hasStudyData = false; + private String studyStatusDesc = "暂无自主学习记录 / 待外部平台数据同步"; + + /* ========== 6. 住宿与门禁客观状态 ========== */ + private Boolean hasAccommodation = false; + private String dormId; + private String roomName; + private String bedNo; + private Integer accommodationStatus; + private String accommodationStatusName; + private String authStatus; + private String authStatusName; + + public String getStudentId() { return studentId; } + public void setStudentId(String studentId) { this.studentId = studentId; } + + public String getStudentName() { return studentName; } + public void setStudentName(String studentName) { this.studentName = studentName; } + + public String getGender() { return gender; } + public void setGender(String gender) { this.gender = gender; } + + public String getGenderName() { return genderName; } + public void setGenderName(String genderName) { this.genderName = genderName; } + + public Date getBirth() { return birth; } + public void setBirth(Date birth) { this.birth = birth; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getOrgName() { return orgName; } + public void setOrgName(String orgName) { this.orgName = orgName; } + + public String getIdCardMasked() { return idCardMasked; } + public void setIdCardMasked(String idCardMasked) { this.idCardMasked = idCardMasked; } + + public String getUserBindingStatus() { return userBindingStatus; } + public void setUserBindingStatus(String userBindingStatus) { this.userBindingStatus = userBindingStatus; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public List getEnrollmentTimeline() { return enrollmentTimeline; } + public void setEnrollmentTimeline(List enrollmentTimeline) { this.enrollmentTimeline = enrollmentTimeline; } + + public Long getExpectedAttendCount() { return expectedAttendCount; } + public void setExpectedAttendCount(Long expectedAttendCount) { this.expectedAttendCount = expectedAttendCount; } + + public Long getNormalCount() { return normalCount; } + public void setNormalCount(Long normalCount) { this.normalCount = normalCount; } + + public Long getLateCount() { return lateCount; } + public void setLateCount(Long lateCount) { this.lateCount = lateCount; } + + public Long getEarlyCount() { return earlyCount; } + public void setEarlyCount(Long earlyCount) { this.earlyCount = earlyCount; } + + public Long getLeaveCount() { return leaveCount; } + public void setLeaveCount(Long leaveCount) { this.leaveCount = leaveCount; } + + public Long getAbsentCount() { return absentCount; } + public void setAbsentCount(Long absentCount) { this.absentCount = absentCount; } + + public Long getNoCheckinCount() { return noCheckinCount; } + public void setNoCheckinCount(Long noCheckinCount) { this.noCheckinCount = noCheckinCount; } + + public Double getAttendanceRate() { return attendanceRate; } + public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; } + + public String getMetricDefinitionNotice() { return metricDefinitionNotice; } + public void setMetricDefinitionNotice(String metricDefinitionNotice) { this.metricDefinitionNotice = metricDefinitionNotice; } + + public Boolean getHasScoreData() { return hasScoreData; } + public void setHasScoreData(Boolean hasScoreData) { this.hasScoreData = hasScoreData; } + + public String getScoreStatusDesc() { return scoreStatusDesc; } + public void setScoreStatusDesc(String scoreStatusDesc) { this.scoreStatusDesc = scoreStatusDesc; } + + public Boolean getHasStudyData() { return hasStudyData; } + public void setHasStudyData(Boolean hasStudyData) { this.hasStudyData = hasStudyData; } + + public String getStudyStatusDesc() { return studyStatusDesc; } + public void setStudyStatusDesc(String studyStatusDesc) { this.studyStatusDesc = studyStatusDesc; } + + public Boolean getHasAccommodation() { return hasAccommodation; } + public void setHasAccommodation(Boolean hasAccommodation) { this.hasAccommodation = hasAccommodation; } + + public String getDormId() { return dormId; } + public void setDormId(String dormId) { this.dormId = dormId; } + + public String getRoomName() { return roomName; } + public void setRoomName(String roomName) { this.roomName = roomName; } + + public String getBedNo() { return bedNo; } + public void setBedNo(String bedNo) { this.bedNo = bedNo; } + + public Integer getAccommodationStatus() { return accommodationStatus; } + public void setAccommodationStatus(Integer accommodationStatus) { this.accommodationStatus = accommodationStatus; } + + public String getAccommodationStatusName() { return accommodationStatusName; } + public void setAccommodationStatusName(String accommodationStatusName) { this.accommodationStatusName = accommodationStatusName; } + + public String getAuthStatus() { return authStatus; } + public void setAuthStatus(String authStatus) { this.authStatus = authStatus; } + + public String getAuthStatusName() { return authStatusName; } + public void setAuthStatusName(String authStatusName) { this.authStatusName = authStatusName; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentEnrollmentTimelineDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentEnrollmentTimelineDto.java new file mode 100644 index 000000000..89bbbba3a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/StudentEnrollmentTimelineDto.java @@ -0,0 +1,63 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 学员参训履历时间轴 DTO + */ +public class StudentEnrollmentTimelineDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + private Long enrollmentId; + private String planId; + private String planName; + private Integer enrollmentStatus; + private String enrollmentStatusName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date joinTime; + + private Integer reportWay; + private String reportWayName; + private Integer verifyResult; + private String verifyResultName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reportTime; + + public Long getEnrollmentId() { return enrollmentId; } + public void setEnrollmentId(Long enrollmentId) { this.enrollmentId = enrollmentId; } + + public String getPlanId() { return planId; } + public void setPlanId(String planId) { this.planId = planId; } + + public String getPlanName() { return planName; } + public void setPlanName(String planName) { this.planName = planName; } + + public Integer getEnrollmentStatus() { return enrollmentStatus; } + public void setEnrollmentStatus(Integer enrollmentStatus) { this.enrollmentStatus = enrollmentStatus; } + + public String getEnrollmentStatusName() { return enrollmentStatusName; } + public void setEnrollmentStatusName(String enrollmentStatusName) { this.enrollmentStatusName = enrollmentStatusName; } + + public Date getJoinTime() { return joinTime; } + public void setJoinTime(Date joinTime) { this.joinTime = joinTime; } + + public Integer getReportWay() { return reportWay; } + public void setReportWay(Integer reportWay) { this.reportWay = reportWay; } + + public String getReportWayName() { return reportWayName; } + public void setReportWayName(String reportWayName) { this.reportWayName = reportWayName; } + + public Integer getVerifyResult() { return verifyResult; } + public void setVerifyResult(Integer verifyResult) { this.verifyResult = verifyResult; } + + public String getVerifyResultName() { return verifyResultName; } + public void setVerifyResultName(String verifyResultName) { this.verifyResultName = verifyResultName; } + + public Date getReportTime() { return reportTime; } + public void setReportTime(Date reportTime) { this.reportTime = reportTime; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/VenueUsageDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/VenueUsageDto.java new file mode 100644 index 000000000..29a85457d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/analysis/VenueUsageDto.java @@ -0,0 +1,38 @@ +package com.ruoyi.train.domain.analysis; + +import java.io.Serializable; + +/** + * 场地使用频次与课时 DTO + */ +public class VenueUsageDto implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 场地名称(教室名称或实训场地名称) */ + private String venueName; + + /** 使用频次(排课次数) */ + private Long usageCount; + + /** 累计占用课时(小时数,保留1位小数) */ + private Double totalHours; + + public VenueUsageDto() {} + + public VenueUsageDto(String venueName, Long usageCount, Double totalHours) + { + this.venueName = venueName; + this.usageCount = usageCount; + this.totalHours = totalHours; + } + + public String getVenueName() { return venueName; } + public void setVenueName(String venueName) { this.venueName = venueName; } + + public Long getUsageCount() { return usageCount; } + public void setUsageCount(Long usageCount) { this.usageCount = usageCount; } + + public Double getTotalHours() { return totalHours; } + public void setTotalHours(Double totalHours) { this.totalHours = totalHours; } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java new file mode 100644 index 000000000..0493cd4c1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAnalysisMapper.java @@ -0,0 +1,72 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.analysis.AttendanceFilterDto; + +/** + * 数据分析与可视化专用聚合 Mapper (严格在数据库层完成 COUNT/SUM/GROUP BY) + */ +public interface TrainAnalysisMapper +{ + /* ==================== 1. 管理驾驶舱 ==================== */ + + Long countStudents(); + Long countTeachers(); + Long countCourses(); + Long countPlans(); + Long countLessons(); + Long countEnrollments(); + Long countReports(); + Long countCurrentAccommodations(); + + List> selectPlanStatusDistribution(); + + Long countAbnormalAttendance(); + Long countPendingReports(); + Long countPreallocatedAccommodations(); + Long countAbnormalDormAuths(); + + List> selectAttendanceTrend30Days(); + + List> selectPlanEnrollmentReportComparisonTop10(); + + List> selectAttendanceStatusDistribution(); + + Long countTotalExpectedAttendanceForPastLessons(); + + List> selectVenueUsageTop5(); + + /* ==================== 2. 考勤专题分析 ==================== */ + + Long countFilteredLessons(@Param("dto") AttendanceFilterDto dto); + + Long countFilteredExpectedAttendance(@Param("dto") AttendanceFilterDto dto); + + Long countFilteredNormalAttendance(@Param("dto") AttendanceFilterDto dto); + + Long countFilteredAbnormalAttendance(@Param("dto") AttendanceFilterDto dto); + + List> selectFilteredDailyTrend(@Param("dto") AttendanceFilterDto dto); + + List> selectFilteredStatusDistribution(@Param("dto") AttendanceFilterDto dto); + + List> selectPlanAttendanceComparison(@Param("dto") AttendanceFilterDto dto); + + List> selectCourseAttendanceComparison(@Param("dto") AttendanceFilterDto dto); + + List> selectAbnormalStudentTop10(@Param("dto") AttendanceFilterDto dto); + + /* ==================== 3. 学员 360 画像 ==================== */ + + Map selectStudentBaseProfile(@Param("studentId") String studentId); + + List> selectStudentEnrollmentTimeline(@Param("studentId") String studentId); + + List> selectStudentAttendanceStats(@Param("studentId") String studentId); + + Long countStudentExpectedAttendance(@Param("studentId") String studentId); + + Map selectStudentAccommodationAuth(@Param("studentId") String studentId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAnalysisService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAnalysisService.java new file mode 100644 index 000000000..c68466eab --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAnalysisService.java @@ -0,0 +1,29 @@ +package com.ruoyi.train.service; + +import com.ruoyi.train.domain.analysis.AttendanceChartsVo; +import com.ruoyi.train.domain.analysis.AttendanceFilterDto; +import com.ruoyi.train.domain.analysis.AttendanceSummaryVo; +import com.ruoyi.train.domain.analysis.DashboardChartsVo; +import com.ruoyi.train.domain.analysis.DashboardOverviewVo; +import com.ruoyi.train.domain.analysis.Student360Vo; + +/** + * 数据分析与可视化服务接口 + */ +public interface ITrainAnalysisService +{ + /** 获取管理驾驶舱宏观概览与业务提醒 */ + DashboardOverviewVo getDashboardOverview(); + + /** 获取管理驾驶舱图表集合 */ + DashboardChartsVo getDashboardCharts(); + + /** 获取考勤专题分析汇总 KPI */ + AttendanceSummaryVo getAttendanceSummary(AttendanceFilterDto filter); + + /** 获取考勤专题分析图表集合 */ + AttendanceChartsVo getAttendanceCharts(AttendanceFilterDto filter); + + /** 获取指定学员 360 客观全景画像 */ + Student360Vo getStudent360(String studentId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java new file mode 100644 index 000000000..5e71c10d4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAnalysisServiceImpl.java @@ -0,0 +1,564 @@ +package com.ruoyi.train.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.analysis.*; +import com.ruoyi.train.mapper.TrainAnalysisMapper; +import com.ruoyi.train.service.ITrainAnalysisService; + +/** + * 数据分析与可视化服务实现类 + */ +@Service +public class TrainAnalysisServiceImpl implements ITrainAnalysisService +{ + private static final Logger log = LoggerFactory.getLogger(TrainAnalysisServiceImpl.class); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired + private TrainAnalysisMapper analysisMapper; + + @Override + public DashboardOverviewVo getDashboardOverview() + { + DashboardOverviewVo vo = new DashboardOverviewVo(); + vo.setStudentTotal(analysisMapper.countStudents()); + vo.setTeacherTotal(analysisMapper.countTeachers()); + vo.setCourseTotal(analysisMapper.countCourses()); + vo.setPlanTotal(analysisMapper.countPlans()); + vo.setLessonTotal(analysisMapper.countLessons()); + vo.setEnrollmentTotal(analysisMapper.countEnrollments()); + vo.setReportTotal(analysisMapper.countReports()); + vo.setAccommodationCurrent(analysisMapper.countCurrentAccommodations()); + + // 计划状态分布 + Map statusMap = new LinkedHashMap<>(); + statusMap.put("notStarted", 0L); + statusMap.put("ongoing", 0L); + statusMap.put("ended", 0L); + statusMap.put("cancelled", 0L); + + List> planStatuses = analysisMapper.selectPlanStatusDistribution(); + if (planStatuses != null) + { + for (Map map : planStatuses) + { + String code = String.valueOf(map.get("status_code")); + Long cnt = ((Number) map.get("count_num")).longValue(); + if ("0".equals(code)) statusMap.put("notStarted", cnt); + else if ("1".equals(code)) statusMap.put("ongoing", cnt); + else if ("2".equals(code)) statusMap.put("ended", cnt); + else if ("3".equals(code)) statusMap.put("cancelled", cnt); + } + } + vo.setPlanStatusCounts(statusMap); + + // 业务待办提醒 + vo.setAbnormalAttendanceCount(analysisMapper.countAbnormalAttendance()); + vo.setPendingReportCount(analysisMapper.countPendingReports()); + vo.setPreallocatedAccommodationCount(analysisMapper.countPreallocatedAccommodations()); + vo.setAbnormalAuthCount(analysisMapper.countAbnormalDormAuths()); + + return vo; + } + + @Override + public DashboardChartsVo getDashboardCharts() + { + DashboardChartsVo vo = new DashboardChartsVo(); + + // 1. 近 30 天考勤走势 + List> trendList = analysisMapper.selectAttendanceTrend30Days(); + List trendDates = new ArrayList<>(); + List normalCounts = new ArrayList<>(); + List abnormalCounts = new ArrayList<>(); + + if (trendList != null) + { + for (Map row : trendList) + { + trendDates.add(String.valueOf(row.get("stat_date"))); + normalCounts.add(row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L); + abnormalCounts.add(row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L); + } + } + vo.setTrendDates(trendDates); + vo.setTrendNormalCounts(normalCounts); + vo.setTrendAbnormalCounts(abnormalCounts); + + // 2. 计划参训与报到对比 (Top 10) + List> planCompList = analysisMapper.selectPlanEnrollmentReportComparisonTop10(); + List planNames = new ArrayList<>(); + List enrolledList = new ArrayList<>(); + List reportedList = new ArrayList<>(); + + if (planCompList != null) + { + for (Map row : planCompList) + { + planNames.add(String.valueOf(row.get("plan_name"))); + enrolledList.add(row.get("enrolled_count") != null ? ((Number) row.get("enrolled_count")).longValue() : 0L); + reportedList.add(row.get("reported_count") != null ? ((Number) row.get("reported_count")).longValue() : 0L); + } + } + vo.setPlanComparisonNames(planNames); + vo.setPlanEnrolledCounts(enrolledList); + vo.setPlanReportedCounts(reportedList); + + // 3. 考勤状态全局构成 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录) + List statusList = new ArrayList<>(); + Map statusCountMap = new HashMap<>(); + List> rawStatus = analysisMapper.selectAttendanceStatusDistribution(); + long loggedTotal = 0L; + + if (rawStatus != null) + { + for (Map row : rawStatus) + { + String code = String.valueOf(row.get("status_code")); + long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L; + statusCountMap.put(code, cnt); + loggedTotal += cnt; + } + } + + statusList.add(new NameValueDto("正常出勤", statusCountMap.getOrDefault("1", 0L))); + statusList.add(new NameValueDto("迟到", statusCountMap.getOrDefault("2", 0L))); + statusList.add(new NameValueDto("早退", statusCountMap.getOrDefault("3", 0L))); + statusList.add(new NameValueDto("请假", statusCountMap.getOrDefault("4", 0L))); + statusList.add(new NameValueDto("明确缺勤", statusCountMap.getOrDefault("0", 0L))); + + // 计算“无签到记录” + Long totalExpected = analysisMapper.countTotalExpectedAttendanceForPastLessons(); + if (totalExpected != null && totalExpected > loggedTotal) + { + statusList.add(new NameValueDto("无签到记录", totalExpected - loggedTotal)); + } + else + { + statusList.add(new NameValueDto("无签到记录", 0L)); + } + vo.setAttendanceStatusDistribution(statusList); + + // 4. 场地使用频次与课时 Top 5 + List venueList = new ArrayList<>(); + List> rawVenues = analysisMapper.selectVenueUsageTop5(); + if (rawVenues != null) + { + for (Map row : rawVenues) + { + String name = String.valueOf(row.get("venue_name")); + long cnt = row.get("usage_count") != null ? ((Number) row.get("usage_count")).longValue() : 0L; + double hours = row.get("total_hours") != null ? ((Number) row.get("total_hours")).doubleValue() : 0.0; + venueList.add(new VenueUsageDto(name, cnt, hours)); + } + } + vo.setVenueUsageTop5(venueList); + + return vo; + } + + @Override + public AttendanceSummaryVo getAttendanceSummary(AttendanceFilterDto filter) + { + AttendanceFilterDto normalized = normalizeFilter(filter); + AttendanceSummaryVo vo = new AttendanceSummaryVo(); + + Long lessonTotal = analysisMapper.countFilteredLessons(normalized); + Long expectedTotal = analysisMapper.countFilteredExpectedAttendance(normalized); + Long normalTotal = analysisMapper.countFilteredNormalAttendance(normalized); + Long abnormalLogged = analysisMapper.countFilteredAbnormalAttendance(normalized); + + long exp = expectedTotal != null ? expectedTotal : 0L; + long norm = normalTotal != null ? normalTotal : 0L; + long abnLogged = abnormalLogged != null ? abnormalLogged : 0L; + + // 异常人次 = 已打卡的异常(迟到/早退/请假/缺勤) + (应到 - 打卡总数) + long totalLogged = norm + abnLogged; + long unlogged = (exp > totalLogged) ? (exp - totalLogged) : 0L; + long totalAbnormal = abnLogged + unlogged; + + vo.setLessonTotal(lessonTotal != null ? lessonTotal : 0L); + vo.setExpectedAttendTotal(exp); + vo.setNormalAttendTotal(norm); + vo.setAbnormalAttendTotal(totalAbnormal); + + if (exp > 0) + { + double rate = BigDecimal.valueOf(norm * 100.0 / exp) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); + vo.setAttendanceRate(rate); + } + else + { + vo.setAttendanceRate(0.0); + } + + return vo; + } + + @Override + public AttendanceChartsVo getAttendanceCharts(AttendanceFilterDto filter) + { + AttendanceFilterDto normalized = normalizeFilter(filter); + AttendanceChartsVo vo = new AttendanceChartsVo(); + + // 1. 每日走势 + List dailyList = new ArrayList<>(); + List> rawDaily = analysisMapper.selectFilteredDailyTrend(normalized); + if (rawDaily != null) + { + for (Map row : rawDaily) + { + String date = String.valueOf(row.get("stat_date")); + long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L; + long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L; + long sum = norm + abn; + double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0; + dailyList.add(new DailyTrendDto(date, norm, abn, rate)); + } + } + vo.setDailyTrend(dailyList); + + // 2. 状态分布环形图 + List statusList = new ArrayList<>(); + Map statusMap = new HashMap<>(); + List> rawStatus = analysisMapper.selectFilteredStatusDistribution(normalized); + long logged = 0L; + if (rawStatus != null) + { + for (Map row : rawStatus) + { + String code = String.valueOf(row.get("status_code")); + long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L; + statusMap.put(code, cnt); + logged += cnt; + } + } + statusList.add(new NameValueDto("正常出勤", statusMap.getOrDefault("1", 0L))); + statusList.add(new NameValueDto("迟到", statusMap.getOrDefault("2", 0L))); + statusList.add(new NameValueDto("早退", statusMap.getOrDefault("3", 0L))); + statusList.add(new NameValueDto("请假", statusMap.getOrDefault("4", 0L))); + statusList.add(new NameValueDto("明确缺勤", statusMap.getOrDefault("0", 0L))); + + Long expTotal = analysisMapper.countFilteredExpectedAttendance(normalized); + if (expTotal != null && expTotal > logged) + { + statusList.add(new NameValueDto("无签到记录", expTotal - logged)); + } + else + { + statusList.add(new NameValueDto("无签到记录", 0L)); + } + vo.setStatusDistribution(statusList); + + // 3. 培训计划对比 (Top 10) + List planCompare = new ArrayList<>(); + List> rawPlan = analysisMapper.selectPlanAttendanceComparison(normalized); + if (rawPlan != null) + { + for (Map row : rawPlan) + { + String id = String.valueOf(row.get("id")); + String name = String.valueOf(row.get("name")); + long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L; + long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L; + long sum = norm + abn; + double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0; + planCompare.add(new CompareItemDto(id, name, sum, norm, abn, rate)); + } + } + vo.setPlanComparison(planCompare); + + // 4. 课程考勤对比 (Top 10) + List courseCompare = new ArrayList<>(); + List> rawCourse = analysisMapper.selectCourseAttendanceComparison(normalized); + if (rawCourse != null) + { + for (Map row : rawCourse) + { + String id = String.valueOf(row.get("id")); + String name = String.valueOf(row.get("name")); + long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L; + long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L; + long sum = norm + abn; + double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0; + courseCompare.add(new CompareItemDto(id, name, sum, norm, abn, rate)); + } + } + vo.setCourseComparison(courseCompare); + + // 5. 异常学员 Top 10 + List abnormalTop10 = new ArrayList<>(); + List> rawAbnormal = analysisMapper.selectAbnormalStudentTop10(normalized); + if (rawAbnormal != null) + { + for (Map row : rawAbnormal) + { + AbnormalStudentDto dto = new AbnormalStudentDto(); + dto.setStudentId(String.valueOf(row.get("student_id"))); + dto.setStudentName(String.valueOf(row.get("student_name"))); + dto.setPlanId(String.valueOf(row.get("plan_id"))); + dto.setPlanName(String.valueOf(row.get("plan_name"))); + dto.setAbsentCount(row.get("absent_count") != null ? ((Number) row.get("absent_count")).longValue() : 0L); + dto.setLateCount(row.get("late_count") != null ? ((Number) row.get("late_count")).longValue() : 0L); + dto.setEarlyLeaveCount(row.get("early_count") != null ? ((Number) row.get("early_count")).longValue() : 0L); + dto.setLeaveCount(row.get("leave_count") != null ? ((Number) row.get("leave_count")).longValue() : 0L); + dto.setNoCheckinCount(0L); + dto.setTotalAbnormalCount(row.get("total_abnormal_count") != null ? ((Number) row.get("total_abnormal_count")).longValue() : 0L); + abnormalTop10.add(dto); + } + } + vo.setAbnormalStudentTop10(abnormalTop10); + + return vo; + } + + @Override + public Student360Vo getStudent360(String studentId) + { + if (studentId == null || studentId.trim().isEmpty()) + { + throw new ServiceException("学员学号不能为空"); + } + + Student360Vo vo = new Student360Vo(); + vo.setStudentId(studentId); + + // 1. 基础主档 (脱敏) + Map base = analysisMapper.selectStudentBaseProfile(studentId); + if (base == null || base.isEmpty()) + { + throw new ServiceException("未查询到学号为 " + studentId + " 的学员主档"); + } + vo.setStudentName(String.valueOf(base.get("student_name"))); + String gender = String.valueOf(base.get("gender")); + vo.setGender(gender); + vo.setGenderName("0".equals(gender) ? "男" : ("1".equals(gender) ? "女" : "未知")); + if (base.get("birth") != null) + { + vo.setBirth((Date) base.get("birth")); + } + vo.setPhone(base.get("phone") != null ? String.valueOf(base.get("phone")) : "-"); + vo.setEmail(base.get("email") != null ? String.valueOf(base.get("email")) : "-"); + vo.setOrgName(base.get("org_name") != null ? String.valueOf(base.get("org_name")) : "-"); + String last4 = base.get("id_card_last4") != null ? String.valueOf(base.get("id_card_last4")) : ""; + vo.setIdCardMasked(last4.isEmpty() ? "未登记" : "**************" + last4); + vo.setUserBindingStatus(String.valueOf(base.get("user_binding_status"))); + vo.setStatus(base.get("status") != null ? ((Number) base.get("status")).intValue() : 1); + + // 2. 参训履历时间轴 + List timeline = new ArrayList<>(); + List> rawTimeline = analysisMapper.selectStudentEnrollmentTimeline(studentId); + if (rawTimeline != null) + { + for (Map row : rawTimeline) + { + StudentEnrollmentTimelineDto dto = new StudentEnrollmentTimelineDto(); + dto.setEnrollmentId(((Number) row.get("enrollment_id")).longValue()); + dto.setPlanId(String.valueOf(row.get("plan_id"))); + dto.setPlanName(String.valueOf(row.get("plan_name"))); + Integer enrStatus = row.get("enrollment_status") != null ? ((Number) row.get("enrollment_status")).intValue() : 0; + dto.setEnrollmentStatus(enrStatus); + dto.setEnrollmentStatusName(formatEnrollmentStatus(enrStatus)); + if (row.get("join_time") != null) dto.setJoinTime((Date) row.get("join_time")); + + if (row.get("report_way") != null) + { + int rw = ((Number) row.get("report_way")).intValue(); + dto.setReportWay(rw); + dto.setReportWayName(formatReportWay(rw)); + } + if (row.get("verify_result") != null) + { + int vr = ((Number) row.get("verify_result")).intValue(); + dto.setVerifyResult(vr); + dto.setVerifyResultName(formatVerifyResult(vr)); + } + if (row.get("report_time") != null) dto.setReportTime((Date) row.get("report_time")); + + timeline.add(dto); + } + } + vo.setEnrollmentTimeline(timeline); + + // 3. 考勤画像 + Map checkinMap = new HashMap<>(); + List> rawCheckin = analysisMapper.selectStudentAttendanceStats(studentId); + long logged = 0L; + if (rawCheckin != null) + { + for (Map row : rawCheckin) + { + String code = String.valueOf(row.get("status_code")); + long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L; + checkinMap.put(code, cnt); + logged += cnt; + } + } + Long expected = analysisMapper.countStudentExpectedAttendance(studentId); + long exp = expected != null ? expected : 0L; + long norm = checkinMap.getOrDefault("1", 0L); + long unlogged = (exp > logged) ? (exp - logged) : 0L; + + vo.setExpectedAttendCount(exp); + vo.setNormalCount(norm); + vo.setLateCount(checkinMap.getOrDefault("2", 0L)); + vo.setEarlyCount(checkinMap.getOrDefault("3", 0L)); + vo.setLeaveCount(checkinMap.getOrDefault("4", 0L)); + vo.setAbsentCount(checkinMap.getOrDefault("0", 0L)); + vo.setNoCheckinCount(unlogged); + + if (exp > 0) + { + double rate = BigDecimal.valueOf(norm * 100.0 / exp).setScale(1, RoundingMode.HALF_UP).doubleValue(); + vo.setAttendanceRate(rate); + } + else + { + vo.setAttendanceRate(0.0); + } + + // 4. 成绩状态 (严格不造假) + vo.setHasScoreData(false); + vo.setScoreStatusDesc("暂无成绩数据 (V2成绩表待考务录入)"); + + // 5. 自主学习状态 (严格不造假) + vo.setHasStudyData(false); + vo.setStudyStatusDesc("暂无自主学习记录 / 待外部平台数据同步"); + + // 6. 住宿与门禁状态 + Map dorm = analysisMapper.selectStudentAccommodationAuth(studentId); + if (dorm != null && !dorm.isEmpty()) + { + vo.setHasAccommodation(true); + vo.setDormId(dorm.get("dorm_id") != null ? String.valueOf(dorm.get("dorm_id")) : "-"); + vo.setRoomName(dorm.get("room_name") != null ? String.valueOf(dorm.get("room_name")) : "宿舍"); + vo.setBedNo(dorm.get("bed_no") != null ? String.valueOf(dorm.get("bed_no")) : "-"); + Integer accStatus = dorm.get("accommodation_status") != null ? ((Number) dorm.get("accommodation_status")).intValue() : 0; + vo.setAccommodationStatus(accStatus); + vo.setAccommodationStatusName(accStatus == 1 ? "已入住" : (accStatus == 0 ? "预分配" : "已退宿")); + + String authStatus = dorm.get("auth_status") != null ? String.valueOf(dorm.get("auth_status")) : "NONE"; + vo.setAuthStatus(authStatus); + vo.setAuthStatusName(formatAuthStatus(authStatus)); + } + else + { + vo.setHasAccommodation(false); + vo.setAccommodationStatusName("未安排住宿"); + vo.setAuthStatusName("无门禁授权"); + } + + return vo; + } + + private AttendanceFilterDto normalizeFilter(AttendanceFilterDto filter) + { + AttendanceFilterDto dto = new AttendanceFilterDto(); + if (filter != null) + { + dto.setPlanId(filter.getPlanId() != null && !filter.getPlanId().trim().isEmpty() ? filter.getPlanId().trim() : null); + dto.setCourseId(filter.getCourseId() != null && !filter.getCourseId().trim().isEmpty() ? filter.getCourseId().trim() : null); + dto.setStartDate(filter.getStartDate() != null && !filter.getStartDate().trim().isEmpty() ? filter.getStartDate().trim() : null); + dto.setEndDate(filter.getEndDate() != null && !filter.getEndDate().trim().isEmpty() ? filter.getEndDate().trim() : null); + } + + // 日期处理:默认近 30 天,最大限制 365 天 + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(30); + + try + { + if (dto.getEndDate() != null) + { + end = LocalDate.parse(dto.getEndDate(), DATE_FORMATTER); + } + if (dto.getStartDate() != null) + { + start = LocalDate.parse(dto.getStartDate(), DATE_FORMATTER); + } + } + catch (Exception e) + { + log.warn("日期格式解析失败,自动回退默认近30天", e); + end = LocalDate.now(); + start = end.minusDays(30); + } + + // 校验范围最大 365 天 + if (start.isAfter(end)) + { + LocalDate tmp = start; + start = end; + end = tmp; + } + long days = ChronoUnit.DAYS.between(start, end); + if (days > 365) + { + start = end.minusDays(365); + } + + dto.setStartDate(start.format(DATE_FORMATTER)); + dto.setEndDate(end.format(DATE_FORMATTER)); + return dto; + } + + private String formatEnrollmentStatus(int status) + { + switch (status) + { + case 0: return "待报到"; + case 1: return "已报到"; + case 2: return "请假"; + case 3: return "放弃"; + case 4: return "已完成"; + default: return "未知状态"; + } + } + + private String formatReportWay(int way) + { + switch (way) + { + case 1: return "人脸识别"; + case 2: return "身份证刷卡"; + case 3: return "二维码"; + case 4: return "人工录入"; + default: return "其他方式"; + } + } + + private String formatVerifyResult(int res) + { + switch (res) + { + case 1: return "核验通过"; + case 2: return "核验不匹配"; + case 3: return "人工放行"; + default: return "未核验"; + } + } + + private String formatAuthStatus(String status) + { + if ("ACTIVE".equalsIgnoreCase(status)) return "生效中"; + if ("PENDING_GRANT".equalsIgnoreCase(status)) return "待下发"; + if ("GRANTING".equalsIgnoreCase(status)) return "下发中"; + if ("PENDING_REVOKE".equalsIgnoreCase(status)) return "待注销"; + if ("REVOKING".equalsIgnoreCase(status)) return "注销中"; + if ("REVOKED".equalsIgnoreCase(status)) return "已注销"; + if ("ERROR".equalsIgnoreCase(status)) return "授权异常"; + return "未授权"; + } +} 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 new file mode 100644 index 000000000..bd488687a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml @@ -0,0 +1,409 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 000000000..dc1b78361 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainAnalysisServiceTest.java @@ -0,0 +1,394 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import java.util.*; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.train.domain.analysis.*; +import com.ruoyi.train.mapper.TrainAnalysisMapper; + +/** + * 数据分析与可视化聚合服务单元测试 + */ +public class TrainAnalysisServiceTest +{ + private TrainAnalysisServiceImpl service; + private MockTrainAnalysisMapper mockMapper; + + @Before + public void setUp() throws Exception + { + service = new TrainAnalysisServiceImpl(); + mockMapper = new MockTrainAnalysisMapper(); + Field field = TrainAnalysisServiceImpl.class.getDeclaredField("analysisMapper"); + field.setAccessible(true); + field.set(service, mockMapper); + } + + @Test + public void testDashboardOverviewAggregation() + { + DashboardOverviewVo vo = service.getDashboardOverview(); + assertNotNull(vo); + assertEquals(Long.valueOf(200), vo.getStudentTotal()); + assertEquals(Long.valueOf(20), vo.getTeacherTotal()); + assertEquals(Long.valueOf(24), vo.getCourseTotal()); + assertEquals(Long.valueOf(13), vo.getPlanTotal()); + assertEquals(Long.valueOf(118), vo.getLessonTotal()); + assertEquals(Long.valueOf(242), vo.getEnrollmentTotal()); + assertEquals(Long.valueOf(63), vo.getReportTotal()); + assertEquals(Long.valueOf(1), vo.getAccommodationCurrent()); + + Map planMap = vo.getPlanStatusCounts(); + assertNotNull(planMap); + assertEquals(Long.valueOf(10), planMap.get("notStarted")); + assertEquals(Long.valueOf(0), planMap.get("ongoing")); + assertEquals(Long.valueOf(3), planMap.get("ended")); + assertEquals(Long.valueOf(0), planMap.get("cancelled")); + + assertEquals(Long.valueOf(4), vo.getAbnormalAttendanceCount()); + assertEquals(Long.valueOf(179), vo.getPendingReportCount()); + assertEquals(Long.valueOf(48), vo.getPreallocatedAccommodationCount()); + assertEquals(Long.valueOf(1), vo.getAbnormalAuthCount()); + } + + @Test + public void testDashboardChartsAggregation() + { + DashboardChartsVo vo = service.getDashboardCharts(); + assertNotNull(vo); + assertEquals(2, vo.getTrendDates().size()); + assertEquals(Long.valueOf(30), vo.getTrendNormalCounts().get(0)); + assertEquals(Long.valueOf(2), vo.getTrendAbnormalCounts().get(0)); + + // 验证考勤状态构成包含“无签到记录” + List dist = vo.getAttendanceStatusDistribution(); + assertNotNull(dist); + boolean foundUnlogged = false; + long unloggedCount = 0; + for (NameValueDto item : dist) + { + if ("无签到记录".equals(item.getName())) + { + foundUnlogged = true; + unloggedCount = item.getValue(); + } + } + assertTrue("应包含无签到记录", foundUnlogged); + // 总应到 100,已有记录 77,无签到记录应为 23 + assertEquals(23L, unloggedCount); + + // 验证场地使用 Top 5 + assertEquals(2, vo.getVenueUsageTop5().size()); + assertEquals("主楼101多媒体教室", vo.getVenueUsageTop5().get(0).getVenueName()); + assertEquals(Long.valueOf(50), vo.getVenueUsageTop5().get(0).getUsageCount()); + assertEquals(Double.valueOf(100.0), vo.getVenueUsageTop5().get(0).getTotalHours()); + } + + @Test + public void testAttendanceSummaryWithUnloggedRateCalculation() + { + AttendanceFilterDto filter = new AttendanceFilterDto(); + filter.setStartDate("2026-08-01"); + filter.setEndDate("2026-08-31"); + + AttendanceSummaryVo vo = service.getAttendanceSummary(filter); + assertNotNull(vo); + assertEquals(Long.valueOf(20), vo.getLessonTotal()); + assertEquals(Long.valueOf(100), vo.getExpectedAttendTotal()); + assertEquals(Long.valueOf(73), vo.getNormalAttendTotal()); + // 异常人次 = 4 (已打卡异常) + (100 - 77) (无签到) = 27 + assertEquals(Long.valueOf(27), vo.getAbnormalAttendTotal()); + // 出勤率 = 73 / 100 = 73.0% + assertEquals(Double.valueOf(73.0), vo.getAttendanceRate()); + assertTrue(vo.getMetricDefinitionNotice().contains("BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION")); + } + + @Test + public void testAttendanceSummaryEmptyData() + { + mockMapper.expectedTotal = 0L; + mockMapper.normalTotal = 0L; + mockMapper.abnormalTotal = 0L; + mockMapper.lessonTotal = 0L; + + AttendanceSummaryVo vo = service.getAttendanceSummary(new AttendanceFilterDto()); + assertNotNull(vo); + assertEquals(Long.valueOf(0), vo.getLessonTotal()); + assertEquals(Long.valueOf(0), vo.getExpectedAttendTotal()); + assertEquals(Double.valueOf(0.0), vo.getAttendanceRate()); + } + + @Test + public void testAttendanceChartsDateRangeSafety() + { + // 传入超过 365 天的大范围日期 + AttendanceFilterDto filter = new AttendanceFilterDto(); + filter.setStartDate("2020-01-01"); + filter.setEndDate("2026-09-20"); + + AttendanceChartsVo vo = service.getAttendanceCharts(filter); + assertNotNull(vo); + assertNotNull(vo.getDailyTrend()); + assertNotNull(vo.getStatusDistribution()); + assertNotNull(vo.getPlanComparison()); + assertNotNull(vo.getCourseComparison()); + assertNotNull(vo.getAbnormalStudentTop10()); + } + + @Test + public void testStudent360ProfileObjectiveCompliance() + { + Student360Vo vo = service.getStudent360("S2026001"); + assertNotNull(vo); + assertEquals("S2026001", vo.getStudentId()); + assertEquals("张三", vo.getStudentName()); + assertEquals("男", vo.getGenderName()); + assertEquals("**************1234", vo.getIdCardMasked()); + assertEquals("已绑定", vo.getUserBindingStatus()); + + // 履历 + assertEquals(1, vo.getEnrollmentTimeline().size()); + assertEquals("已报到", vo.getEnrollmentTimeline().get(0).getEnrollmentStatusName()); + assertEquals("人脸识别", vo.getEnrollmentTimeline().get(0).getReportWayName()); + + // 考勤 + assertEquals(Long.valueOf(10), vo.getExpectedAttendCount()); + assertEquals(Long.valueOf(8), vo.getNormalCount()); + assertEquals(Long.valueOf(1), vo.getAbsentCount()); + assertEquals(Long.valueOf(1), vo.getNoCheckinCount()); + assertEquals(Double.valueOf(80.0), vo.getAttendanceRate()); + + // 成绩与自主学习坚决不造假 + assertFalse(vo.getHasScoreData()); + assertEquals("暂无成绩数据 (V2成绩表待考务录入)", vo.getScoreStatusDesc()); + assertFalse(vo.getHasStudyData()); + assertEquals("暂无自主学习记录 / 待外部平台数据同步", vo.getStudyStatusDesc()); + + // 住宿与门禁 + assertTrue(vo.getHasAccommodation()); + assertEquals("101", vo.getRoomName()); + assertEquals("A床", vo.getBedNo()); + assertEquals("已入住", vo.getAccommodationStatusName()); + assertEquals("生效中", vo.getAuthStatusName()); + } + + // ==================== Mock Mapper Implementation ==================== + private static class MockTrainAnalysisMapper implements TrainAnalysisMapper + { + Long lessonTotal = 20L; + Long expectedTotal = 100L; + Long normalTotal = 73L; + Long abnormalTotal = 4L; + + public Long countStudents() { return 200L; } + public Long countTeachers() { return 20L; } + public Long countCourses() { return 24L; } + public Long countPlans() { return 13L; } + public Long countLessons() { return 118L; } + public Long countEnrollments() { return 242L; } + public Long countReports() { return 63L; } + public Long countCurrentAccommodations() { return 1L; } + + public List> selectPlanStatusDistribution() + { + List> list = new ArrayList<>(); + Map m1 = new HashMap<>(); + m1.put("status_code", "0"); + m1.put("count_num", 10L); + list.add(m1); + Map m2 = new HashMap<>(); + m2.put("status_code", "2"); + m2.put("count_num", 3L); + list.add(m2); + return list; + } + + public Long countAbnormalAttendance() { return 4L; } + public Long countPendingReports() { return 179L; } + public Long countPreallocatedAccommodations() { return 48L; } + public Long countAbnormalDormAuths() { return 1L; } + + public List> selectAttendanceTrend30Days() + { + List> list = new ArrayList<>(); + Map row1 = new HashMap<>(); + row1.put("stat_date", "2026-09-01"); + row1.put("normal_count", 30L); + row1.put("abnormal_count", 2L); + list.add(row1); + Map row2 = new HashMap<>(); + row2.put("stat_date", "2026-09-02"); + row2.put("normal_count", 43L); + row2.put("abnormal_count", 2L); + list.add(row2); + return list; + } + + public List> selectPlanEnrollmentReportComparisonTop10() + { + List> list = new ArrayList<>(); + Map row = new HashMap<>(); + row.put("plan_id", "PLAN001"); + row.put("plan_name", "2026年第一期房桥线路维修专项培训"); + row.put("enrolled_count", 50L); + row.put("reported_count", 45L); + list.add(row); + return list; + } + + public List> selectAttendanceStatusDistribution() + { + List> list = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("status_code", "1"); + r1.put("count_num", 73L); + list.add(r1); + Map r2 = new HashMap<>(); + r2.put("status_code", "0"); + r2.put("count_num", 2L); + list.add(r2); + Map r3 = new HashMap<>(); + r3.put("status_code", "4"); + r3.put("count_num", 2L); + list.add(r3); + return list; + } + + public Long countTotalExpectedAttendanceForPastLessons() { return 100L; } + + public List> selectVenueUsageTop5() + { + List> list = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("venue_name", "主楼101多媒体教室"); + r1.put("usage_count", 50L); + r1.put("total_hours", 100.0); + list.add(r1); + Map r2 = new HashMap<>(); + r2.put("venue_name", "室外综合接触网实训场"); + r2.put("usage_count", 30L); + r2.put("total_hours", 60.0); + list.add(r2); + return list; + } + + public Long countFilteredLessons(AttendanceFilterDto dto) { return lessonTotal; } + public Long countFilteredExpectedAttendance(AttendanceFilterDto dto) { return expectedTotal; } + public Long countFilteredNormalAttendance(AttendanceFilterDto dto) { return normalTotal; } + public Long countFilteredAbnormalAttendance(AttendanceFilterDto dto) { return abnormalTotal; } + + public List> selectFilteredDailyTrend(AttendanceFilterDto dto) + { + return selectAttendanceTrend30Days(); + } + + public List> selectFilteredStatusDistribution(AttendanceFilterDto dto) + { + return selectAttendanceStatusDistribution(); + } + + public List> selectPlanAttendanceComparison(AttendanceFilterDto dto) + { + List> list = new ArrayList<>(); + Map row = new HashMap<>(); + row.put("id", "PLAN001"); + row.put("name", "计划一"); + row.put("normal_count", 73L); + row.put("abnormal_count", 4L); + list.add(row); + return list; + } + + public List> selectCourseAttendanceComparison(AttendanceFilterDto dto) + { + List> list = new ArrayList<>(); + Map row = new HashMap<>(); + row.put("id", "C001"); + row.put("name", "铁道概论"); + row.put("normal_count", 50L); + row.put("abnormal_count", 2L); + list.add(row); + return list; + } + + public List> selectAbnormalStudentTop10(AttendanceFilterDto dto) + { + List> list = new ArrayList<>(); + Map row = new HashMap<>(); + row.put("student_id", "S2026001"); + row.put("student_name", "张三"); + row.put("plan_id", "PLAN001"); + row.put("plan_name", "计划一"); + row.put("absent_count", 2L); + row.put("late_count", 1L); + row.put("early_count", 0L); + row.put("leave_count", 1L); + row.put("total_abnormal_count", 4L); + list.add(row); + return list; + } + + public Map selectStudentBaseProfile(String studentId) + { + Map map = new HashMap<>(); + map.put("student_id", studentId); + map.put("student_name", "张三"); + map.put("gender", "0"); + map.put("birth", new Date()); + map.put("phone", "13800000000"); + map.put("email", "zhangsan@example.com"); + map.put("org_name", "房建公寓段"); + map.put("id_card_last4", "1234"); + map.put("user_binding_status", "已绑定"); + map.put("status", 1); + return map; + } + + public List> selectStudentEnrollmentTimeline(String studentId) + { + List> list = new ArrayList<>(); + Map row = new HashMap<>(); + row.put("enrollment_id", 1001L); + row.put("plan_id", "PLAN001"); + row.put("plan_name", "计划一"); + row.put("enrollment_status", 1); + row.put("join_time", new Date()); + row.put("report_way", 1); + row.put("verify_result", 1); + row.put("report_time", new Date()); + list.add(row); + return list; + } + + public List> selectStudentAttendanceStats(String studentId) + { + List> list = new ArrayList<>(); + Map row1 = new HashMap<>(); + row1.put("status_code", "1"); + row1.put("count_num", 8L); + list.add(row1); + Map row2 = new HashMap<>(); + row2.put("status_code", "0"); + row2.put("count_num", 1L); + list.add(row2); + return list; + } + + public Long countStudentExpectedAttendance(String studentId) { return 10L; } + + public Map selectStudentAccommodationAuth(String studentId) + { + Map map = new HashMap<>(); + map.put("accommodation_id", 801L); + map.put("dorm_id", "DORM01"); + map.put("room_name", "101"); + map.put("bed_no", "A床"); + map.put("accommodation_status", 1); + map.put("auth_status", "ACTIVE"); + return map; + } + } +} diff --git a/RuoYi-Vue/sql/system/kingbase/012_analytics_v1_permissions.sql b/RuoYi-Vue/sql/system/kingbase/012_analytics_v1_permissions.sql new file mode 100644 index 000000000..3970b330f --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/012_analytics_v1_permissions.sql @@ -0,0 +1,68 @@ +-- ============================================================================= +-- Migration: Analytics V1 Permissions & Legacy Menu Deactivation +-- Target: KingbaseES, schema public +-- Scope: Deactivate legacy dropped score menus, add attendance analytics menu & dashboard permissions +-- ============================================================================= + +BEGIN; + +-- 1. Deactivate legacy score menus (2166, 2175, 2176) +UPDATE public.sys_menu +SET status = '1', visible = '1', update_by = 'admin', update_time = CURRENT_TIMESTAMP, remark = '已废弃旧表停用(V2架构)' +WHERE menu_id IN (2166, 2175, 2176); + +-- 2. Insert attendance analytics menu under '统计分析' (parent_id = 2169) +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 2251, '考勤分析', 2169, 1, 'attendance', 'train/analysis/attendance/index', '', 1, 0, + 'C', '0', '0', 'train:analysis:attendance', 'chart', 'admin', CURRENT_TIMESTAMP, '', NULL, '考勤专题分析页面' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2251); + +-- 3. Insert attendance query button permission +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 2252, '考勤分析查询', 2251, 1, '#', '', '', 1, 0, + 'F', '0', '0', 'train:analysis:attendance:query', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '考勤分析数据查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2252); + +-- 4. Insert dashboard button permission +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 2253, '管理驾驶舱', 2169, 0, '#', '', '', 1, 0, + 'F', '0', '0', 'train:analysis:dashboard', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '管理端驾驶舱权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2253); + +-- 5. Grant permissions to train_admin role (role_id = 3) +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 3, 2251 +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2251); + +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 3, 2252 +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2252); + +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 3, 2253 +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2253); + +-- 6. Verification +DO $$ +BEGIN + IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2251 AND perms = 'train:analysis:attendance') <> 1 THEN + RAISE EXCEPTION 'Menu 2251 verification failed'; + END IF; + IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2252 AND perms = 'train:analysis:attendance:query') <> 1 THEN + RAISE EXCEPTION 'Menu 2252 verification failed'; + END IF; + IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2253 AND perms = 'train:analysis:dashboard') <> 1 THEN + RAISE EXCEPTION 'Menu 2253 verification failed'; + END IF; + IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id IN (2251, 2252, 2253)) <> 3 THEN + RAISE EXCEPTION 'Role assignment verification failed'; + END IF; +END $$; + +COMMIT; diff --git a/build_ui/src/api/train/analysis.js b/build_ui/src/api/train/analysis.js new file mode 100644 index 000000000..0ab0d2291 --- /dev/null +++ b/build_ui/src/api/train/analysis.js @@ -0,0 +1,43 @@ +import request from '@/utils/request' + +// 获取管理驾驶舱宏观概览 +export function getDashboardOverview() { + return request({ + url: '/train/analysis/dashboard/overview', + method: 'get' + }) +} + +// 获取管理驾驶舱图表数据 +export function getDashboardCharts() { + return request({ + url: '/train/analysis/dashboard/charts', + method: 'get' + }) +} + +// 获取考勤专题分析汇总 KPI +export function getAttendanceSummary(params) { + return request({ + url: '/train/analysis/attendance/summary', + method: 'get', + params: params + }) +} + +// 获取考勤专题分析图表集合 +export function getAttendanceCharts(params) { + return request({ + url: '/train/analysis/attendance/charts', + method: 'get', + params: params + }) +} + +// 获取指定学员 360 客观全景画像 +export function getStudent360(studentId) { + return request({ + url: '/train/analysis/portrait/student-360/' + studentId, + method: 'get' + }) +} diff --git a/build_ui/src/views/index.vue b/build_ui/src/views/index.vue index 6ab754e53..5691d60b0 100644 --- a/build_ui/src/views/index.vue +++ b/build_ui/src/views/index.vue @@ -4,29 +4,7 @@
-
- - - -
- SYFJGYD学员管理系统 -
-
-

欢迎使用SYFJGYD学员管理系统。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。

-
-
- 系统架构 -
- -
- 系统版本:V1.0 - 版权所有:中国科学院沈阳计算技术研究所有限公司 -
-
-
-
- -
+
@@ -59,12 +37,14 @@ + +