using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ViewerRoles)] [Route("api/statistics")] public sealed class StatisticsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ViewerRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Leader; private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId; private Guid? ResolveCollegeId(Guid? requestedCollegeId) { if (RestrictedCollegeId.HasValue) return RestrictedCollegeId; return requestedCollegeId; } // ── 1. Student Statistics ──────────────────────────────────────── [HttpGet("students/summary")] public async Task> GetStudentSummary( Guid? collegeId, Guid? majorId, Guid? classId, int? grade, int? enrollmentYear, CancellationToken cancellationToken) { var effectiveCollegeId = ResolveCollegeId(collegeId); if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); var baseQuery = db.Students.AsNoTracking() .Where(s => effectiveCollegeId == null || s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .Where(s => majorId == null || s.AdministrativeClass!.MajorId == majorId) .Where(s => classId == null || s.AdministrativeClassId == classId) .Where(s => grade == null || s.AdministrativeClass!.Grade == grade) .Where(s => enrollmentYear == null || s.EnrollmentYear == enrollmentYear); var byCollege = await baseQuery .GroupBy(s => new { s.AdministrativeClass!.Major!.CollegeId, CollegeName = s.AdministrativeClass.Major.College!.Name }) .Select(g => new { collegeId = g.Key.CollegeId, collegeName = g.Key.CollegeName, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(cancellationToken); var byMajor = await baseQuery .GroupBy(s => new { s.AdministrativeClass!.MajorId, MajorName = s.AdministrativeClass.Major!.Name, CollegeName = s.AdministrativeClass.Major.College!.Name }) .Select(g => new { majorId = g.Key.MajorId, majorName = g.Key.MajorName, collegeName = g.Key.CollegeName, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(cancellationToken); var byClass = await baseQuery .GroupBy(s => new { s.AdministrativeClassId, ClassName = s.AdministrativeClass!.Name, s.AdministrativeClass.Grade, MajorName = s.AdministrativeClass.Major!.Name }) .Select(g => new { classId = g.Key.AdministrativeClassId, className = g.Key.ClassName, grade = g.Key.Grade, majorName = g.Key.MajorName, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(cancellationToken); var byGrade = await baseQuery .GroupBy(s => s.AdministrativeClass!.Grade) .Select(g => new { grade = g.Key, count = g.Count() }) .OrderBy(x => x.grade) .ToListAsync(cancellationToken); var byStatus = await baseQuery .GroupBy(s => s.Status) .Select(g => new { status = g.Key.ToString(), count = g.Count() }) .ToListAsync(cancellationToken); var byGender = await baseQuery .GroupBy(s => s.Gender) .Select(g => new { gender = g.Key.ToString(), count = g.Count() }) .ToListAsync(cancellationToken); var enrollmentTrend = await db.Students.AsNoTracking() .Where(s => effectiveCollegeId == null || s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .GroupBy(s => s.EnrollmentYear) .Select(g => new { year = g.Key, count = g.Count() }) .OrderBy(x => x.year) .ToListAsync(cancellationToken); var totalStudents = await baseQuery.CountAsync(cancellationToken); return new { byCollege, byMajor, byClass, byGrade, byStatus, byGender, enrollmentTrend, totals = new { totalStudents } }; } [HttpGet("students/export")] public async Task ExportStudents( Guid? collegeId, Guid? majorId, Guid? classId, int? grade, int? enrollmentYear, CancellationToken cancellationToken) { var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("学生统计", new() { { "各学院人数", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) }, { "各专业人数", ((IEnumerable)data.byMajor).Select(x => new object?[] { x.majorName, x.collegeName, x.count }) }, { "各班级人数", ((IEnumerable)data.byClass).Select(x => new object?[] { x.className, x.grade, x.majorName, x.count }) }, { "历年招生", ((IEnumerable)data.enrollmentTrend).Select(x => new object?[] { x.year, x.count }) }, { "性别分布", ((IEnumerable)data.byGender).Select(x => new object?[] { x.gender, x.count }) }, { "学籍状态", ((IEnumerable)data.byStatus).Select(x => new object?[] { x.status, x.count }) }, }); } // ── 2. Course Statistics ───────────────────────────────────────── [HttpGet("courses/summary")] public async Task> GetCourseSummary( Guid? collegeId, Guid? categoryId, CourseNature? nature, CancellationToken cancellationToken) { var effectiveCollegeId = ResolveCollegeId(collegeId); if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); var baseQuery = db.Courses.AsNoTracking() .Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId) .Where(c => categoryId == null || c.CourseCategoryId == categoryId) .Where(c => nature == null || c.Nature == nature); var byCollege = await baseQuery .GroupBy(c => new { c.CollegeId, CollegeName = c.College!.Name }) .Select(g => new { collegeId = g.Key.CollegeId, collegeName = g.Key.CollegeName, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(cancellationToken); var byCategory = await baseQuery .GroupBy(c => new { c.CourseCategoryId, CategoryName = c.CourseCategory!.Name }) .Select(g => new { categoryId = g.Key.CourseCategoryId, categoryName = g.Key.CategoryName, count = g.Count() }) .OrderByDescending(x => x.count) .ToListAsync(cancellationToken); var byNature = await baseQuery .GroupBy(c => c.Nature) .Select(g => new { nature = g.Key.ToString(), natureLabel = MapCourseNature(g.Key), count = g.Count() }) .ToListAsync(cancellationToken); var byAssessment = await baseQuery .GroupBy(c => c.AssessmentMethod) .Select(g => new { method = g.Key.ToString(), label = g.Key == AssessmentMethod.Examination ? "考试" : "考查", count = g.Count() }) .ToListAsync(cancellationToken); // credit distribution in-memory var credits = await baseQuery.Select(c => c.Credits).ToListAsync(cancellationToken); var creditDistribution = new[] { new { range = "0-1", min = 0m, max = 1m, count = credits.Count(c => c > 0 && c <= 1) }, new { range = "1-2", min = 1m, max = 2m, count = credits.Count(c => c > 1 && c <= 2) }, new { range = "2-3", min = 2m, max = 3m, count = credits.Count(c => c > 2 && c <= 3) }, new { range = "3-4", min = 3m, max = 4m, count = credits.Count(c => c > 3 && c <= 4) }, new { range = "4-5", min = 4m, max = 5m, count = credits.Count(c => c > 4 && c <= 5) }, new { range = "5+", min = 5m, max = 99m, count = credits.Count(c => c > 5) }, }; var totalCourses = await baseQuery.CountAsync(cancellationToken); return new { byCollege, byCategory, byNature, creditDistribution, byAssessment, totals = new { totalCourses } }; } [HttpGet("courses/export")] public async Task ExportCourses( Guid? collegeId, Guid? categoryId, CourseNature? nature, CancellationToken cancellationToken) { var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("课程统计", new() { { "各学院课程数", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) }, { "各分类课程数", ((IEnumerable)data.byCategory).Select(x => new object?[] { x.categoryName, x.count }) }, { "课程性质", ((IEnumerable)data.byNature).Select(x => new object?[] { x.natureLabel, x.count }) }, { "学分分布", ((IEnumerable)data.creditDistribution).Select(x => new object?[] { x.range, x.count }) }, { "考核方式", ((IEnumerable)data.byAssessment).Select(x => new object?[] { x.label, x.count }) }, }); } // ── 3. Grade Statistics ────────────────────────────────────────── [HttpGet("grades/summary")] public async Task> GetGradeSummary( Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId, Guid? courseId, CancellationToken cancellationToken) { var effectiveCollegeId = ResolveCollegeId(collegeId); if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); var recordsQuery = db.GradeRecords.AsNoTracking() .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .Where(r => majorId == null || r.Student!.AdministrativeClass!.MajorId == majorId) .Where(r => classId == null || r.Student!.AdministrativeClassId == classId) .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId); // fetch scores in-memory for distribution bucketing var scores = await recordsQuery .Where(r => r.TotalScore != null) .Select(r => r.TotalScore!.Value) .ToListAsync(cancellationToken); var scoreDistribution = new[] { new { range = "0-59", label = "不及格", count = scores.Count(s => s < 60) }, new { range = "60-69", label = "及格", count = scores.Count(s => s >= 60 && s < 70) }, new { range = "70-79", label = "中等", count = scores.Count(s => s >= 70 && s < 80) }, new { range = "80-89", label = "良好", count = scores.Count(s => s >= 80 && s < 90) }, new { range = "90-100", label = "优秀", count = scores.Count(s => s >= 90) }, }; var gpas = await recordsQuery .Where(r => r.GradePoint != null) .Select(r => r.GradePoint!.Value) .ToListAsync(cancellationToken); var gpaDistribution = new[] { new { range = "0-0.9", count = gpas.Count(g => g < 1.0m) }, new { range = "1.0-1.9", count = gpas.Count(g => g >= 1.0m && g < 2.0m) }, new { range = "2.0-2.9", count = gpas.Count(g => g >= 2.0m && g < 3.0m) }, new { range = "3.0-3.5", count = gpas.Count(g => g >= 3.0m && g < 3.6m) }, new { range = "3.6-4.0", count = gpas.Count(g => g >= 3.6m) }, }; var passRateByCollege = await db.GradeRecords.AsNoTracking() .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .GroupBy(r => new { r.Student!.AdministrativeClass!.Major!.CollegeId, CollegeName = r.Student.AdministrativeClass.Major.College!.Name }) .Select(g => new { collegeName = g.Key.CollegeName, totalRecords = g.Count(), passedRecords = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), averageScore = g.Where(r => r.TotalScore != null).Average(r => (double?)r.TotalScore) ?? 0, }) .OrderByDescending(x => x.totalRecords) .ToListAsync(cancellationToken); var passRateByCollegeResult = passRateByCollege.Select(x => new { x.collegeName, x.totalRecords, passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0, averageScore = Math.Round(x.averageScore, 1) }).ToList(); var averageByCourse = await db.GradeRecords.AsNoTracking() .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) .Where(r => effectiveCollegeId == null || r.GradeSheet!.TeachingTask!.Course!.CollegeId == effectiveCollegeId) .GroupBy(r => new { r.GradeSheet!.TeachingTask!.CourseId, CourseName = r.GradeSheet.TeachingTask.Course!.Name }) .Select(g => new { courseName = g.Key.CourseName, recordCount = g.Count(), averageScore = g.Where(r => r.TotalScore != null).Average(r => (double?)r.TotalScore) ?? 0, }) .OrderByDescending(x => x.recordCount) .Take(20) .ToListAsync(cancellationToken); var hasScores = scores.Count > 0; return new { scoreDistribution, gpaDistribution, passRateByCollege = passRateByCollegeResult, averageByCourse = averageByCourse.Select(x => new { x.courseName, x.recordCount, averageScore = Math.Round(x.averageScore, 1) }), overall = new { averageScore = hasScores ? Math.Round(scores.Average(), 1) : 0, passRate = hasScores ? Math.Round((double)scores.Count(s => s >= 60) / scores.Count, 4) : 0, totalRecords = scores.Count } }; } [HttpGet("grades/export")] public async Task ExportGrades( Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId, Guid? courseId, CancellationToken cancellationToken) { var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("成绩统计", new() { { "分数段分布", ((IEnumerable)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) }, { "GPA分布", ((IEnumerable)data.gpaDistribution).Select(x => new object?[] { x.range, x.count }) }, { "各学院通过率", ((IEnumerable)data.passRateByCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.averageScore, x.totalRecords }) }, { "课程均分Top20", ((IEnumerable)data.averageByCourse).Select(x => new object?[] { x.courseName, x.averageScore, x.recordCount }) }, { "总体", new List { new object?[] { data.overall.averageScore, data.overall.passRate, data.overall.totalRecords } } }, }); } // ── 4. Pass Rate Statistics ────────────────────────────────────── [HttpGet("pass-rates/summary")] public async Task> GetPassRateSummary( Guid? academicTermId, Guid? collegeId, Guid? courseId, CancellationToken cancellationToken) { var effectiveCollegeId = ResolveCollegeId(collegeId); if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); var recordsQuery = db.GradeRecords.AsNoTracking() .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) .Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId) .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId); var byCollege = await recordsQuery .GroupBy(r => new { r.Student!.AdministrativeClass!.Major!.CollegeId, CollegeName = r.Student.AdministrativeClass.Major.College!.Name }) .Select(g => new { collegeName = g.Key.CollegeName, total = g.Count(), passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), }) .ToListAsync(cancellationToken); var byCollegeResult = byCollege.Select(x => new { x.collegeName, x.total, passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 }).OrderByDescending(x => x.passRate).ToList(); var byCourse = await recordsQuery .GroupBy(r => new { r.GradeSheet!.TeachingTask!.CourseId, CourseCode = r.GradeSheet.TeachingTask.Course!.Code, CourseName = r.GradeSheet.TeachingTask.Course!.Name }) .Select(g => new { courseCode = g.Key.CourseCode, courseName = g.Key.CourseName, total = g.Count(), passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), }) .ToListAsync(cancellationToken); var byCourseResult = byCourse.Select(x => new { x.courseCode, x.courseName, x.total, passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 }).OrderByDescending(x => x.passRate).ToList(); var topFail = byCourseResult.Where(x => x.total >= 10).OrderBy(x => x.passRate).Take(10).ToList(); // trend by term var termRecords = await db.GradeRecords.AsNoTracking() .Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published) .Where(r => effectiveCollegeId == null || r.Student!.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId) .Where(r => courseId == null || r.GradeSheet!.TeachingTask!.CourseId == courseId) .Select(r => new { r.TotalScore, TermName = r.GradeSheet!.TeachingTask!.AcademicTerm!.Name }) .ToListAsync(cancellationToken); var trendByTerm = termRecords .GroupBy(x => x.TermName) .Select(g => new { termName = g.Key, passRate = Math.Round((double)g.Count(x => x.TotalScore != null && x.TotalScore >= 60) / Math.Max(1, g.Count(x => x.TotalScore != null)), 4) }) .OrderBy(x => x.termName) .ToList(); // by course nature var byNature = await recordsQuery .GroupBy(r => r.GradeSheet!.TeachingTask!.Course!.Nature) .Select(g => new { nature = g.Key.ToString(), total = g.Count(), passed = g.Count(r => r.TotalScore != null && r.TotalScore >= 60), }) .ToListAsync(cancellationToken); var byNatureResult = byNature.Select(x => new { natureLabel = MapCourseNature(x.nature), x.total, passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 }).ToList(); var totalRecords = await recordsQuery.CountAsync(cancellationToken); var totalPassed = await recordsQuery.CountAsync(r => r.TotalScore != null && r.TotalScore >= 60, cancellationToken); return new { overallPassRate = totalRecords > 0 ? Math.Round((double)totalPassed / totalRecords, 4) : 0, totalRecords, byCollege = byCollegeResult, byCourse = byCourseResult, byCourseTopFail = topFail, trendByTerm, passRateByNature = byNatureResult, }; } [HttpGet("pass-rates/export")] public async Task ExportPassRates( Guid? academicTermId, Guid? collegeId, Guid? courseId, CancellationToken cancellationToken) { var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("通过率统计", new() { { "各学院通过率", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) }, { "各课程通过率", ((IEnumerable)data.byCourse).Select(x => new object?[] { x.courseCode, x.courseName, x.passRate, x.total }) }, { "学期趋势", ((IEnumerable)data.trendByTerm).Select(x => new object?[] { x.termName, x.passRate }) }, { "课程性质", ((IEnumerable)data.passRateByNature).Select(x => new object?[] { x.natureLabel, x.passRate, x.total }) }, }); } // ── 5. Teacher Workload Statistics ─────────────────────────────── [HttpGet("teacher-workload/summary")] public async Task> GetTeacherWorkloadSummary( Guid? academicTermId, Guid? collegeId, CancellationToken cancellationToken) { var effectiveCollegeId = ResolveCollegeId(collegeId); if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid(); var tasks = await db.TeachingTaskTeachers.AsNoTracking() .Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId) .Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId) .Select(tt => new { tt.TeacherId, TeacherName = tt.Teacher!.Name, tt.Teacher.TeacherNumber, tt.IsPrimary, CollegeName = tt.Teacher.College!.Name, tt.Teacher.Title, CourseId = tt.TeachingTask!.CourseId, tt.TeachingTask.WeeklyHours, tt.TeachingTask.StartWeek, tt.TeachingTask.EndWeek, }) .ToListAsync(cancellationToken); // compute workload per teacher var byTeacher = tasks .GroupBy(x => new { x.TeacherId, x.TeacherName, x.TeacherNumber, x.CollegeName, x.Title }) .Select(g => new { teacherId = g.Key.TeacherId, teacherName = g.Key.TeacherName, teacherNumber = g.Key.TeacherNumber, collegeName = g.Key.CollegeName, title = g.Key.Title ?? "-", totalHours = g.Sum(x => x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), courseCount = g.Select(x => x.CourseId).Distinct().Count(), taskCount = g.Count(), weeklyHours = (int)Math.Round(g.Average(x => x.WeeklyHours * (g.Count(y => y.CourseId == x.CourseId) > 0 ? 1 : 1))), }) .OrderByDescending(x => x.totalHours) .ToList(); var byCollege = byTeacher .GroupBy(x => x.collegeName) .Select(g => new { collegeName = g.Key, totalHours = g.Sum(x => x.totalHours), teacherCount = g.Count(), avgHoursPerTeacher = (int)Math.Round(g.Average(x => (double)x.totalHours)), }) .OrderByDescending(x => x.totalHours) .ToList(); var byTitle = byTeacher .GroupBy(x => x.title) .Select(g => new { title = g.Key, teacherCount = g.Count(), avgHours = (int)Math.Round(g.Average(x => (double)x.totalHours)), }) .OrderByDescending(x => x.teacherCount) .ToList(); return new { byTeacher, byCollege, byTitle, totals = new { totalTeachers = byTeacher.Count, totalHours = byTeacher.Sum(x => x.totalHours), avgHoursPerTeacher = byTeacher.Count > 0 ? (int)Math.Round(byTeacher.Average(x => (double)x.totalHours)) : 0, } }; } [HttpGet("teacher-workload/export")] public async Task ExportTeacherWorkload( Guid? academicTermId, Guid? collegeId, CancellationToken cancellationToken) { var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("教师工作量统计", new() { { "教师明细", ((IEnumerable)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) }, { "各学院汇总", ((IEnumerable)data.byCollege).Select(x => new object?[] { x.collegeName, x.totalHours, x.teacherCount, x.avgHoursPerTeacher }) }, { "各职称", ((IEnumerable)data.byTitle).Select(x => new object?[] { x.title, x.avgHours, x.teacherCount }) }, }); } // ── 6. Classroom Utilization Statistics ────────────────────────── [HttpGet("classroom-utilization/summary")] public async Task> GetClassroomUtilizationSummary( Guid? academicTermId, Guid? buildingId, Guid? campusId, CancellationToken cancellationToken) { // Find published schedule plan var planQuery = db.SchedulePlans.AsNoTracking() .Where(p => p.Status == SchedulePlanStatus.Published); if (academicTermId.HasValue) planQuery = planQuery.Where(p => p.AcademicTermId == academicTermId); var plan = await planQuery .OrderByDescending(p => p.PublishedAt) .Select(p => new { p.Id, p.AcademicTermId }) .FirstOrDefaultAsync(cancellationToken); // classroom filter var classroomsQuery = db.Classrooms.AsNoTracking() .Where(c => buildingId == null || c.BuildingId == buildingId) .Where(c => campusId == null || c.Building!.CampusId == campusId); var classroomIds = await classroomsQuery.Select(c => c.Id).ToListAsync(cancellationToken); var totalClassrooms = classroomIds.Count; if (plan == null || totalClassrooms == 0) { return new { byBuilding = Array.Empty(), byDayOfWeek = Array.Empty(), byTimeSlot = Array.Empty(), byClassroomType = Array.Empty(), byCampus = Array.Empty(), totals = new { totalClassrooms, overallUtilizationRate = 0.0 } }; } // get entries var entries = await db.ScheduleEntries.AsNoTracking() .Where(e => e.SchedulePlanId == plan.Id) .Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value)) .Select(e => new { e.ClassroomId, e.DayOfWeek, e.StartPeriod, e.PeriodCount, BuildingName = e.Classroom!.Building!.Name, ClassroomType = e.Classroom.RoomType, CampusName = e.Classroom.Building.Campus!.Name, e.Classroom.BuildingId, CampusId = e.Classroom.Building.CampusId, }) .ToListAsync(cancellationToken); // max periods per day from time slots var maxPeriods = await db.ScheduleTimeSlots.AsNoTracking() .Where(s => s.AcademicTermId == plan.AcademicTermId && s.IsEnabled) .Select(s => s.PeriodNumber) .Distinct() .CountAsync(cancellationToken); if (maxPeriods == 0) maxPeriods = 10; int totalAvailableSlots = totalClassrooms * 5 * maxPeriods; int totalUsedSlots = entries.Sum(e => e.PeriodCount); double overallRate = totalAvailableSlots > 0 ? Math.Round((double)totalUsedSlots / totalAvailableSlots, 4) : 0; // by building var byBuilding = entries .GroupBy(e => new { e.BuildingId, e.BuildingName }) .Select(g => new { buildingName = g.Key.BuildingName, usedSlots = g.Sum(e => e.PeriodCount), // count distinct classrooms in this building used or available }) .ToList(); // better: compute per building var classroomsByBuilding = await classroomsQuery .GroupBy(c => new { c.BuildingId, BuildingName = c.Building!.Name }) .Select(g => new { g.Key.BuildingId, g.Key.BuildingName, count = g.Count() }) .ToListAsync(cancellationToken); var byBuildingResult = classroomsByBuilding.Select(b => { var used = entries.Where(e => e.BuildingId == b.BuildingId).Sum(e => e.PeriodCount); int available = b.count * 5 * maxPeriods; return new { buildingName = b.BuildingName, totalClassrooms = b.count, utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0, totalUsedPeriods = used, totalAvailablePeriods = available }; }).OrderByDescending(x => x.utilizationRate).ToList(); // by day of week var dayLabels = new[] { "", "周一", "周二", "周三", "周四", "周五", "周六", "周日" }; int dailyAvailable = totalClassrooms * maxPeriods; var byDayOfWeek = Enumerable.Range(1, 7).Select(day => { var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount); return new { day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}", utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0 }; }).ToList(); // by time slot var byTimeSlot = Enumerable.Range(1, maxPeriods).Select(period => { int periodAvailable = totalClassrooms * 5; // 5 days var used = entries.Where(e => e.StartPeriod <= period && period < e.StartPeriod + e.PeriodCount).Sum(_ => 1); return new { period, utilizationRate = periodAvailable > 0 ? Math.Round((double)used / periodAvailable, 4) : 0 }; }).ToList(); // by classroom type var byClassroomTypeResult = await classroomsQuery .GroupBy(c => c.RoomType) .Select(g => new { roomType = g.Key, count = g.Count() }) .ToListAsync(cancellationToken); var byClassroomType = byClassroomTypeResult.Select(ct => { var typeClassroomIds = entries.Where(e => e.ClassroomType == ct.roomType).Select(e => e.ClassroomId).Distinct().Count(); var used = entries.Where(e => e.ClassroomType == ct.roomType).Sum(e => e.PeriodCount); int available = ct.count * 5 * maxPeriods; return new { roomType = ct.roomType, count = ct.count, utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0 }; }).ToList(); // by campus var byCampus = await classroomsQuery .GroupBy(c => c.Building!.Campus!.Name) .Select(g => new { campusName = g.Key, count = g.Count() }) .ToListAsync(cancellationToken); var byCampusResult = byCampus.Select(c => { var used = entries.Where(e => e.CampusName == c.campusName).Sum(e => e.PeriodCount); int available = c.count * 5 * maxPeriods; return new { c.campusName, utilizationRate = available > 0 ? Math.Round((double)used / available, 4) : 0 }; }).OrderByDescending(x => x.utilizationRate).ToList(); return new { byBuilding = byBuildingResult, byDayOfWeek, byTimeSlot, byClassroomType, byCampus = byCampusResult, totals = new { totalClassrooms, overallUtilizationRate = overallRate } }; } [HttpGet("classroom-utilization/export")] public async Task ExportClassroomUtilization( Guid? academicTermId, Guid? buildingId, Guid? campusId, CancellationToken cancellationToken) { var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken) .ConfigureAwait(false)).Value!; return ExportSummary("教室利用率统计", new() { { "各教学楼", ((IEnumerable)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) }, { "各工作日", ((IEnumerable)data.byDayOfWeek).Select(x => new object?[] { x.dayLabel, x.utilizationRate }) }, { "各节次", ((IEnumerable)data.byTimeSlot).Select(x => new object?[] { x.period, x.utilizationRate }) }, { "教室类型", ((IEnumerable)data.byClassroomType).Select(x => new object?[] { x.roomType, x.count, x.utilizationRate }) }, { "各校区", ((IEnumerable)data.byCampus).Select(x => new object?[] { x.campusName, x.utilizationRate }) }, }); } // ── Helpers ────────────────────────────────────────────────────── private FileContentResult ExportSummary( string title, Dictionary> sheets) { var bytes = ExcelWorkbookHelper.Create(title, new[] { "数据" }, new List { new object?[] { title } }); // MultiSheet export: use ClosedXML directly using var workbook = new ClosedXML.Excel.XLWorkbook(); foreach (var (sheetName, rows) in sheets) { var sheet = workbook.Worksheets.Add(sheetName); var rowList = rows.ToList(); if (rowList.Count > 0 && rowList[0].Length > 0) { // Write rows for (int r = 0; r < rowList.Count; r++) { for (int c = 0; c < rowList[r].Length; c++) { var cell = sheet.Cell(r + 1, c + 1); var val = rowList[r][c]; if (val is double d) cell.Value = d; else if (val is int i) cell.Value = i; else if (val is decimal m) cell.Value = (double)m; else cell.SetValue(val?.ToString() ?? ""); } } // bold header row sheet.Row(1).Style.Font.Bold = true; sheet.Row(1).Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#1F3A6D"); sheet.Row(1).Style.Font.FontColor = ClosedXML.Excel.XLColor.White; sheet.SheetView.FreezeRows(1); sheet.Columns().AdjustToContents(10, 38); sheet.RangeUsed()?.SetAutoFilter(); } } using var stream = new MemoryStream(); workbook.SaveAs(stream); return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{title}.xlsx"); } private static string MapCourseNature(CourseNature nature) => nature switch { CourseNature.GeneralRequired => "通识必修", CourseNature.MajorRequired => "专业必修", CourseNature.MajorElective => "专业选修", CourseNature.GeneralElective => "通识选修", CourseNature.Practice => "实践环节", _ => nature.ToString() }; private static string MapCourseNature(string nature) => nature switch { "GeneralRequired" => "通识必修", "MajorRequired" => "专业必修", "MajorElective" => "专业选修", "GeneralElective" => "通识选修", "Practice" => "实践环节", _ => nature }; }