Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs
T
biss 26a918a52a 新增 PublishedScheduleOccurrences:发布时将周期规则按实际周次展开为只读课次明细。
明细保留课表版本、学期、来源排课条目、教学任务、场地、周次、星期和节次,并建立面向教学任务/周次、课表/场地/周次的索引。
明细在“发布课表”的同一事务内生成;发布失败不会留下半成品。
2026-08-09 16:00:09 +08:00

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);