From e6d25791b346d560a9746d3cf751a78d191851da Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 17 Aug 2026 19:33:07 +0800 Subject: [PATCH] Add batch proxy booking for self-scheduled experiments --- .../Controllers/ExperimentsController.cs | 101 ++++++++++++++++++ .../ExperimentsControllerTests.cs | 35 ++++++ web/src/views/ExperimentsView.vue | 27 +++-- 3 files changed, 152 insertions(+), 11 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 3bbc26d..56ea887 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -1308,6 +1308,104 @@ public sealed class ExperimentsController( IsolationLevel.Serializable); } + [HttpPost("sessions/{id:guid}/proxy-bookings/batch")] + [Authorize(Roles = Managers)] + public async Task CreateProxyBookings( + Guid id, + ProxyExperimentBookingBatchRequest request, + CancellationToken cancellationToken) + { + var studentIds = request.StudentIds.Distinct().ToArray(); + if (studentIds.Length == 0) + return ValidationProblem("请至少选择一名学生。"); + if (studentIds.Length > 100) + return ValidationProblem("单次最多可代选 100 名学生。"); + + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var session = await LoadManagedSessionAsync(id, cancellationToken); + if (session is null) return NotFound(); + if (!CanManageSession(session)) return Forbid(); + if (session.Status != ExperimentSessionStatus.Scheduled || + session.ExperimentProject!.Status != ExperimentProjectStatus.Published || + session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) + return ConflictProblem("只能为已发布的自行安排实验场次代选学生。"); + + var students = await db.Students + .WhereIn(studentIds, x => x.Id) + .OrderBy(x => x.StudentNumber) + .ToListAsync(cancellationToken); + if (students.Count != studentIds.Length) + return ValidationProblem("存在无效的学生档案。"); + var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active); + if (inactive is not null) + return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。"); + + var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, [session.ExperimentProject.TeachingTaskId], cancellationToken); + var eligibleStudentIds = roster.Select(x => x.StudentId).ToHashSet(); + var outOfRoster = students.FirstOrDefault(x => !eligibleStudentIds.Contains(x.Id)); + if (outOfRoster is not null) + return ConflictProblem($"学生 {outOfRoster.StudentNumber} {outOfRoster.Name} 不在本实验项目的教学任务名单内。"); + + var existingBookings = await db.ExperimentBookings + .Where(x => x.ExperimentProjectId == session.ExperimentProjectId) + .WhereIn(studentIds, x => x.StudentId) + .ToListAsync(cancellationToken); + var booked = existingBookings.FirstOrDefault(x => + x.Status == ExperimentBookingStatus.Booked); + if (booked is not null) + { + var student = students.First(x => x.Id == booked.StudentId); + return ConflictProblem(booked.ExperimentSessionId == session.Id + ? $"学生 {student.StudentNumber} {student.Name} 已预约本实验场次。" + : $"学生 {student.StudentNumber} {student.Name} 已预约本实验项目的其他场次。"); + } + + foreach (var student in students) + { + var scheduleProblem = await StudentConflictAsync(student.Id, session, cancellationToken); + if (scheduleProblem is not null) + return ConflictProblem($"学生 {student.StudentNumber} {student.Name}:{scheduleProblem}"); + } + + var reservedCount = await db.ExperimentBookings.CountAsync(x => + x.ExperimentSessionId == session.Id && x.Status == ExperimentBookingStatus.Booked, + cancellationToken); + if (reservedCount + students.Count > session.Capacity) + return ConflictProblem($"该实验场次仅剩 {Math.Max(0, session.Capacity - reservedCount)} 个名额,无法完成本次代选。"); + + foreach (var student in students) + { + var existing = existingBookings.FirstOrDefault(x => x.StudentId == student.Id); + if (existing is null) + { + db.ExperimentBookings.Add(new ExperimentBooking + { + ExperimentProjectId = session.ExperimentProjectId, + ExperimentSessionId = session.Id, + StudentId = student.Id + }); + } + else + { + existing.ExperimentSessionId = session.Id; + existing.Status = ExperimentBookingStatus.Booked; + existing.BookedAt = DateTime.UtcNow; + existing.CancelledAt = null; + } + } + session.ReservedCount = reservedCount + students.Count; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { BookedCount = students.Count }); + }, + cancellationToken, + IsolationLevel.Serializable); + } + [HttpPost("sessions/{id:guid}/book")] [Authorize(Roles = SystemRoles.Student)] public async Task Book( @@ -1967,6 +2065,9 @@ public sealed record ExperimentSessionRequest( public sealed record ProxyExperimentBookingRequest(Guid StudentId); +public sealed record ProxyExperimentBookingBatchRequest( + [Required, MinLength(1)] IReadOnlyCollection StudentIds); + public sealed record ExperimentSessionBatchRequest( [Required] IReadOnlyList Items); diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 087a485..f63e382 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -427,6 +427,41 @@ public sealed class ExperimentsControllerTests x.Status == ExperimentBookingStatus.Booked)); } + [Fact] + public async Task ManagerCanProxyBookMultipleStudentsForASelfScheduledSession() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var manager = fixture.Controller(fixture.ManagerScope); + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var project = await fixture.Db.ExperimentProjects.SingleAsync(); + await manager.CreateSession( + project.Id, fixture.SessionRequest(3, 2, 3), CancellationToken.None); + await manager.PublishProject(project.Id, CancellationToken.None); + var session = await fixture.Db.ExperimentSessions.SingleAsync(); + var originalStudent = await fixture.Db.Students.SingleAsync(); + fixture.Db.Students.Add(new Student + { + StudentNumber = "20990002", + Name = "第二名实验学生", + AdministrativeClassId = originalStudent.AdministrativeClassId, + EnrollmentYear = originalStudent.EnrollmentYear, + EnrollmentDate = originalStudent.EnrollmentDate + }); + await fixture.Db.SaveChangesAsync(); + var studentIds = await fixture.Db.Students.Select(x => x.Id).ToListAsync(); + + var result = await manager.CreateProxyBookings( + session.Id, new ProxyExperimentBookingBatchRequest(studentIds), CancellationToken.None); + + var response = Assert.IsType(result); + Assert.Equal(studentIds.Count, response.Value!.GetType().GetProperty("BookedCount")!.GetValue(response.Value)); + Assert.Equal(studentIds.Count, await fixture.Db.ExperimentBookings.CountAsync(x => + x.ExperimentSessionId == session.Id && + x.Status == ExperimentBookingStatus.Booked)); + } + [Fact] public async Task StudentBooking_RejectsPublishedTimetableConflict() { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 741be4f..40da898 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -82,7 +82,7 @@ const proxyBookingDialog = ref(false) const proxyBookingStudents = ref([]) const proxyBookingLoading = ref(false) const proxyBookingKeyword = ref('') -const proxyBookingStudentId = ref('') +const proxyBookingStudentIds = ref([]) const selectedTask = computed(() => options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]), @@ -635,22 +635,22 @@ async function loadProxyBookingStudents() { } async function openProxyBooking() { - proxyBookingStudentId.value = '' + proxyBookingStudentIds.value = [] proxyBookingKeyword.value = '' proxyBookingDialog.value = true await loadProxyBookingStudents() } async function saveProxyBooking() { - if (!participantSession.value || !proxyBookingStudentId.value) { + if (!participantSession.value || !proxyBookingStudentIds.value.length) { ElMessage.warning('请选择需要代选场次的学生') return } try { - await http.post(`/experiments/sessions/${participantSession.value.id}/proxy-bookings`, { - studentId: proxyBookingStudentId.value, + const { data } = await http.post(`/experiments/sessions/${participantSession.value.id}/proxy-bookings/batch`, { + studentIds: proxyBookingStudentIds.value, }) - ElMessage.success('已为该学生代选实验场次') + ElMessage.success(`已为 ${data.bookedCount} 名学生代选实验场次`) proxyBookingDialog.value = false await showParticipants(participantSession.value.project, participantSession.value) } catch (error) { @@ -1518,10 +1518,13 @@ onMounted(async () => { 查询 - - - - + + @@ -1529,7 +1532,9 @@ onMounted(async () => {