选课
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user