补考系统
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/makeup-exams")]
|
||||
public sealed class MakeupExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Plans
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("plans")]
|
||||
public async Task<ActionResult> GetPlans(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = db.MakeupExamPlans.AsNoTracking().AsQueryable();
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (!IsManager())
|
||||
source = source.Where(x => x.Status == MakeupExamPlanStatus.Published);
|
||||
return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("plans")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreatePlan(
|
||||
MakeupExamPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选学期不存在或已停用。");
|
||||
var plan = new MakeupExamPlan
|
||||
{
|
||||
AcademicTermId = request.AcademicTermId,
|
||||
Name = request.Name.Trim(),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.MakeupExamPlans.Add(plan);
|
||||
return await SaveAsync(plan.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("plans/{id:guid}")]
|
||||
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var manager = IsManager();
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, x.Notes, x.PublishedAt,
|
||||
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
||||
.ThenBy(item => item.StartPeriod).Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.TeachingTaskId,
|
||||
item.TeachingTask!.TaskNumber,
|
||||
TaskName = item.TeachingTask.Name,
|
||||
CourseCode = item.TeachingTask.Course!.Code,
|
||||
CourseName = item.TeachingTask.Course.Name,
|
||||
item.ClassroomId,
|
||||
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
|
||||
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
|
||||
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
|
||||
item.ExamDate,
|
||||
item.StartPeriod,
|
||||
item.PeriodCount,
|
||||
item.StartsAt,
|
||||
item.EndsAt,
|
||||
item.RequiredBuildingId,
|
||||
RequiredBuildingName = item.RequiredBuilding != null
|
||||
? item.RequiredBuilding.Name : null,
|
||||
item.RequiredInvigilatorCount,
|
||||
item.Notes,
|
||||
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
||||
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
|
||||
EnrolledCount = item.Enrollments.Count,
|
||||
Enrollments = item.Enrollments.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.SourceGradeRecordId,
|
||||
e.SourceDeferredExamId,
|
||||
e.MakeupScore
|
||||
})
|
||||
})
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return plan is null ? NotFound() : Ok(plan);
|
||||
}
|
||||
|
||||
[HttpPost("plans/{id:guid}/publish")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||
if (plan.Sessions.Count == 0)
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
|
||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
||||
if (empty > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("plans/{id:guid}/archive")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||
return ConflictProblem("只有已发布的补考计划可以归档。");
|
||||
plan.Status = MakeupExamPlanStatus.Archived;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Sessions
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpPost("plans/{planId:guid}/sessions")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSession(
|
||||
Guid planId,
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||
|
||||
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var validation = await ValidateSessionAsync(plan, null,
|
||||
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
|
||||
startsAt, endsAt, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var session = new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlanId = planId,
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
ClassroomId = request.ClassroomId,
|
||||
ExamDate = request.ExamDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes),
|
||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||
new MakeupExamSessionInvigilator { TeacherId = id }).ToList()
|
||||
};
|
||||
db.MakeupExamSessions.Add(session);
|
||||
return await SaveAsync(session.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSession(
|
||||
Guid planId,
|
||||
Guid id,
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Invigilators)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||
|
||||
var timeResult = ResolveExamTime(session.MakeupExamPlan.AcademicTermId,
|
||||
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var validation = await ValidateSessionAsync(session.MakeupExamPlan, id,
|
||||
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
|
||||
startsAt, endsAt, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
session.TeachingTaskId = request.TeachingTaskId;
|
||||
session.ClassroomId = request.ClassroomId;
|
||||
session.ExamDate = request.ExamDate;
|
||||
session.StartPeriod = request.StartPeriod;
|
||||
session.PeriodCount = request.PeriodCount;
|
||||
session.StartsAt = startsAt;
|
||||
session.EndsAt = endsAt;
|
||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||
session.Notes = Normalize(request.Notes);
|
||||
|
||||
db.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
|
||||
new MakeupExamSessionInvigilator { TeacherId = tid }).ToList();
|
||||
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> DeleteSession(
|
||||
Guid planId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||
db.MakeupExamSessions.Remove(session);
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Auto-arrange
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpPost("plans/{planId:guid}/auto-arrange")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> AutoArrange(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
return Ok(new { message = result.Message });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Enrollments
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("eligible-students")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetEligibleStudents(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var students = await eligibilityService.GetEligibleStudentsAsync(
|
||||
teachingTaskId, cancellationToken);
|
||||
return Ok(students);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:guid}/enroll")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> EnrollStudents(
|
||||
Guid id,
|
||||
EnrollStudentsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整登记。");
|
||||
|
||||
var eligible = await eligibilityService.GetEligibleStudentsAsync(
|
||||
session.TeachingTaskId, cancellationToken);
|
||||
var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet();
|
||||
|
||||
var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet();
|
||||
var validIds = request.StudentIds
|
||||
.Where(sid => !alreadyEnrolled.Contains(sid))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (validIds.Count == 0)
|
||||
return ConflictProblem("所选学生均已登记或无效。");
|
||||
|
||||
var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList();
|
||||
if (invalidIds.Count > 0)
|
||||
return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。");
|
||||
|
||||
// Check for time conflicts with other makeup sessions
|
||||
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(x => validIds.Contains(x.StudentId) &&
|
||||
x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.MakeupExamSession.EndsAt)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (conflictIds.Count > 0)
|
||||
{
|
||||
var conflictNumbers = await db.Students
|
||||
.Where(s => conflictIds.Contains(s.Id))
|
||||
.Select(s => s.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ConflictProblem(
|
||||
$"以下学生该时段已有其他补考:{string.Join("、", conflictNumbers)}");
|
||||
}
|
||||
|
||||
foreach (var studentId in validIds)
|
||||
{
|
||||
var info = eligible.First(e => e.StudentId == studentId);
|
||||
session.Enrollments.Add(new MakeupExamEnrollment
|
||||
{
|
||||
MakeupExamSessionId = id,
|
||||
StudentId = studentId,
|
||||
Reason = info.Reason,
|
||||
SourceGradeRecordId = info.SourceGradeRecordId,
|
||||
SourceDeferredExamId = info.SourceDeferredExamId
|
||||
});
|
||||
}
|
||||
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> RemoveEnrollment(
|
||||
Guid id,
|
||||
Guid studentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enrollment = await db.MakeupExamEnrollments
|
||||
.Include(x => x.MakeupExamSession)
|
||||
.ThenInclude(x => x!.MakeupExamPlan)
|
||||
.FirstOrDefaultAsync(x => x.MakeupExamSessionId == id &&
|
||||
x.StudentId == studentId, cancellationToken);
|
||||
if (enrollment is null) return NotFound();
|
||||
if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整登记。");
|
||||
db.MakeupExamEnrollments.Remove(enrollment);
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Scores
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpPut("sessions/{id:guid}/scores")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> RecordScores(
|
||||
Guid id,
|
||||
List<RecordMakeupScoreRequest> scores,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
|
||||
|
||||
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
|
||||
|
||||
foreach (var entry in scores)
|
||||
{
|
||||
if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment))
|
||||
continue;
|
||||
|
||||
enrollment.MakeupScore = entry.Score;
|
||||
|
||||
// Update the grade record
|
||||
if (enrollment.SourceGradeRecordId.HasValue)
|
||||
{
|
||||
var record = await db.GradeRecords
|
||||
.Include(x => x.GradeSheet)
|
||||
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
|
||||
cancellationToken);
|
||||
if (record is not null)
|
||||
{
|
||||
record.ExamStatus = GradeExamStatus.Makeup;
|
||||
record.TotalScore = entry.Score;
|
||||
record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score);
|
||||
record.Notes = "补考成绩";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Roster
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("sessions/{id:guid}/roster")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||
x.StartsAt,
|
||||
Students = 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()
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Query helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("available-classrooms")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetAvailableClassrooms(
|
||||
Guid academicTermId,
|
||||
DateOnly examDate,
|
||||
int startPeriod,
|
||||
int periodCount,
|
||||
Guid? requiredBuildingId,
|
||||
Guid? planId,
|
||||
Guid? excludeSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeResult = ResolveExamTime(
|
||||
academicTermId, examDate, startPeriod, periodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled);
|
||||
|
||||
if (requiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == requiredBuildingId.Value);
|
||||
|
||||
var occupiedQuery = db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.ClassroomId != null &&
|
||||
x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||
if (planId.HasValue)
|
||||
occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == planId.Value);
|
||||
if (excludeSessionId.HasValue)
|
||||
occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);
|
||||
|
||||
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||
|
||||
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Capacity)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.BuildingId,
|
||||
BuildingName = x.Building!.Name
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("available-teachers")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetAvailableTeachers(
|
||||
Guid academicTermId,
|
||||
DateOnly examDate,
|
||||
int startPeriod,
|
||||
int periodCount,
|
||||
Guid? planId,
|
||||
Guid? excludeSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeResult = ResolveExamTime(
|
||||
academicTermId, examDate, startPeriod, periodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var busyQuery = db.MakeupExamSessionInvigilators.AsNoTracking()
|
||||
.Where(x => x.MakeupExamSession!.StartsAt < endsAt &&
|
||||
startsAt < x.MakeupExamSession!.EndsAt);
|
||||
if (planId.HasValue)
|
||||
busyQuery = busyQuery.Where(x =>
|
||||
x.MakeupExamSession!.MakeupExamPlanId == planId.Value);
|
||||
if (excludeSessionId.HasValue)
|
||||
busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != excludeSessionId.Value);
|
||||
|
||||
var busyIds = await busyQuery.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyIds.Contains(x.Id))
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeacherNumber,
|
||||
x.Name,
|
||||
x.Title,
|
||||
CollegeName = x.College!.Name
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("time-slots-for-term")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetTimeSlotsForTerm(
|
||||
Guid academicTermId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PeriodNumber,
|
||||
x.Name,
|
||||
StartsAt = x.StartsAt.ToString("HH:mm"),
|
||||
EndsAt = x.EndsAt.ToString("HH:mm"),
|
||||
x.IsEnabled
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// My schedule
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
[HttpGet("my-schedule")]
|
||||
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (scope.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
|
||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == studentId &&
|
||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||
.ThenBy(x => x.MakeupExamSession!.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.MakeupExamSession!.Id,
|
||||
PlanName = x.MakeupExamSession.MakeupExamPlan!.Name,
|
||||
x.MakeupExamSession.ExamDate,
|
||||
x.MakeupExamSession.StartPeriod,
|
||||
x.MakeupExamSession.PeriodCount,
|
||||
x.MakeupExamSession.StartsAt,
|
||||
x.MakeupExamSession.EndsAt,
|
||||
x.MakeupExamSession.TeachingTask!.TaskNumber,
|
||||
CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code,
|
||||
CourseName = x.MakeupExamSession.TeachingTask.Course.Name,
|
||||
ClassroomName = x.MakeupExamSession.Classroom != null
|
||||
? x.MakeupExamSession.Classroom.Name : null,
|
||||
BuildingName = x.MakeupExamSession.Classroom != null
|
||||
? x.MakeupExamSession.Classroom.Building!.Name : null,
|
||||
x.Reason,
|
||||
x.MakeupScore,
|
||||
IsMakeup = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
return Ok(await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||
x.Invigilators.Any(i => i.Teacher!.UserId == scope.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,
|
||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||
EnrolledCount = x.Enrollments.Count,
|
||||
IsMakeup = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
|
||||
Guid academicTermId,
|
||||
DateOnly examDate,
|
||||
int startPeriod,
|
||||
int periodCount)
|
||||
{
|
||||
var slots = db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.ToList();
|
||||
|
||||
if (slots.Count == 0)
|
||||
return (default, default,
|
||||
ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。"));
|
||||
|
||||
var slotDict = slots.ToDictionary(x => x.PeriodNumber);
|
||||
var startSlot = slotDict.GetValueOrDefault(startPeriod);
|
||||
var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1);
|
||||
|
||||
if (startSlot is null)
|
||||
return (default, default,
|
||||
ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。"));
|
||||
if (endSlot is null)
|
||||
return (default, default,
|
||||
ValidationProblem(
|
||||
$"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。"));
|
||||
|
||||
return (
|
||||
examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc),
|
||||
examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ValidateSessionAsync(
|
||||
MakeupExamPlan plan,
|
||||
Guid? currentId,
|
||||
Guid teachingTaskId,
|
||||
Guid? classroomId,
|
||||
IReadOnlyCollection<Guid>? invigilatorIds,
|
||||
DateTime startsAt,
|
||||
DateTime endsAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||
if (task is null || task.AcademicTermId != plan.AcademicTermId)
|
||||
return ValidationProblem("教学班与补考计划必须属于同一学期。");
|
||||
|
||||
if (classroomId.HasValue)
|
||||
{
|
||||
var room = await db.Classrooms.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
|
||||
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
||||
}
|
||||
|
||||
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
||||
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效监考教师。");
|
||||
}
|
||||
|
||||
var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id &&
|
||||
x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||
|
||||
if (classroomId.HasValue)
|
||||
{
|
||||
if (await overlaps.AnyAsync(
|
||||
x => x.ClassroomId == classroomId.Value, cancellationToken))
|
||||
return ConflictProblem("该时段考场已被占用。");
|
||||
}
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await overlaps.AnyAsync(x =>
|
||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||
cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有补考任务。");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsManager() =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||
CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(token);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("补考场次重复,或关联数据已发生变化。");
|
||||
}
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成补考操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Request/Response models
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public sealed record MakeupExamPlanRequest(
|
||||
Guid AcademicTermId,
|
||||
[Required, MaxLength(120)] string Name,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record CreateMakeupExamSessionRequest(
|
||||
Guid TeachingTaskId,
|
||||
Guid? ClassroomId,
|
||||
DateOnly ExamDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record EnrollStudentsRequest(
|
||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||
|
||||
public sealed record RecordMakeupScoreRequest(
|
||||
Guid StudentId,
|
||||
[Range(0, 100)] decimal Score);
|
||||
Reference in New Issue
Block a user