Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

487 lines
17 KiB
C#

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);
// 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<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)
.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<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);
}
private async Task<List<TimetableEntryDto>> LoadExamEntriesAsync(
TimetableResourceType resourceType,
Guid resourceId,
Guid academicTermId,
Guid? studentId,
IReadOnlyList<TimetableSlotDto> slots,
CancellationToken cancellationToken)
{
if (resourceType == TimetableResourceType.Classroom) return [];
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
IQueryable<ExamSession> 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<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> 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<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,
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<string> TeacherNames,
IEnumerable<string> ClassNames,
string? Notes);