考试排考
This commit is contained in:
@@ -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");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+158
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user