using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Timetables; public sealed class TimetableDataService(AppDbContext db) { public async Task BuildAsync( TimetableResourceType resourceType, Guid resourceId, Guid? academicTermId, Guid? schedulePlanId, bool allowUnpublishedPlan, Guid? studentId, TimetableStudentDto? student, CancellationToken cancellationToken) { var subject = await LoadSubjectAsync(resourceType, resourceId, cancellationToken); if (subject is null) return null; var term = await ResolveTermAsync(academicTermId, cancellationToken); if (term is null) return null; var plan = await ResolvePlanAsync( term.Id, schedulePlanId, allowUnpublishedPlan, cancellationToken); var slots = await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == term.Id && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .Select(x => new TimetableSlotDto( x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt)) .ToListAsync(cancellationToken); var entries = new List(); if (plan is not null) { var source = db.ScheduleEntries.AsNoTracking() .Where(x => x.SchedulePlanId == plan.Id); switch (resourceType) { case TimetableResourceType.Class: if (studentId.HasValue) { var selectedTaskIds = db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == studentId.Value && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound! .AcademicTermId == term.Id) .Select(x => x.CourseSelectionOffering!.TeachingTaskId); source = source.Where(x => x.TeachingTask!.Classes.Any(item => item.AdministrativeClassId == resourceId) || selectedTaskIds.Contains(x.TeachingTaskId)); } else { source = source.Where(x => x.TeachingTask!.Classes.Any(item => item.AdministrativeClassId == resourceId)); } break; case TimetableResourceType.Teacher: source = source.Where(x => x.TeachingTask!.Teachers.Any(item => item.TeacherId == resourceId)); break; case TimetableResourceType.Classroom: source = source.Where(x => x.ClassroomId == resourceId); break; } entries = await source .OrderBy(x => x.DayOfWeek) .ThenBy(x => x.StartPeriod) .ThenBy(x => x.TeachingTask!.Course!.Code) .Select(x => new TimetableEntryDto( x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, x.TeachingTask.Name, x.TeachingTask.Course!.Code, x.TeachingTask.Course.Name, x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.TeachingTask.Classes .Select(item => item.AdministrativeClass!.Name), x.Classroom == null ? "不占用教室" : x.Classroom.Name, x.Classroom == null ? null : x.Classroom.Building!.Name, x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name, x.DayOfWeek, x.StartPeriod, x.PeriodCount, x.StartWeek, x.EndWeek, x.WeekPattern, x.Notes)) .ToListAsync(cancellationToken); } var flexibleCourses = await LoadFlexibleCoursesAsync( resourceType, resourceId, term.Id, studentId, cancellationToken); // Load exam sessions for student/teacher timetables var examEntries = await LoadExamEntriesAsync( resourceType, resourceId, term.Id, studentId, slots, cancellationToken); return new TimetableData( term, subject, resourceType == TimetableResourceType.Class ? subject : null, student, plan, slots, entries, flexibleCourses, examEntries); } private async Task LoadSubjectAsync( TimetableResourceType resourceType, Guid resourceId, CancellationToken cancellationToken) { return resourceType switch { TimetableResourceType.Class => await db.AdministrativeClasses.AsNoTracking() .Where(x => x.Id == resourceId && x.IsEnabled) .Select(x => new TimetableSubjectDto( x.Id, x.Code, x.Name, TimetableResourceType.Class, x.Grade, x.MajorId, x.Major!.Name, x.Major.CollegeId, x.Major.College!.Name, null, null, null)) .FirstOrDefaultAsync(cancellationToken), TimetableResourceType.Teacher => await db.Teachers.AsNoTracking() .Where(x => x.Id == resourceId && x.Status == TeacherStatus.Active) .Select(x => new TimetableSubjectDto( x.Id, x.TeacherNumber, x.Name, TimetableResourceType.Teacher, null, null, null, x.CollegeId, x.College!.Name, null, null, x.Title)) .FirstOrDefaultAsync(cancellationToken), TimetableResourceType.Classroom => await db.Classrooms.AsNoTracking() .Where(x => x.Id == resourceId && x.IsEnabled) .Select(x => new TimetableSubjectDto( x.Id, x.Code, x.Name, TimetableResourceType.Classroom, null, null, null, null, null, x.Building!.CampusId, x.BuildingId, $"{x.Building.Campus!.Name} · {x.Building.Name} · {x.Capacity} 人")) .FirstOrDefaultAsync(cancellationToken), _ => null }; } private async Task ResolveTermAsync( Guid? academicTermId, CancellationToken cancellationToken) { var query = db.AcademicTerms.AsNoTracking().Where(x => x.IsEnabled); if (academicTermId.HasValue) query = query.Where(x => x.Id == academicTermId); else query = query.OrderByDescending(x => x.IsCurrent) .ThenBy(x => x.IsArchived) .ThenByDescending(x => x.StartDate); return await query.Select(x => new TimetableTermDto( x.Id, x.Name, x.AcademicYear, x.Season, x.StartDate, x.EndDate, x.IsCurrent)) .FirstOrDefaultAsync(cancellationToken); } private async Task ResolvePlanAsync( Guid academicTermId, Guid? schedulePlanId, bool allowUnpublishedPlan, CancellationToken cancellationToken) { var query = db.SchedulePlans.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId); if (schedulePlanId.HasValue) query = query.Where(x => x.Id == schedulePlanId); if (!allowUnpublishedPlan) query = query.Where(x => x.Status == SchedulePlanStatus.Published); else query = query.Where(x => x.Status != SchedulePlanStatus.Archived); return await query .OrderByDescending(x => x.Status == SchedulePlanStatus.Published) .ThenByDescending(x => x.PublishedAt) .ThenByDescending(x => x.CreatedAt) .Select(x => new TimetablePlanDto( x.Id, x.Name, x.Version, x.Status, x.PublishedAt, x.UpdatedAt)) .FirstOrDefaultAsync(cancellationToken); } private async Task> LoadFlexibleCoursesAsync( TimetableResourceType resourceType, Guid resourceId, Guid academicTermId, Guid? studentId, CancellationToken cancellationToken) { if (resourceType == TimetableResourceType.Classroom) return []; var source = db.TeachingTasks.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.Status == TeachingTaskStatus.Published && x.SchedulingMode == TeachingTaskSchedulingMode.Flexible); if (resourceType == TimetableResourceType.Teacher) { source = source.Where(x => x.Teachers.Any(item => item.TeacherId == resourceId)); } else if (studentId.HasValue) { var selectedTaskIds = db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == studentId.Value && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound! .AcademicTermId == academicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId); source = source.Where(x => x.Classes.Any(item => item.AdministrativeClassId == resourceId) || selectedTaskIds.Contains(x.Id)); } else { source = source.Where(x => x.Classes.Any(item => item.AdministrativeClassId == resourceId)); } return await source .OrderBy(x => x.Course!.Code) .ThenBy(x => x.TaskNumber) .Select(x => new FlexibleCourseDto( x.Id, x.TaskNumber, x.Name, x.Course!.Code, x.Course.Name, x.Course.Credits, x.Course.TotalHours, x.StartWeek, x.EndWeek, x.WeeklyHours, x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Classes.Select(item => item.AdministrativeClass!.Name), x.Notes)) .ToListAsync(cancellationToken); } private async Task> LoadExamEntriesAsync( TimetableResourceType resourceType, Guid resourceId, Guid academicTermId, Guid? studentId, IReadOnlyList slots, CancellationToken cancellationToken) { if (resourceType == TimetableResourceType.Classroom) return []; var slotLookup = slots.ToDictionary(x => x.PeriodNumber); IQueryable source = db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.AcademicTermId == academicTermId && x.ExamPlan.Status == ExamPlanStatus.Published && x.ClassroomId != null); if (resourceType == TimetableResourceType.Teacher) { source = source.Where(x => x.Invigilators.Any(i => i.TeacherId == resourceId)); } else if (studentId.HasValue) { source = source.Where(x => db.CourseEnrollments.Any(e => e.StudentId == studentId.Value && e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)); } else { // Class timetable: exams for the class's teaching tasks source = source.Where(x => x.TeachingTask!.Classes.Any(c => c.AdministrativeClassId == resourceId)); } var sessions = await source .OrderBy(x => x.ExamDate) .ThenBy(x => x.StartPeriod) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name), ClassNames = x.TeachingTask.Classes .Select(c => c.AdministrativeClass!.Name), ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name, CampusName = x.Classroom.Building.Campus!.Name, x.ExamDate, x.StartPeriod, x.PeriodCount, x.ExamPlan!.Name, InvigilatorNames = x.Invigilators .Select(i => i.Teacher!.Name), x.Notes }) .ToListAsync(cancellationToken); return sessions.Select(x => { var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek; return new TimetableEntryDto( x.Id, x.TeachingTaskId, x.TaskNumber, x.TaskName, x.CourseCode, x.CourseName, x.TeacherNames.Concat( new[] { "监考:" + string.Join("、", x.InvigilatorNames) }), x.ClassNames, x.ClassroomName, x.BuildingName, x.CampusName, dayOfWeek, x.StartPeriod, x.PeriodCount, 1, 1, WeekPattern.All, x.Notes, true, x.Name, x.ExamDate); }).ToList(); } } public enum TimetableResourceType { Class = 1, Teacher = 2, Classroom = 3 } public sealed record TimetableData( TimetableTermDto Term, TimetableSubjectDto Subject, TimetableSubjectDto? Class, TimetableStudentDto? Student, TimetablePlanDto? Plan, IReadOnlyList Slots, IReadOnlyList Entries, IReadOnlyList FlexibleCourses, IReadOnlyList ExamEntries); public sealed record TimetableTermDto( Guid Id, string Name, string AcademicYear, TermSeason Season, DateOnly StartDate, DateOnly EndDate, bool IsCurrent); public sealed record TimetableSubjectDto( Guid Id, string Code, string Name, TimetableResourceType Type, int? Grade, Guid? MajorId, string? MajorName, Guid? CollegeId, string? CollegeName, Guid? CampusId, Guid? BuildingId, string? Description); public sealed record TimetableStudentDto(string StudentNumber, string Name); public sealed record TimetablePlanDto( Guid Id, string Name, string Version, SchedulePlanStatus Status, DateTime? PublishedAt, DateTime UpdatedAt); public sealed record TimetableSlotDto( int PeriodNumber, string Name, TimeOnly StartsAt, TimeOnly EndsAt); public sealed record TimetableEntryDto( Guid Id, Guid TeachingTaskId, string TaskNumber, string TaskName, string CourseCode, string CourseName, IEnumerable TeacherNames, IEnumerable ClassNames, string ClassroomName, string? BuildingName, string? CampusName, int DayOfWeek, int StartPeriod, int PeriodCount, int StartWeek, int EndWeek, WeekPattern WeekPattern, string? Notes, bool IsExam = false, string? ExamPlanName = null, DateOnly? ExamDate = null); public sealed record FlexibleCourseDto( Guid Id, string TaskNumber, string Name, string CourseCode, string CourseName, decimal Credits, int TotalHours, int StartWeek, int EndWeek, int WeeklyHours, IEnumerable TeacherNames, IEnumerable ClassNames, string? Notes);