排课版本新建、编辑、复制、发布、归档和删除。

周课表视图,支持12节课、单双周和起止周。
自动校验教师、行政班、教室、时间段冲突。
校验教室容量、教学任务状态和学期范围。
SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。
Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。
桌面端和390px移动端页面均已验收。
This commit is contained in:
2026-07-24 14:27:01 +08:00 Unverified
parent 628944f64d
commit 0b463fa4f2
19 changed files with 2950 additions and 9 deletions
@@ -34,6 +34,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
Courses = await db.Courses.CountAsync(cancellationToken),
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,406 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ManagementRoles)]
[Route("api/schedules")]
public sealed class SchedulesController(AppDbContext db) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("plans")]
public async Task<ActionResult> GetPlans(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.SchedulePlans.AsNoTracking().AsQueryable();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id,
x.Name,
x.Version,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.PublishedAt,
EntryCount = x.Entries.Count,
x.UpdatedAt
})
.ToListAsync(cancellationToken));
}
[HttpGet("plans/{id:guid}")]
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
{
var plan = await db.SchedulePlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Name,
x.Version,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Entries = x.Entries
.OrderBy(entry => entry.DayOfWeek)
.ThenBy(entry => entry.StartPeriod)
.Select(entry => new
{
entry.Id,
entry.TeachingTaskId,
TaskNumber = entry.TeachingTask!.TaskNumber,
TaskName = entry.TeachingTask.Name,
CourseCode = entry.TeachingTask.Course!.Code,
CourseName = entry.TeachingTask.Course.Name,
TeacherNames = entry.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = entry.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
entry.ClassroomId,
ClassroomName = entry.Classroom!.Name,
BuildingName = entry.Classroom.Building!.Name,
CampusName = entry.Classroom.Building.Campus!.Name,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes
})
})
.FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
[HttpPost("plans")]
public async Task<ActionResult> CreatePlan(
SchedulePlanRequest request,
CancellationToken cancellationToken)
{
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
var plan = new SchedulePlan
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
Version = request.Version.Trim(),
Notes = Normalize(request.Notes)
};
db.SchedulePlans.Add(plan);
return await SaveAsync(plan.Id, true, cancellationToken);
}
[HttpPut("plans/{id:guid}")]
public async Task<ActionResult> UpdatePlan(
Guid id,
SchedulePlanRequest request,
CancellationToken cancellationToken)
{
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
plan.AcademicTermId = request.AcademicTermId;
plan.Name = request.Name.Trim();
plan.Version = request.Version.Trim();
plan.Notes = Normalize(request.Notes);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("plans/{id:guid}/clone")]
public async Task<ActionResult> ClonePlan(
Guid id,
CloneSchedulePlanRequest request,
CancellationToken cancellationToken)
{
var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (source is null) return NotFound();
var copy = new SchedulePlan
{
AcademicTermId = source.AcademicTermId,
Name = request.Name.Trim(),
Version = request.Version.Trim(),
Notes = source.Notes,
Entries = source.Entries.Select(entry => new ScheduleEntry
{
TeachingTaskId = entry.TeachingTaskId,
ClassroomId = entry.ClassroomId,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
PeriodCount = entry.PeriodCount,
StartWeek = entry.StartWeek,
EndWeek = entry.EndWeek,
WeekPattern = entry.WeekPattern,
Notes = entry.Notes
}).ToList()
};
db.SchedulePlans.Add(copy);
return await SaveAsync(copy.Id, true, cancellationToken);
}
[HttpDelete("plans/{id:guid}")]
public async Task<ActionResult> DeletePlan(Guid id, CancellationToken cancellationToken)
{
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("plans/{id:guid}/publish")]
public async Task<ActionResult> PublishPlan(Guid id, CancellationToken cancellationToken)
{
var plan = await db.SchedulePlans
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0)
return ConflictProblem("排课版本中至少需要一条课表安排。");
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null) return ConflictProblem(conflict);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var previous = await db.SchedulePlans
.Where(x =>
x.Id != plan.Id &&
x.AcademicTermId == plan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
}
[HttpPost("plans/{planId:guid}/entries")]
public async Task<ActionResult> CreateEntry(
Guid planId,
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
if (validation is not null) return validation;
var entry = CreateEntryEntity(planId, request);
db.ScheduleEntries.Add(entry);
return await SaveAsync(entry.Id, true, cancellationToken);
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry(
Guid planId,
Guid entryId,
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var entry = await db.ScheduleEntries
.FirstOrDefaultAsync(
x => x.Id == entryId && x.SchedulePlanId == planId,
cancellationToken);
if (entry is null) return NotFound();
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
if (validation is not null) return validation;
entry.TeachingTaskId = request.TeachingTaskId;
entry.ClassroomId = request.ClassroomId;
entry.DayOfWeek = request.DayOfWeek;
entry.StartPeriod = request.StartPeriod;
entry.PeriodCount = request.PeriodCount;
entry.StartWeek = request.StartWeek;
entry.EndWeek = request.EndWeek;
entry.WeekPattern = request.WeekPattern;
entry.Notes = Normalize(request.Notes);
return await SaveAsync(entryId, false, cancellationToken);
}
[HttpDelete("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> DeleteEntry(
Guid planId,
Guid entryId,
CancellationToken cancellationToken)
{
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries
.FirstOrDefaultAsync(
x => x.Id == entryId && x.SchedulePlanId == planId,
cancellationToken);
if (entry is null) return NotFound();
db.ScheduleEntries.Remove(entry);
return await SaveAsync(entryId, false, cancellationToken);
}
private async Task<SchedulePlan?> DraftPlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await db.SchedulePlans.FirstOrDefaultAsync(
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
cancellationToken);
private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan,
Guid? entryId,
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (request.StartWeek > request.EndWeek)
return ValidationProblem("开始周不能晚于结束周。");
if (request.StartPeriod + request.PeriodCount - 1 > 12)
return ValidationProblem("结束节次不能超过第 12 节。");
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null ||
task.Status != TeachingTaskStatus.Published ||
task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("只能安排同一学期内已发布的教学任务。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
var classroom = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
if (studentCount > classroom.Capacity)
return ConflictProblem(
$"教室容量不足:教学班有 {studentCount} 名学生,教室仅容纳 {classroom.Capacity} 人。");
var candidates = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.Id != entryId &&
x.DayOfWeek == request.DayOfWeek &&
x.StartWeek <= request.EndWeek &&
x.EndWeek >= request.StartWeek &&
x.StartPeriod < request.StartPeriod + request.PeriodCount &&
request.StartPeriod < x.StartPeriod + x.PeriodCount)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.ToListAsync(cancellationToken);
var proposed = CreateEntryEntity(plan.Id, request);
proposed.TeachingTask = task;
foreach (var candidate in candidates)
{
if (!ScheduleConflictDetector.TimeOverlaps(candidate, proposed)) continue;
var reason = ScheduleConflictDetector.ConflictReason(candidate, proposed);
if (reason is not null)
return ConflictProblem(
$"与“{candidate.TeachingTask!.Name}”发生{reason}冲突。");
}
return null;
}
private static ScheduleEntry CreateEntryEntity(Guid planId, ScheduleEntryRequest request) =>
new()
{
SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId,
ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeekPattern = request.WeekPattern,
Notes = Normalize(request.Notes)
};
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("排课版本重复,或关联数据已失效。");
}
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "排课冲突",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record SchedulePlanRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Name,
[Required, MaxLength(30)] string Version,
[MaxLength(500)] string? Notes);
public sealed record CloneSchedulePlanRequest(
[Required, MaxLength(120)] string Name,
[Required, MaxLength(30)] string Version);
public sealed record ScheduleEntryRequest(
Guid TeachingTaskId,
Guid ClassroomId,
[Range(1, 7)] int DayOfWeek,
[Range(1, 12)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
WeekPattern WeekPattern,
[MaxLength(500)] string? Notes);
@@ -0,0 +1,46 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class SchedulePlan : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public required string Name { get; set; }
public required string Version { get; set; }
public SchedulePlanStatus Status { get; set; } = SchedulePlanStatus.Draft;
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ScheduleEntry> Entries { get; set; } = [];
}
public sealed class ScheduleEntry : EntityBase
{
public Guid SchedulePlanId { get; set; }
public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int StartWeek { get; set; }
public int EndWeek { get; set; }
public WeekPattern WeekPattern { get; set; }
public string? Notes { get; set; }
}
public enum SchedulePlanStatus
{
Draft = 1,
Published = 2,
Archived = 3
}
public enum WeekPattern
{
All = 1,
Odd = 2,
Even = 3
}
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -221,6 +223,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<SchedulePlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Version).HasMaxLength(30);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Version }).IsUnique();
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.SchedulePlanId,
x.DayOfWeek,
x.StartPeriod
});
entity.HasOne(x => x.SchedulePlan)
.WithMany(x => x.Entries)
.HasForeignKey(x => x.SchedulePlanId)
.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);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -389,6 +389,36 @@ public sealed class DatabaseInitializer(
});
await db.SaveChangesAsync();
}
if (!await db.SchedulePlans.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var classroom = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = task.Id,
ClassroomId = classroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
}
]
});
await db.SaveChangesAsync();
}
}
private static void EnsureSucceeded(IdentityResult result, string action)
@@ -9,6 +9,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string PeopleAndCoursesMigration = "20260724_01_people_courses";
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -38,6 +39,10 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTasksMigration,
TeachingTasksStatements,
cancellationToken);
await ApplyMigrationAsync(
SchedulesMigration,
SchedulesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -332,4 +337,66 @@ public sealed class DevelopmentSqliteMigrator(
ON "TeachingTaskClasses" ("AdministrativeClassId");
"""
];
private static readonly string[] SchedulesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "SchedulePlans" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePlans" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Version" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_SchedulePlans_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Version"
ON "SchedulePlans" ("AcademicTermId", "Version");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Status"
ON "SchedulePlans" ("AcademicTermId", "Status");
""",
"""
CREATE TABLE IF NOT EXISTS "ScheduleEntries" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"DayOfWeek" INTEGER NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeekPattern" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod"
ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_TeachingTaskId"
ON "ScheduleEntries" ("TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_ClassroomId"
ON "ScheduleEntries" ("ClassroomId");
"""
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class Schedules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SchedulePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
Version = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
PublishedAt = 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_SchedulePlans", x => x.Id);
table.ForeignKey(
name: "FK_SchedulePlans_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ScheduleEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
DayOfWeek = table.Column<int>(type: "int", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
StartWeek = table.Column<int>(type: "int", nullable: false),
EndWeek = table.Column<int>(type: "int", nullable: false),
WeekPattern = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_ScheduleEntries", x => x.Id);
table.ForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ScheduleEntries_SchedulePlans_SchedulePlanId",
column: x => x.SchedulePlanId,
principalTable: "SchedulePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ScheduleEntries_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod",
table: "ScheduleEntries",
columns: new[] { "SchedulePlanId", "DayOfWeek", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_TeachingTaskId",
table: "ScheduleEntries",
column: "TeachingTaskId");
migrationBuilder.CreateIndex(
name: "IX_SchedulePlans_AcademicTermId_Status",
table: "SchedulePlans",
columns: new[] { "AcademicTermId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_SchedulePlans_AcademicTermId_Version",
table: "SchedulePlans",
columns: new[] { "AcademicTermId", "Version" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ScheduleEntries");
migrationBuilder.DropTable(
name: "SchedulePlans");
}
}
}
@@ -557,6 +557,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Majors");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<int>("StartWeek")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("WeekPattern")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("TeachingTaskId");
b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.HasIndex("AcademicTermId", "Version")
.IsUnique();
b.ToTable("SchedulePlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.Property<Guid>("Id")
@@ -1159,6 +1258,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
.WithMany("Entries")
.HasForeignKey("SchedulePlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("SchedulePlan");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -1314,6 +1451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.Navigation("Classes");
@@ -0,0 +1,62 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public static class ScheduleConflictDetector
{
public static string? FindConflict(IReadOnlyList<ScheduleEntry> entries)
{
for (var i = 0; i < entries.Count; i++)
{
for (var j = i + 1; j < entries.Count; j++)
{
var first = entries[i];
var second = entries[j];
if (!TimeOverlaps(first, second)) continue;
var reason = ConflictReason(first, second);
if (reason is not null)
return $"“{first.TeachingTask!.Name}”与“{second.TeachingTask!.Name}”存在{reason}冲突。";
}
}
return null;
}
public static bool TimeOverlaps(ScheduleEntry first, ScheduleEntry second) =>
first.DayOfWeek == second.DayOfWeek &&
first.StartPeriod < second.StartPeriod + second.PeriodCount &&
second.StartPeriod < first.StartPeriod + first.PeriodCount &&
WeeksOverlap(first, second);
public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second)
{
if (first.ClassroomId == second.ClassroomId) return "教室";
var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet();
if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId)))
return "教师";
var firstClasses = first.TeachingTask.Classes
.Select(x => x.AdministrativeClassId)
.ToHashSet();
return second.TeachingTask.Classes.Any(x =>
firstClasses.Contains(x.AdministrativeClassId))
? "行政班"
: null;
}
private static bool WeeksOverlap(ScheduleEntry first, ScheduleEntry second)
{
var start = Math.Max(first.StartWeek, second.StartWeek);
var end = Math.Min(first.EndWeek, second.EndWeek);
for (var week = start; week <= end; week++)
{
if (IncludesWeek(first.WeekPattern, week) &&
IncludesWeek(second.WeekPattern, week))
return true;
}
return false;
}
private static bool IncludesWeek(WeekPattern pattern, int week) =>
pattern == WeekPattern.All ||
pattern == WeekPattern.Odd && week % 2 == 1 ||
pattern == WeekPattern.Even && week % 2 == 0;
}