This commit is contained in:
2026-07-24 20:37:42 +08:00 Unverified
parent 5da1a8a403
commit 20ca205080
9 changed files with 1115 additions and 7 deletions
@@ -179,6 +179,7 @@ public sealed class CourseSelectionsController(
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CourseNature = x.TeachingTask.Course.Nature,
CollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
@@ -279,10 +280,14 @@ public sealed class CourseSelectionsController(
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course!.Name,
CourseNature = x.TeachingTask.Course.Nature,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId),
x.CourseSelectionRoundId,
RoundStatus = x.CourseSelectionRound!.Status,
x.Capacity
})
.FirstOrDefaultAsync(cancellationToken);
@@ -316,13 +321,281 @@ public sealed class CourseSelectionsController(
offering.TeachingTaskId,
offering.TaskNumber,
offering.TaskName,
offering.CourseCode,
offering.CourseName,
offering.CourseNature,
offering.Capacity,
EnrolledCount = students.Count,
Students = students
Students = students,
CanProxyEnroll =
CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) &&
offering.RoundStatus != CourseSelectionRoundStatus.Draft &&
(currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin))
});
}
[HttpGet("offerings/{id:guid}/eligible-students")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> GetEligibleStudents(
Guid id,
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.IsOpenToAll,
CourseNature = x.TeachingTask!.Course!.Nature,
RoundStatus = x.CourseSelectionRound!.Status,
ClassIds = x.TeachingTask.Classes
.Select(item => item.AdministrativeClassId)
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
var source = db.Students.AsNoTracking()
.Where(x =>
x.Status == StudentStatus.Active &&
(offering.IsOpenToAll ||
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
!db.CourseEnrollments.Any(enrollment =>
enrollment.CourseSelectionOfferingId == id &&
enrollment.StudentId == x.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled));
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.StudentNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.AdministrativeClass!.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name,
CollegeName = x.AdministrativeClass.Major.College!.Name
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpPost("offerings/{id:guid}/admin-enrollments")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> AdminEnroll(
Guid id,
AdminEnrollmentRequest request,
CancellationToken cancellationToken)
{
var studentIds = request.StudentIds.Distinct().ToArray();
if (studentIds.Length == 0)
return ValidationProblem("请至少选择一名学生。");
if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生代选。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可办理代选。");
var students = await db.Students
.Include(x => x.AdministrativeClass)
.Where(x => studentIds.Contains(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 outOfScope = students.FirstOrDefault(student =>
!offering.IsOpenToAll &&
!task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId));
if (outOfScope is not null)
{
return ConflictProblem(
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
}
var existingEnrollments = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOfferingId == id &&
studentIds.Contains(x.StudentId))
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
}
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount + students.Count > offering.Capacity)
{
return ConflictProblem(
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
}
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
studentIds.Contains(x.StudentId) &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (duplicateStudentIds.Count > 0)
{
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
foreach (var student in students)
{
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course.Credits > round.MaxCredits)
{
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId,
selectedTaskIds,
cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
{
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
}
}
var now = DateTime.UtcNow;
foreach (var student in students)
{
var enrollment = existingEnrollments.FirstOrDefault(x =>
x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = id,
StudentId = student.Id,
EnrolledAt = now
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
}
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { EnrolledCount = students.Count });
}
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> AdminWithdraw(
Guid offeringId,
Guid enrollmentId,
CancellationToken cancellationToken)
{
var enrollment = await db.CourseEnrollments
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(
x =>
x.Id == enrollmentId &&
x.CourseSelectionOfferingId == offeringId,
cancellationToken);
if (enrollment is null) return NotFound();
var offering = enrollment.CourseSelectionOffering!;
if (!CourseSelectionRules.SupportsProxyEnrollment(
offering.TeachingTask!.Course!.Nature))
return ConflictProblem("管理员名单调整仅适用于公共必修课。");
if (offering.TeachingTask.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可调整名单。");
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
return ConflictProblem("该学生已不在教学班名单中。");
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
enrollment.WithdrawnAt = DateTime.UtcNow;
return await SaveAsync(enrollmentId, false, cancellationToken);
}
[HttpGet("student/options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentOptions(
@@ -722,6 +995,9 @@ public sealed record CourseSelectionOfferingRequest(
public sealed record StudentEnrollmentRequest(Guid OfferingId);
public sealed record AdminEnrollmentRequest(
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
public sealed record StudentOfferingDto(
Guid Id,
Guid TeachingTaskId,