问题根因 TimetableDataService.LoadExamEntriesAsync 只查询旧的 ExamSession(legacy 直接分配教室),完全忽略自动编排生成的 ExamRoomAssignment(混排考场),导致: ┌──────────┬──────────────────────────┬───────────────────────────────────┐ │ 课表类型 │ 之前 │ 之后 │ ├──────────┼──────────────────────────┼───────────────────────────────────┤ │ 教室课表 │ return [] 硬编码空数组 │ 查询 legacy + mixed room 两类数据 │ ├──────────┼──────────────────────────┼───────────────────────────────────┤ │ 教师课表 │ 只显示监考 │ 监考 + 自己授课的考试 │ ├──────────┼──────────────────────────┼───────────────────────────────────┤ │ 学生课表 │ 只通过 CourseEnrollments │ 同时查 ExamSeats(座位分配) │ ├──────────┼──────────────────────────┼───────────────────────────────────┤ │ 空闲教室 │ 只排除 legacy 考试占用 │ 同时排除混排考场占用 │ └──────────┴──────────────────────────┴───────────────────────────────────┘ 修改的文件 1. TimetableDataService.cs — 重写 LoadExamEntriesAsync - 拆分为两个数据源:legacy ExamSessions + mixed ExamRoomAssignment(通过 ExamRoomSession 关联) - 新增 ApplyLegacyResourceFilter / ApplyMixedResourceFilter 两个过滤方法,按资源类型(Classroom/Teacher/Student/Class)分别过滤 - 新增 MapToEntryDto 统一映射到 TimetableEntryDto - 新增 ExamSessionProjection record 统一两个数据源的投影 各资源类型的数据来源: ┌───────────┬────────────────────────────────┬──────────────────────────────────────┐ │ 资源类型 │ Legacy 过滤 │ Mixed-room 过滤 │ ├───────────┼────────────────────────────────┼──────────────────────────────────────┤ │ Classroom │ ExamSessions.ClassroomId == id │ ExamRoom.ClassroomId == id │ ├───────────┼────────────────────────────────┼──────────────────────────────────────┤ │ Teacher │ 监考 + 授课教师 │ 监考 + 授课教师 │ ├───────────┼────────────────────────────────┼──────────────────────────────────────┤ │ Student │ CourseEnrollments + 行政班 │ ExamSeats.StudentId │ ├───────────┼────────────────────────────────┼──────────────────────────────────────┤ │ Class │ TeachingTask.Classes │ SessionLinks → ExamSession → Classes │ └───────────┴────────────────────────────────┴──────────────────────────────────────┘ 2. ClassroomReservationAvailabilityService.cs — 补充混排考场占用 在 GetOccupiedClassroomIdsAsync 新增 ExamRoomAssignment 查询,空闲教室查询现在会正确排除混排考场占用的教室。 3. TimetablesController.cs — 课表选项含考试学期 - 学期级的 HasPublishedTimetable 增加 ExamPlans.Status == Published 判断 - 班级级的 HasPublishedTimetable 增加 ExamSessions 关联判断
521 lines
19 KiB
C#
521 lines
19 KiB
C#
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Excel;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Timetables;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/timetables")]
|
|
public sealed class TimetablesController(
|
|
AppDbContext db,
|
|
TimetableDataService timetableDataService,
|
|
PersonalCalendarService personalCalendarService,
|
|
IAppCache cache) : ControllerBase
|
|
{
|
|
[HttpGet("options")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
|
{
|
|
var result = await cache.GetOrCreateAsync(
|
|
AppCacheKeys.TimetableOptions,
|
|
LoadOptionsAsync,
|
|
AppCacheProfile.ReferenceData,
|
|
[
|
|
AppCacheTags.BaseData,
|
|
AppCacheTags.Timetables,
|
|
AppCacheTags.TimetableOptions
|
|
],
|
|
cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("classes/{classId:guid}")]
|
|
[AllowAnonymous]
|
|
public Task<ActionResult> GetClassTimetable(
|
|
Guid classId,
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken) =>
|
|
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
|
|
|
[HttpGet("teachers/{teacherId:guid}")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult> GetTeacherTimetable(
|
|
Guid teacherId,
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await GetPublishedTimetableAsync(
|
|
TimetableResourceType.Teacher,
|
|
teacherId,
|
|
academicTermId,
|
|
cancellationToken);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpGet("teachers/{teacherId:guid}/export.xlsx")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult> ExportTeacherTimetable(
|
|
Guid teacherId,
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await GetPublishedTimetableAsync(
|
|
TimetableResourceType.Teacher,
|
|
teacherId,
|
|
academicTermId,
|
|
cancellationToken);
|
|
if (result is null) return NotFound();
|
|
return ExcelFile(result);
|
|
}
|
|
|
|
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult> ExportClassTimetable(
|
|
Guid classId,
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await GetPublishedTimetableAsync(
|
|
TimetableResourceType.Class,
|
|
classId,
|
|
academicTermId,
|
|
cancellationToken);
|
|
if (result is null) return NotFound();
|
|
return ExcelFile(result);
|
|
}
|
|
|
|
[HttpGet("mine")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> GetMyTimetable(
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
|
return Unauthorized();
|
|
|
|
var student = await db.Students.AsNoTracking()
|
|
.Where(x => x.UserId == userId)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.AdministrativeClassId,
|
|
x.StudentNumber,
|
|
x.Name
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (student is not null)
|
|
{
|
|
return await BuildTimetableAsync(
|
|
student.AdministrativeClassId,
|
|
academicTermId,
|
|
student.Id,
|
|
cancellationToken,
|
|
new TimetableStudentDto(student.StudentNumber, student.Name));
|
|
}
|
|
|
|
var teacher = await db.Teachers.AsNoTracking()
|
|
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
|
|
.Select(x => new { x.Id })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (teacher is not null)
|
|
{
|
|
var result = await timetableDataService.BuildAsync(
|
|
TimetableResourceType.Teacher,
|
|
teacher.Id,
|
|
academicTermId,
|
|
null,
|
|
false,
|
|
null,
|
|
null,
|
|
cancellationToken);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
return Conflict(new ProblemDetails
|
|
{
|
|
Title = "档案未关联",
|
|
Detail = "当前登录账号没有关联教师或学生档案,请联系教务管理员。",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
|
|
[HttpGet("mine/export.xlsx")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> ExportMyTimetable(
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
|
return Unauthorized();
|
|
|
|
var student = await db.Students.AsNoTracking()
|
|
.Where(x => x.UserId == userId)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.AdministrativeClassId,
|
|
x.StudentNumber,
|
|
x.Name
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (student is not null)
|
|
{
|
|
var result = await timetableDataService.BuildAsync(
|
|
TimetableResourceType.Class,
|
|
student.AdministrativeClassId,
|
|
academicTermId,
|
|
null,
|
|
false,
|
|
student.Id,
|
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
|
cancellationToken);
|
|
if (result is null) return NotFound();
|
|
return ExcelFile(result);
|
|
}
|
|
|
|
var teacher = await db.Teachers.AsNoTracking()
|
|
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
|
|
.Select(x => new { x.Id })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (teacher is not null)
|
|
{
|
|
var result = await timetableDataService.BuildAsync(
|
|
TimetableResourceType.Teacher,
|
|
teacher.Id,
|
|
academicTermId,
|
|
null,
|
|
false,
|
|
null,
|
|
null,
|
|
cancellationToken);
|
|
if (result is null) return NotFound();
|
|
return ExcelFile(result);
|
|
}
|
|
|
|
return NotFound();
|
|
}
|
|
|
|
[HttpGet("mine/calendar-subscription")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> GetMyCalendarSubscription(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await CurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
return Ok(BuildCalendarSubscriptionResponse(user));
|
|
}
|
|
|
|
[HttpPost("mine/calendar-subscription")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> EnableMyCalendarSubscription(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await CurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
|
{
|
|
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
|
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
return Ok(BuildCalendarSubscriptionResponse(user));
|
|
}
|
|
|
|
[HttpPost("mine/calendar-subscription/rotate")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> RotateMyCalendarSubscription(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await CurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
|
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return Ok(BuildCalendarSubscriptionResponse(user));
|
|
}
|
|
|
|
[HttpDelete("mine/calendar-subscription")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> DisableMyCalendarSubscription(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await CurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
user.CalendarSubscriptionStamp = null;
|
|
user.CalendarSubscriptionCreatedAt = null;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("mine/calendar.ics")]
|
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
|
public async Task<ActionResult> DownloadMyCalendar(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await CurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
|
if (result is null) return CalendarProfileNotFound();
|
|
return File(
|
|
result.Content,
|
|
"text/calendar; charset=utf-8",
|
|
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
|
|
}
|
|
|
|
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult> GetCalendarFeed(
|
|
Guid userId,
|
|
string accessToken,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await db.Users.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
|
if (user is null ||
|
|
!personalCalendarService.IsAccessTokenValid(user, accessToken))
|
|
return NotFound();
|
|
|
|
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
|
if (result is null) return NotFound();
|
|
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
|
|
if (Request.Headers.IfNoneMatch.ToString()
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Contains(etag, StringComparer.Ordinal))
|
|
{
|
|
return StatusCode(StatusCodes.Status304NotModified);
|
|
}
|
|
Response.Headers.CacheControl = "private, max-age=300";
|
|
Response.Headers.ETag = etag;
|
|
Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
|
return File(result.Content, "text/calendar; charset=utf-8");
|
|
}
|
|
|
|
private async Task<ActionResult> BuildTimetableAsync(
|
|
Guid classId,
|
|
Guid? academicTermId,
|
|
Guid? studentId,
|
|
CancellationToken cancellationToken,
|
|
TimetableStudentDto? student = null)
|
|
{
|
|
var result = studentId.HasValue || student is not null
|
|
? await timetableDataService.BuildAsync(
|
|
TimetableResourceType.Class,
|
|
classId,
|
|
academicTermId,
|
|
null,
|
|
false,
|
|
studentId,
|
|
student,
|
|
cancellationToken)
|
|
: await GetPublishedTimetableAsync(
|
|
TimetableResourceType.Class,
|
|
classId,
|
|
academicTermId,
|
|
cancellationToken);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
private Task<TimetableData?> GetPublishedTimetableAsync(
|
|
TimetableResourceType resourceType,
|
|
Guid resourceId,
|
|
Guid? academicTermId,
|
|
CancellationToken cancellationToken) =>
|
|
cache.GetOrCreateAsync(
|
|
AppCacheKeys.PublishedTimetable(
|
|
resourceType.ToString().ToLowerInvariant(),
|
|
resourceId,
|
|
academicTermId),
|
|
token => timetableDataService.BuildAsync(
|
|
resourceType,
|
|
resourceId,
|
|
academicTermId,
|
|
null,
|
|
false,
|
|
null,
|
|
null,
|
|
token),
|
|
AppCacheProfile.PublishedTimetable,
|
|
[AppCacheTags.BaseData, AppCacheTags.Timetables],
|
|
cancellationToken);
|
|
|
|
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var terms = await db.AcademicTerms.AsNoTracking()
|
|
.Where(x => x.IsEnabled)
|
|
.OrderByDescending(x => x.StartDate)
|
|
.Select(x => new TimetableTermOption(
|
|
x.Id,
|
|
x.Name,
|
|
x.AcademicYear,
|
|
x.Season,
|
|
x.StartDate,
|
|
x.EndDate,
|
|
x.IsCurrent,
|
|
x.IsArchived,
|
|
db.SchedulePlans.Any(plan =>
|
|
plan.AcademicTermId == x.Id &&
|
|
plan.Status == SchedulePlanStatus.Published) ||
|
|
db.TeachingTasks.Any(task =>
|
|
task.AcademicTermId == x.Id &&
|
|
task.Status == TeachingTaskStatus.Published &&
|
|
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
|
|
db.ExamPlans.Any(plan =>
|
|
plan.AcademicTermId == x.Id &&
|
|
plan.Status == ExamPlanStatus.Published)))
|
|
.ToListAsync(cancellationToken);
|
|
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
|
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
|
.OrderByDescending(x => x.Grade)
|
|
.ThenBy(x => x.Code)
|
|
.Select(x => new TimetableClassOption(
|
|
x.Id,
|
|
x.Code,
|
|
x.Name,
|
|
x.Grade,
|
|
x.MajorId,
|
|
x.Major!.Name,
|
|
x.Major.CollegeId,
|
|
x.Major.College!.Name,
|
|
defaultTermId.HasValue &&
|
|
(db.ScheduleEntries.Any(entry =>
|
|
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
|
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
|
entry.TeachingTask!.Classes.Any(item =>
|
|
item.AdministrativeClassId == x.Id)) ||
|
|
db.TeachingTasks.Any(task =>
|
|
task.AcademicTermId == defaultTermId.Value &&
|
|
task.Status == TeachingTaskStatus.Published &&
|
|
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
|
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
|
|
db.ExamSessions.Any(session =>
|
|
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
|
|
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
|
session.TeachingTask!.Classes.Any(item =>
|
|
item.AdministrativeClassId == x.Id)))))
|
|
.ToListAsync(cancellationToken);
|
|
var colleges = await db.Colleges.AsNoTracking()
|
|
.Where(x => x.IsEnabled)
|
|
.OrderBy(x => x.Code)
|
|
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
|
|
.ToListAsync(cancellationToken);
|
|
var majors = await db.Majors.AsNoTracking()
|
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
|
.OrderBy(x => x.Code)
|
|
.Select(x => new TimetableMajorOption(
|
|
x.Id, x.Code, x.Name, x.CollegeId))
|
|
.ToListAsync(cancellationToken);
|
|
return new TimetableOptionsResponse(terms, colleges, majors, classes);
|
|
}
|
|
|
|
private ActionResult ExcelFile(TimetableData result)
|
|
{
|
|
var bytes = TimetableExcelExporter.Create(result);
|
|
var fileName =
|
|
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx";
|
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
|
}
|
|
|
|
private static string SafeFileName(string value)
|
|
{
|
|
foreach (var character in Path.GetInvalidFileNameChars())
|
|
value = value.Replace(character, '-');
|
|
return value.Trim();
|
|
}
|
|
|
|
private async Task<ApplicationUser?> CurrentUserAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
|
return null;
|
|
return await db.Users.FirstOrDefaultAsync(
|
|
x => x.Id == userId && x.IsEnabled,
|
|
cancellationToken);
|
|
}
|
|
|
|
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
|
|
ApplicationUser user)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
|
return new(false, null, null, null, null);
|
|
|
|
var accessToken = personalCalendarService.CreateAccessToken(user);
|
|
var feedPath =
|
|
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
|
|
var feedUrl =
|
|
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
|
|
$"/api/{feedPath}";
|
|
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
|
? $"webcal://{feedUrl[8..]}"
|
|
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
|
? $"webcal://{feedUrl[7..]}"
|
|
: feedUrl;
|
|
return new(
|
|
true,
|
|
feedUrl,
|
|
feedPath,
|
|
webcalUrl,
|
|
user.CalendarSubscriptionCreatedAt);
|
|
}
|
|
|
|
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
|
|
{
|
|
Title = "档案未关联",
|
|
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
|
|
public sealed record PersonalCalendarSubscriptionResponse(
|
|
bool IsEnabled,
|
|
string? FeedUrl,
|
|
string? FeedPath,
|
|
string? WebcalUrl,
|
|
DateTime? CreatedAt);
|
|
|
|
public sealed record TimetableOptionsResponse(
|
|
IReadOnlyList<TimetableTermOption> Terms,
|
|
IReadOnlyList<TimetableCollegeOption> Colleges,
|
|
IReadOnlyList<TimetableMajorOption> Majors,
|
|
IReadOnlyList<TimetableClassOption> Classes);
|
|
|
|
public sealed record TimetableTermOption(
|
|
Guid Id,
|
|
string Name,
|
|
string AcademicYear,
|
|
TermSeason Season,
|
|
DateOnly StartDate,
|
|
DateOnly EndDate,
|
|
bool IsCurrent,
|
|
bool IsArchived,
|
|
bool HasPublishedTimetable);
|
|
|
|
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
|
|
|
|
public sealed record TimetableMajorOption(
|
|
Guid Id,
|
|
string Code,
|
|
string Name,
|
|
Guid CollegeId);
|
|
|
|
public sealed record TimetableClassOption(
|
|
Guid Id,
|
|
string Code,
|
|
string Name,
|
|
int Grade,
|
|
Guid MajorId,
|
|
string MajorName,
|
|
Guid CollegeId,
|
|
string CollegeName,
|
|
bool HasPublishedTimetable);
|