排课
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 32m28s
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 32m28s
This commit is contained in:
1 parent
2f5c6af37c
commit
727a429abf
7 files changed
+1091
-108
No files matched your search
@@ -169,6 +169,95 @@ public sealed class ExamsController(
|
|||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
return await SaveAsync(session.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSessionsBatch(
|
||||||
|
Guid planId,
|
||||||
|
CreateExamSessionsBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||||
|
|
||||||
|
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教学班。");
|
||||||
|
if (taskIds.Length > 100)
|
||||||
|
return ValidationProblem("一次最多选择100个教学班。");
|
||||||
|
|
||||||
|
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 validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.WhereIn(taskIds, x => x.Id)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (validTaskIds.Count != taskIds.Length)
|
||||||
|
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||||
|
|
||||||
|
var duplicateTaskIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == planId)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
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
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
|
||||||
|
if (conflictedStudentCount > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。");
|
||||||
|
|
||||||
|
foreach (var taskId in taskIds)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSessionAsync(plan, null,
|
||||||
|
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
|
||||||
|
{
|
||||||
|
ExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
|
return Ok(new { createdCount = taskIds.Length });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("批量创建考试场次失败,关联数据可能已发生变化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateSession(
|
public async Task<ActionResult> UpdateSession(
|
||||||
@@ -237,11 +326,19 @@ public sealed class ExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> AutoArrange(
|
public async Task<ActionResult> AutoArrange(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
ExamAutoArrangeRequest? request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await examArrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
|
var result = await examArrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,20 +501,40 @@ public sealed class ExamsController(
|
|||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
x.StartsAt,
|
x.StartsAt
|
||||||
Students = db.CourseEnrollments
|
|
||||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
|
||||||
.OrderBy(e => e.Student!.StudentNumber)
|
|
||||||
.Select(e => new
|
|
||||||
{
|
|
||||||
e.StudentId,
|
|
||||||
e.Student!.StudentNumber,
|
|
||||||
e.Student.Name,
|
|
||||||
ClassName = e.Student.AdministrativeClass!.Name
|
|
||||||
}).ToList()
|
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
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);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.CourseName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.StartsAt,
|
||||||
|
Students = students.Select((student, index) => new
|
||||||
|
{
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -434,7 +551,7 @@ public sealed class ExamsController(
|
|||||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue)
|
if (!studentId.HasValue)
|
||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.ExamSessions.AsNoTracking()
|
var schedule = await db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||||
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
@@ -455,9 +572,52 @@ public sealed class ExamsController(
|
|||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
x.TeachingTaskId,
|
||||||
IsExam = true
|
IsExam = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
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 seatNumbers = seatRows
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.SelectMany(group => group.Select((row, index) => new
|
||||||
|
{
|
||||||
|
row.TeachingTaskId,
|
||||||
|
row.StudentId,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
}))
|
||||||
|
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
|
||||||
|
|
||||||
|
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,
|
||||||
|
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
||||||
|
x.IsExam
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (scope.IsInRole(SystemRoles.Teacher))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -650,3 +810,17 @@ public sealed record CreateExamSessionRequest(
|
|||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateExamSessionsBatchRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ExamAutoArrangeRequest(
|
||||||
|
IReadOnlyCollection<Guid>? SessionIds = null,
|
||||||
|
bool AssignClassrooms = true,
|
||||||
|
bool AssignInvigilators = true);
|
||||||
@@ -224,6 +224,78 @@ public sealed class MakeupExamsController(
|
|||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
return await SaveAsync(session.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSessionsBatch(
|
||||||
|
Guid planId,
|
||||||
|
CreateMakeupExamSessionsBatchRequest 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 taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教学班。");
|
||||||
|
if (taskIds.Length > 100)
|
||||||
|
return ValidationProblem("一次最多选择100个教学班。");
|
||||||
|
|
||||||
|
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 validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.WhereIn(taskIds, x => x.Id)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (validTaskIds.Count != taskIds.Length)
|
||||||
|
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||||
|
|
||||||
|
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == planId)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateTaskIds.Count > 0)
|
||||||
|
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
|
||||||
|
|
||||||
|
foreach (var taskId in taskIds)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSessionAsync(plan, null,
|
||||||
|
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { createdCount = taskIds.Length });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateSession(
|
public async Task<ActionResult> UpdateSession(
|
||||||
@@ -292,9 +364,16 @@ public sealed class MakeupExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> AutoArrange(
|
public async Task<ActionResult> AutoArrange(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
ExamAutoArrangeRequest? request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
|
var result = await arrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
@@ -555,19 +634,43 @@ public sealed class MakeupExamsController(
|
|||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
x.StartsAt,
|
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);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
if (session is null) return NotFound();
|
||||||
|
|
||||||
|
var students = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.Where(e => e.MakeupExamSessionId == id)
|
||||||
|
.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
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.CourseName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.StartsAt,
|
||||||
|
Students = students.Select((student, index) => new
|
||||||
|
{
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
student.Reason,
|
||||||
|
student.MakeupScore,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -699,7 +802,7 @@ public sealed class MakeupExamsController(
|
|||||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue)
|
if (!studentId.HasValue)
|
||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
.Where(x => x.StudentId == studentId &&
|
.Where(x => x.StudentId == studentId &&
|
||||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||||
@@ -724,7 +827,49 @@ public sealed class MakeupExamsController(
|
|||||||
x.MakeupScore,
|
x.MakeupScore,
|
||||||
IsMakeup = true
|
IsMakeup = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
if (schedule.Count == 0) return Ok(schedule);
|
||||||
|
|
||||||
|
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
|
||||||
|
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.MakeupExamSessionId,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var seatNumbers = seatRows
|
||||||
|
.GroupBy(x => x.MakeupExamSessionId)
|
||||||
|
.SelectMany(group => group.Select((row, index) => new
|
||||||
|
{
|
||||||
|
row.MakeupExamSessionId,
|
||||||
|
row.StudentId,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
}))
|
||||||
|
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
|
||||||
|
|
||||||
|
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.Reason,
|
||||||
|
x.MakeupScore,
|
||||||
|
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
|
||||||
|
x.IsMakeup
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (scope.IsInRole(SystemRoles.Teacher))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -941,6 +1086,15 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateMakeupExamSessionsBatchRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
public sealed record EnrollStudentsRequest(
|
public sealed record EnrollStudentsRequest(
|
||||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||||
|
bool assignClassrooms,
|
||||||
|
bool assignInvigilators,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!assignClassrooms && !assignInvigilators)
|
||||||
|
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
var plan = await db.ExamPlans
|
var plan = await db.ExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -27,9 +33,23 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
if (plan.Status != ExamPlanStatus.Draft)
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
||||||
|
|
||||||
var sessions = plan.Sessions.ToList();
|
var requestedIds = (requestedSessionIds ?? [])
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
if (requestedIds.Count > 100)
|
||||||
|
return ExamArrangementResult.Fail("一次最多处理100个考试场次。");
|
||||||
|
if (requestedIds.Count > 0 &&
|
||||||
|
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||||
|
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
||||||
|
|
||||||
|
var sessions = plan.Sessions
|
||||||
|
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
||||||
|
.ToList();
|
||||||
if (sessions.Count == 0)
|
if (sessions.Count == 0)
|
||||||
return ExamArrangementResult.Fail("考试计划中没有场次。");
|
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||||
|
|
||||||
var termId = plan.AcademicTermId;
|
var termId = plan.AcademicTermId;
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
@@ -44,8 +64,8 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
int assignedRooms = 0;
|
int assignedRooms = 0;
|
||||||
int assignedInvigilators = 0;
|
int assignedInvigilators = 0;
|
||||||
int skippedRooms = 0;
|
int unavailableRooms = 0;
|
||||||
int skippedInvigilators = 0;
|
int unavailableInvigilators = 0;
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
|
|
||||||
// Track occupied time slots to avoid conflicts
|
// Track occupied time slots to avoid conflicts
|
||||||
@@ -69,7 +89,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// ── Auto-assign classroom ──
|
// ── Auto-assign classroom ──
|
||||||
if (!session.ClassroomId.HasValue)
|
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
var room = await FindBestClassroomAsync(
|
||||||
session, studentCount, occupiedRooms, cancellationToken);
|
session, studentCount, occupiedRooms, cancellationToken);
|
||||||
@@ -83,20 +103,15 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
skippedRooms++;
|
unavailableRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-assign invigilators ──
|
// ── Auto-assign invigilators ──
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
var currentInvigilatorCount = session.Invigilators.Count;
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||||
if (needed > 0)
|
if (assignInvigilators && needed > 0)
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
.Select(x => x.TeacherId).ToHashSet();
|
||||||
@@ -116,12 +131,11 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (newlyAssigned.Count < needed)
|
||||||
|
{
|
||||||
|
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedInvigilators++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +143,9 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||||
|
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user