考试排考

This commit is contained in:
2026-07-25 14:51:17 +08:00 Unverified
parent 36049af692
commit 4e7da6709e
12 changed files with 1306 additions and 118 deletions
@@ -0,0 +1,227 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db)
{
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await db.ExamPlans
.Include(x => x.AcademicTerm)
.Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
return ExamArrangementResult.Fail("考试计划不存在。");
if (plan.Status != ExamPlanStatus.Draft)
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
var sessions = plan.Sessions.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("考试计划中没有场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
int assignedRooms = 0;
int assignedInvigilators = 0;
int skippedRooms = 0;
int skippedInvigilators = 0;
var messages = new List<string>();
// Track occupied time slots to avoid conflicts
var occupiedRooms = sessions
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
foreach (var session in sessions)
{
// Compute StartsAt/EndsAt from time slots
ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = await db.CourseEnrollments.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
cancellationToken);
// ── Auto-assign classroom ──
if (!session.ClassroomId.HasValue)
{
var room = await FindBestClassroomAsync(
session, studentCount, occupiedRooms, cancellationToken);
if (room is not null)
{
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
}
else
{
skippedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
}
}
else
{
skippedRooms++;
}
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (needed > 0)
{
var courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds,
occupiedInvigilators, cancellationToken);
foreach (var teacher in newlyAssigned)
{
session.Invigilators.Add(new ExamSessionInvigilator
{
ExamSessionId = session.Id,
TeacherId = teacher.Id
});
occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt));
assignedInvigilators++;
}
if (newlyAssigned.Count < needed)
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
else
{
skippedInvigilators++;
}
}
await db.SaveChangesAsync(cancellationToken);
return new ExamArrangementResult(
true,
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : ""));
}
private static void ComputeTimesFromSlots(
ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
{
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate;
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
}
private async Task<Classroom?> FindBestClassroomAsync(
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyTeacherIds.Contains(x.Id))
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
}
}
public sealed record ExamArrangementResult(bool Success, string Message)
{
public static ExamArrangementResult Fail(string message) => new(false, message);
}
@@ -564,13 +564,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => x.ClassroomId);
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId);
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.RequiredBuilding).WithMany()
.HasForeignKey(x => x.RequiredBuildingId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ExamSessionInvigilator>(entity =>
{
@@ -583,8 +583,12 @@ public sealed class DatabaseInitializer(
{
TeachingTaskId = task.Id,
ClassroomId = room.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 2,
Invigilators =
[
new ExamSessionInvigilator { TeacherId = teacher.Id }
@@ -32,6 +32,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_18_schedule_publish_jobs";
private const string FlexibleGradesMigration =
"20260725_19_flexible_grades";
private const string ExamSchedulingOptimizationMigration =
"20260725_20_exam_scheduling_optimization";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -208,6 +210,21 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceMigration,
attendanceSheetsExist ? [] : AttendanceStatements,
cancellationToken);
var examSchedulingOptimizationExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ExamSessions')
WHERE name = 'ExamDate'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamSchedulingOptimizationMigration,
examSchedulingOptimizationExists
? []
: ExamSchedulingOptimizationStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1367,4 +1384,81 @@ public sealed class DevelopmentSqliteMigrator(
ON "SchedulePublishJobs" ("RequestedByUserId");
"""
];
private static readonly string[] ExamSchedulingOptimizationStatements =
[
"""
ALTER TABLE "ExamSessions" ADD COLUMN "ExamDate" TEXT NOT NULL DEFAULT '2027-01-01';
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "StartPeriod" INTEGER NOT NULL DEFAULT 1;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "PeriodCount" INTEGER NOT NULL DEFAULT 2;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
""",
"""
CREATE TABLE "ExamSessions_Temp" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NULL,
"ExamDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"RequiredBuildingId" TEXT NULL,
"RequiredInvigilatorCount" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId"
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL,
CONSTRAINT "FK_ExamSessions_Buildings_RequiredBuildingId"
FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL
);
""",
"""
INSERT INTO "ExamSessions_Temp"
("Id","ExamPlanId","TeachingTaskId","ClassroomId","ExamDate",
"StartPeriod","PeriodCount","StartsAt","EndsAt",
"RequiredBuildingId","RequiredInvigilatorCount",
"Notes","CreatedAt","UpdatedAt")
SELECT "Id","ExamPlanId","TeachingTaskId","ClassroomId",
'2027-01-01',
1,2,
"StartsAt","EndsAt",
NULL,2,
"Notes","CreatedAt","UpdatedAt"
FROM "ExamSessions";
""",
"DROP TABLE \"ExamSessions\";",
"ALTER TABLE \"ExamSessions_Temp\" RENAME TO \"ExamSessions\";",
"""
CREATE INDEX "IX_ExamSessions_ExamPlanId_StartsAt"
ON "ExamSessions" ("ExamPlanId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamSessions_TeachingTaskId"
ON "ExamSessions" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_ExamSessions_ExamPlanId_ExamDate"
ON "ExamSessions" ("ExamPlanId", "ExamDate");
""",
"""
CREATE INDEX "IX_ExamSessions_RequiredBuildingId"
ON "ExamSessions" ("RequiredBuildingId");
"""
];
}
@@ -0,0 +1,158 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamSchedulingOptimization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop old FK/index on ClassroomId
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
// Make ClassroomId nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: true,
oldClrType: typeof(Guid),
oldType: "char(36)");
// Add new columns
migrationBuilder.AddColumn<DateOnly>(
name: "ExamDate",
table: "ExamSessions",
type: "date",
nullable: false,
defaultValue: new DateOnly(2027, 1, 1));
migrationBuilder.AddColumn<int>(
name: "StartPeriod",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "PeriodCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
migrationBuilder.AddColumn<Guid>(
name: "RequiredBuildingId",
table: "ExamSessions",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RequiredInvigilatorCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
// Re-add FK/index on ClassroomId (nullable, SetNull)
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
// New indices
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions",
columns: new[] { "ExamPlanId", "ExamDate" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId",
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Remove new FK/index
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions");
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions");
// Drop new columns
migrationBuilder.DropColumn(name: "RequiredInvigilatorCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "RequiredBuildingId", table: "ExamSessions");
migrationBuilder.DropColumn(name: "PeriodCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "StartPeriod", table: "ExamSessions");
migrationBuilder.DropColumn(name: "ExamDate", table: "ExamSessions");
// Restore ClassroomId to non-nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: false,
defaultValue: Guid.Empty,
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true);
// Restore original FK/index
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -109,6 +109,11 @@ public sealed class TimetableDataService(AppDbContext db)
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,
@@ -117,7 +122,8 @@ public sealed class TimetableDataService(AppDbContext db)
plan,
slots,
entries,
flexibleCourses);
flexibleCourses,
examEntries);
}
private async Task<TimetableSubjectDto?> LoadSubjectAsync(
@@ -286,6 +292,101 @@ public sealed class TimetableDataService(AppDbContext db)
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
@@ -303,7 +404,8 @@ public sealed record TimetableData(
TimetablePlanDto? Plan,
IReadOnlyList<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses);
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> ExamEntries);
public sealed record TimetableTermDto(
Guid Id,
@@ -362,7 +464,10 @@ public sealed record TimetableEntryDto(
int StartWeek,
int EndWeek,
WeekPattern WeekPattern,
string? Notes);
string? Notes,
bool IsExam = false,
string? ExamPlanName = null,
DateOnly? ExamDate = null);
public sealed record FlexibleCourseDto(
Guid Id,