重修选课

This commit is contained in:
biss committed 2026-08-15 14:57:22 +08:00
1 parent be49a2586f
commit 57a60ff8d5
10 files changed
+7273 -28

No files matched your search

@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection; using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -185,6 +186,7 @@ public sealed class CourseSelectionsController(
x.WithdrawalEndsAt, x.WithdrawalEndsAt,
x.MaxCredits, x.MaxCredits,
x.MaxCourseCount, x.MaxCourseCount,
x.Scope,
EligibleGrades = x.EligibleGrades EligibleGrades = x.EligibleGrades
.OrderBy(item => item.Grade) .OrderBy(item => item.Grade)
.Select(item => item.Grade), .Select(item => item.Grade),
@@ -231,6 +233,7 @@ public sealed class CourseSelectionsController(
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(), WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
MaxCredits = request.MaxCredits, MaxCredits = request.MaxCredits,
MaxCourseCount = request.MaxCourseCount, MaxCourseCount = request.MaxCourseCount,
Scope = request.Scope,
EligibleGrades = NormalizeGrades(request.EligibleGrades) EligibleGrades = NormalizeGrades(request.EligibleGrades)
.Select(grade => new CourseSelectionRoundGrade { Grade = grade }) .Select(grade => new CourseSelectionRoundGrade { Grade = grade })
.ToList(), .ToList(),
@@ -262,6 +265,7 @@ public sealed class CourseSelectionsController(
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
round.MaxCredits = request.MaxCredits; round.MaxCredits = request.MaxCredits;
round.MaxCourseCount = request.MaxCourseCount; round.MaxCourseCount = request.MaxCourseCount;
round.Scope = request.Scope;
var requestedGrades = NormalizeGrades(request.EligibleGrades); var requestedGrades = NormalizeGrades(request.EligibleGrades);
var requestedGradeSet = requestedGrades.ToHashSet(); var requestedGradeSet = requestedGrades.ToHashSet();
db.CourseSelectionRoundGrades.RemoveRange( db.CourseSelectionRoundGrades.RemoveRange(
@@ -1186,6 +1190,10 @@ public sealed class CourseSelectionsController(
{ {
return Forbid(); return Forbid();
} }
var retakeCourseIds = await RetakeCourseIdsAsync(
student.Id,
round.AcademicTermId,
cancellationToken);
var offerings = await db.CourseSelectionOfferings.AsNoTracking() var offerings = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => .Where(x =>
@@ -1220,14 +1228,7 @@ public sealed class CourseSelectionsController(
.Select(item => (CourseEnrollmentStatus?)item.Status) .Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(), .FirstOrDefault(),
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
db.CourseEnrollments.Any(e => retakeCourseIds.Contains(x.TeachingTask.CourseId),
e.StudentId == student.Id &&
(e.Status == CourseEnrollmentStatus.Enrolled ||
e.Status == CourseEnrollmentStatus.Withdrawn) &&
e.CourseSelectionOffering!.TeachingTask!.CourseId ==
x.TeachingTask.CourseId &&
e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId),
db.ScheduleEntries db.ScheduleEntries
.Where(entry => .Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId && entry.TeachingTaskId == x.TeachingTaskId &&
@@ -1247,6 +1248,15 @@ public sealed class CourseSelectionsController(
null)) null))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly)
{
offerings = offerings.Where(x =>
x.IsRetake ||
x.EnrollmentStatus == CourseEnrollmentStatus.Enrolled ||
x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted)
.ToList();
}
var waitlistedOfferingIds = offerings var waitlistedOfferingIds = offerings
.Where(x => x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted) .Where(x => x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted)
.Select(x => x.Id) .Select(x => x.Id)
@@ -1296,6 +1306,7 @@ public sealed class CourseSelectionsController(
round.WithdrawalEndsAt, round.WithdrawalEndsAt,
round.MaxCredits, round.MaxCredits,
round.MaxCourseCount, round.MaxCourseCount,
round.Scope,
EligibleGrades = eligibleGrades, EligibleGrades = eligibleGrades,
round.Status, round.Status,
IsAvailableNow = CourseSelectionRules.IsSelectionOpen( IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
@@ -1405,16 +1416,13 @@ public sealed class CourseSelectionsController(
x.AdministrativeClassId == student.AdministrativeClassId)) x.AdministrativeClassId == student.AdministrativeClassId))
return Forbid(); return Forbid();
// Detect retake: student previously took the same course in any term var isRetake = await IsEligibleForRetakeAsync(
var isRetake = await db.CourseEnrollments.AnyAsync( student.Id,
x => task.CourseId,
x.StudentId == student.Id &&
(x.Status == CourseEnrollmentStatus.Enrolled ||
x.Status == CourseEnrollmentStatus.Withdrawn) &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId, round.AcademicTermId,
cancellationToken); cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly && !isRetake)
return ConflictProblem("该批次仅限有未通过历史成绩的学生办理重修选课。");
var existing = await db.CourseEnrollments.FirstOrDefaultAsync( var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x => x =>
@@ -2013,6 +2021,8 @@ public sealed class CourseSelectionsController(
return ValidationProblem("选课开始时间必须早于结束时间。"); return ValidationProblem("选课开始时间必须早于结束时间。");
if (withdrawalEndsAt < endsAt) if (withdrawalEndsAt < endsAt)
return ValidationProblem("退课截止时间不能早于选课结束时间。"); return ValidationProblem("退课截止时间不能早于选课结束时间。");
if (!Enum.IsDefined(request.Scope))
return ValidationProblem("选课批次类型无效。");
var eligibleGrades = NormalizeGrades(request.EligibleGrades); var eligibleGrades = NormalizeGrades(request.EligibleGrades);
if (eligibleGrades.Length > 20) if (eligibleGrades.Length > 20)
return ValidationProblem("单个选课批次最多配置 20 个适用年级。"); return ValidationProblem("单个选课批次最多配置 20 个适用年级。");
@@ -2048,6 +2058,57 @@ public sealed class CourseSelectionsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
private async Task<HashSet<Guid>> RetakeCourseIdsAsync(
Guid studentId,
Guid targetAcademicTermId,
CancellationToken cancellationToken)
{
var attempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.AcademicTermId != targetAcademicTermId)
.Select(x => new RetakeGradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId,
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
return attempts
.GroupBy(x => x.CourseId)
.Where(group =>
{
var evaluation = StudentCourseProgressRules.Evaluate(
group.Select(x => new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus)),
false);
return evaluation.HasFailedAttempt && !evaluation.HasPassedAttempt;
})
.Select(group => group.Key)
.ToHashSet();
}
private async Task<bool> IsEligibleForRetakeAsync(
Guid studentId,
Guid courseId,
Guid targetAcademicTermId,
CancellationToken cancellationToken)
{
var attempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == courseId &&
x.GradeSheet.TeachingTask.AcademicTermId != targetAcademicTermId)
.Select(x => new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
var evaluation = StudentCourseProgressRules.Evaluate(attempts, false);
return evaluation.HasFailedAttempt && !evaluation.HasPassedAttempt;
}
private async Task<EnrollmentEligibility> EvaluateEnrollmentEligibilityAsync( private async Task<EnrollmentEligibility> EvaluateEnrollmentEligibilityAsync(
Student student, Student student,
CourseSelectionOffering offering, CourseSelectionOffering offering,
@@ -2073,15 +2134,13 @@ public sealed class CourseSelectionsController(
cancellationToken)) cancellationToken))
return new(false, "你不属于该教学班的选课对象。"); return new(false, "你不属于该教学班的选课对象。");
var isRetake = await db.CourseEnrollments.AnyAsync( var isRetake = await IsEligibleForRetakeAsync(
x => student.Id,
x.StudentId == student.Id && task.CourseId,
(x.Status == CourseEnrollmentStatus.Enrolled ||
x.Status == CourseEnrollmentStatus.Withdrawn) &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId, round.AcademicTermId,
cancellationToken); cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly && !isRetake)
return new(false, "该批次仅限有未通过历史成绩的学生办理重修选课或候补。");
if (!isRetake) if (!isRetake)
{ {
var duplicateCourse = await db.CourseEnrollments.AnyAsync( var duplicateCourse = await db.CourseEnrollments.AnyAsync(
@@ -2439,6 +2498,11 @@ public sealed class CourseSelectionsController(
private sealed record EnrollmentEligibility(bool IsRetake, string? Error); private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
private sealed record RetakeGradeAttempt(
Guid CourseId,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private sealed record TeacherTeachingTaskInfo( private sealed record TeacherTeachingTaskInfo(
Guid Id, Guid Id,
string TaskNumber, string TaskNumber,
@@ -2473,7 +2537,8 @@ public sealed record CourseSelectionRoundRequest(
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits, [Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
[Range(1, 100)] int? MaxCourseCount, [Range(1, 100)] int? MaxCourseCount,
IReadOnlyCollection<int>? EligibleGrades, IReadOnlyCollection<int>? EligibleGrades,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes,
CourseSelectionScope Scope = CourseSelectionScope.All);
public sealed record CourseSelectionOfferingRequest( public sealed record CourseSelectionOfferingRequest(
Guid TeachingTaskId, Guid TeachingTaskId,
@@ -12,6 +12,7 @@ public sealed class CourseSelectionRound : EntityBase
public DateTime WithdrawalEndsAt { get; set; } public DateTime WithdrawalEndsAt { get; set; }
public decimal MaxCredits { get; set; } = 30; public decimal MaxCredits { get; set; } = 30;
public int? MaxCourseCount { get; set; } public int? MaxCourseCount { get; set; }
public CourseSelectionScope Scope { get; set; } = CourseSelectionScope.All;
public CourseSelectionRoundStatus Status { get; set; } = public CourseSelectionRoundStatus Status { get; set; } =
CourseSelectionRoundStatus.Draft; CourseSelectionRoundStatus.Draft;
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -58,6 +59,12 @@ public enum CourseSelectionRoundStatus
Closed = 3 Closed = 3
} }
public enum CourseSelectionScope
{
All = 1,
RetakeOnly = 2
}
public enum CourseEnrollmentStatus public enum CourseEnrollmentStatus
{ {
Enrolled = 1, Enrolled = 1,
@@ -860,6 +860,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.MaxCredits).HasPrecision(6, 1); entity.Property(x => x.MaxCredits).HasPrecision(6, 1);
entity.Property(x => x.Scope).HasDefaultValue(CourseSelectionScope.All);
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status }); entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm) entity.HasOne(x => x.AcademicTerm)
Loaded 3 of 10 files, more files were not shown because too many files have changed in this diff. Show more