788 lines
37 KiB
C#
788 lines
37 KiB
C#
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<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
|
{ "各专业人数", ((IEnumerable<dynamic>)data.byMajor).Select(x => new object?[] { x.majorName, x.collegeName, x.count }) },
|
|
{ "各班级人数", ((IEnumerable<dynamic>)data.byClass).Select(x => new object?[] { x.className, x.grade, x.majorName, x.count }) },
|
|
{ "历年招生", ((IEnumerable<dynamic>)data.enrollmentTrend).Select(x => new object?[] { x.year, x.count }) },
|
|
{ "性别分布", ((IEnumerable<dynamic>)data.byGender).Select(x => new object?[] { x.gender, x.count }) },
|
|
{ "学籍状态", ((IEnumerable<dynamic>)data.byStatus).Select(x => new object?[] { x.status, x.count }) },
|
|
});
|
|
}
|
|
|
|
// ── 2. Course Statistics ─────────────────────────────────────────
|
|
|
|
[HttpGet("courses/summary")]
|
|
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
|
{ "各分类课程数", ((IEnumerable<dynamic>)data.byCategory).Select(x => new object?[] { x.categoryName, x.count }) },
|
|
{ "课程性质", ((IEnumerable<dynamic>)data.byNature).Select(x => new object?[] { x.natureLabel, x.count }) },
|
|
{ "学分分布", ((IEnumerable<dynamic>)data.creditDistribution).Select(x => new object?[] { x.range, x.count }) },
|
|
{ "考核方式", ((IEnumerable<dynamic>)data.byAssessment).Select(x => new object?[] { x.label, x.count }) },
|
|
});
|
|
}
|
|
|
|
// ── 3. Grade Statistics ──────────────────────────────────────────
|
|
|
|
[HttpGet("grades/summary")]
|
|
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
|
{ "GPA分布", ((IEnumerable<dynamic>)data.gpaDistribution).Select(x => new object?[] { x.range, x.count }) },
|
|
{ "各学院通过率", ((IEnumerable<dynamic>)data.passRateByCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.averageScore, x.totalRecords }) },
|
|
{ "课程均分Top20", ((IEnumerable<dynamic>)data.averageByCourse).Select(x => new object?[] { x.courseName, x.averageScore, x.recordCount }) },
|
|
{ "总体", new List<object?[]> { new object?[] { data.overall.averageScore, data.overall.passRate, data.overall.totalRecords } } },
|
|
});
|
|
}
|
|
|
|
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
|
|
|
[HttpGet("pass-rates/summary")]
|
|
public async Task<ActionResult<object>> 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<IActionResult> 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<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
|
{ "各课程通过率", ((IEnumerable<dynamic>)data.byCourse).Select(x => new object?[] { x.courseCode, x.courseName, x.passRate, x.total }) },
|
|
{ "学期趋势", ((IEnumerable<dynamic>)data.trendByTerm).Select(x => new object?[] { x.termName, x.passRate }) },
|
|
{ "课程性质", ((IEnumerable<dynamic>)data.passRateByNature).Select(x => new object?[] { x.natureLabel, x.passRate, x.total }) },
|
|
});
|
|
}
|
|
|
|
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
|
|
|
[HttpGet("teacher-workload/summary")]
|
|
public async Task<ActionResult<object>> 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<IActionResult> ExportTeacherWorkload(
|
|
Guid? academicTermId, Guid? collegeId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
|
.ConfigureAwait(false)).Value!;
|
|
return ExportSummary("教师工作量统计", new()
|
|
{
|
|
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
|
{ "各学院汇总", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.totalHours, x.teacherCount, x.avgHoursPerTeacher }) },
|
|
{ "各职称", ((IEnumerable<dynamic>)data.byTitle).Select(x => new object?[] { x.title, x.avgHours, x.teacherCount }) },
|
|
});
|
|
}
|
|
|
|
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
|
|
|
[HttpGet("classroom-utilization/summary")]
|
|
public async Task<ActionResult<object>> 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<object>(),
|
|
byDayOfWeek = Array.Empty<object>(),
|
|
byTimeSlot = Array.Empty<object>(),
|
|
byClassroomType = Array.Empty<object>(),
|
|
byCampus = Array.Empty<object>(),
|
|
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<IActionResult> 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<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
|
{ "各工作日", ((IEnumerable<dynamic>)data.byDayOfWeek).Select(x => new object?[] { x.dayLabel, x.utilizationRate }) },
|
|
{ "各节次", ((IEnumerable<dynamic>)data.byTimeSlot).Select(x => new object?[] { x.period, x.utilizationRate }) },
|
|
{ "教室类型", ((IEnumerable<dynamic>)data.byClassroomType).Select(x => new object?[] { x.roomType, x.count, x.utilizationRate }) },
|
|
{ "各校区", ((IEnumerable<dynamic>)data.byCampus).Select(x => new object?[] { x.campusName, x.utilizationRate }) },
|
|
});
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
|
|
private FileContentResult ExportSummary(
|
|
string title,
|
|
Dictionary<string, IEnumerable<object?[]>> sheets)
|
|
{
|
|
var bytes = ExcelWorkbookHelper.Create(title,
|
|
new[] { "数据" },
|
|
new List<object?[]> { 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
|
|
};
|
|
}
|