明细保留课表版本、学期、来源排课条目、教学任务、场地、周次、星期和节次,并建立面向教学任务/周次、课表/场地/周次的索引。 明细在“发布课表”的同一事务内生成;发布失败不会留下半成品。
341 lines
15 KiB
C#
341 lines
15 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Timetables;
|
|
using Jiaowu.Api.Infrastructure.Teaching;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
|
|
|
public sealed class SchedulePublishJobProcessor(
|
|
AppDbContext db,
|
|
SchedulePlanPublisher publisher,
|
|
IAppCache cache,
|
|
ILogger<SchedulePublishJobProcessor> logger)
|
|
{
|
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
|
{
|
|
try
|
|
{
|
|
var job = await db.SchedulePublishJobs
|
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
|
if (job is null ||
|
|
job.Status is SchedulePublishJobStatus.Succeeded
|
|
or SchedulePublishJobStatus.Failed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
job.Status = SchedulePublishJobStatus.Running;
|
|
job.StartedAt = DateTime.UtcNow;
|
|
job.CompletedAt = null;
|
|
job.CompletedSteps = 0;
|
|
job.CurrentStep = "读取排课版本";
|
|
job.ErrorMessage = null;
|
|
await db.SaveChangesAsync(stoppingToken);
|
|
|
|
async Task ReportProgress(
|
|
int completedSteps,
|
|
string currentStep,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
job.CompletedSteps = completedSteps;
|
|
job.CurrentStep = currentStep;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
var plan = await publisher.ValidateAsync(
|
|
job.SchedulePlanId,
|
|
ReportProgress,
|
|
stoppingToken);
|
|
|
|
var publishedPlanId = plan.Id;
|
|
await db.ExecuteInRetriableTransactionAsync(
|
|
async transaction =>
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var publishJob = await db.SchedulePublishJobs
|
|
.FirstAsync(x => x.Id == jobId, stoppingToken);
|
|
var publishPlan = await db.SchedulePlans
|
|
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
|
|
if (publishPlan.Status != SchedulePlanStatus.Draft)
|
|
{
|
|
throw new SchedulePublishValidationException(
|
|
"排课草稿状态已发生变化,请刷新后重试。");
|
|
}
|
|
|
|
var previous = await db.SchedulePlans
|
|
.Where(x =>
|
|
x.Id != publishPlan.Id &&
|
|
x.AcademicTermId == publishPlan.AcademicTermId &&
|
|
x.Status == SchedulePlanStatus.Published)
|
|
.ToListAsync(stoppingToken);
|
|
foreach (var oldPlan in previous)
|
|
oldPlan.Status = SchedulePlanStatus.Archived;
|
|
|
|
await new PublishedTimetableProjectionService(db)
|
|
.RebuildAsync(publishPlan, stoppingToken);
|
|
publishPlan.Status = SchedulePlanStatus.Published;
|
|
publishPlan.PublishedAt = DateTime.UtcNow;
|
|
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
|
publishJob.ActiveAcademicTermId = null;
|
|
publishJob.CompletedSteps = publishJob.TotalSteps;
|
|
publishJob.CurrentStep = "课表已发布";
|
|
publishJob.CompletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(stoppingToken);
|
|
await transaction.CommitAsync(stoppingToken);
|
|
},
|
|
stoppingToken);
|
|
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
|
logger.LogInformation(
|
|
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
|
jobId,
|
|
publishedPlanId);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
logger.LogInformation(
|
|
"Schedule publish job {JobId} was interrupted by application shutdown.",
|
|
jobId);
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Schedule publish job {JobId} failed.", jobId);
|
|
await MarkFailedAsync(jobId, exception);
|
|
}
|
|
}
|
|
|
|
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var job = await db.SchedulePublishJobs.FirstOrDefaultAsync(
|
|
x => x.Id == jobId,
|
|
CancellationToken.None);
|
|
if (job is null)
|
|
return;
|
|
|
|
var message = exception.GetBaseException().Message;
|
|
job.Status = SchedulePublishJobStatus.Failed;
|
|
job.ActiveAcademicTermId = null;
|
|
job.CurrentStep = "检查未通过";
|
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
|
job.CompletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
public sealed class SchedulePlanPublisher(AppDbContext db)
|
|
{
|
|
public async Task<SchedulePlan> ValidateAsync(
|
|
Guid planId,
|
|
Func<int, string, CancellationToken, Task> reportProgress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await db.SchedulePlans
|
|
.AsSplitQuery()
|
|
.Include(x => x.Entries)
|
|
.ThenInclude(x => x.Classroom)
|
|
.ThenInclude(x => x!.Building)
|
|
.Include(x => x.Entries)
|
|
.ThenInclude(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Teachers)
|
|
.Include(x => x.Entries)
|
|
.ThenInclude(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Classes)
|
|
.ThenInclude(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Students)
|
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken)
|
|
?? throw new SchedulePublishValidationException("排课草稿不存在。");
|
|
|
|
if (plan.Status != SchedulePlanStatus.Draft)
|
|
throw new SchedulePublishValidationException("只有草稿排课版本可以发布。");
|
|
if (plan.Entries.Count == 0)
|
|
throw new SchedulePublishValidationException(
|
|
"排课版本中至少需要一条课表安排。");
|
|
|
|
await reportProgress(1, "校验课程、节次与教室", cancellationToken);
|
|
var activePeriods = (await db.ScheduleTimeSlots.AsNoTracking()
|
|
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
|
.Select(x => x.PeriodNumber)
|
|
.ToListAsync(cancellationToken))
|
|
.ToHashSet();
|
|
if (activePeriods.Count == 0)
|
|
throw new SchedulePublishValidationException(
|
|
"请先维护该学期的上课时间表。");
|
|
|
|
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
|
|
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
|
.Include(x => x.AllowedClassrooms)
|
|
.Include(x => x.AllowedExperimentClassrooms)
|
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
|
|
|
foreach (var entry in plan.Entries)
|
|
{
|
|
ValidateEntry(plan, entry, activePeriods, constraints);
|
|
}
|
|
|
|
await reportProgress(2, "校验教学任务完整性", cancellationToken);
|
|
var requiredTasks = await db.TeachingTasks.AsNoTracking()
|
|
.Where(x =>
|
|
x.AcademicTermId == plan.AcademicTermId &&
|
|
x.Status == TeachingTaskStatus.Published &&
|
|
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
CourseTotalHours = x.Course!.TotalHours,
|
|
CoursePracticeHours = x.Course.PracticeHours
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var scheduledHours = plan.Entries
|
|
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
|
|
.ToDictionary(
|
|
group => (group.Key.TeachingTaskId, group.Key.Kind),
|
|
group => group.Sum(TeachingTaskHours.ScheduledHours));
|
|
foreach (var task in requiredTasks)
|
|
{
|
|
var targets = new[]
|
|
{
|
|
(Kind: ScheduleEntryKind.Lecture,
|
|
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
|
|
Label: "理论课"),
|
|
(Kind: ScheduleEntryKind.Experiment,
|
|
Hours: Math.Max(0, task.CoursePracticeHours),
|
|
Label: "实验课")
|
|
};
|
|
foreach (var target in targets)
|
|
{
|
|
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
|
|
if (actual == target.Hours) continue;
|
|
throw new SchedulePublishValidationException(
|
|
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
|
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
|
}
|
|
}
|
|
|
|
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
|
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
|
|
if (conflict is not null)
|
|
throw new SchedulePublishValidationException(conflict);
|
|
|
|
await reportProgress(4, "写入正式课表", cancellationToken);
|
|
return plan;
|
|
}
|
|
|
|
private static void ValidateEntry(
|
|
SchedulePlan plan,
|
|
ScheduleEntry entry,
|
|
HashSet<int> activePeriods,
|
|
IReadOnlyDictionary<Guid, TeachingTaskScheduleConstraint> constraints)
|
|
{
|
|
var task = entry.TeachingTask;
|
|
if (entry.StartWeek > entry.EndWeek)
|
|
Fail(entry, "开始周不能晚于结束周");
|
|
if (Enumerable.Range(entry.StartPeriod, entry.PeriodCount)
|
|
.Any(period => !activePeriods.Contains(period)))
|
|
Fail(entry, "所选节次包含未启用或不存在的上课时间");
|
|
if (task is null ||
|
|
task.Status != TeachingTaskStatus.Published ||
|
|
task.AcademicTermId != plan.AcademicTermId)
|
|
Fail(entry, "只能安排同一学期内已发布的教学任务");
|
|
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
|
Fail(entry, "非排时课程不应进入正常课表");
|
|
if (entry.StartWeek < task.StartWeek || entry.EndWeek > task.EndWeek)
|
|
Fail(entry, "排课周次不在教学任务的授课周次内");
|
|
|
|
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
|
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
|
|
constraint?.RequiresClassroom != false;
|
|
if (requiresClassroom && entry.ClassroomId is null)
|
|
Fail(entry, "该课程需要占用教室");
|
|
if (!requiresClassroom && entry.ClassroomId is not null)
|
|
Fail(entry, "该课程已设置为不占用教室");
|
|
|
|
var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks);
|
|
if (allowedDays.Count > 0 && !allowedDays.Contains(entry.DayOfWeek))
|
|
Fail(entry, "上课日不在教学任务允许范围内");
|
|
if (constraint?.EarliestPeriod is int earliest &&
|
|
entry.StartPeriod < earliest)
|
|
Fail(entry, $"最早只能从第 {earliest} 节开始");
|
|
if (constraint?.LatestPeriod is int latest &&
|
|
entry.StartPeriod + entry.PeriodCount - 1 > latest)
|
|
Fail(entry, $"最晚必须在第 {latest} 节结束");
|
|
|
|
var classroom = entry.Classroom;
|
|
if (entry.ClassroomId.HasValue)
|
|
{
|
|
if (classroom is null || !classroom.IsEnabled)
|
|
Fail(entry, "所选教室不存在或已停用");
|
|
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
|
constraint?.RequiredCampusId is Guid campusId &&
|
|
classroom.Building!.CampusId != campusId)
|
|
Fail(entry, "所选教室不在指定校区");
|
|
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
|
constraint?.RequiredBuildingId is Guid buildingId &&
|
|
classroom.BuildingId != buildingId)
|
|
Fail(entry, "所选教室不在指定教学楼");
|
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
|
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
|
|
classroom.Building!.CampusId != experimentCampusId)
|
|
Fail(entry, "所选场地不在实验课指定校区");
|
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
|
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
|
|
classroom.BuildingId != experimentBuildingId)
|
|
Fail(entry, "所选场地不在实验课指定教学楼");
|
|
var allowedClassroomIds = constraint?.AllowedClassrooms
|
|
.Select(x => x.ClassroomId)
|
|
.ToHashSet() ?? [];
|
|
if (entry.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
|
|
!allowedClassroomIds.Contains(classroom.Id))
|
|
Fail(entry, "所选教室不在指定教室范围内");
|
|
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
|
.Select(x => x.ClassroomId)
|
|
.ToHashSet() ?? [];
|
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
|
allowedExperimentClassroomIds.Count > 0 &&
|
|
!allowedExperimentClassroomIds.Contains(classroom.Id))
|
|
Fail(entry, "所选场地不在实验课指定场地范围内");
|
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
|
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
|
|
allowedNatures != 0 &&
|
|
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
|
Fail(entry, "所选场地不在实验课允许的场地性质范围内");
|
|
}
|
|
|
|
var studentCount = task.Classes.Sum(x =>
|
|
x.AdministrativeClass!.Students.Count(student =>
|
|
student.Status == StudentStatus.Active));
|
|
var requiredCapacity = Math.Max(task.Capacity, studentCount);
|
|
if (classroom is not null && requiredCapacity > classroom.Capacity)
|
|
{
|
|
Fail(entry,
|
|
$"教室容量不足:需要 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人");
|
|
}
|
|
}
|
|
|
|
private static HashSet<int> ParseDays(string? value) =>
|
|
string.IsNullOrWhiteSpace(value)
|
|
? []
|
|
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(int.Parse)
|
|
.ToHashSet();
|
|
|
|
[DoesNotReturn]
|
|
private static void Fail(ScheduleEntry entry, string message)
|
|
{
|
|
var name = entry.TeachingTask?.Name ?? entry.TeachingTaskId.ToString();
|
|
throw new SchedulePublishValidationException($"“{name}”:{message}。");
|
|
}
|
|
}
|
|
|
|
public sealed class SchedulePublishValidationException(string message)
|
|
: InvalidOperationException(message);
|