From fb2acc675119d25058418217587531a634d79c92 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 27 Jul 2026 18:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E4=BF=AE=E6=94=B9=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E8=A7=A3=E5=86=B3=E4=BA=86=E2=80=9C?= =?UTF-8?q?=E8=80=83=E7=94=9F=E5=90=8D=E5=8D=95=E9=83=BD=E6=98=AF=200=20?= =?UTF-8?q?=E4=BA=BA=E2=80=9D=E7=9A=84=E6=A0=B9=E5=9B=A0=E3=80=82=20?= =?UTF-8?q?=E8=80=83=E7=94=9F=E8=8C=83=E5=9B=B4=E7=BB=9F=E4=B8=80=E4=B8=BA?= =?UTF-8?q?=E2=80=9C=E8=A1=8C=E6=94=BF=E7=8F=AD=E5=85=B3=E8=81=94=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=20+=20=E5=B7=B2=E9=80=89=E8=AF=BE=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E2=80=9D=EF=BC=8C=E5=B9=B6=E8=87=AA=E5=8A=A8=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=EF=BC=9B=E8=80=83=E5=9C=BA=E5=88=86=E9=85=8D=E3=80=81=E4=BA=BA?= =?UTF-8?q?=E6=95=B0=E6=98=BE=E7=A4=BA=E3=80=81=E5=86=B2=E7=AA=81=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E3=80=81=E5=AD=A6=E7=94=9F=E8=80=83=E8=AF=95=E5=AE=89?= =?UTF-8?q?=E6=8E=92=E5=9D=87=E4=BD=BF=E7=94=A8=E5=90=8C=E4=B8=80=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E3=80=82[TeachingTaskRosterQuery.cs=20(line=2040)](E:?= =?UTF-8?q?/jiaowu/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRost?= =?UTF-8?q?erQuery.cs:40)=20=E6=96=B0=E5=A2=9E=E8=80=83=E5=9C=BA=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E5=8D=95=20Excel=20=E5=AF=BC=E5=87=BA=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=82[ExamsController.cs=20(line=20568)](E:/jiaowu?= =?UTF-8?q?/src/Jiaowu.Api/Controllers/ExamsController.cs:568)=20=E8=80=83?= =?UTF-8?q?=E8=AF=95=E8=AE=A1=E5=88=92=E9=A1=B5=E9=9D=A2=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E2=80=9C=E5=AF=BC=E5=87=BA=E8=80=83=E5=9C=BA=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E2=80=9D=E6=8C=89=E9=92=AE=E3=80=82[ExamsView.vue=20(?= =?UTF-8?q?line=20255)](E:/jiaowu/web/src/views/ExamsView.vue:255)=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=B0=BF=E5=8C=85=E5=90=AB=E2=80=9C=E8=80=83?= =?UTF-8?q?=E5=9C=BA=E6=B1=87=E6=80=BB=E2=80=9D=E4=BB=A5=E5=8F=8A=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E8=80=83=E8=AF=95=E5=9C=BA=E6=AC=A1=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9A=84=20A4=20=E7=AD=BE=E5=88=B0=E8=A1=A8=EF=BC=8C=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8C=85=E6=8B=AC=E5=BA=A7=E4=BD=8D=E5=8F=B7=E3=80=81?= =?UTF-8?q?=E5=AD=A6=E5=8F=B7=E3=80=81=E5=A7=93=E5=90=8D=E3=80=81=E8=A1=8C?= =?UTF-8?q?=E6=94=BF=E7=8F=AD=E3=80=81=E8=80=83=E7=94=9F=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E5=92=8C=E5=A4=87=E6=B3=A8=E3=80=82[ExamSignInWorkbookExporter?= =?UTF-8?q?.cs=20(line=2012)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Exam?= =?UTF-8?q?s/ExamSignInWorkbookExporter.cs:12)=20=E6=9C=AA=E5=88=86?= =?UTF-8?q?=E9=85=8D=E8=80=83=E5=9C=BA=E6=88=96=E7=9B=91=E8=80=83=E6=95=99?= =?UTF-8?q?=E5=B8=88=E6=97=B6=EF=BC=8C=E4=B9=9F=E8=83=BD=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=B9=B6=E6=98=BE=E7=A4=BA=E2=80=9C=E5=BE=85=E5=88=86=E9=85=8D?= =?UTF-8?q?=E2=80=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Jiaowu.Api/Controllers/ExamsController.cs | 260 +++++++++++---- .../Exams/ExamArrangementService.cs | 12 +- .../Exams/ExamSignInWorkbookExporter.cs | 300 ++++++++++++++++++ .../Teaching/TeachingTaskRosterQuery.cs | 104 ++++++ .../ExamArrangementServiceTests.cs | 55 +++- .../ExamSignInWorkbookExporterTests.cs | 60 ++++ tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs | 13 + .../TeachingWorkflowRosterTests.cs | 98 +++++- web/src/views/ExamsView.vue | 25 +- 9 files changed, 856 insertions(+), 71 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs create mode 100644 tests/Jiaowu.Api.Tests/ExamSignInWorkbookExporterTests.cs diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 31ffa20..a66dcee 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -4,7 +4,9 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -113,13 +115,52 @@ public sealed class ExamsController( item.RequiredInvigilatorCount, item.Notes, InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), - InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), - StudentCount = db.CourseEnrollments.Count(e => - e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) + InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name) }) }).FirstOrDefaultAsync(cancellationToken); - return plan is null ? NotFound() : Ok(plan); + if (plan is null) return NotFound(); + + var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + plan.Sessions.Select(x => x.TeachingTaskId), + cancellationToken)) + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(x => x.Key, x => x.Count()); + return Ok(new + { + plan.Id, + plan.Name, + plan.AcademicTermId, + plan.TermName, + plan.Status, + plan.Notes, + plan.PublishedAt, + Sessions = plan.Sessions.Select(item => new + { + item.Id, + item.TeachingTaskId, + item.TaskNumber, + item.TaskName, + item.CourseCode, + item.CourseName, + item.ClassroomId, + item.ClassroomName, + item.BuildingName, + item.ClassroomCapacity, + item.ExamDate, + item.StartPeriod, + item.PeriodCount, + item.StartsAt, + item.EndsAt, + item.RequiredBuildingId, + item.RequiredBuildingName, + item.RequiredInvigilatorCount, + item.Notes, + item.InvigilatorIds, + item.InvigilatorNames, + StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId) + }) + }); } // ═══════════════════════════════════════════ @@ -209,16 +250,11 @@ public sealed class ExamsController( if (duplicateTaskIds.Count > 0) return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。"); - var enrollmentPairs = await db.CourseEnrollments.AsNoTracking() - .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) - .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) - .Select(x => new - { - x.StudentId, - x.CourseSelectionOffering!.TeachingTaskId - }) - .ToListAsync(cancellationToken); - var conflictedStudentCount = enrollmentPairs + var rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + taskIds, + cancellationToken); + var conflictedStudentCount = rosterPairs .GroupBy(x => x.StudentId) .Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any()); if (conflictedStudentCount > 0) @@ -470,7 +506,7 @@ public sealed class ExamsController( if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("只有草稿考试计划可以发布。"); - if (plan.Sessions.Count == 0) + if (!plan.Sessions.Any()) return ConflictProblem("至少安排一个考试场次后才能发布。"); var unassigned = plan.Sessions.Count(x => @@ -505,18 +541,10 @@ public sealed class ExamsController( }).FirstOrDefaultAsync(cancellationToken); if (session is null) return NotFound(); - var students = await db.CourseEnrollments.AsNoTracking() - .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId) - .OrderBy(e => e.Student!.StudentNumber) - .Select(e => new - { - e.StudentId, - e.Student!.StudentNumber, - e.Student.Name, - ClassName = e.Student.AdministrativeClass!.Name - }) - .ToListAsync(cancellationToken); + var students = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + [session.TeachingTaskId], + cancellationToken); return Ok(new { @@ -537,6 +565,82 @@ public sealed class ExamsController( }); } + [HttpGet("plans/{id:guid}/sign-in-sheets.xlsx")] + [Authorize(Roles = Managers)] + public async Task ExportSignInSheets( + Guid id, + CancellationToken cancellationToken) + { + var plan = await db.ExamPlans.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Name, + TermName = x.AcademicTerm!.Name, + Sessions = x.Sessions + .OrderBy(session => session.ExamDate) + .ThenBy(session => session.StartPeriod) + .Select(session => new + { + session.Id, + session.TeachingTaskId, + session.ExamDate, + session.StartsAt, + session.EndsAt, + CourseCode = session.TeachingTask!.Course!.Code, + CourseName = session.TeachingTask.Course.Name, + session.TeachingTask.TaskNumber, + BuildingName = session.Classroom != null + ? session.Classroom.Building!.Name + : null, + ClassroomName = session.Classroom != null + ? session.Classroom.Name + : null, + InvigilatorNames = session.Invigilators + .OrderBy(item => item.Teacher!.TeacherNumber) + .Select(item => item.Teacher!.Name) + }) + }) + .FirstOrDefaultAsync(cancellationToken); + if (plan is null) return NotFound(); + if (!plan.Sessions.Any()) + return ConflictProblem("当前考试计划没有可导出的考试场次。"); + + var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + plan.Sessions.Select(x => x.TeachingTaskId), + cancellationToken); + var studentsByTask = roster.ToLookup(x => x.TeachingTaskId); + var data = new ExamSignInWorkbookData( + plan.Name, + plan.TermName, + plan.Sessions.Select(session => new ExamSignInSessionData( + session.Id, + session.ExamDate, + session.StartsAt, + session.EndsAt, + session.CourseCode, + session.CourseName, + session.TaskNumber, + session.BuildingName, + session.ClassroomName, + session.InvigilatorNames.ToList(), + studentsByTask[session.TeachingTaskId] + .Select(student => new ExamSignInStudentData( + student.StudentId, + student.StudentNumber, + student.Name, + student.ClassName)) + .ToList())) + .ToList()); + + var bytes = ExamSignInWorkbookExporter.Create(data); + return File( + bytes, + ExcelWorkbookHelper.ContentType, + $"考场签名单-{FileNamePart(plan.Name)}.xlsx"); + } + // ═══════════════════════════════════════════ // My schedule // ═══════════════════════════════════════════ @@ -551,11 +655,12 @@ public sealed class ExamsController( .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); + var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent( + db, + studentId.Value); var schedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && - db.CourseEnrollments.Any(e => e.StudentId == studentId && - e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)) + rosterTaskIds.Contains(x.TeachingTaskId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new { @@ -579,17 +684,10 @@ public sealed class ExamsController( if (schedule.Count == 0) return Ok(schedule); var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); - var seatRows = await db.CourseEnrollments.AsNoTracking() - .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) - .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) - .OrderBy(x => x.Student!.StudentNumber) - .Select(x => new - { - x.CourseSelectionOffering!.TeachingTaskId, - x.StudentId, - x.Student!.StudentNumber - }) - .ToListAsync(cancellationToken); + var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + taskIds, + cancellationToken); var seatNumbers = seatRows .GroupBy(x => x.TeachingTaskId) .SelectMany(group => group.Select((row, index) => new @@ -621,7 +719,7 @@ public sealed class ExamsController( } if (scope.IsInRole(SystemRoles.Teacher)) { - return Ok(await db.ExamSessions.AsNoTracking() + var schedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) @@ -640,12 +738,34 @@ public sealed class ExamsController( 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), - StudentCount = db.CourseEnrollments.Count(e => - e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId), + x.TeachingTaskId, IsExam = true }) - .ToListAsync(cancellationToken)); + .ToListAsync(cancellationToken); + var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + schedule.Select(x => x.TeachingTaskId), + cancellationToken)) + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(x => x.Key, x => x.Count()); + return Ok(schedule.Select(x => new + { + x.Id, + x.PlanName, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TaskNumber, + x.CourseCode, + x.CourseName, + x.ClassroomName, + x.BuildingName, + x.InvigilatorNames, + StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId), + x.IsExam + })); } return Ok(Array.Empty()); } @@ -704,9 +824,9 @@ public sealed class ExamsController( if (task is null || task.AcademicTermId != plan.AcademicTermId) return ValidationProblem("教学班与考试计划必须属于同一学期。"); - var studentCount = await db.CourseEnrollments.CountAsync(x => - x.Status == CourseEnrollmentStatus.Enrolled && - x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); + var studentCount = await TeachingTaskRosterQuery + .ForTask(db, task.Id) + .CountAsync(cancellationToken); if (classroomId.HasValue) { @@ -748,17 +868,25 @@ public sealed class ExamsController( return ConflictProblem("监考教师在该时段已有考试任务。"); } - var studentIds = db.CourseEnrollments.Where(x => - x.Status == CourseEnrollmentStatus.Enrolled && - x.CourseSelectionOffering!.TeachingTaskId == task.Id) - .Select(x => x.StudentId); - if (await overlaps.AnyAsync(other => - db.CourseEnrollments.Any(e => - studentIds.Contains(e.StudentId) && - e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId), - cancellationToken)) - return ConflictProblem("存在学生考试时间冲突。"); + var overlappingTaskIds = await overlaps + .Select(x => x.TeachingTaskId) + .Distinct() + .ToListAsync(cancellationToken); + if (overlappingTaskIds.Count > 0) + { + var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + overlappingTaskIds.Append(task.Id), + cancellationToken); + var currentStudentIds = rosterRows + .Where(x => x.TeachingTaskId == task.Id) + .Select(x => x.StudentId) + .ToHashSet(); + if (rosterRows.Any(x => + x.TeachingTaskId != task.Id && + currentStudentIds.Contains(x.StudentId))) + return ConflictProblem("存在学生考试时间冲突。"); + } return null; } @@ -789,6 +917,16 @@ public sealed class ExamsController( }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string FileNamePart(string value) + { + var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet(); + var normalized = new string(value + .Select(character => + invalidCharacters.Contains(character) ? '-' : character) + .ToArray()).Trim(); + if (normalized.Length == 0) return "考试计划"; + return normalized.Length <= 80 ? normalized : normalized[..80]; + } } // ═══════════════════════════════════════════ diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs index 2dbd2c2..50eff39 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Exams; @@ -64,6 +65,12 @@ public sealed class ExamArrangementService(AppDbContext db) return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); + var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + sessions.Select(x => x.TeachingTaskId), + cancellationToken)) + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(x => x.Key, x => x.Count()); int assignedRooms = 0; int assignedInvigilators = 0; @@ -86,10 +93,7 @@ public sealed class ExamArrangementService(AppDbContext db) { // Compute StartsAt/EndsAt from time slots ComputeTimesFromSlots(session, timeSlotLookup); - var studentCount = await db.CourseEnrollments.CountAsync( - x => x.Status == CourseEnrollmentStatus.Enrolled && - x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId, - cancellationToken); + var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId); // ── Auto-assign classroom ── if (assignClassrooms && !session.ClassroomId.HasValue) diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs new file mode 100644 index 0000000..14846b2 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs @@ -0,0 +1,300 @@ +using ClosedXML.Excel; + +namespace Jiaowu.Api.Infrastructure.Exams; + +public static class ExamSignInWorkbookExporter +{ + private static readonly XLColor Navy = XLColor.FromHtml("#173E72"); + private static readonly XLColor Teal = XLColor.FromHtml("#24706A"); + private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6"); + private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA"); + + public static byte[] Create(ExamSignInWorkbookData data) + { + using var workbook = new XLWorkbook(); + AddSummarySheet(workbook, data); + + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "考场汇总" + }; + foreach (var session in data.Sessions + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartsAt) + .ThenBy(x => x.BuildingName) + .ThenBy(x => x.ClassroomName) + .ThenBy(x => x.CourseCode)) + { + AddSignInSheet( + workbook, + data, + session, + UniqueSheetName(session, usedNames)); + } + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + private static void AddSummarySheet( + XLWorkbook workbook, + ExamSignInWorkbookData data) + { + var sheet = workbook.Worksheets.Add("考场汇总"); + sheet.Style.Font.FontName = "Microsoft YaHei"; + sheet.ShowGridLines = false; + + sheet.Range("A1:G1").Merge(); + sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单"; + StyleTitle(sheet.Range("A1:G1")); + sheet.Row(1).Height = 34; + + sheet.Range("A2:G2").Merge(); + sheet.Cell("A2").Value = + $"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; + StyleSubTitle(sheet.Range("A2:G2")); + sheet.Row(2).Height = 24; + + var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" }; + for (var column = 1; column <= headers.Length; column++) + sheet.Cell(4, column).Value = headers[column - 1]; + StyleHeader(sheet.Range(4, 1, 4, headers.Length)); + + var row = 5; + foreach (var session in data.Sessions + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartsAt) + .ThenBy(x => x.BuildingName) + .ThenBy(x => x.ClassroomName)) + { + sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue); + sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd"; + sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}"; + sheet.Cell(row, 3).Value = Location(session); + sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}"; + sheet.Cell(row, 5).Value = session.TaskNumber; + sheet.Cell(row, 6).Value = session.Students.Count; + sheet.Cell(row, 7).Value = Invigilators(session); + row++; + } + + if (row > 5) + { + var table = sheet.Range(4, 1, row - 1, headers.Length); + table.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + table.Style.Border.InsideBorderColor = Rule; + table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium; + table.Style.Border.OutsideBorderColor = Rule; + table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + sheet.Rows(5, row - 1).Height = 25; + } + + sheet.Column(1).Width = 13; + sheet.Column(2).Width = 15; + sheet.Column(3).Width = 24; + sheet.Column(4).Width = 30; + sheet.Column(5).Width = 22; + sheet.Column(6).Width = 11; + sheet.Column(7).Width = 24; + sheet.Columns(3, 7).Style.Alignment.WrapText = true; + sheet.SheetView.FreezeRows(4); + sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape; + sheet.PageSetup.PaperSize = XLPaperSize.A4Paper; + sheet.PageSetup.FitToPages(1, 0); + sheet.PageSetup.Margins + .SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35); + } + + private static void AddSignInSheet( + XLWorkbook workbook, + ExamSignInWorkbookData data, + ExamSignInSessionData session, + string sheetName) + { + var sheet = workbook.Worksheets.Add(sheetName); + sheet.Style.Font.FontName = "Microsoft YaHei"; + sheet.ShowGridLines = false; + + sheet.Range("A1:G1").Merge(); + sheet.Cell("A1").Value = "考试考场签名单"; + StyleTitle(sheet.Range("A1:G1")); + sheet.Cell("A1").Style.Alignment.Horizontal = + XLAlignmentHorizontalValues.Center; + sheet.Row(1).Height = 36; + + sheet.Range("A2:G2").Merge(); + sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}"; + StyleSubTitle(sheet.Range("A2:G2")); + sheet.Cell("A2").Style.Alignment.Horizontal = + XLAlignmentHorizontalValues.Center; + sheet.Row(2).Height = 23; + + sheet.Range("A3:D3").Merge(); + sheet.Cell("A3").Value = + $"课程:{session.CourseCode} {session.CourseName}({session.TaskNumber})"; + sheet.Range("E3:G3").Merge(); + sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人"; + + sheet.Range("A4:D4").Merge(); + sheet.Cell("A4").Value = + $"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}"; + sheet.Range("E4:G4").Merge(); + sheet.Cell("E4").Value = $"考场:{Location(session)}"; + + sheet.Range("A5:D5").Merge(); + sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}"; + sheet.Range("E5:G5").Merge(); + sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______"; + + var metadata = sheet.Range("A3:G5"); + metadata.Style.Fill.BackgroundColor = XLColor.White; + metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60"); + metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + metadata.Style.Alignment.WrapText = true; + metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin; + metadata.Style.Border.BottomBorderColor = Rule; + sheet.Rows(3, 5).Height = 24; + + var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" }; + for (var column = 1; column <= headers.Length; column++) + sheet.Cell(7, column).Value = headers[column - 1]; + StyleHeader(sheet.Range(7, 1, 7, headers.Length)); + sheet.Row(7).Height = 27; + + var row = 8; + foreach (var student in session.Students.OrderBy(x => x.StudentNumber)) + { + var index = row - 7; + sheet.Cell(row, 1).Value = index; + sheet.Cell(row, 2).Value = index.ToString("D3"); + sheet.Cell(row, 3).Value = student.StudentNumber; + sheet.Cell(row, 4).Value = student.Name; + sheet.Cell(row, 5).Value = student.ClassName; + sheet.Row(row).Height = 28; + row++; + } + + if (session.Students.Count == 0) + { + sheet.Range("A8:G8").Merge(); + sheet.Cell("A8").Value = "本场次暂无考生"; + sheet.Cell("A8").Style + .Font.SetFontColor(XLColor.FromHtml("#8A5A20")) + .Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA")) + .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center); + sheet.Row(8).Height = 28; + row = 9; + } + + var roster = sheet.Range(7, 1, row - 1, headers.Length); + roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + roster.Style.Border.InsideBorderColor = Rule; + roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium; + roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7"); + roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal = + XLAlignmentHorizontalValues.Center; + sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true; + + sheet.Column(1).Width = 7; + sheet.Column(2).Width = 10; + sheet.Column(3).Width = 16; + sheet.Column(4).Width = 12; + sheet.Column(5).Width = 20; + sheet.Column(6).Width = 22; + sheet.Column(7).Width = 17; + sheet.SheetView.FreezeRows(7); + sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait; + sheet.PageSetup.PaperSize = XLPaperSize.A4Paper; + sheet.PageSetup.FitToPages(1, 0); + sheet.PageSetup.Margins + .SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3); + } + + private static void StyleTitle(IXLRange range) => + range.Style + .Font.SetBold() + .Font.SetFontSize(18) + .Font.SetFontColor(XLColor.White) + .Fill.SetBackgroundColor(Navy) + .Alignment.SetVertical(XLAlignmentVerticalValues.Center); + + private static void StyleSubTitle(IXLRange range) => + range.Style + .Font.SetFontColor(XLColor.FromHtml("#52677A")) + .Fill.SetBackgroundColor(Pale) + .Alignment.SetVertical(XLAlignmentVerticalValues.Center); + + private static void StyleHeader(IXLRange range) => + range.Style + .Font.SetBold() + .Font.SetFontColor(XLColor.White) + .Fill.SetBackgroundColor(Teal) + .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center) + .Alignment.SetVertical(XLAlignmentVerticalValues.Center); + + private static string Location(ExamSignInSessionData session) => + string.Join(" · ", new[] + { + session.BuildingName, + session.ClassroomName + }.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value + ? value + : "待分配考场"; + + private static string Invigilators(ExamSignInSessionData session) => + session.InvigilatorNames.Count > 0 + ? string.Join('、', session.InvigilatorNames) + : "待分配"; + + private static string UniqueSheetName( + ExamSignInSessionData session, + ISet usedNames) + { + var location = string.IsNullOrWhiteSpace(session.ClassroomName) + ? "待分配" + : session.ClassroomName; + var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}"; + var invalidCharacters = new HashSet(":\\/?*[]"); + var sanitized = new string(raw + .Select(character => invalidCharacters.Contains(character) ? '-' : character) + .ToArray()).Trim(' ', '\''); + if (sanitized.Length == 0) sanitized = "考场"; + if (sanitized.Length > 31) sanitized = sanitized[..31]; + + var candidate = sanitized; + var suffix = 2; + while (!usedNames.Add(candidate)) + { + var marker = $"-{suffix++}"; + var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length); + candidate = sanitized[..prefixLength] + marker; + } + return candidate; + } +} + +public sealed record ExamSignInWorkbookData( + string PlanName, + string TermName, + IReadOnlyList Sessions); + +public sealed record ExamSignInSessionData( + Guid SessionId, + DateOnly ExamDate, + DateTime StartsAt, + DateTime EndsAt, + string CourseCode, + string CourseName, + string TaskNumber, + string? BuildingName, + string? ClassroomName, + IReadOnlyList InvigilatorNames, + IReadOnlyList Students); + +public sealed record ExamSignInStudentData( + Guid StudentId, + string StudentNumber, + string Name, + string ClassName); diff --git a/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs index f2c6f77..d065173 100644 --- a/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs +++ b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Teaching; @@ -15,4 +16,107 @@ public static class TeachingTaskRosterQuery enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)); + + public static IQueryable TaskIdsForStudent(AppDbContext db, Guid studentId) + { + var classTaskIds = db.Students + .Where(student => + student.Id == studentId && + student.Status == StudentStatus.Active) + .SelectMany(student => db.TeachingTaskClasses + .Where(assignment => + assignment.AdministrativeClassId == + student.AdministrativeClassId) + .Select(assignment => assignment.TeachingTaskId)); + var enrolledTaskIds = db.CourseEnrollments + .Where(enrollment => + enrollment.StudentId == studentId && + enrollment.Status == CourseEnrollmentStatus.Enrolled) + .Select(enrollment => + enrollment.CourseSelectionOffering!.TeachingTaskId); + return classTaskIds.Concat(enrolledTaskIds).Distinct(); + } + + public static async Task> LoadForTasksAsync( + AppDbContext db, + IEnumerable teachingTaskIds, + CancellationToken cancellationToken) + { + var taskIds = teachingTaskIds.Distinct().ToArray(); + if (taskIds.Length == 0) return []; + + var assignments = await db.TeachingTaskClasses.AsNoTracking() + .WhereIn(taskIds, x => x.TeachingTaskId) + .Select(x => new + { + x.TeachingTaskId, + x.AdministrativeClassId + }) + .ToListAsync(cancellationToken); + + var classIds = assignments + .Select(x => x.AdministrativeClassId) + .Distinct() + .ToArray(); + var classStudents = classIds.Length == 0 + ? [] + : await db.Students.AsNoTracking() + .Where(x => x.Status == StudentStatus.Active) + .WhereIn(classIds, x => x.AdministrativeClassId) + .Select(x => new RosterStudent( + x.Id, + x.StudentNumber, + x.Name, + x.AdministrativeClassId, + x.AdministrativeClass!.Name)) + .ToListAsync(cancellationToken); + + var enrolledStudents = await db.CourseEnrollments.AsNoTracking() + .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) + .Select(x => new TeachingTaskRosterEntry( + x.CourseSelectionOffering!.TeachingTaskId, + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + x.Student.AdministrativeClass!.Name)) + .ToListAsync(cancellationToken); + + var result = enrolledStudents.ToDictionary( + x => (x.TeachingTaskId, x.StudentId)); + var studentsByClass = classStudents.ToLookup(x => x.AdministrativeClassId); + foreach (var assignment in assignments) + { + foreach (var student in studentsByClass[assignment.AdministrativeClassId]) + { + result.TryAdd( + (assignment.TeachingTaskId, student.StudentId), + new TeachingTaskRosterEntry( + assignment.TeachingTaskId, + student.StudentId, + student.StudentNumber, + student.Name, + student.ClassName)); + } + } + + return result.Values + .OrderBy(x => x.TeachingTaskId) + .ThenBy(x => x.StudentNumber) + .ToList(); + } + + private sealed record RosterStudent( + Guid StudentId, + string StudentNumber, + string Name, + Guid AdministrativeClassId, + string ClassName); } + +public sealed record TeachingTaskRosterEntry( + Guid TeachingTaskId, + Guid StudentId, + string StudentNumber, + string Name, + string ClassName); diff --git a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs index 28b02bb..7b73410 100644 --- a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs +++ b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs @@ -48,6 +48,7 @@ public sealed class ExamArrangementServiceTests .SingleAsync(x => x.Id == untouched.Id); Assert.True(result.Success); Assert.NotNull(persistedSelected.ClassroomId); + Assert.Equal(seed.LargeClassroom.Id, persistedSelected.ClassroomId); Assert.Null(persistedUntouched.ClassroomId); Assert.Single(persistedSelected.Invigilators); Assert.Empty(persistedUntouched.Invigilators); @@ -159,6 +160,27 @@ public sealed class ExamArrangementServiceTests BuildingId = building.Id, Capacity = 80 }; + var smallClassroom = new Classroom + { + Code = $"R-SMALL-{suffix}", + Name = "102", + BuildingId = building.Id, + Capacity = 1 + }; + var major = new Major + { + Code = $"MAJOR-{suffix}", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士" + }; + var administrativeClass = new AdministrativeClass + { + Code = $"CLASS-{suffix}", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; var firstCourse = new Course { Code = $"COURSE-1-{suffix}", @@ -177,7 +199,14 @@ public sealed class ExamArrangementServiceTests Name = "程序设计教学班", AcademicTermId = term.Id, CourseId = firstCourse.Id, - Status = TeachingTaskStatus.Published + Status = TeachingTaskStatus.Published, + Classes = + [ + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + } + ] }; var secondTask = new TeachingTask { @@ -201,6 +230,25 @@ public sealed class ExamArrangementServiceTests college, building, classroom, + smallClassroom, + major, + administrativeClass, + new Student + { + StudentNumber = $"STUDENT-1-{suffix}", + Name = "学生甲", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, + new Student + { + StudentNumber = $"STUDENT-2-{suffix}", + Name = "学生乙", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, firstCourse, secondCourse, firstTask, @@ -223,11 +271,12 @@ public sealed class ExamArrangementServiceTests EndsAt = new TimeOnly(9, 50) }); await db.SaveChangesAsync(); - return new SeedData(term, firstTask, secondTask); + return new SeedData(term, firstTask, secondTask, classroom); } private sealed record SeedData( AcademicTerm Term, TeachingTask FirstTask, - TeachingTask SecondTask); + TeachingTask SecondTask, + Classroom LargeClassroom); } diff --git a/tests/Jiaowu.Api.Tests/ExamSignInWorkbookExporterTests.cs b/tests/Jiaowu.Api.Tests/ExamSignInWorkbookExporterTests.cs new file mode 100644 index 0000000..9274628 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExamSignInWorkbookExporterTests.cs @@ -0,0 +1,60 @@ +using ClosedXML.Excel; +using Jiaowu.Api.Infrastructure.Exams; + +namespace Jiaowu.Api.Tests; + +public sealed class ExamSignInWorkbookExporterTests +{ + [Fact] + public void Create_builds_summary_and_printable_session_sheets() + { + var sessionId = Guid.NewGuid(); + var session = new ExamSignInSessionData( + sessionId, + new DateOnly(2027, 1, 8), + new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc), + new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc), + "CS101", + "程序设计基础", + "2026-1-CS101-01", + "第一教学楼", + "101", + ["张老师", "李老师"], + [ + new ExamSignInStudentData( + Guid.NewGuid(), + "202601001", + "周同学", + "计科 2026-1 班") + ]); + var data = new ExamSignInWorkbookData( + "2026—2027 学年第一学期期末考试", + "2026—2027 学年第一学期", + [ + session, + session with { SessionId = Guid.NewGuid() } + ]); + + var bytes = ExamSignInWorkbookExporter.Create(data); + + using var stream = new MemoryStream(bytes); + using var workbook = new XLWorkbook(stream); + Assert.Equal(3, workbook.Worksheets.Count); + Assert.Equal("考场汇总", workbook.Worksheet(1).Name); + Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F5").GetDouble()); + Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F6").GetDouble()); + Assert.Equal( + workbook.Worksheets.Count, + workbook.Worksheets.Select(x => x.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count()); + + var signInSheet = workbook.Worksheet(2); + Assert.Equal("考试考场签名单", signInSheet.Cell("A1").GetString()); + Assert.Contains("第一教学楼", signInSheet.Cell("E4").GetString()); + Assert.Equal("202601001", signInSheet.Cell("C8").GetString()); + Assert.Equal("周同学", signInSheet.Cell("D8").GetString()); + Assert.Equal(XLPageOrientation.Portrait, signInSheet.PageSetup.PageOrientation); + Assert.Equal(XLPaperSize.A4Paper, signInSheet.PageSetup.PaperSize); + } +} diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 36e6814..72c06d0 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -119,15 +119,28 @@ public sealed class MySqlMigrationTests public void MySql_teaching_task_roster_query_is_translatable() { using var db = new AppDbContext(CreateMySqlOptions()); + var studentId = + Guid.Parse("22222222-2222-2222-2222-222222222222"); var sql = TeachingTaskRosterQuery .ForTask( db, Guid.Parse("11111111-1111-1111-1111-111111111111")) .ToQueryString(); + var taskIdsSql = TeachingTaskRosterQuery + .TaskIdsForStudent(db, studentId) + .ToQueryString(); Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "TeachingTaskClasses", + taskIdsSql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "CourseEnrollments", + taskIdsSql, + StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs index 9ea1c02..16ff8a7 100644 --- a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs @@ -1,7 +1,10 @@ +using ClosedXML.Excel; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; @@ -12,7 +15,7 @@ namespace Jiaowu.Api.Tests; public sealed class TeachingWorkflowRosterTests { [Fact] - public async Task ClassAssignedStudents_AppearAcrossTeacherWorkflows() + public async Task ClassAssignedStudents_AppearAcrossTeachingAndExamWorkflows() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); @@ -23,6 +26,7 @@ public sealed class TeachingWorkflowRosterTests await db.Database.EnsureCreatedAsync(); var teacherUserId = Guid.NewGuid(); + var studentUserId = Guid.NewGuid(); var teacherUser = new ApplicationUser { Id = teacherUserId, @@ -30,6 +34,13 @@ public sealed class TeachingWorkflowRosterTests NormalizedUserName = "T001", DisplayName = "张老师" }; + var studentUser = new ApplicationUser + { + Id = studentUserId, + UserName = "202601001", + NormalizedUserName = "202601001", + DisplayName = "周同学" + }; var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { @@ -51,7 +62,8 @@ public sealed class TeachingWorkflowRosterTests Name = "周同学", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, - EnrollmentDate = new DateOnly(2026, 9, 1) + EnrollmentDate = new DateOnly(2026, 9, 1), + UserId = studentUserId }; var teacher = new Teacher { @@ -107,6 +119,7 @@ public sealed class TeachingWorkflowRosterTests }; db.AddRange( teacherUser, + studentUser, college, major, administrativeClass, @@ -117,6 +130,28 @@ public sealed class TeachingWorkflowRosterTests task); await db.SaveChangesAsync(); + var examPlan = new ExamPlan + { + AcademicTermId = term.Id, + Name = "期末考试", + Status = ExamPlanStatus.Published, + Sessions = + [ + new ExamSession + { + TeachingTaskId = task.Id, + ExamDate = new DateOnly(2027, 1, 8), + StartPeriod = 1, + PeriodCount = 2, + StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc), + RequiredInvigilatorCount = 1 + } + ] + }; + db.ExamPlans.Add(examPlan); + await db.SaveChangesAsync(); + var scope = new TeacherDataScope(teacherUserId); var attendance = new AttendanceController(db, scope); var attendanceResult = await attendance.CreateSheet( @@ -153,6 +188,39 @@ public sealed class TeachingWorkflowRosterTests Assert.Single(ReadItems(await adjustments.GetTaskOptions( term.Id, CancellationToken.None))); + + var managerScope = new ManagerDataScope(); + var exams = new ExamsController( + db, + managerScope, + new ExamArrangementService(db), + NoOpAppCache.Instance); + var planResult = Assert.IsType( + await exams.GetPlan(examPlan.Id, CancellationToken.None)); + var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions"); + Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount")); + + var examSessionId = examPlan.Sessions.Single().Id; + var rosterResult = Assert.IsType( + await exams.GetRoster(examSessionId, CancellationToken.None)); + Assert.Single(ReadEnumerableProperty(rosterResult.Value!, "Students")); + + var studentExams = new ExamsController( + db, + new StudentDataScope(studentUserId), + new ExamArrangementService(db), + NoOpAppCache.Instance); + Assert.Single(ReadItems( + await studentExams.GetMySchedule(CancellationToken.None))); + + var exportResult = Assert.IsType( + await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None)); + using var workbookStream = new MemoryStream(exportResult.FileContents); + using var workbook = new XLWorkbook(workbookStream); + Assert.Equal(2, workbook.Worksheets.Count); + var signInSheet = workbook.Worksheets.Single(x => x.Name != "考场汇总"); + Assert.Equal("202601001", signInSheet.Cell("C8").GetString()); + Assert.Equal("周同学", signInSheet.Cell("D8").GetString()); } private static List ReadItems(ActionResult result) @@ -166,6 +234,12 @@ public sealed class TeachingWorkflowRosterTests private static int ReadIntProperty(object value, string name) => (int)value.GetType().GetProperty(name)!.GetValue(value)!; + private static List ReadEnumerableProperty(object value, string name) => + Assert.IsAssignableFrom( + value.GetType().GetProperty(name)!.GetValue(value)) + .Cast() + .ToList(); + private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( @@ -175,4 +249,24 @@ public sealed class TeachingWorkflowRosterTests DataScope.Self, new HashSet([SystemRoles.Teacher])); } + + private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + userId, + "周同学", + null, + DataScope.Self, + new HashSet([SystemRoles.Student])); + } + + private sealed class ManagerDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "考试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.AcademicAdmin])); + } } diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index 1c40d43..b1411e8 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -1,7 +1,8 @@