教室预约

This commit is contained in:
2026-07-26 20:11:56 +08:00 Unverified
parent b0679a253d
commit 9f534c22f4
14 changed files with 7436 additions and 31 deletions
@@ -41,6 +41,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>();
public DbSet<ClassroomReservation> ClassroomReservations =>
Set<ClassroomReservation>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -472,6 +474,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ClassroomReservation>(entity =>
{
entity.Property(x => x.ApplicantName).HasMaxLength(50);
entity.Property(x => x.Purpose).HasMaxLength(200);
entity.Property(x => x.ContactPhone).HasMaxLength(30);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
entity.HasIndex(x => new
{
x.ApplicantCollegeId,
x.Status,
x.ReservationDate
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.ReservationDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ApplicantUser)
.WithMany()
.HasForeignKey(x => x.ApplicantUserId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ApplicantCollege)
.WithMany()
.HasForeignKey(x => x.ApplicantCollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ReviewedByUser)
.WithMany()
.HasForeignKey(x => x.ReviewedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -56,6 +56,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_30_official_documents";
private const string UnifiedMessageCenterMigration =
"20260726_31_unified_message_center";
private const string ClassroomReservationsMigration =
"20260726_32_classroom_reservations";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -406,6 +408,19 @@ public sealed class DevelopmentSqliteMigrator(
UnifiedMessageCenterMigration,
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
cancellationToken);
var classroomReservationsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ClassroomReservations'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ClassroomReservationsMigration,
classroomReservationsExist ? [] : ClassroomReservationStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1917,4 +1932,76 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
];
private static readonly string[] ClassroomReservationStatements =
[
"""
CREATE TABLE "ClassroomReservations" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ClassroomReservations" PRIMARY KEY,
"ApplicantUserId" TEXT NOT NULL,
"ApplicantName" TEXT NOT NULL,
"ApplicantCollegeId" TEXT NOT NULL,
"AcademicTermId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"ReservationDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"AttendeeCount" INTEGER NOT NULL,
"Purpose" TEXT NOT NULL,
"ContactPhone" TEXT NOT NULL,
"Notes" TEXT NULL,
"Status" INTEGER NOT NULL,
"ReviewedByUserId" TEXT NULL,
"ReviewedAt" TEXT NULL,
"ReviewComment" TEXT NULL,
"CancelledAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ApplicantUserId"
FOREIGN KEY ("ApplicantUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_Colleges_ApplicantCollegeId"
FOREIGN KEY ("ApplicantCollegeId") REFERENCES "Colleges" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId"
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt"
ON "ClassroomReservations" ("ApplicantUserId", "Status", "CreatedAt");
""",
"""
CREATE INDEX "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationDate"
ON "ClassroomReservations" (
"ApplicantCollegeId",
"Status",
"ReservationDate"
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_StartPeriod"
ON "ClassroomReservations" (
"ClassroomId",
"ReservationDate",
"Status",
"StartPeriod"
);
""",
"""
CREATE INDEX "IX_ClassroomReservations_AcademicTermId"
ON "ClassroomReservations" ("AcademicTermId");
""",
"""
CREATE INDEX "IX_ClassroomReservations_ReviewedByUserId"
ON "ClassroomReservations" ("ReviewedByUserId");
"""
];
}
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ClassroomReservations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ClassroomReservations",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
ApplicantName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
ApplicantCollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
ReservationDate = table.Column<DateTime>(type: "date", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
AttendeeCount = table.Column<int>(type: "int", nullable: false),
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
ContactPhone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClassroomReservations", x => x.Id);
table.ForeignKey(
name: "FK_ClassroomReservations_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_AspNetUsers_ApplicantUserId",
column: x => x.ApplicantUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId",
column: x => x.ReviewedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ClassroomReservations_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClassroomReservations_Colleges_ApplicantCollegeId",
column: x => x.ApplicantCollegeId,
principalTable: "Colleges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_AcademicTermId",
table: "ClassroomReservations",
column: "AcademicTermId");
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationD~",
table: "ClassroomReservations",
columns: new[] { "ApplicantCollegeId", "Status", "ReservationDate" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt",
table: "ClassroomReservations",
columns: new[] { "ApplicantUserId", "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_Sta~",
table: "ClassroomReservations",
columns: new[] { "ClassroomId", "ReservationDate", "Status", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_ClassroomReservations_ReviewedByUserId",
table: "ClassroomReservations",
column: "ReviewedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClassroomReservations");
}
}
}
@@ -465,6 +465,92 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Classrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid>("ApplicantCollegeId")
.HasColumnType("char(36)");
b.Property<string>("ApplicantName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<Guid>("ApplicantUserId")
.HasColumnType("char(36)");
b.Property<int>("AttendeeCount")
.HasColumnType("int");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<DateTime>("ReservationDate")
.HasColumnType("date");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ReviewedByUserId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId");
b.HasIndex("ReviewedByUserId");
b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
b.ToTable("ClassroomReservations");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{
b.Property<Guid>("Id")
@@ -3522,6 +3608,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Building");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege")
.WithMany()
.HasForeignKey("ApplicantCollegeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser")
.WithMany()
.HasForeignKey("ApplicantUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser")
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AcademicTerm");
b.Navigation("ApplicantCollege");
b.Navigation("ApplicantUser");
b.Navigation("Classroom");
b.Navigation("ReviewedByUser");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
@@ -0,0 +1,102 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables;
public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
{
public async Task<HashSet<Guid>> GetOccupiedClassroomIdsAsync(
AcademicTerm term,
DateOnly reservationDate,
int startPeriod,
int periodCount,
Guid? excludedReservationId,
CancellationToken cancellationToken)
{
var occupiedIds = new HashSet<Guid>();
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(entry =>
entry.ClassroomId.HasValue &&
entry.SchedulePlan!.AcademicTermId == term.Id &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.DayOfWeek == dayOfWeek &&
entry.StartWeek <= week &&
entry.EndWeek >= week &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => new
{
entry.ClassroomId,
entry.WeekPattern,
entry.StartPeriod,
entry.PeriodCount
})
.ToListAsync(cancellationToken);
foreach (var entry in scheduleEntries.Where(entry =>
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
entry.StartPeriod,
entry.PeriodCount)))
{
occupiedIds.Add(entry.ClassroomId!.Value);
}
var examRoomIds = await db.ExamSessions.AsNoTracking()
.Where(session =>
session.ClassroomId.HasValue &&
session.ExamPlan!.AcademicTermId == term.Id &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.ExamDate == reservationDate &&
session.StartPeriod < startPeriod + periodCount &&
startPeriod < session.StartPeriod + session.PeriodCount)
.Select(session => session.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(examRoomIds);
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
.Where(session =>
session.ClassroomId.HasValue &&
session.MakeupExamPlan!.AcademicTermId == term.Id &&
session.MakeupExamPlan.Status == MakeupExamPlanStatus.Published &&
session.ExamDate == reservationDate &&
session.StartPeriod < startPeriod + periodCount &&
startPeriod < session.StartPeriod + session.PeriodCount)
.Select(session => session.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(makeupExamRoomIds);
var reservationQuery = db.ClassroomReservations.AsNoTracking()
.Where(reservation =>
reservation.AcademicTermId == term.Id &&
reservation.Status == ClassroomReservationStatus.Approved &&
reservation.ReservationDate == reservationDate &&
reservation.StartPeriod < startPeriod + periodCount &&
startPeriod <
reservation.StartPeriod + reservation.PeriodCount);
if (excludedReservationId.HasValue)
reservationQuery = reservationQuery.Where(reservation =>
reservation.Id != excludedReservationId.Value);
var reservationRoomIds = await reservationQuery
.Select(reservation => reservation.ClassroomId)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(reservationRoomIds);
return occupiedIds;
}
public static (int Week, int DayOfWeek) ResolveTeachingWeek(
AcademicTerm term,
DateOnly date)
{
var termStartDay = (int)term.StartDate.DayOfWeek;
var daysSinceMonday = (termStartDay + 6) % 7;
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
var week = (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1;
return (week, dayOfWeek);
}
}