排课版本新建、编辑、复制、发布、归档和删除。
周课表视图,支持12节课、单双周和起止周。 自动校验教师、行政班、教室、时间段冲突。 校验教室容量、教学任务状态和学期范围。 SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。 Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。 桌面端和390px移动端页面均已验收。
This commit is contained in:
@@ -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");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
Generated
+1471
File diff suppressed because it is too large
Load Diff
+119
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user