using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)] [Route("api/schedules")] public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase { [HttpGet("time-slots")] public async Task GetTimeSlots( Guid academicTermId, CancellationToken cancellationToken) => Ok(await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId) .OrderBy(x => x.PeriodNumber) .Select(x => new { x.Id, x.PeriodNumber, x.Name, StartsAt = x.StartsAt.ToString("HH:mm"), EndsAt = x.EndsAt.ToString("HH:mm"), x.IsEnabled }) .ToListAsync(cancellationToken)); [HttpPut("time-slots/{academicTermId:guid}")] public async Task ReplaceTimeSlots( Guid academicTermId, IReadOnlyList requests, CancellationToken cancellationToken) { if (!await db.AcademicTerms.AnyAsync( x => x.Id == academicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("学期不存在或已停用。"); if (requests.Count == 0) return ValidationProblem("上课时间表至少需要一个节次。"); if (requests.Select(x => x.PeriodNumber).Distinct().Count() != requests.Count) return ValidationProblem("节次编号不能重复。"); if (requests.Any(x => x.StartsAt >= x.EndsAt)) return ValidationProblem("每个节次的上课时间必须早于下课时间。"); var ordered = requests.OrderBy(x => x.StartsAt).ToList(); if (ordered.Zip(ordered.Skip(1)).Any(pair => pair.First.EndsAt > pair.Second.StartsAt)) return ValidationProblem("上课时间段不能相互重叠。"); var existing = await db.ScheduleTimeSlots .Where(x => x.AcademicTermId == academicTermId) .ToListAsync(cancellationToken); db.ScheduleTimeSlots.RemoveRange(existing); db.ScheduleTimeSlots.AddRange(requests.Select(request => new ScheduleTimeSlot { AcademicTermId = academicTermId, PeriodNumber = request.PeriodNumber, Name = request.Name.Trim(), StartsAt = request.StartsAt, EndsAt = request.EndsAt, IsEnabled = request.IsEnabled })); await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return NoContent(); } [HttpGet("constraints")] public async Task GetConstraints( Guid academicTermId, CancellationToken cancellationToken) { var tasks = await db.TeachingTasks.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.Status == TeachingTaskStatus.Published) .OrderBy(x => x.TaskNumber) .Select(x => new { x.Id, x.TaskNumber, x.Name, CourseCode = x.Course!.Code, CourseName = x.Course!.Name, CollegeId = x.Course.CollegeId, CollegeName = x.Course.College!.Name, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Capacity, x.StartWeek, x.EndWeek, x.WeeklyHours, CourseTotalHours = x.Course.TotalHours, CoursePracticeHours = x.Course.PracticeHours, x.SchedulingMode }) .ToListAsync(cancellationToken); var taskIds = tasks.Select(x => x.Id).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); return Ok(tasks.Select(task => { constraints.TryGetValue(task.Id, out var constraint); return new { task.Id, task.TaskNumber, task.Name, task.CourseCode, task.CourseName, task.CollegeId, task.CollegeName, task.TeacherNames, task.Capacity, task.StartWeek, task.EndWeek, task.WeeklyHours, task.CourseTotalHours, task.CoursePracticeHours, task.SchedulingMode, HasCustomConstraint = constraint is not null, RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible ? false : constraint?.RequiresClassroom ?? true, constraint?.RequiredCampusId, constraint?.RequiredBuildingId, constraint?.ExperimentRequiredCampusId, constraint?.ExperimentRequiredBuildingId, AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), constraint?.EarliestPeriod, constraint?.LatestPeriod, AllowedClassroomIds = constraint?.AllowedClassrooms .Select(x => x.ClassroomId) ?? [], AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms .Select(x => x.ClassroomId) ?? [], AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0 }; })); } [HttpPut("constraints/{teachingTaskId:guid}")] public async Task SaveConstraint( Guid teachingTaskId, TeachingTaskScheduleConstraintRequest request, CancellationToken cancellationToken) { if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) return ValidationProblem("允许上课日必须位于星期一至星期日。"); if (request.EarliestPeriod.HasValue && request.LatestPeriod.HasValue && request.EarliestPeriod > request.LatestPeriod) return ValidationProblem("最早节次不能晚于最晚节次。"); if (!Enum.IsDefined(request.SchedulingMode)) return ValidationProblem("授课方式无效。"); var task = await db.TeachingTasks .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); if (task is null) return NotFound(); if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); task.SchedulingMode = request.SchedulingMode; if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) { var flexibleConstraint = await db.TeachingTaskScheduleConstraints .Include(x => x.AllowedClassrooms) .Include(x => x.AllowedExperimentClassrooms) .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); if (flexibleConstraint is not null) { ClearConstraint(flexibleConstraint); } await db.SaveChangesAsync(cancellationToken); return NoContent(); } Building? building = null; if (request.RequiredBuildingId.HasValue) { building = await db.Buildings.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == request.RequiredBuildingId && x.IsEnabled, cancellationToken); if (building is null) return ValidationProblem("指定教学楼不存在或已停用。"); if (request.RequiredCampusId.HasValue && building.CampusId != request.RequiredCampusId) return ValidationProblem("指定教学楼不属于所选校区。"); } if (request.RequiredCampusId.HasValue && !await db.Campuses.AnyAsync( x => x.Id == request.RequiredCampusId && x.IsEnabled, cancellationToken)) return ValidationProblem("指定校区不存在或已停用。"); Building? experimentBuilding = null; if (request.ExperimentRequiredBuildingId.HasValue) { experimentBuilding = await db.Buildings.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled, cancellationToken); if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。"); if (request.ExperimentRequiredCampusId.HasValue && experimentBuilding.CampusId != request.ExperimentRequiredCampusId) return ValidationProblem("指定实验教学楼不属于所选校区。"); } if (request.ExperimentRequiredCampusId.HasValue && !await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled, cancellationToken)) return ValidationProblem("指定实验校区不存在或已停用。"); var allowedRooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .WhereIn(request.AllowedClassroomIds, x => x.Id) .Include(x => x.Building) .ToListAsync(cancellationToken); if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count()) return ValidationProblem("部分指定教室不存在或已停用。"); if (building is not null && allowedRooms.Any(x => x.BuildingId != building.Id)) return ValidationProblem("指定教室必须位于所选教学楼。"); if (request.RequiredCampusId.HasValue && allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId)) return ValidationProblem("指定教室必须位于所选校区。"); var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? []; var allowedExperimentRooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .WhereIn(allowedExperimentRoomIds, x => x.Id) .Include(x => x.Building) .ToListAsync(cancellationToken); if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length) return ValidationProblem("部分指定实验场地不存在或已停用。"); if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id)) return ValidationProblem("指定实验场地必须位于所选实验教学楼。"); if (request.ExperimentRequiredCampusId.HasValue && allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId)) return ValidationProblem("指定实验场地必须位于所选实验校区。"); var constraint = await db.TeachingTaskScheduleConstraints .Include(x => x.AllowedClassrooms) .Include(x => x.AllowedExperimentClassrooms) .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); if (constraint is null) { constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = teachingTaskId }; db.TeachingTaskScheduleConstraints.Add(constraint); } constraint.RequiresClassroom = request.RequiresClassroom; constraint.RequiredCampusId = request.RequiresClassroom ? request.RequiredCampusId : null; constraint.RequiredBuildingId = request.RequiresClassroom ? request.RequiredBuildingId : null; constraint.ExperimentRequiredCampusId = request.RequiresClassroom ? request.ExperimentRequiredCampusId : null; constraint.ExperimentRequiredBuildingId = request.RequiresClassroom ? request.ExperimentRequiredBuildingId : null; constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 ? null : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); constraint.EarliestPeriod = request.EarliestPeriod; constraint.LatestPeriod = request.LatestPeriod; constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures; db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms); constraint.AllowedClassrooms = request.RequiresClassroom ? request.AllowedClassroomIds.Distinct().Select(classroomId => new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() : []; constraint.AllowedExperimentClassrooms = request.RequiresClassroom ? allowedExperimentRoomIds.Select(classroomId => new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList() : []; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPut("constraints/batch")] public async Task SaveConstraintsBatch( TeachingTaskScheduleConstraintBatchRequest request, CancellationToken cancellationToken) { var taskIds = request.TeachingTaskIds.Distinct().ToArray(); if (taskIds.Length == 0) return ValidationProblem("请至少选择一个教学任务。"); if (request.AllowedDayOfWeeks?.Any(day => day is < 1 or > 7) == true) return ValidationProblem("允许上课日必须位于星期一至星期日。"); if (request.EarliestPeriod.HasValue && request.LatestPeriod.HasValue && request.EarliestPeriod > request.LatestPeriod) return ValidationProblem("最早节次不能晚于最晚节次。"); if (request.SchedulingMode.HasValue && !Enum.IsDefined(request.SchedulingMode.Value)) return ValidationProblem("授课方式无效。"); if (!request.SchedulingMode.HasValue && !request.RequiresClassroom.HasValue && request.AllowedDayOfWeeks is null && !request.UpdateClassroomScope && !request.UpdateExperimentClassroomScope && !request.AllowedExperimentVenueNatures.HasValue && !request.UpdatePeriodRange && !request.EarliestPeriod.HasValue && !request.LatestPeriod.HasValue) return ValidationProblem("请至少选择一项需要批量修改的设置。"); if (request.UpdateClassroomScope && request.RequiresClassroom == false) return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false) return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。"); var tasks = await db.TeachingTasks .Where(x => x.AcademicTermId == request.AcademicTermId && x.Status == TeachingTaskStatus.Published) .WhereIn(taskIds, x => x.Id) .Include(x => x.Course) .ToListAsync(cancellationToken); if (tasks.Count != taskIds.Length) return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && await HasScheduleEntriesAsync(taskIds, cancellationToken)) return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。"); if (request.UpdateClassroomScope && tasks.Any(task => (request.SchedulingMode ?? task.SchedulingMode) == TeachingTaskSchedulingMode.Flexible)) return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。"); if (request.UpdateExperimentClassroomScope && tasks.Any(task => (request.SchedulingMode ?? task.SchedulingMode) == TeachingTaskSchedulingMode.Flexible)) return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。"); Building? building = null; List allowedRooms = []; var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? []; Building? experimentBuilding = null; List allowedExperimentRooms = []; if (request.UpdateClassroomScope) { if (request.RequiredBuildingId.HasValue) { building = await db.Buildings.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == request.RequiredBuildingId && x.IsEnabled, cancellationToken); if (building is null) return ValidationProblem("指定教学楼不存在或已停用。"); if (request.RequiredCampusId.HasValue && building.CampusId != request.RequiredCampusId) return ValidationProblem("指定教学楼不属于所选校区。"); } if (request.RequiredCampusId.HasValue && !await db.Campuses.AnyAsync( x => x.Id == request.RequiredCampusId && x.IsEnabled, cancellationToken)) return ValidationProblem("指定校区不存在或已停用。"); var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? []; allowedRooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .WhereIn(roomIds, x => x.Id) .Include(x => x.Building) .ToListAsync(cancellationToken); if (allowedRooms.Count != roomIds.Length) return ValidationProblem("部分指定教室不存在或已停用。"); if (building is not null && allowedRooms.Any(x => x.BuildingId != building.Id)) return ValidationProblem("指定教室必须位于所选教学楼。"); if (request.RequiredCampusId.HasValue && allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId)) return ValidationProblem("指定教室必须位于所选校区。"); } if (request.UpdateExperimentClassroomScope) { if (request.ExperimentRequiredBuildingId.HasValue) { experimentBuilding = await db.Buildings.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled, cancellationToken); if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。"); if (request.ExperimentRequiredCampusId.HasValue && experimentBuilding.CampusId != request.ExperimentRequiredCampusId) return ValidationProblem("指定实验教学楼不属于所选实验校区。"); } if (request.ExperimentRequiredCampusId.HasValue && !await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled, cancellationToken)) return ValidationProblem("指定实验校区不存在或已停用。"); allowedExperimentRooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .WhereIn(experimentRoomIds, x => x.Id) .Include(x => x.Building) .ToListAsync(cancellationToken); if (allowedExperimentRooms.Count != experimentRoomIds.Length) return ValidationProblem("部分指定实验场地不存在或已停用。"); if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id)) return ValidationProblem("指定实验场地必须位于所选实验教学楼。"); if (request.ExperimentRequiredCampusId.HasValue && allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId)) return ValidationProblem("指定实验场地必须位于所选实验校区。"); } var constraints = await db.TeachingTaskScheduleConstraints .WhereIn(taskIds, x => x.TeachingTaskId) .Include(x => x.AllowedClassrooms) .Include(x => x.AllowedExperimentClassrooms) .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); foreach (var task in tasks) { if (request.SchedulingMode.HasValue) task.SchedulingMode = request.SchedulingMode.Value; if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) { if (constraints.TryGetValue(task.Id, out var flexibleConstraint)) ClearConstraint(flexibleConstraint); continue; } if (!constraints.TryGetValue(task.Id, out var constraint)) { var changesConstraint = request.RequiresClassroom.HasValue || request.AllowedDayOfWeeks is not null || request.UpdateClassroomScope || request.UpdateExperimentClassroomScope || request.AllowedExperimentVenueNatures.HasValue || request.UpdatePeriodRange; if (!changesConstraint) continue; constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id }; constraints[task.Id] = constraint; db.TeachingTaskScheduleConstraints.Add(constraint); } if (request.RequiresClassroom.HasValue) { constraint.RequiresClassroom = request.RequiresClassroom.Value; if (!request.RequiresClassroom.Value) { constraint.RequiredCampusId = null; constraint.RequiredBuildingId = null; db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedExperimentClassrooms.RemoveRange( constraint.AllowedExperimentClassrooms); constraint.AllowedClassrooms = []; constraint.AllowedExperimentClassrooms = []; } } if (request.AllowedDayOfWeeks is not null) { constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 ? null : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); } if (request.UpdateClassroomScope) { constraint.RequiresClassroom = true; constraint.RequiredCampusId = request.RequiredCampusId; constraint.RequiredBuildingId = request.RequiredBuildingId; db.TeachingTaskAllowedClassrooms.RemoveRange( constraint.AllowedClassrooms); constraint.AllowedClassrooms = allowedRooms.Select(room => new TeachingTaskAllowedClassroom { ClassroomId = room.Id }).ToList(); } if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue) constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value; if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope) { constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId; constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId; db.TeachingTaskAllowedExperimentClassrooms.RemoveRange( constraint.AllowedExperimentClassrooms); constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room => new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList(); } if (request.UpdatePeriodRange) { constraint.EarliestPeriod = request.EarliestPeriod; constraint.LatestPeriod = request.LatestPeriod; } } await db.SaveChangesAsync(cancellationToken); return Ok(new { AffectedCount = tasks.Count }); } private Task HasScheduleEntriesAsync( IReadOnlyCollection taskIds, CancellationToken cancellationToken) => db.ScheduleEntries.AsNoTracking() .WhereIn(taskIds, x => x.TeachingTaskId) .AnyAsync(cancellationToken); private void ClearConstraint(TeachingTaskScheduleConstraint constraint) { constraint.RequiresClassroom = false; constraint.RequiredCampusId = null; constraint.RequiredBuildingId = null; constraint.ExperimentRequiredCampusId = null; constraint.ExperimentRequiredBuildingId = null; constraint.AllowedDayOfWeeks = null; constraint.EarliestPeriod = null; constraint.LatestPeriod = null; db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms); constraint.AllowedClassrooms = []; constraint.AllowedExperimentClassrooms = []; } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "操作冲突", Detail = detail, Status = StatusCodes.Status409Conflict }); private static int[] ParseDays(string? value) => string.IsNullOrWhiteSpace(value) ? [] : value.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(int.Parse) .ToArray(); } public sealed record ScheduleTimeSlotRequest( [Range(1, 30)] int PeriodNumber, [Required, MaxLength(40)] string Name, TimeOnly StartsAt, TimeOnly EndsAt, bool IsEnabled); public sealed record TeachingTaskScheduleConstraintRequest( TeachingTaskSchedulingMode SchedulingMode, bool RequiresClassroom, Guid? RequiredCampusId, Guid? RequiredBuildingId, IReadOnlyList AllowedClassroomIds, IReadOnlyList AllowedDayOfWeeks, [Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? LatestPeriod, TeachingVenueNature AllowedExperimentVenueNatures = 0, IReadOnlyList? AllowedExperimentClassroomIds = null, Guid? ExperimentRequiredCampusId = null, Guid? ExperimentRequiredBuildingId = null); public sealed record TeachingTaskScheduleConstraintBatchRequest( Guid AcademicTermId, [MinLength(1), MaxLength(500)] IReadOnlyCollection TeachingTaskIds, TeachingTaskSchedulingMode? SchedulingMode, bool? RequiresClassroom, IReadOnlyList? AllowedDayOfWeeks, bool UpdateClassroomScope, Guid? RequiredCampusId, Guid? RequiredBuildingId, IReadOnlyList? AllowedClassroomIds, bool UpdatePeriodRange, [Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? LatestPeriod, bool UpdateExperimentClassroomScope = false, TeachingVenueNature? AllowedExperimentVenueNatures = null, IReadOnlyList? AllowedExperimentClassroomIds = null, Guid? ExperimentRequiredCampusId = null, Guid? ExperimentRequiredBuildingId = null);