重修选课
This commit is contained in:
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
Reference in new issue
Block a user