using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Text.Json; 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, AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; [HttpGet("plans")] public async Task 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 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 == null ? null : entry.Classroom.Name, BuildingName = entry.Classroom == null ? null : entry.Classroom.Building!.Name, CampusName = entry.Classroom == null ? null : 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 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 UpdatePlan( Guid id, SchedulePlanRequest request, CancellationToken cancellationToken) { var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); if (plan is null) return NotFound(); if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) return AutomaticScheduleRunningProblem(); 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 ClonePlan( Guid id, CloneSchedulePlanRequest request, CancellationToken cancellationToken) { if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) return AutomaticScheduleRunningProblem(); 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 DeletePlan(Guid id, CancellationToken cancellationToken) { var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); if (plan is null) return NotFound(); if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) return AutomaticScheduleRunningProblem(); 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 PublishPlan(Guid id, CancellationToken cancellationToken) { if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) return AutomaticScheduleRunningProblem(); 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("排课版本中至少需要一条课表安排。"); foreach (var entry in plan.Entries) { var validation = await ValidateEntryAsync( plan, entry.Id, new ScheduleEntryRequest( entry.TeachingTaskId, entry.ClassroomId, entry.DayOfWeek, entry.StartPeriod, entry.PeriodCount, entry.StartWeek, entry.EndWeek, entry.WeekPattern, entry.Notes), cancellationToken); if (validation is not null) return validation; } 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.WeeklyHours }) .ToListAsync(cancellationToken); var scheduledHours = plan.Entries .GroupBy(x => x.TeachingTaskId) .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); var incomplete = requiredTasks.FirstOrDefault(task => !scheduledHours.TryGetValue(task.Id, out var hours) || hours < task.WeeklyHours); if (incomplete is not null) return ConflictProblem( $"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。"); 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 CreateEntry( Guid planId, ScheduleEntryRequest request, CancellationToken cancellationToken) { if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) return AutomaticScheduleRunningProblem(); 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); } [HttpPost("plans/{planId:guid}/auto-schedule")] public async Task> AutoSchedule( Guid planId, CancellationToken cancellationToken) { var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var existing = await db.AutomaticScheduleJobs.AsNoTracking() .FirstOrDefaultAsync( x => x.ActiveSchedulePlanId == planId, cancellationToken); if (existing is not null) { return AcceptedAtAction( nameof(GetAutomaticScheduleJob), new { jobId = existing.Id }, ToResponse(existing)); } var requestedByUserId = Guid.TryParse( User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) ? userId : (Guid?)null; var job = new AutomaticScheduleJob { SchedulePlanId = planId, ActiveSchedulePlanId = planId, RequestedByUserId = requestedByUserId }; db.AutomaticScheduleJobs.Add(job); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { db.Entry(job).State = EntityState.Detached; existing = await db.AutomaticScheduleJobs.AsNoTracking() .FirstOrDefaultAsync( x => x.ActiveSchedulePlanId == planId, cancellationToken); if (existing is null) throw; return AcceptedAtAction( nameof(GetAutomaticScheduleJob), new { jobId = existing.Id }, ToResponse(existing)); } automaticScheduleJobQueue.Enqueue(job.Id); return AcceptedAtAction( nameof(GetAutomaticScheduleJob), new { jobId = job.Id }, ToResponse(job)); } [HttpGet("auto-schedule-jobs/{jobId:guid}")] public async Task> GetAutomaticScheduleJob( Guid jobId, CancellationToken cancellationToken) { var job = await db.AutomaticScheduleJobs.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); return job is null ? NotFound() : Ok(ToResponse(job)); } [HttpGet("plans/{planId:guid}/auto-schedule-job")] public async Task> GetActiveAutomaticScheduleJob( Guid planId, CancellationToken cancellationToken) { var job = await db.AutomaticScheduleJobs.AsNoTracking() .Where(x => x.ActiveSchedulePlanId == planId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return Ok(job is null ? null : ToResponse(job)); } [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] public async Task UpdateEntry( Guid planId, Guid entryId, ScheduleEntryRequest request, CancellationToken cancellationToken) { if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) return AutomaticScheduleRunningProblem(); 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 DeleteEntry( Guid planId, Guid entryId, CancellationToken cancellationToken) { if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) return AutomaticScheduleRunningProblem(); 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 DraftPlanAsync( Guid id, CancellationToken cancellationToken) => await db.SchedulePlans.FirstOrDefaultAsync( x => x.Id == id && x.Status == SchedulePlanStatus.Draft, cancellationToken); private Task HasActiveAutomaticScheduleJobAsync( Guid planId, CancellationToken cancellationToken) => db.AutomaticScheduleJobs.AsNoTracking().AnyAsync( x => x.ActiveSchedulePlanId == planId, cancellationToken); private async Task ValidateEntryAsync( SchedulePlan plan, Guid? entryId, ScheduleEntryRequest request, CancellationToken cancellationToken) { if (request.StartWeek > request.EndWeek) return ValidationProblem("开始周不能晚于结束周。"); var activePeriods = await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) .Select(x => x.PeriodNumber) .ToListAsync(cancellationToken); if (activePeriods.Count == 0) return ValidationProblem("请先维护该学期的上课时间表。"); if (Enumerable.Range(request.StartPeriod, request.PeriodCount) .Any(period => !activePeriods.Contains(period))) return ValidationProblem("所选节次包含未启用或不存在的上课时间。"); 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 (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking() .Include(x => x.AllowedClassrooms) .FirstOrDefaultAsync( x => x.TeachingTaskId == request.TeachingTaskId, cancellationToken); var requiresClassroom = constraint?.RequiresClassroom ?? true; if (requiresClassroom && !request.ClassroomId.HasValue) return ValidationProblem("该课程需要占用教室,请选择教室。"); if (!requiresClassroom && request.ClassroomId.HasValue) return ValidationProblem("该课程已设置为不占用教室,请清空教室。"); var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks); if (allowedDays.Count > 0 && !allowedDays.Contains(request.DayOfWeek)) return ValidationProblem("所选星期不在该教学任务允许的上课日内。"); if (constraint?.EarliestPeriod is int earliest && request.StartPeriod < earliest) return ValidationProblem($"该教学任务最早只能从第 {earliest} 节开始。"); if (constraint?.LatestPeriod is int latest && request.StartPeriod + request.PeriodCount - 1 > latest) return ValidationProblem($"该教学任务最晚必须在第 {latest} 节结束。"); Classroom? classroom = null; if (request.ClassroomId.HasValue) { classroom = await db.Classrooms.AsNoTracking() .Include(x => x.Building) .FirstOrDefaultAsync( x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken); if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); if (constraint?.RequiredCampusId is Guid campusId && classroom.Building!.CampusId != campusId) return ValidationProblem("所选教室不在该课程指定的校区。"); if (constraint?.RequiredBuildingId is Guid buildingId && classroom.BuildingId != buildingId) return ValidationProblem("所选教室不在该课程指定的教学楼。"); var allowedClassroomIds = constraint?.AllowedClassrooms .Select(x => x.ClassroomId) .ToHashSet() ?? []; if (allowedClassroomIds.Count > 0 && !allowedClassroomIds.Contains(classroom.Id)) return ValidationProblem("所选教室不在该课程指定的教室范围内。"); } 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) return ConflictProblem( $"教室容量不足:教学任务容量为 {requiredCapacity} 人,教室仅容纳 {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 static HashSet ParseDays(string? value) => string.IsNullOrWhiteSpace(value) ? [] : value.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(int.Parse) .ToHashSet(); private async Task 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 ActionResult AutomaticScheduleRunningProblem() => ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。"); private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job) { IReadOnlyList messages = []; if (!string.IsNullOrWhiteSpace(job.MessagesJson)) { messages = JsonSerializer.Deserialize(job.MessagesJson) ?? []; } return new( job.Id, job.SchedulePlanId, job.Status, job.TotalTasks, job.ProcessedTasks, job.CreatedEntries, job.CompletedTasks, messages, job.ErrorMessage, job.CreatedAt, job.StartedAt, job.CompletedAt); } 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, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, [Range(1, 30)] int StartWeek, [Range(1, 30)] int EndWeek, WeekPattern WeekPattern, [MaxLength(500)] string? Notes); public sealed record AutomaticScheduleJobResponse( Guid Id, Guid SchedulePlanId, AutomaticScheduleJobStatus Status, int TotalTasks, int ProcessedTasks, int CreatedEntries, int CompletedTasks, IReadOnlyList Messages, string? ErrorMessage, DateTime CreatedAt, DateTime? StartedAt, DateTime? CompletedAt);