选课
This commit is contained in:
@@ -337,12 +337,13 @@ public sealed class CourseSelectionsController(
|
||||
}
|
||||
|
||||
[HttpGet("offerings/{id:guid}/eligible-students")]
|
||||
[Authorize(Roles = RoundManagers)]
|
||||
[Authorize(Roles = OfferingManagers)]
|
||||
public async Task<ActionResult> GetEligibleStudents(
|
||||
Guid id,
|
||||
string? keyword = null,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
bool forceMode = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
@@ -360,10 +361,10 @@ public sealed class CourseSelectionsController(
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (offering is null) return NotFound();
|
||||
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||
return ConflictProblem("选课批次开放后才能办理管理员代选。");
|
||||
return ConflictProblem("选课批次开放后才能办理。");
|
||||
|
||||
var source = db.Students.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -567,6 +568,88 @@ public sealed class CourseSelectionsController(
|
||||
return Ok(new { EnrolledCount = students.Count });
|
||||
}
|
||||
|
||||
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
||||
[Authorize(Roles = OfferingManagers)]
|
||||
public async Task<ActionResult> ForceEnroll(
|
||||
Guid offeringId,
|
||||
ForceEnrollmentRequest 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)
|
||||
.FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken);
|
||||
if (offering is null) return NotFound();
|
||||
var round = offering.CourseSelectionRound!;
|
||||
var task = offering.TeachingTask!;
|
||||
|
||||
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 existingEnrollments = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == offeringId &&
|
||||
studentIds.Contains(x.StudentId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var alreadyEnrolled = existingEnrollments
|
||||
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
|
||||
if (alreadyEnrolled is not null)
|
||||
{
|
||||
var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
||||
return ConflictProblem(
|
||||
$"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var enrolled = 0;
|
||||
foreach (var student in students)
|
||||
{
|
||||
var enrollment = existingEnrollments
|
||||
.FirstOrDefault(x => x.StudentId == student.Id);
|
||||
if (enrollment is null)
|
||||
{
|
||||
db.CourseEnrollments.Add(new CourseEnrollment
|
||||
{
|
||||
CourseSelectionOfferingId = offeringId,
|
||||
StudentId = student.Id,
|
||||
EnrollmentType = EnrollmentType.Retake,
|
||||
EnrolledAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||
enrollment.EnrolledAt = now;
|
||||
enrollment.WithdrawnAt = null;
|
||||
enrollment.EnrollmentType = EnrollmentType.Retake;
|
||||
}
|
||||
enrolled++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = enrolled });
|
||||
}
|
||||
|
||||
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||
[Authorize(Roles = RoundManagers)]
|
||||
public async Task<ActionResult> AdminWithdraw(
|
||||
@@ -638,6 +721,12 @@ public sealed class CourseSelectionsController(
|
||||
.Select(item => (CourseEnrollmentStatus?)item.Status)
|
||||
.FirstOrDefault(),
|
||||
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
|
||||
db.CourseEnrollments.Any(e =>
|
||||
e.StudentId == student.Id &&
|
||||
e.CourseSelectionOffering!.TeachingTask!.CourseId ==
|
||||
x.TeachingTask.CourseId &&
|
||||
e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
|
||||
round.AcademicTermId),
|
||||
db.ScheduleEntries
|
||||
.Where(entry =>
|
||||
entry.TeachingTaskId == x.TeachingTaskId &&
|
||||
@@ -716,6 +805,7 @@ public sealed class CourseSelectionsController(
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.Status,
|
||||
x.EnrollmentType,
|
||||
x.EnrolledAt,
|
||||
x.WithdrawnAt,
|
||||
CanWithdraw =
|
||||
@@ -762,6 +852,15 @@ public sealed class CourseSelectionsController(
|
||||
x.AdministrativeClassId == student.AdministrativeClassId))
|
||||
return Forbid();
|
||||
|
||||
// Detect retake: student previously took the same course in any term
|
||||
var isRetake = await db.CourseEnrollments.AnyAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
|
||||
round.AcademicTermId,
|
||||
cancellationToken);
|
||||
|
||||
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
|
||||
x =>
|
||||
x.CourseSelectionOfferingId == offering.Id &&
|
||||
@@ -769,25 +868,34 @@ public sealed class CourseSelectionsController(
|
||||
cancellationToken);
|
||||
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
|
||||
return ConflictProblem("你已经选择了该教学班。");
|
||||
|
||||
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
||||
x =>
|
||||
x.CourseSelectionOfferingId == offering.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled,
|
||||
cancellationToken);
|
||||
if (enrolledCount >= offering.Capacity)
|
||||
var effectiveCapacity = isRetake
|
||||
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
|
||||
: offering.Capacity;
|
||||
if (enrolledCount >= effectiveCapacity)
|
||||
return ConflictProblem("该教学班名额已满。");
|
||||
|
||||
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||
round.AcademicTermId,
|
||||
cancellationToken);
|
||||
if (duplicateCourse)
|
||||
return ConflictProblem("同一学期不能重复选择相同课程。");
|
||||
// Normal enrollment: no duplicate course in same term
|
||||
if (!isRetake)
|
||||
{
|
||||
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||
round.AcademicTermId,
|
||||
cancellationToken);
|
||||
if (duplicateCourse)
|
||||
return ConflictProblem("同一学期不能重复选择相同课程。");
|
||||
}
|
||||
|
||||
// Credit limit check
|
||||
var selectedCredits = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
@@ -799,16 +907,17 @@ public sealed class CourseSelectionsController(
|
||||
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
}
|
||||
|
||||
// Schedule conflict check
|
||||
var candidateEntries = await PublishedScheduleEntries(
|
||||
round.AcademicTermId,
|
||||
[task.Id],
|
||||
cancellationToken);
|
||||
round.AcademicTermId, [task.Id], cancellationToken);
|
||||
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||
candidateEntries.Count == 0)
|
||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||
|
||||
var selectedTaskIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
@@ -819,18 +928,28 @@ public sealed class CourseSelectionsController(
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var selectedEntries = await PublishedScheduleEntries(
|
||||
round.AcademicTermId,
|
||||
selectedTaskIds,
|
||||
cancellationToken);
|
||||
round.AcademicTermId, selectedTaskIds, cancellationToken);
|
||||
|
||||
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
||||
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
||||
{
|
||||
if (!isRetake)
|
||||
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
||||
|
||||
// Retake: allow ≤50% overlap
|
||||
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
|
||||
candidateEntries, selectedEntries);
|
||||
if (overlap > 50)
|
||||
return ConflictProblem(
|
||||
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new CourseEnrollment
|
||||
{
|
||||
CourseSelectionOfferingId = offering.Id,
|
||||
StudentId = student.Id
|
||||
StudentId = student.Id,
|
||||
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
|
||||
};
|
||||
db.CourseEnrollments.Add(existing);
|
||||
}
|
||||
@@ -839,10 +958,11 @@ public sealed class CourseSelectionsController(
|
||||
existing.Status = CourseEnrollmentStatus.Enrolled;
|
||||
existing.EnrolledAt = now;
|
||||
existing.WithdrawnAt = null;
|
||||
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal;
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Created(string.Empty, new { existing.Id });
|
||||
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
|
||||
}
|
||||
|
||||
[HttpDelete("student/enrollments/{id:guid}")]
|
||||
@@ -1096,6 +1216,9 @@ public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
||||
public sealed record AdminEnrollmentRequest(
|
||||
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||
|
||||
public sealed record ForceEnrollmentRequest(
|
||||
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||
|
||||
public sealed record StudentOfferingDto(
|
||||
Guid Id,
|
||||
Guid TeachingTaskId,
|
||||
@@ -1109,6 +1232,7 @@ public sealed record StudentOfferingDto(
|
||||
bool IsOpenToAll,
|
||||
CourseEnrollmentStatus? EnrollmentStatus,
|
||||
bool IsFlexible,
|
||||
bool IsRetake,
|
||||
IEnumerable<StudentScheduleDto> Schedules);
|
||||
|
||||
public sealed record StudentScheduleDto(
|
||||
|
||||
Reference in New Issue
Block a user