Add batch proxy booking for self-scheduled experiments
This commit is contained in:
1 parent
1e43d910c0
commit
e6d25791b3
3 files changed
+152
-11
No files matched your search
@@ -1308,6 +1308,104 @@ public sealed class ExperimentsController(
|
|||||||
IsolationLevel.Serializable);
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("sessions/{id:guid}/proxy-bookings/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> 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<ActionResult>(
|
||||||
|
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")]
|
[HttpPost("sessions/{id:guid}/book")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> Book(
|
public async Task<ActionResult> Book(
|
||||||
@@ -1967,6 +2065,9 @@ public sealed record ExperimentSessionRequest(
|
|||||||
|
|
||||||
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
|
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
|
||||||
|
|
||||||
|
public sealed record ProxyExperimentBookingBatchRequest(
|
||||||
|
[Required, MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
public sealed record ExperimentSessionBatchRequest(
|
public sealed record ExperimentSessionBatchRequest(
|
||||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,41 @@ public sealed class ExperimentsControllerTests
|
|||||||
x.Status == ExperimentBookingStatus.Booked));
|
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<OkObjectResult>(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]
|
[Fact]
|
||||||
public async Task StudentBooking_RejectsPublishedTimetableConflict()
|
public async Task StudentBooking_RejectsPublishedTimetableConflict()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const proxyBookingDialog = ref(false)
|
|||||||
const proxyBookingStudents = ref<any[]>([])
|
const proxyBookingStudents = ref<any[]>([])
|
||||||
const proxyBookingLoading = ref(false)
|
const proxyBookingLoading = ref(false)
|
||||||
const proxyBookingKeyword = ref('')
|
const proxyBookingKeyword = ref('')
|
||||||
const proxyBookingStudentId = ref('')
|
const proxyBookingStudentIds = ref<string[]>([])
|
||||||
|
|
||||||
const selectedTask = computed(() =>
|
const selectedTask = computed(() =>
|
||||||
options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]),
|
options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]),
|
||||||
@@ -635,22 +635,22 @@ async function loadProxyBookingStudents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openProxyBooking() {
|
async function openProxyBooking() {
|
||||||
proxyBookingStudentId.value = ''
|
proxyBookingStudentIds.value = []
|
||||||
proxyBookingKeyword.value = ''
|
proxyBookingKeyword.value = ''
|
||||||
proxyBookingDialog.value = true
|
proxyBookingDialog.value = true
|
||||||
await loadProxyBookingStudents()
|
await loadProxyBookingStudents()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProxyBooking() {
|
async function saveProxyBooking() {
|
||||||
if (!participantSession.value || !proxyBookingStudentId.value) {
|
if (!participantSession.value || !proxyBookingStudentIds.value.length) {
|
||||||
ElMessage.warning('请选择需要代选场次的学生')
|
ElMessage.warning('请选择需要代选场次的学生')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await http.post(`/experiments/sessions/${participantSession.value.id}/proxy-bookings`, {
|
const { data } = await http.post(`/experiments/sessions/${participantSession.value.id}/proxy-bookings/batch`, {
|
||||||
studentId: proxyBookingStudentId.value,
|
studentIds: proxyBookingStudentIds.value,
|
||||||
})
|
})
|
||||||
ElMessage.success('已为该学生代选实验场次')
|
ElMessage.success(`已为 ${data.bookedCount} 名学生代选实验场次`)
|
||||||
proxyBookingDialog.value = false
|
proxyBookingDialog.value = false
|
||||||
await showParticipants(participantSession.value.project, participantSession.value)
|
await showParticipants(participantSession.value.project, participantSession.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1518,10 +1518,13 @@ onMounted(async () => {
|
|||||||
<el-input v-model="proxyBookingKeyword" clearable placeholder="输入学号、姓名或行政班" @keyup.enter="loadProxyBookingStudents" />
|
<el-input v-model="proxyBookingKeyword" clearable placeholder="输入学号、姓名或行政班" @keyup.enter="loadProxyBookingStudents" />
|
||||||
<el-button @click="loadProxyBookingStudents">查询</el-button>
|
<el-button @click="loadProxyBookingStudents">查询</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="proxyBookingLoading" :data="proxyBookingStudents" max-height="400" @current-change="(row) => proxyBookingStudentId = row?.studentId ?? ''">
|
<el-table
|
||||||
<el-table-column width="54">
|
v-loading="proxyBookingLoading"
|
||||||
<template #default="{ row }"><el-radio v-model="proxyBookingStudentId" :value="row.studentId" /></template>
|
:data="proxyBookingStudents"
|
||||||
</el-table-column>
|
max-height="400"
|
||||||
|
@selection-change="(rows) => proxyBookingStudentIds = rows.map((row) => row.studentId)"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="54" />
|
||||||
<el-table-column prop="studentNumber" label="学号" min-width="130" />
|
<el-table-column prop="studentNumber" label="学号" min-width="130" />
|
||||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||||
<el-table-column prop="className" label="行政班" min-width="180" />
|
<el-table-column prop="className" label="行政班" min-width="180" />
|
||||||
@@ -1529,7 +1532,9 @@ onMounted(async () => {
|
|||||||
<el-empty v-if="!proxyBookingLoading && !proxyBookingStudents.length" description="没有可代选的学生" />
|
<el-empty v-if="!proxyBookingLoading && !proxyBookingStudents.length" description="没有可代选的学生" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="proxyBookingDialog = false">取消</el-button>
|
<el-button @click="proxyBookingDialog = false">取消</el-button>
|
||||||
<el-button type="primary" :disabled="!proxyBookingStudentId" @click="saveProxyBooking">确认代选</el-button>
|
<el-button type="primary" :disabled="!proxyBookingStudentIds.length" @click="saveProxyBooking">
|
||||||
|
确认代选({{ proxyBookingStudentIds.length }})
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user