From 44e90ae35f06b8973d65af532e4d5ca1f342ffef Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 19:16:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E8=80=83=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/MakeupExamsController.cs | 797 ++++++++++++++++++ .../Domain/Academic/GradeEntities.cs | 3 +- .../Domain/Academic/MakeupExamEntities.cs | 71 ++ .../Exams/MakeupExamArrangementService.cs | 217 +++++ .../Exams/MakeupExamEligibilityService.cs | 125 +++ .../Infrastructure/Grades/GradeCalculator.cs | 2 +- .../Persistence/AppDbContext.cs | 52 ++ .../Persistence/DevelopmentSqliteMigrator.cs | 24 + src/Jiaowu.Api/Program.cs | 2 + web/src/layouts/AdminLayout.vue | 7 + web/src/router/index.ts | 6 + web/src/views/MakeupExamsView.vue | 461 ++++++++++ 12 files changed, 1765 insertions(+), 2 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/MakeupExamsController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/MakeupExamEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs create mode 100644 web/src/views/MakeupExamsView.vue diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs new file mode 100644 index 0000000..e4f5b35 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -0,0 +1,797 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Grades; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/makeup-exams")] +public sealed class MakeupExamsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope, + MakeupExamEligibilityService eligibilityService, + MakeupExamArrangementService arrangementService) : ControllerBase +{ + private const string Managers = + SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; + + // ═══════════════════════════════════════════ + // Plans + // ═══════════════════════════════════════════ + + [HttpGet("plans")] + public async Task GetPlans( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var source = db.MakeupExamPlans.AsNoTracking().AsQueryable(); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + if (!IsManager()) + source = source.Where(x => x.Status == MakeupExamPlanStatus.Published); + return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenByDescending(x => x.CreatedAt) + .Select(x => new + { + x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, + x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt + }).ToListAsync(cancellationToken)); + } + + [HttpPost("plans")] + [Authorize(Roles = Managers)] + public async Task CreatePlan( + MakeupExamPlanRequest request, + CancellationToken cancellationToken) + { + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + var plan = new MakeupExamPlan + { + AcademicTermId = request.AcademicTermId, + Name = request.Name.Trim(), + Notes = Normalize(request.Notes) + }; + db.MakeupExamPlans.Add(plan); + return await SaveAsync(plan.Id, true, cancellationToken); + } + + [HttpGet("plans/{id:guid}")] + public async Task GetPlan(Guid id, CancellationToken cancellationToken) + { + var manager = IsManager(); + var plan = await db.MakeupExamPlans.AsNoTracking() + .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published)) + .Select(x => new + { + x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, + x.Status, x.Notes, x.PublishedAt, + Sessions = x.Sessions.OrderBy(item => item.ExamDate) + .ThenBy(item => item.StartPeriod).Select(item => new + { + item.Id, + item.TeachingTaskId, + item.TeachingTask!.TaskNumber, + TaskName = item.TeachingTask.Name, + CourseCode = item.TeachingTask.Course!.Code, + CourseName = item.TeachingTask.Course.Name, + item.ClassroomId, + ClassroomName = item.Classroom != null ? item.Classroom.Name : null, + BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null, + ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null, + item.ExamDate, + item.StartPeriod, + item.PeriodCount, + item.StartsAt, + item.EndsAt, + item.RequiredBuildingId, + RequiredBuildingName = item.RequiredBuilding != null + ? item.RequiredBuilding.Name : null, + item.RequiredInvigilatorCount, + item.Notes, + InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), + InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), + EnrolledCount = item.Enrollments.Count, + Enrollments = item.Enrollments.Select(e => new + { + e.StudentId, + e.Student!.StudentNumber, + e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name, + e.Reason, + e.SourceGradeRecordId, + e.SourceDeferredExamId, + e.MakeupScore + }) + }) + }).FirstOrDefaultAsync(cancellationToken); + return plan is null ? NotFound() : Ok(plan); + } + + [HttpPost("plans/{id:guid}/publish")] + [Authorize(Roles = Managers)] + public async Task Publish(Guid id, CancellationToken cancellationToken) + { + var plan = await db.MakeupExamPlans + .Include(x => x.Sessions) + .ThenInclude(x => x.Invigilators) + .Include(x => x.Sessions) + .ThenInclude(x => x.Enrollments) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("只有草稿补考计划可以发布。"); + if (plan.Sessions.Count == 0) + return ConflictProblem("至少安排一个考试场次后才能发布。"); + + var unassigned = plan.Sessions.Count(x => + !x.ClassroomId.HasValue || x.Invigilators.Count == 0); + if (unassigned > 0) + return ConflictProblem( + $"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); + + var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0); + if (empty > 0) + return ConflictProblem( + $"还有 {empty} 个场次没有登记补考学生。"); + + plan.Status = MakeupExamPlanStatus.Published; + plan.PublishedAt = DateTime.UtcNow; + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("plans/{id:guid}/archive")] + [Authorize(Roles = Managers)] + public async Task Archive(Guid id, CancellationToken cancellationToken) + { + var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != MakeupExamPlanStatus.Published) + return ConflictProblem("只有已发布的补考计划可以归档。"); + plan.Status = MakeupExamPlanStatus.Archived; + return await SaveAsync(id, false, cancellationToken); + } + + // ═══════════════════════════════════════════ + // Sessions + // ═══════════════════════════════════════════ + + [HttpPost("plans/{planId:guid}/sessions")] + [Authorize(Roles = Managers)] + public async Task CreateSession( + Guid planId, + CreateMakeupExamSessionRequest request, + CancellationToken cancellationToken) + { + var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整场次。"); + + var timeResult = ResolveExamTime(plan.AcademicTermId, + request.ExamDate, request.StartPeriod, request.PeriodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var validation = await ValidateSessionAsync(plan, null, + request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, + startsAt, endsAt, cancellationToken); + if (validation is not null) return validation; + + var session = new MakeupExamSession + { + MakeupExamPlanId = planId, + TeachingTaskId = request.TeachingTaskId, + ClassroomId = request.ClassroomId, + ExamDate = request.ExamDate, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + StartsAt = startsAt, + EndsAt = endsAt, + RequiredBuildingId = request.RequiredBuildingId, + RequiredInvigilatorCount = request.RequiredInvigilatorCount, + Notes = Normalize(request.Notes), + Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => + new MakeupExamSessionInvigilator { TeacherId = id }).ToList() + }; + db.MakeupExamSessions.Add(session); + return await SaveAsync(session.Id, true, cancellationToken); + } + + [HttpPut("plans/{planId:guid}/sessions/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task UpdateSession( + Guid planId, + Guid id, + CreateMakeupExamSessionRequest request, + CancellationToken cancellationToken) + { + var session = await db.MakeupExamSessions + .Include(x => x.MakeupExamPlan) + .Include(x => x.Invigilators) + .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); + if (session is null) return NotFound(); + if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整场次。"); + + var timeResult = ResolveExamTime(session.MakeupExamPlan.AcademicTermId, + request.ExamDate, request.StartPeriod, request.PeriodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var validation = await ValidateSessionAsync(session.MakeupExamPlan, id, + request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, + startsAt, endsAt, cancellationToken); + if (validation is not null) return validation; + + session.TeachingTaskId = request.TeachingTaskId; + session.ClassroomId = request.ClassroomId; + session.ExamDate = request.ExamDate; + session.StartPeriod = request.StartPeriod; + session.PeriodCount = request.PeriodCount; + session.StartsAt = startsAt; + session.EndsAt = endsAt; + session.RequiredBuildingId = request.RequiredBuildingId; + session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; + session.Notes = Normalize(request.Notes); + + db.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators); + session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => + new MakeupExamSessionInvigilator { TeacherId = tid }).ToList(); + + return await SaveAsync(id, false, cancellationToken); + } + + [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task DeleteSession( + Guid planId, + Guid id, + CancellationToken cancellationToken) + { + var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan) + .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); + if (session is null) return NotFound(); + if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整场次。"); + db.MakeupExamSessions.Remove(session); + return await SaveAsync(id, false, cancellationToken); + } + + // ═══════════════════════════════════════════ + // Auto-arrange + // ═══════════════════════════════════════════ + + [HttpPost("plans/{planId:guid}/auto-arrange")] + [Authorize(Roles = Managers)] + public async Task AutoArrange( + Guid planId, + CancellationToken cancellationToken) + { + var result = await arrangementService.ArrangeAsync(planId, cancellationToken); + if (!result.Success) + return ConflictProblem(result.Message); + return Ok(new { message = result.Message }); + } + + // ═══════════════════════════════════════════ + // Enrollments + // ═══════════════════════════════════════════ + + [HttpGet("eligible-students")] + [Authorize(Roles = Managers)] + public async Task GetEligibleStudents( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var students = await eligibilityService.GetEligibleStudentsAsync( + teachingTaskId, cancellationToken); + return Ok(students); + } + + [HttpPost("sessions/{id:guid}/enroll")] + [Authorize(Roles = Managers)] + public async Task EnrollStudents( + Guid id, + EnrollStudentsRequest request, + CancellationToken cancellationToken) + { + var session = await db.MakeupExamSessions + .Include(x => x.MakeupExamPlan) + .Include(x => x.Enrollments) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (session is null) return NotFound(); + if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整登记。"); + + var eligible = await eligibilityService.GetEligibleStudentsAsync( + session.TeachingTaskId, cancellationToken); + var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet(); + + var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet(); + var validIds = request.StudentIds + .Where(sid => !alreadyEnrolled.Contains(sid)) + .Distinct() + .ToList(); + + if (validIds.Count == 0) + return ConflictProblem("所选学生均已登记或无效。"); + + var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList(); + if (invalidIds.Count > 0) + return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。"); + + // Check for time conflicts with other makeup sessions + var conflictIds = await db.MakeupExamEnrollments.AsNoTracking() + .Where(x => validIds.Contains(x.StudentId) && + x.MakeupExamSession!.StartsAt < session.EndsAt && + session.StartsAt < x.MakeupExamSession.EndsAt) + .Select(x => x.StudentId) + .Distinct() + .ToListAsync(cancellationToken); + + if (conflictIds.Count > 0) + { + var conflictNumbers = await db.Students + .Where(s => conflictIds.Contains(s.Id)) + .Select(s => s.StudentNumber) + .ToListAsync(cancellationToken); + return ConflictProblem( + $"以下学生该时段已有其他补考:{string.Join("、", conflictNumbers)}"); + } + + foreach (var studentId in validIds) + { + var info = eligible.First(e => e.StudentId == studentId); + session.Enrollments.Add(new MakeupExamEnrollment + { + MakeupExamSessionId = id, + StudentId = studentId, + Reason = info.Reason, + SourceGradeRecordId = info.SourceGradeRecordId, + SourceDeferredExamId = info.SourceDeferredExamId + }); + } + + return await SaveAsync(id, false, cancellationToken); + } + + [HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")] + [Authorize(Roles = Managers)] + public async Task RemoveEnrollment( + Guid id, + Guid studentId, + CancellationToken cancellationToken) + { + var enrollment = await db.MakeupExamEnrollments + .Include(x => x.MakeupExamSession) + .ThenInclude(x => x!.MakeupExamPlan) + .FirstOrDefaultAsync(x => x.MakeupExamSessionId == id && + x.StudentId == studentId, cancellationToken); + if (enrollment is null) return NotFound(); + if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整登记。"); + db.MakeupExamEnrollments.Remove(enrollment); + return await SaveAsync(id, false, cancellationToken); + } + + // ═══════════════════════════════════════════ + // Scores + // ═══════════════════════════════════════════ + + [HttpPut("sessions/{id:guid}/scores")] + [Authorize(Roles = Managers)] + public async Task RecordScores( + Guid id, + List scores, + CancellationToken cancellationToken) + { + var session = await db.MakeupExamSessions + .Include(x => x.MakeupExamPlan) + .Include(x => x.Enrollments) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (session is null) return NotFound(); + if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published) + return ConflictProblem("只有已发布的补考计划可以录入成绩。"); + + var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId); + + foreach (var entry in scores) + { + if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment)) + continue; + + enrollment.MakeupScore = entry.Score; + + // Update the grade record + if (enrollment.SourceGradeRecordId.HasValue) + { + var record = await db.GradeRecords + .Include(x => x.GradeSheet) + .FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value, + cancellationToken); + if (record is not null) + { + record.ExamStatus = GradeExamStatus.Makeup; + record.TotalScore = entry.Score; + record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score); + record.Notes = "补考成绩"; + } + } + } + + return await SaveAsync(id, false, cancellationToken); + } + + // ═══════════════════════════════════════════ + // Roster + // ═══════════════════════════════════════════ + + [HttpGet("sessions/{id:guid}/roster")] + [Authorize(Roles = Managers)] + public async Task GetRoster(Guid id, CancellationToken cancellationToken) + { + var session = await db.MakeupExamSessions.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + CourseName = x.TeachingTask.Course!.Name, + ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配", + x.StartsAt, + Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber) + .Select(e => new + { + e.StudentId, + e.Student!.StudentNumber, + e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name, + e.Reason, + e.MakeupScore + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + return session is null ? NotFound() : Ok(session); + } + + // ═══════════════════════════════════════════ + // Query helpers + // ═══════════════════════════════════════════ + + [HttpGet("available-classrooms")] + [Authorize(Roles = Managers)] + public async Task GetAvailableClassrooms( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount, + Guid? requiredBuildingId, + Guid? planId, + Guid? excludeSessionId, + CancellationToken cancellationToken) + { + var timeResult = ResolveExamTime( + academicTermId, examDate, startPeriod, periodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var query = db.Classrooms.AsNoTracking() + .Where(x => x.IsEnabled); + + if (requiredBuildingId.HasValue) + query = query.Where(x => x.BuildingId == requiredBuildingId.Value); + + var occupiedQuery = db.MakeupExamSessions.AsNoTracking() + .Where(x => x.ClassroomId != null && + x.StartsAt < endsAt && startsAt < x.EndsAt); + if (planId.HasValue) + occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value); + + var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value) + .ToListAsync(cancellationToken); + + if (occupiedIds.Count > 0) + query = query.Where(x => !occupiedIds.Contains(x.Id)); + + return Ok(await query.OrderBy(x => x.Building!.Name) + .ThenBy(x => x.Capacity) + .Select(x => new + { + x.Id, + x.Name, + x.Capacity, + x.RoomType, + x.BuildingId, + BuildingName = x.Building!.Name + }).ToListAsync(cancellationToken)); + } + + [HttpGet("available-teachers")] + [Authorize(Roles = Managers)] + public async Task GetAvailableTeachers( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount, + Guid? planId, + Guid? excludeSessionId, + CancellationToken cancellationToken) + { + var timeResult = ResolveExamTime( + academicTermId, examDate, startPeriod, periodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var busyQuery = db.MakeupExamSessionInvigilators.AsNoTracking() + .Where(x => x.MakeupExamSession!.StartsAt < endsAt && + startsAt < x.MakeupExamSession!.EndsAt); + if (planId.HasValue) + busyQuery = busyQuery.Where(x => + x.MakeupExamSession!.MakeupExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != excludeSessionId.Value); + + var busyIds = await busyQuery.Select(x => x.TeacherId) + .ToListAsync(cancellationToken); + + return Ok(await db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && + !busyIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .Select(x => new + { + x.Id, + x.TeacherNumber, + x.Name, + x.Title, + CollegeName = x.College!.Name + }).ToListAsync(cancellationToken)); + } + + [HttpGet("time-slots-for-term")] + [Authorize(Roles = Managers)] + public async Task GetTimeSlotsForTerm( + Guid academicTermId, + CancellationToken cancellationToken) => + Ok(await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) + .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)); + + // ═══════════════════════════════════════════ + // My schedule + // ═══════════════════════════════════════════ + + [HttpGet("my-schedule")] + public async Task GetMySchedule(CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + if (scope.IsInRole(SystemRoles.Student)) + { + var studentId = await db.Students.Where(x => x.UserId == scope.UserId) + .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + return Ok(await db.MakeupExamEnrollments.AsNoTracking() + .Where(x => x.StudentId == studentId && + x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published) + .OrderBy(x => x.MakeupExamSession!.ExamDate) + .ThenBy(x => x.MakeupExamSession!.StartPeriod) + .Select(x => new + { + x.MakeupExamSession!.Id, + PlanName = x.MakeupExamSession.MakeupExamPlan!.Name, + x.MakeupExamSession.ExamDate, + x.MakeupExamSession.StartPeriod, + x.MakeupExamSession.PeriodCount, + x.MakeupExamSession.StartsAt, + x.MakeupExamSession.EndsAt, + x.MakeupExamSession.TeachingTask!.TaskNumber, + CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code, + CourseName = x.MakeupExamSession.TeachingTask.Course.Name, + ClassroomName = x.MakeupExamSession.Classroom != null + ? x.MakeupExamSession.Classroom.Name : null, + BuildingName = x.MakeupExamSession.Classroom != null + ? x.MakeupExamSession.Classroom.Building!.Name : null, + x.Reason, + x.MakeupScore, + IsMakeup = true + }) + .ToListAsync(cancellationToken)); + } + if (scope.IsInRole(SystemRoles.Teacher)) + { + return Ok(await db.MakeupExamSessions.AsNoTracking() + .Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published && + x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) + .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) + .Select(x => new + { + x.Id, + PlanName = x.MakeupExamPlan!.Name, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TeachingTask!.TaskNumber, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + ClassroomName = x.Classroom != null ? x.Classroom.Name : null, + BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, + InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name), + EnrolledCount = x.Enrollments.Count, + IsMakeup = true + }) + .ToListAsync(cancellationToken)); + } + return Ok(Array.Empty()); + } + + // ═══════════════════════════════════════════ + // Private helpers + // ═══════════════════════════════════════════ + + private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount) + { + var slots = db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .ToList(); + + if (slots.Count == 0) + return (default, default, + ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。")); + + var slotDict = slots.ToDictionary(x => x.PeriodNumber); + var startSlot = slotDict.GetValueOrDefault(startPeriod); + var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1); + + if (startSlot is null) + return (default, default, + ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。")); + if (endSlot is null) + return (default, default, + ValidationProblem( + $"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。")); + + return ( + examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc), + examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc), + null + ); + } + + private async Task ValidateSessionAsync( + MakeupExamPlan plan, + Guid? currentId, + Guid teachingTaskId, + Guid? classroomId, + IReadOnlyCollection? invigilatorIds, + DateTime startsAt, + DateTime endsAt, + CancellationToken cancellationToken) + { + if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。"); + var task = await db.TeachingTasks.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); + if (task is null || task.AcademicTermId != plan.AcademicTermId) + return ValidationProblem("教学班与补考计划必须属于同一学期。"); + + if (classroomId.HasValue) + { + var room = await db.Classrooms.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken); + if (room is null) return ValidationProblem("所选考场不存在或已停用。"); + } + + var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); + if (teacherIds.Length > 0) + { + if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && + x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) + return ValidationProblem("存在无效监考教师。"); + } + + var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id && + x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt); + + if (classroomId.HasValue) + { + if (await overlaps.AnyAsync( + x => x.ClassroomId == classroomId.Value, cancellationToken)) + return ConflictProblem("该时段考场已被占用。"); + } + + if (teacherIds.Length > 0) + { + if (await overlaps.AnyAsync(x => + x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), + cancellationToken)) + return ConflictProblem("监考教师在该时段已有补考任务。"); + } + + return null; + } + + private bool IsManager() => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); + + private async Task SaveAsync(Guid id, bool created, + CancellationToken token) + { + try + { + await db.SaveChangesAsync(token); + 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 static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +// ═══════════════════════════════════════════ +// Request/Response models +// ═══════════════════════════════════════════ + +public sealed record MakeupExamPlanRequest( + Guid AcademicTermId, + [Required, MaxLength(120)] string Name, + [MaxLength(500)] string? Notes); + +public sealed record CreateMakeupExamSessionRequest( + Guid TeachingTaskId, + Guid? ClassroomId, + DateOnly ExamDate, + [Range(1, 30)] int StartPeriod, + [Range(1, 6)] int PeriodCount, + Guid? RequiredBuildingId, + [Range(1, 10)] int RequiredInvigilatorCount, + IReadOnlyCollection? InvigilatorIds, + [MaxLength(500)] string? Notes); + +public sealed record EnrollStudentsRequest( + [Required] IReadOnlyCollection StudentIds); + +public sealed record RecordMakeupScoreRequest( + Guid StudentId, + [Range(0, 100)] decimal Score); diff --git a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs index 9cbfa38..c2ab028 100644 --- a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs @@ -65,5 +65,6 @@ public enum GradeExamStatus Normal = 1, Absent = 2, Deferred = 3, - Exempt = 4 + Exempt = 4, + Makeup = 5 } diff --git a/src/Jiaowu.Api/Domain/Academic/MakeupExamEntities.cs b/src/Jiaowu.Api/Domain/Academic/MakeupExamEntities.cs new file mode 100644 index 0000000..19e03b5 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/MakeupExamEntities.cs @@ -0,0 +1,71 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class MakeupExamPlan : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public required string Name { get; set; } + public MakeupExamPlanStatus Status { get; set; } = MakeupExamPlanStatus.Draft; + public string? Notes { get; set; } + public DateTime? PublishedAt { get; set; } + public ICollection Sessions { get; set; } = []; +} + +public sealed class MakeupExamSession : EntityBase +{ + public Guid MakeupExamPlanId { get; set; } + public MakeupExamPlan? MakeupExamPlan { get; set; } + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public Guid? ClassroomId { get; set; } + public Classroom? Classroom { get; set; } + public DateOnly ExamDate { get; set; } + public int StartPeriod { get; set; } + public int PeriodCount { get; set; } = 2; + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public Guid? RequiredBuildingId { get; set; } + public Building? RequiredBuilding { get; set; } + public int RequiredInvigilatorCount { get; set; } = 2; + public string? Notes { get; set; } + public ICollection Invigilators { get; set; } = []; + public ICollection Enrollments { get; set; } = []; +} + +public sealed class MakeupExamSessionInvigilator +{ + public Guid MakeupExamSessionId { get; set; } + public MakeupExamSession? MakeupExamSession { get; set; } + public Guid TeacherId { get; set; } + public Teacher? Teacher { get; set; } +} + +public sealed class MakeupExamEnrollment +{ + public Guid MakeupExamSessionId { get; set; } + public MakeupExamSession? MakeupExamSession { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public MakeupReason Reason { get; set; } + public Guid? SourceGradeRecordId { get; set; } + public GradeRecord? SourceGradeRecord { get; set; } + public Guid? SourceDeferredExamId { get; set; } + public DeferredExam? SourceDeferredExam { get; set; } + public decimal? MakeupScore { get; set; } +} + +public enum MakeupExamPlanStatus +{ + Draft = 1, + Published = 2, + Archived = 3 +} + +public enum MakeupReason +{ + Failed = 1, + Absent = 2, + DeferredApproved = 3 +} diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs new file mode 100644 index 0000000..1efbca4 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs @@ -0,0 +1,217 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Exams; + +public sealed class MakeupExamArrangementService(AppDbContext db) +{ + private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt); + private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt); + + public async Task ArrangeAsync( + Guid planId, + CancellationToken cancellationToken) + { + var plan = await db.MakeupExamPlans + .Include(x => x.AcademicTerm) + .Include(x => x.Sessions) + .ThenInclude(x => x.Invigilators) + .Include(x => x.Sessions) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); + + if (plan is null) + return ExamArrangementResult.Fail("补考计划不存在。"); + if (plan.Status != MakeupExamPlanStatus.Draft) + return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。"); + + var sessions = plan.Sessions.ToList(); + if (sessions.Count == 0) + return ExamArrangementResult.Fail("补考计划中没有场次。"); + + var termId = plan.AcademicTermId; + var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == termId && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .ToListAsync(cancellationToken); + + if (timeSlots.Count == 0) + return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); + + var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); + + int assignedRooms = 0; + int assignedInvigilators = 0; + int skippedRooms = 0; + int skippedInvigilators = 0; + var messages = new List(); + + var occupiedRooms = sessions + .Where(x => x.ClassroomId.HasValue) + .Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt)) + .ToList(); + + var occupiedInvigilators = sessions + .SelectMany(x => x.Invigilators.Select(i => + new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt))) + .ToList(); + + foreach (var session in sessions) + { + ComputeTimesFromSlots(session, timeSlotLookup); + + var enrolledCount = await db.MakeupExamEnrollments + .CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken); + + // Auto-assign classroom + if (!session.ClassroomId.HasValue) + { + var room = await FindBestClassroomAsync( + session, enrolledCount, occupiedRooms, cancellationToken); + if (room is not null) + { + session.ClassroomId = room.Id; + occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt)); + assignedRooms++; + messages.Add( + $"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)"); + } + else + { + skippedRooms++; + messages.Add( + $"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)"); + } + } + else + { + skippedRooms++; + } + + // Auto-assign invigilators + var currentInvigilatorCount = session.Invigilators.Count; + var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; + if (needed > 0) + { + var courseTeacherIds = session.TeachingTask!.Teachers + .Select(x => x.TeacherId).ToHashSet(); + var newlyAssigned = await FindInvigilatorsAsync( + session, needed, courseTeacherIds, + occupiedInvigilators, cancellationToken); + foreach (var teacher in newlyAssigned) + { + session.Invigilators.Add(new MakeupExamSessionInvigilator + { + MakeupExamSessionId = session.Id, + TeacherId = teacher.Id + }); + occupiedInvigilators.Add(new InvigilatorOccupancy( + teacher.Id, session.StartsAt, session.EndsAt)); + assignedInvigilators++; + } + + if (newlyAssigned.Count < needed) + messages.Add( + $"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师"); + } + else + { + skippedInvigilators++; + } + } + + await db.SaveChangesAsync(cancellationToken); + + return new ExamArrangementResult( + true, + $"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" + + (skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") + + (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); + } + + private static void ComputeTimesFromSlots( + MakeupExamSession session, + Dictionary timeSlotLookup) + { + var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod); + var endSlot = timeSlotLookup.GetValueOrDefault( + session.StartPeriod + session.PeriodCount - 1); + if (startSlot is null || endSlot is null) return; + + var examDate = session.ExamDate; + session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); + session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); + } + + private async Task FindBestClassroomAsync( + MakeupExamSession session, + int enrolledCount, + List occupied, + CancellationToken cancellationToken) + { + var query = db.Classrooms.AsNoTracking() + .Where(x => x.IsEnabled && x.Capacity >= enrolledCount); + + if (session.RequiredBuildingId.HasValue) + query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value); + + var occupiedRoomIds = occupied + .Where(x => ExamConflictRules.TimeOverlaps( + x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt)) + .Select(x => x.ClassroomId) + .ToHashSet(); + + if (occupiedRoomIds.Count > 0) + query = query.Where(x => !occupiedRoomIds.Contains(x.Id)); + + var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking() + .Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId && + x.Id != session.Id && + x.ClassroomId != null && + x.StartsAt < session.EndsAt && + session.StartsAt < x.EndsAt) + .Select(x => x.ClassroomId!.Value) + .ToListAsync(cancellationToken); + + if (dbOccupiedRooms.Count > 0) + query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); + + return await query + .OrderBy(x => x.Capacity) + .FirstOrDefaultAsync(cancellationToken); + } + + private async Task> FindInvigilatorsAsync( + MakeupExamSession session, + int needed, + HashSet excludeTeacherIds, + List occupied, + CancellationToken cancellationToken) + { + var busyTeacherIds = occupied + .Where(x => ExamConflictRules.TimeOverlaps( + x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt)) + .Select(x => x.TeacherId) + .ToHashSet(); + + var dbBusyIds = await db.MakeupExamSessionInvigilators.AsNoTracking() + .Where(x => x.MakeupExamSession!.MakeupExamPlanId == session.MakeupExamPlanId && + x.MakeupExamSessionId != session.Id && + x.MakeupExamSession!.StartsAt < session.EndsAt && + session.StartsAt < x.MakeupExamSession.EndsAt) + .Select(x => x.TeacherId) + .ToListAsync(cancellationToken); + + foreach (var id in dbBusyIds) busyTeacherIds.Add(id); + foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); + + return await db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && + !busyTeacherIds.Contains(x.Id)) + .OrderBy(x => Guid.NewGuid()) + .Take(needed) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs new file mode 100644 index 0000000..71ce755 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs @@ -0,0 +1,125 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Exams; + +public sealed class MakeupExamEligibilityService(AppDbContext db) +{ + public async Task> GetEligibleStudentsAsync( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var gradeSheet = await db.GradeSheets.AsNoTracking() + .Where(x => x.TeachingTaskId == teachingTaskId) + .Select(x => new { x.Id, x.TeachingTask!.AcademicTermId }) + .FirstOrDefaultAsync(cancellationToken); + + if (gradeSheet is null) + return []; + + // Find students already enrolled in any makeup session for this teaching task + var alreadyEnrolledIds = await db.MakeupExamEnrollments.AsNoTracking() + .Where(x => x.MakeupExamSession!.TeachingTaskId == teachingTaskId && + x.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Archived) + .Select(x => x.StudentId) + .Distinct() + .ToListAsync(cancellationToken); + + var enrolledSet = alreadyEnrolledIds.ToHashSet(); + var results = new Dictionary(); + + // 1. Approved deferred exams (highest priority) + var deferredStudents = await db.DeferredExams.AsNoTracking() + .Where(x => x.TeachingTaskId == teachingTaskId && + x.Status == ApprovalStatus.Approved && + !enrolledSet.Contains(x.StudentId)) + .Select(x => new + { + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + x.Id + }) + .ToListAsync(cancellationToken); + + foreach (var s in deferredStudents) + { + if (!results.ContainsKey(s.StudentId)) + { + results[s.StudentId] = new EligibleStudent( + s.StudentId, s.StudentNumber, s.Name, s.ClassName, + MakeupReason.DeferredApproved, null, + SourceDeferredExamId: s.Id); + } + } + + // 2. Absent students + var absentStudents = await db.GradeRecords.AsNoTracking() + .Where(x => x.GradeSheetId == gradeSheet.Id && + x.ExamStatus == GradeExamStatus.Absent && + !enrolledSet.Contains(x.StudentId)) + .Select(x => new + { + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + GradeRecordId = (Guid?)x.Id + }) + .ToListAsync(cancellationToken); + + foreach (var s in absentStudents) + { + if (!results.ContainsKey(s.StudentId)) + { + results[s.StudentId] = new EligibleStudent( + s.StudentId, s.StudentNumber, s.Name, s.ClassName, + MakeupReason.Absent, null, + SourceGradeRecordId: s.GradeRecordId); + } + } + + // 3. Failed students (TotalScore < 60, Normal status) + var failedStudents = await db.GradeRecords.AsNoTracking() + .Where(x => x.GradeSheetId == gradeSheet.Id && + x.ExamStatus == GradeExamStatus.Normal && + x.TotalScore < 60 && + !enrolledSet.Contains(x.StudentId)) + .Select(x => new + { + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + x.TotalScore, + GradeRecordId = (Guid?)x.Id + }) + .ToListAsync(cancellationToken); + + foreach (var s in failedStudents) + { + if (!results.ContainsKey(s.StudentId)) + { + results[s.StudentId] = new EligibleStudent( + s.StudentId, s.StudentNumber, s.Name, s.ClassName, + MakeupReason.Failed, s.TotalScore, + SourceGradeRecordId: s.GradeRecordId); + } + } + + return [.. results.Values.OrderBy(x => x.StudentNumber)]; + } +} + +public sealed record EligibleStudent( + Guid StudentId, + string StudentNumber, + string Name, + string ClassName, + MakeupReason Reason, + decimal? TotalScore = null, + Guid? SourceGradeRecordId = null, + Guid? SourceDeferredExamId = null, + string? Detail = null); diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs index d8431fd..1de9e32 100644 --- a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs @@ -22,7 +22,7 @@ public static class GradeCalculator IReadOnlyCollection items, GradeExamStatus examStatus) { - if (examStatus != GradeExamStatus.Normal) + if (examStatus != GradeExamStatus.Normal && examStatus != GradeExamStatus.Makeup) return null; if (regularWeight > 0 && !regularScore.HasValue) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 731a670..3dc6f3b 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -55,6 +55,12 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet ExamSessions => Set(); public DbSet ExamSessionInvigilators => Set(); + public DbSet MakeupExamPlans => Set(); + public DbSet MakeupExamSessions => Set(); + public DbSet MakeupExamSessionInvigilators => + Set(); + public DbSet MakeupExamEnrollments => + Set(); public DbSet CourseAdjustments => Set(); public DbSet CourseExemptions => Set(); public DbSet DeferredExams => Set(); @@ -598,6 +604,52 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasOne(x => x.Teacher).WithMany() .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new { x.AcademicTermId, x.Status }); + entity.HasOne(x => x.AcademicTerm).WithMany() + .HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt }); + entity.HasIndex(x => x.TeachingTaskId); + entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate }); + entity.HasIndex(x => x.RequiredBuildingId); + entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions) + .HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.TeachingTask).WithMany() + .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Classroom).WithMany() + .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.RequiredBuilding).WithMany() + .HasForeignKey(x => x.RequiredBuildingId).OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.MakeupExamSessionId, x.TeacherId }); + entity.HasOne(x => x.MakeupExamSession).WithMany(x => x.Invigilators) + .HasForeignKey(x => x.MakeupExamSessionId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Teacher).WithMany() + .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.MakeupExamSessionId, x.StudentId }); + entity.HasIndex(x => new { x.StudentId, x.MakeupExamSessionId }); + entity.Property(x => x.MakeupScore).HasPrecision(5, 1); + entity.HasOne(x => x.MakeupExamSession).WithMany(x => x.Enrollments) + .HasForeignKey(x => x.MakeupExamSessionId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Student).WithMany() + .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.SourceGradeRecord).WithMany() + .HasForeignKey(x => x.SourceGradeRecordId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.SourceDeferredExam).WithMany() + .HasForeignKey(x => x.SourceDeferredExamId).OnDelete(DeleteBehavior.SetNull); + }); builder.Entity(entity => { entity.Property(x => x.Reason).HasMaxLength(1000); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 409ebe0..52a42eb 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -298,6 +298,14 @@ public sealed class DevelopmentSqliteMigrator( TeachingEvaluationMigration, evaluationSetupsExist ? [] : TeachingEvaluationStatements, cancellationToken); + + var makeupExamsExist = await db.Database + .SqlQueryRaw("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'MakeupExamPlans'") + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + MakeupExamsMigration, + makeupExamsExist ? [] : MakeupExamStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1651,4 +1659,20 @@ public sealed class DevelopmentSqliteMigrator( """CREATE UNIQUE INDEX "IX_EvaluationRecords_SetupId_StudentId_TaskId" ON "EvaluationRecords" ("EvaluationSetupId", "StudentId", "TeachingTaskId");""", """CREATE TABLE "EvaluationScores" ("EvaluationRecordId" TEXT NOT NULL, "EvaluationDimensionId" TEXT NOT NULL, "Score" INTEGER NOT NULL, CONSTRAINT "PK_EvaluationScores" PRIMARY KEY ("EvaluationRecordId", "EvaluationDimensionId"), CONSTRAINT "FK_EvaluationScores_EvaluationRecords_EvaluationRecordId" FOREIGN KEY ("EvaluationRecordId") REFERENCES "EvaluationRecords" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_EvaluationScores_EvaluationDimensions_EvaluationDimensionId" FOREIGN KEY ("EvaluationDimensionId") REFERENCES "EvaluationDimensions" ("Id") ON DELETE RESTRICT);""", ]; + + private const string MakeupExamsMigration = "MakeupExams"; + + private static readonly string[] MakeupExamStatements = + [ + """CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""", + """CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""", + """CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""", + """CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""", + """CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""", + """CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""", + """CREATE INDEX "IX_MakeupExamSessions_RequiredBuildingId" ON "MakeupExamSessions" ("RequiredBuildingId");""", + """CREATE TABLE "MakeupExamSessionInvigilators" ("MakeupExamSessionId" TEXT NOT NULL, "TeacherId" TEXT NOT NULL, CONSTRAINT "PK_MakeupExamSessionInvigilators" PRIMARY KEY ("MakeupExamSessionId", "TeacherId"), CONSTRAINT "FK_MakeupExamSessionInvigilators_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessionInvigilators_Teachers_TeacherId" FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT);""", + """CREATE TABLE "MakeupExamEnrollments" ("MakeupExamSessionId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "Reason" INTEGER NOT NULL, "SourceGradeRecordId" TEXT NULL, "SourceDeferredExamId" TEXT NULL, "MakeupScore" TEXT NULL, CONSTRAINT "PK_MakeupExamEnrollments" PRIMARY KEY ("MakeupExamSessionId", "StudentId"), CONSTRAINT "FK_MakeupExamEnrollments_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamEnrollments_Students_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamEnrollments_GradeRecords_SourceGradeRecordId" FOREIGN KEY ("SourceGradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamEnrollments_DeferredExams_SourceDeferredExamId" FOREIGN KEY ("SourceDeferredExamId") REFERENCES "DeferredExams" ("Id") ON DELETE SET NULL);""", + """CREATE INDEX "IX_MakeupExamEnrollments_StudentId_MakeupExamSessionId" ON "MakeupExamEnrollments" ("StudentId", "MakeupExamSessionId");""", + ]; } diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index b05d486..1e60ca8 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -103,6 +103,8 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 96f7b49..3eabf8b 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -159,6 +159,13 @@ const navigationGroups = computed(() => [ label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理', }, ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']), + { + path: '/makeup-exams', + label: isStudent.value ? '我的补考' : isTeacher.value ? '补考监考' : '补考安排', + }, + ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']), { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 24848cf..2293c3a 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -195,6 +195,12 @@ const router = createRouter({ component: () => import('../views/ExamsView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] }, }, + { + path: 'makeup-exams', + name: 'makeup-exams', + component: () => import('../views/MakeupExamsView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] }, + }, { path: 'course-adjustments', name: 'course-adjustments', diff --git a/web/src/views/MakeupExamsView.vue b/web/src/views/MakeupExamsView.vue new file mode 100644 index 0000000..6ac8a9c --- /dev/null +++ b/web/src/views/MakeupExamsView.vue @@ -0,0 +1,461 @@ + + + + +