补考增强
This commit is contained in:
1 parent
44e90ae35f
commit
1da6543a38
7 files changed
+714
-10
No files matched your search
@@ -18,10 +18,13 @@ public sealed class MakeupExamsController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
MakeupExamEligibilityService eligibilityService,
|
||||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
MakeupExamArrangementService arrangementService,
|
||||||
|
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
private const string ScoreEnterers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// Plans
|
// Plans
|
||||||
@@ -284,6 +287,74 @@ public sealed class MakeupExamsController(
|
|||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Auto-create (background job)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/auto-create")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> AutoCreate(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||||
|
|
||||||
|
// Check for existing active job
|
||||||
|
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == planId &&
|
||||||
|
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||||
|
x.Status == MakeupExamAutoJobStatus.Running))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
|
||||||
|
message = "该计划已有正在执行的任务。" });
|
||||||
|
|
||||||
|
var job = new MakeupExamAutoJob
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = planId
|
||||||
|
};
|
||||||
|
db.MakeupExamAutoJobs.Add(job);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
autoJobQueue.Enqueue(job.Id);
|
||||||
|
|
||||||
|
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
||||||
|
new { jobId = job.Id, status = job.Status.ToString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("auto-jobs/{jobId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetAutoJob(
|
||||||
|
Guid jobId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.MakeupExamPlanId,
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.TotalCourses,
|
||||||
|
job.ProcessedCourses,
|
||||||
|
job.CreatedSessions,
|
||||||
|
job.EnrolledStudents,
|
||||||
|
Messages = !string.IsNullOrEmpty(job.MessagesJson)
|
||||||
|
? System.Text.Json.JsonSerializer.Deserialize<List<string>>(job.MessagesJson)
|
||||||
|
: new List<string>(),
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// Enrollments
|
// Enrollments
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -390,7 +461,7 @@ public sealed class MakeupExamsController(
|
|||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpPut("sessions/{id:guid}/scores")]
|
[HttpPut("sessions/{id:guid}/scores")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = ScoreEnterers)]
|
||||||
public async Task<ActionResult> RecordScores(
|
public async Task<ActionResult> RecordScores(
|
||||||
Guid id,
|
Guid id,
|
||||||
List<RecordMakeupScoreRequest> scores,
|
List<RecordMakeupScoreRequest> scores,
|
||||||
@@ -399,11 +470,23 @@ public sealed class MakeupExamsController(
|
|||||||
var session = await db.MakeupExamSessions
|
var session = await db.MakeupExamSessions
|
||||||
.Include(x => x.MakeupExamPlan)
|
.Include(x => x.MakeupExamPlan)
|
||||||
.Include(x => x.Enrollments)
|
.Include(x => x.Enrollments)
|
||||||
|
.Include(x => x.TeachingTask!)
|
||||||
|
.ThenInclude(x => x.Teachers)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null) return NotFound();
|
if (session is null) return NotFound();
|
||||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||||
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
|
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
|
||||||
|
|
||||||
|
// Teachers can only record scores for courses they teach
|
||||||
|
if (!IsManager())
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var teaches = session.TeachingTask!.Teachers
|
||||||
|
.Any(t => t.Teacher!.UserId == userId);
|
||||||
|
if (!teaches)
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
|
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
|
||||||
|
|
||||||
foreach (var entry in scores)
|
foreach (var entry in scores)
|
||||||
@@ -413,19 +496,22 @@ public sealed class MakeupExamsController(
|
|||||||
|
|
||||||
enrollment.MakeupScore = entry.Score;
|
enrollment.MakeupScore = entry.Score;
|
||||||
|
|
||||||
// Update the grade record
|
// Update the grade record — 补考合格按60分记
|
||||||
if (enrollment.SourceGradeRecordId.HasValue)
|
if (enrollment.SourceGradeRecordId.HasValue)
|
||||||
{
|
{
|
||||||
var record = await db.GradeRecords
|
var record = await db.GradeRecords
|
||||||
.Include(x => x.GradeSheet)
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
|
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (record is not null)
|
if (record is not null)
|
||||||
{
|
{
|
||||||
record.ExamStatus = GradeExamStatus.Makeup;
|
record.ExamStatus = GradeExamStatus.Makeup;
|
||||||
record.TotalScore = entry.Score;
|
// Cap passing score at 60
|
||||||
record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score);
|
var cappedScore = entry.Score >= 60 ? 60m : entry.Score;
|
||||||
record.Notes = "补考成绩";
|
record.TotalScore = cappedScore;
|
||||||
|
record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore);
|
||||||
|
record.Notes = entry.Score >= 60
|
||||||
|
? $"补考合格(原始{entry.Score}分,按60分记)"
|
||||||
|
: $"补考不合格({entry.Score}分)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,6 +736,50 @@ public sealed class MakeupExamsController(
|
|||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// My teaching sessions (for score entry)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("my-teaching-sessions")]
|
||||||
|
[Authorize(Roles = SystemRoles.Teacher)]
|
||||||
|
public async Task<ActionResult> GetMyTeachingSessions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
return Ok(await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||||
|
x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
|
||||||
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
PlanName = x.MakeupExamPlan!.Name,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
|
EnrolledCount = x.Enrollments.Count,
|
||||||
|
GradedCount = x.Enrollments.Count(e => e.MakeupScore != null),
|
||||||
|
Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
|
||||||
|
.Select(e => new
|
||||||
|
{
|
||||||
|
e.StudentId,
|
||||||
|
e.Student!.StudentNumber,
|
||||||
|
e.Student.Name,
|
||||||
|
ClassName = e.Student.AdministrativeClass!.Name,
|
||||||
|
e.Reason,
|
||||||
|
e.MakeupScore
|
||||||
|
}).ToList(),
|
||||||
|
IsMakeup = true
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -69,3 +69,26 @@ public enum MakeupReason
|
|||||||
Absent = 2,
|
Absent = 2,
|
||||||
DeferredApproved = 3
|
DeferredApproved = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamAutoJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid MakeupExamPlanId { get; set; }
|
||||||
|
public MakeupExamPlan? MakeupExamPlan { get; set; }
|
||||||
|
public MakeupExamAutoJobStatus Status { get; set; } = MakeupExamAutoJobStatus.Queued;
|
||||||
|
public int TotalCourses { get; set; }
|
||||||
|
public int ProcessedCourses { get; set; }
|
||||||
|
public int CreatedSessions { get; set; }
|
||||||
|
public int EnrolledStudents { get; set; }
|
||||||
|
public string? MessagesJson { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MakeupExamAutoJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class MakeupExamAutoJobQueue
|
||||||
|
{
|
||||||
|
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||||
|
new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Enqueue(Guid jobId)
|
||||||
|
{
|
||||||
|
if (!_channel.Writer.TryWrite(jobId))
|
||||||
|
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||||
|
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamAutoJobWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
MakeupExamAutoJobQueue queue,
|
||||||
|
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Could not recover makeup exam auto jobs (table may not exist yet).");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var processor = scope.ServiceProvider
|
||||||
|
.GetRequiredService<MakeupExamAutoJobProcessor>();
|
||||||
|
await processor.ProcessAsync(jobId, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
|
||||||
|
jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Makeup exam auto job worker is stopping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var jobs = await db.MakeupExamAutoJobs
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||||
|
x.Status == MakeupExamAutoJobStatus.Running)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
job.Status = MakeupExamAutoJobStatus.Queued;
|
||||||
|
job.StartedAt = null;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.Count > 0)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
foreach (var job in jobs)
|
||||||
|
queue.Enqueue(job.Id);
|
||||||
|
|
||||||
|
if (jobs.Count > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
|
||||||
|
jobs.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamAutoJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
MakeupExamEligibilityService eligibilityService,
|
||||||
|
ILogger<MakeupExamAutoJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.MakeupExamAutoJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is MakeupExamAutoJobStatus.Succeeded
|
||||||
|
or MakeupExamAutoJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await db.MakeupExamPlans
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.MakeupExamPlanId, stoppingToken);
|
||||||
|
if (plan is null || plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
throw new InvalidOperationException("补考计划草稿不存在或已不允许修改。");
|
||||||
|
|
||||||
|
job.Status = MakeupExamAutoJobStatus.Running;
|
||||||
|
job.StartedAt = DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.MessagesJson = null;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Get time slots for the term
|
||||||
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (timeSlots.Count == 0)
|
||||||
|
throw new InvalidOperationException("当前学期未配置上课时间表。");
|
||||||
|
|
||||||
|
var slotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
|
||||||
|
// Get all published teaching tasks for this term with grade sheets
|
||||||
|
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.Select(x => new { x.Id, x.TaskNumber, x.Name, CourseName = x.Course!.Name })
|
||||||
|
.OrderBy(x => x.TaskNumber)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
job.TotalCourses = tasks.Count;
|
||||||
|
job.ProcessedCourses = 0;
|
||||||
|
job.CreatedSessions = 0;
|
||||||
|
job.EnrolledStudents = 0;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
var existingTaskIds = plan.Sessions.Select(s => s.TeachingTaskId).ToHashSet();
|
||||||
|
|
||||||
|
// Get available classrooms for mixed-room assignment
|
||||||
|
var allRooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Capacity)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Capacity, x.BuildingId })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Get available teachers
|
||||||
|
var allTeachers = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new { x.Id, x.Name })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Auto-assign dates: spread sessions across available dates
|
||||||
|
// Start from tomorrow, skip weekends, assign one session per time slot per day
|
||||||
|
var startDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7));
|
||||||
|
var dateCursor = startDate;
|
||||||
|
var periodSlots = timeSlots
|
||||||
|
.Where(x => x.PeriodNumber <= timeSlots.Count - 1)
|
||||||
|
.Select(x => x.PeriodNumber)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var task in tasks)
|
||||||
|
{
|
||||||
|
stoppingToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Skip if session already exists for this task
|
||||||
|
if (existingTaskIds.Contains(task.Id))
|
||||||
|
{
|
||||||
|
job.ProcessedCourses++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query eligible students
|
||||||
|
var eligible = await eligibilityService.GetEligibleStudentsAsync(
|
||||||
|
task.Id, stoppingToken);
|
||||||
|
|
||||||
|
if (eligible.Count == 0)
|
||||||
|
{
|
||||||
|
job.ProcessedCourses++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip weekends for date assignment
|
||||||
|
while (dateCursor.DayOfWeek == DayOfWeek.Saturday ||
|
||||||
|
dateCursor.DayOfWeek == DayOfWeek.Sunday)
|
||||||
|
dateCursor = dateCursor.AddDays(1);
|
||||||
|
|
||||||
|
// Determine time slot (rotate through available period slots)
|
||||||
|
var periodIdx = (job.CreatedSessions) % Math.Max(periodSlots.Count, 1);
|
||||||
|
var startPeriod = periodSlots[periodIdx];
|
||||||
|
var periodCount = 2;
|
||||||
|
|
||||||
|
// Resolve time
|
||||||
|
var startSlot = slotLookup.GetValueOrDefault(startPeriod);
|
||||||
|
var endSlot = slotLookup.GetValueOrDefault(startPeriod + periodCount - 1);
|
||||||
|
if (startSlot is null || endSlot is null)
|
||||||
|
{
|
||||||
|
job.ProcessedCourses++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startsAt = dateCursor.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
||||||
|
var endsAt = dateCursor.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// Find a classroom (mixed-subject: no room conflict check)
|
||||||
|
var room = allRooms.FirstOrDefault(r => r.Capacity >= eligible.Count);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
var session = new MakeupExamSession
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = plan.Id,
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ClassroomId = room?.Id,
|
||||||
|
ExamDate = dateCursor,
|
||||||
|
StartPeriod = startPeriod,
|
||||||
|
PeriodCount = periodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredInvigilatorCount = 2,
|
||||||
|
Enrollments = eligible.Select(e => new MakeupExamEnrollment
|
||||||
|
{
|
||||||
|
StudentId = e.StudentId,
|
||||||
|
Reason = e.Reason,
|
||||||
|
SourceGradeRecordId = e.SourceGradeRecordId,
|
||||||
|
SourceDeferredExamId = e.SourceDeferredExamId
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-assign invigilators
|
||||||
|
var excludeTeacherIds = new HashSet<Guid>();
|
||||||
|
var assigned = 0;
|
||||||
|
foreach (var teacher in allTeachers)
|
||||||
|
{
|
||||||
|
if (assigned >= 2) break;
|
||||||
|
if (excludeTeacherIds.Contains(teacher.Id)) continue;
|
||||||
|
session.Invigilators.Add(new MakeupExamSessionInvigilator
|
||||||
|
{
|
||||||
|
MakeupExamSessionId = session.Id,
|
||||||
|
TeacherId = teacher.Id
|
||||||
|
});
|
||||||
|
excludeTeacherIds.Add(teacher.Id);
|
||||||
|
assigned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MakeupExamSessions.Add(session);
|
||||||
|
existingTaskIds.Add(task.Id);
|
||||||
|
|
||||||
|
job.CreatedSessions++;
|
||||||
|
job.EnrolledStudents += eligible.Count;
|
||||||
|
job.ProcessedCourses++;
|
||||||
|
|
||||||
|
messages.Add(
|
||||||
|
$"\"{task.CourseName}\"→{dateCursor:MM/dd} 第{startPeriod}节 " +
|
||||||
|
$"{(room != null ? room.Name : "无可用教室")} · {eligible.Count}人");
|
||||||
|
|
||||||
|
// Advance date cursor every few sessions
|
||||||
|
if (job.CreatedSessions % periodSlots.Count == 0)
|
||||||
|
dateCursor = dateCursor.AddDays(1);
|
||||||
|
|
||||||
|
// Persist progress periodically
|
||||||
|
if (job.ProcessedCourses % 10 == 0 || job.ProcessedCourses >= job.TotalCourses)
|
||||||
|
await PersistProgressAsync(job, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = MakeupExamAutoJobStatus.Succeeded;
|
||||||
|
job.MessagesJson = JsonSerializer.Serialize(messages.Take(20).ToList());
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Makeup exam auto job {JobId} completed: {Sessions} sessions, {Students} students.",
|
||||||
|
job.Id, job.CreatedSessions, job.EnrolledStudents);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Makeup exam auto job {JobId} was interrupted by application shutdown.", jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Makeup exam auto job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistProgressAsync(
|
||||||
|
MakeupExamAutoJob job,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.MakeupExamAutoJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId, CancellationToken.None);
|
||||||
|
if (job is null) return;
|
||||||
|
|
||||||
|
var message = exception.GetBaseException().Message;
|
||||||
|
job.Status = MakeupExamAutoJobStatus.Failed;
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user