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.Files; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.OfficialDocuments; 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, OfficialDocumentOptions officialDocumentOptions) : 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 = DownloadFileName.Export("课表", ".xlsx", result.Result.Subject.Name, result.Result.Term.Name); return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } [HttpPost("export/batch.xlsx")] public async Task ExportBatch( TimetableBatchExportRequest request, CancellationToken cancellationToken) { var resourceIds = request.ResourceIds.Distinct().ToArray(); if (resourceIds.Length is 0 or > 100) return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest); var timetables = new List(resourceIds.Length); foreach (var resourceId in resourceIds) { var result = await LoadAuthorizedAsync( request.ResourceType, resourceId, request.AcademicTermId, request.SchedulePlanId, cancellationToken); if (result.Result is null) return result.Error!; timetables.Add(result.Result); } var bytes = TimetableExcelExporter.Create(timetables); var fileName = DownloadFileName.Export("课表批量导出", ".xlsx", timetables[0].Term.Name); return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } [HttpGet("export.pdf")] public async Task ExportPdf( 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!; return PdfFile(result.Result); } [HttpPost("export/batch.pdf")] public async Task ExportBatchPdf( TimetableBatchExportRequest request, CancellationToken cancellationToken) { var resourceIds = request.ResourceIds.Distinct().ToArray(); if (resourceIds.Length is 0 or > 100) return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest); var timetables = new List(resourceIds.Length); foreach (var resourceId in resourceIds) { var result = await LoadAuthorizedAsync( request.ResourceType, resourceId, request.AcademicTermId, request.SchedulePlanId, cancellationToken); if (result.Result is null) return result.Error!; timetables.Add(result.Result); } var fileName = DownloadFileName.Export("课表批量导出", ".pdf", timetables[0].Term.Name); return File(TimetablePdfExporter.Create(timetables, officialDocumentOptions.FontPath), "application/pdf", fileName); } [HttpPost("display-links/export.xlsx")] public async Task ExportDisplayLinks(VenueDisplayLinkExportRequest request, CancellationToken cancellationToken) { var classroomIds = request.ClassroomIds.Distinct().Take(200).ToArray(); var buildingIds = request.BuildingIds.Distinct().Take(100).ToArray(); if (classroomIds.Length + buildingIds.Length == 0) return ValidationProblem("请至少选择一个教室或教学楼。", statusCode: StatusCodes.Status400BadRequest); var classrooms = await db.Classrooms.AsNoTracking().WhereIn(classroomIds, x => x.Id) .OrderBy(x => x.Building!.Name).ThenBy(x => x.Code) .Select(x => new object?[] { "教室当天课程", x.Building!.Name + " · " + x.Name, "/venue-display/classroom/" + x.Id }) .ToListAsync(cancellationToken); var buildings = await db.Buildings.AsNoTracking().WhereIn(buildingIds, x => x.Id).OrderBy(x => x.Name) .Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id }) .ToListAsync(cancellationToken); var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList()); return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Export("场地信息展牌链接", ".xlsx")); } 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(); } private ActionResult PdfFile(TimetableData timetable) => File( TimetablePdfExporter.Create(timetable, officialDocumentOptions.FontPath), "application/pdf", DownloadFileName.Export("课表", ".pdf", timetable.Subject.Name, timetable.Term.Name)); } public sealed record TimetableBatchExportRequest( TimetableResourceType ResourceType, [param: Required, MinLength(1), MaxLength(100)] IReadOnlyCollection ResourceIds, Guid AcademicTermId, Guid? SchedulePlanId); public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection ClassroomIds, IReadOnlyCollection BuildingIds); [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);