主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。 管理员课表查询中心支持班级、教师、场地三种课表。 超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。 增加周视图、日视图切换。 支持 Excel 导出和当前视图 PDF 导出。 学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。 空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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<TimetableData?> 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<TimetableEntryDto>();
|
||||
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);
|
||||
return new TimetableData(
|
||||
term,
|
||||
subject,
|
||||
resourceType == TimetableResourceType.Class ? subject : null,
|
||||
student,
|
||||
plan,
|
||||
slots,
|
||||
entries,
|
||||
flexibleCourses);
|
||||
}
|
||||
|
||||
private async Task<TimetableSubjectDto?> 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<TimetableTermDto?> 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)
|
||||
.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<TimetablePlanDto?> 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<List<FlexibleCourseDto>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
public enum TimetableResourceType
|
||||
{
|
||||
Class = 1,
|
||||
Teacher = 2,
|
||||
Classroom = 3
|
||||
}
|
||||
|
||||
public sealed record TimetableData(
|
||||
TimetableTermDto Term,
|
||||
TimetableSubjectDto Subject,
|
||||
TimetableSubjectDto? Class,
|
||||
TimetableStudentDto? Student,
|
||||
TimetablePlanDto? Plan,
|
||||
IReadOnlyList<TimetableSlotDto> Slots,
|
||||
IReadOnlyList<TimetableEntryDto> Entries,
|
||||
IReadOnlyList<FlexibleCourseDto> FlexibleCourses);
|
||||
|
||||
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<string> TeacherNames,
|
||||
IEnumerable<string> ClassNames,
|
||||
string ClassroomName,
|
||||
string? BuildingName,
|
||||
string? CampusName,
|
||||
int DayOfWeek,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
int StartWeek,
|
||||
int EndWeek,
|
||||
WeekPattern WeekPattern,
|
||||
string? Notes);
|
||||
|
||||
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<string> TeacherNames,
|
||||
IEnumerable<string> ClassNames,
|
||||
string? Notes);
|
||||
Reference in New Issue
Block a user