using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ManagementRoles)] [Route("api/timetables/management")] public sealed class TimetableManagementController( AppDbContext db, TimetableDataService timetableDataService, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Leader; [HttpGet("options")] public async Task GetOptions( Guid academicTermId, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; var collegeId = scope.RestrictedCollegeId; var classes = db.AdministrativeClasses.AsNoTracking() .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled); var teachers = db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active && x.College!.IsEnabled); var colleges = db.Colleges.AsNoTracking().Where(x => x.IsEnabled); var majors = db.Majors.AsNoTracking().Where(x => x.IsEnabled); if (collegeId.HasValue) { classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); teachers = teachers.Where(x => x.CollegeId == collegeId.Value); colleges = colleges.Where(x => x.Id == collegeId.Value); majors = majors.Where(x => x.CollegeId == collegeId.Value); } var plans = await db.SchedulePlans.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.Status != SchedulePlanStatus.Archived) .OrderByDescending(x => x.Status == SchedulePlanStatus.Published) .ThenByDescending(x => x.UpdatedAt) .Select(x => new { x.Id, x.Name, x.Version, x.Status, x.PublishedAt, x.UpdatedAt, EntryCount = x.Entries.Count }) .ToListAsync(cancellationToken); var classOptions = await classes .OrderByDescending(x => x.Grade) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId, MajorName = x.Major!.Name, CollegeId = x.Major.CollegeId, CollegeName = x.Major.College!.Name }) .ToListAsync(cancellationToken); var teacherOptions = await teachers .OrderBy(x => x.TeacherNumber) .Select(x => new { x.Id, x.TeacherNumber, x.Name, x.Title, x.CollegeId, CollegeName = x.College!.Name }) .ToListAsync(cancellationToken); var campusOptions = await db.Campuses.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.SortOrder) .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(cancellationToken); var buildingOptions = await db.Buildings.AsNoTracking() .Where(x => x.IsEnabled && x.Campus!.IsEnabled) .OrderBy(x => x.SortOrder) .Select(x => new { x.Id, x.Code, x.Name, x.CampusId }) .ToListAsync(cancellationToken); var classroomOptions = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled && x.Building!.IsEnabled && x.Building.Campus!.IsEnabled) .OrderBy(x => x.Building!.Code) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.Capacity, x.BuildingId, BuildingName = x.Building!.Name, CampusId = x.Building.CampusId, CampusName = x.Building.Campus!.Name }) .ToListAsync(cancellationToken); return Ok(new { Plans = plans, Colleges = await colleges.OrderBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(cancellationToken), Majors = await majors.OrderBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) .ToListAsync(cancellationToken), Classes = classOptions, Teachers = teacherOptions, Campuses = campusOptions, Buildings = buildingOptions, Classrooms = classroomOptions }); } [HttpGet("query")] public async Task Query( TimetableResourceType resourceType, Guid resourceId, Guid academicTermId, Guid? schedulePlanId, CancellationToken cancellationToken) { var result = await LoadAuthorizedAsync( resourceType, resourceId, academicTermId, schedulePlanId, cancellationToken); return result.Result is null ? result.Error! : Ok(result.Result); } [HttpGet("export.xlsx")] public async Task Export( TimetableResourceType resourceType, Guid resourceId, Guid academicTermId, Guid? schedulePlanId, CancellationToken cancellationToken) { var result = await LoadAuthorizedAsync( resourceType, resourceId, academicTermId, schedulePlanId, cancellationToken); if (result.Result is null) return result.Error!; var bytes = TimetableExcelExporter.Create(result.Result); var fileName = $"{FileName(result.Result.Subject.Name)}-{FileName(result.Result.Term.Name)}-课表.xlsx"; return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( TimetableResourceType resourceType, Guid resourceId, Guid academicTermId, Guid? schedulePlanId, CancellationToken cancellationToken) { if (!Enum.IsDefined(resourceType)) return (null, ValidationProblem("课表类型无效。")); var result = await timetableDataService.BuildAsync( resourceType, resourceId, academicTermId, schedulePlanId, true, null, null, cancellationToken); if (result is null || schedulePlanId.HasValue && result.Plan is null) return (null, NotFound()); var scope = currentUserDataScope.Current; if (resourceType is TimetableResourceType.Class or TimetableResourceType.Teacher && result.Subject.CollegeId.HasValue && !scope.CanAccessCollege(result.Subject.CollegeId.Value)) return (null, Forbid()); return (result, null); } private static string FileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '-'); return value.Trim(); } } [ApiController] [Route("api/timetables")] public sealed class FreeClassroomsController( AppDbContext db, ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase { [HttpGet("free-classrooms/options")] [Authorize(Roles = SystemRoles.Student)] public async Task GetOptions( Guid? academicTermId, CancellationToken cancellationToken) { var terms = await db.AcademicTerms.AsNoTracking() .Where(x => x.IsEnabled) .OrderByDescending(x => x.IsCurrent) .ThenBy(x => x.IsArchived) .ThenByDescending(x => x.StartDate) .Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate, x.IsCurrent, x.IsArchived, HasPublishedTimetable = db.SchedulePlans.Any(plan => plan.AcademicTermId == x.Id && plan.Status == SchedulePlanStatus.Published) }) .ToListAsync(cancellationToken); var selectedTermId = academicTermId ?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id ?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; var campuses = await db.Campuses.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(cancellationToken); var buildings = await db.Buildings.AsNoTracking() .Where(x => x.IsEnabled && x.Campus!.IsEnabled) .OrderBy(x => x.Campus!.SortOrder) .ThenBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CampusId }) .ToListAsync(cancellationToken); var timeSlots = selectedTermId.HasValue ? await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == selectedTermId.Value && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .Select(x => new FreeClassroomTimeSlotDto( x.PeriodNumber, x.Name, x.StartsAt.ToString("HH:mm"), x.EndsAt.ToString("HH:mm"))) .ToListAsync(cancellationToken) : []; if (timeSlots.Count == 0) { timeSlots = Enumerable.Range(1, 12) .Select(period => new FreeClassroomTimeSlotDto( period, $"第 {period} 节", "", "")) .ToList(); } return Ok(new { Terms = terms, SelectedTermId = selectedTermId, Campuses = campuses, Buildings = buildings, TimeSlots = timeSlots }); } [HttpGet("free-classrooms")] [Authorize(Roles = SystemRoles.Student)] public async Task Get( Guid academicTermId, [Range(1, 30)] int week, [Range(1, 7)] int dayOfWeek, [Range(1, 30)] int startPeriod, [Range(1, 6)] int periodCount = 1, Guid? campusId = null, Guid? buildingId = null, [Range(0, 10000)] int? minimumCapacity = null, CancellationToken cancellationToken = default) { var term = await db.AcademicTerms.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == academicTermId && x.IsEnabled, cancellationToken); if (term is null) return NotFound(); var plan = await db.SchedulePlans.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.Status == SchedulePlanStatus.Published) .Select(x => new { x.Id, x.Version }) .FirstOrDefaultAsync(cancellationToken); if (plan is null) return Conflict(new ProblemDetails { Title = "课表尚未发布", Detail = "所选学期尚未发布正式课表,暂时无法准确查询空闲教室。", Status = StatusCodes.Status409Conflict }); var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray(); var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking() .CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken); var activePeriodCount = configuredPeriodCount == 0 ? requestedPeriods.Length : await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) .WhereIn(requestedPeriods, x => x.PeriodNumber) .CountAsync(cancellationToken); if (activePeriodCount != requestedPeriods.Length) return ValidationProblem("查询范围包含不存在或未启用的节次。"); var reservationDate = ResolveReservationDate(term, week, dayOfWeek); if (reservationDate < term.StartDate || reservationDate > term.EndDate) return ValidationProblem("所选周次和星期不在学期日期范围内。"); var occupiedIds = await reservationAvailability.GetOccupiedClassroomIdsAsync( term, reservationDate, startPeriod, periodCount, null, cancellationToken); var rooms = db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled && x.Building!.IsEnabled && x.Building.Campus!.IsEnabled) .WhereNotIn(occupiedIds, x => x.Id); if (campusId.HasValue) rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value); if (buildingId.HasValue) rooms = rooms.Where(x => x.BuildingId == buildingId.Value); if (minimumCapacity.HasValue) rooms = rooms.Where(x => x.Capacity >= minimumCapacity.Value); var result = await rooms .OrderBy(x => x.Building!.Campus!.SortOrder) .ThenBy(x => x.Building!.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.Capacity, x.BuildingId, BuildingName = x.Building!.Name, CampusId = x.Building.CampusId, CampusName = x.Building.Campus!.Name }) .ToListAsync(cancellationToken); return Ok(new { Term = term, Plan = plan, Week = week, DayOfWeek = dayOfWeek, StartPeriod = startPeriod, PeriodCount = periodCount, Items = result }); } private static DateOnly ResolveReservationDate( AcademicTerm term, int week, int dayOfWeek) { var startDay = (int)term.StartDate.DayOfWeek; var daysSinceMonday = (startDay + 6) % 7; var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday); return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1); } } public sealed record FreeClassroomTimeSlotDto( int PeriodNumber, string Name, string StartTime, string EndTime);