选课
This commit is contained in:
1 parent
5da1a8a403
commit
20ca205080
9 files changed
+1115
-7
No files matched your search
@@ -10,6 +10,14 @@
|
|||||||
|
|
||||||
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
||||||
|
|
||||||
|
Development 环境还会按业务编码幂等补齐一套大规模测试数据:16 个常见学院或教学单位、52 个常见本科专业、每个专业 2 个 2026 级行政班、每班 35 名学生、每学院 8 名教师,以及覆盖公共必修、公共选修、专业必修、专业选修和实践教学的 150 余门课程。测试教师同时生成当前学期已审核通过的授课资格;重复启动不会重复累加数据,也不会覆盖已有记录。
|
||||||
|
|
||||||
|
只执行数据库迁移和测试数据补齐、不启动 Web 服务时,可以运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/Jiaowu.Api -- --seed-only
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project src/Jiaowu.Api
|
dotnet run --project src/Jiaowu.Api
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ public sealed class CourseSelectionsController(
|
|||||||
TaskName = x.TeachingTask.Name,
|
TaskName = x.TeachingTask.Name,
|
||||||
CourseCode = x.TeachingTask.Course!.Code,
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
CourseNature = x.TeachingTask.Course.Nature,
|
||||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||||
x.TeachingTask.Course.Credits,
|
x.TeachingTask.Course.Credits,
|
||||||
TeacherNames = x.TeachingTask.Teachers
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
@@ -279,10 +280,14 @@ public sealed class CourseSelectionsController(
|
|||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
TaskName = x.TeachingTask.Name,
|
TaskName = x.TeachingTask.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
CourseNature = x.TeachingTask.Course.Nature,
|
||||||
CollegeId = x.TeachingTask.Course.CollegeId,
|
CollegeId = x.TeachingTask.Course.CollegeId,
|
||||||
TeacherUserIds = x.TeachingTask.Teachers
|
TeacherUserIds = x.TeachingTask.Teachers
|
||||||
.Select(item => item.Teacher!.UserId),
|
.Select(item => item.Teacher!.UserId),
|
||||||
|
x.CourseSelectionRoundId,
|
||||||
|
RoundStatus = x.CourseSelectionRound!.Status,
|
||||||
x.Capacity
|
x.Capacity
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -316,13 +321,281 @@ public sealed class CourseSelectionsController(
|
|||||||
offering.TeachingTaskId,
|
offering.TeachingTaskId,
|
||||||
offering.TaskNumber,
|
offering.TaskNumber,
|
||||||
offering.TaskName,
|
offering.TaskName,
|
||||||
|
offering.CourseCode,
|
||||||
offering.CourseName,
|
offering.CourseName,
|
||||||
|
offering.CourseNature,
|
||||||
offering.Capacity,
|
offering.Capacity,
|
||||||
EnrolledCount = students.Count,
|
EnrolledCount = students.Count,
|
||||||
Students = students
|
Students = students,
|
||||||
|
CanProxyEnroll =
|
||||||
|
CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) &&
|
||||||
|
offering.RoundStatus != CourseSelectionRoundStatus.Draft &&
|
||||||
|
(currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("offerings/{id:guid}/eligible-students")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> GetEligibleStudents(
|
||||||
|
Guid id,
|
||||||
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.IsOpenToAll,
|
||||||
|
CourseNature = x.TeachingTask!.Course!.Nature,
|
||||||
|
RoundStatus = x.CourseSelectionRound!.Status,
|
||||||
|
ClassIds = x.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClassId)
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (offering is null) return NotFound();
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||||
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
|
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||||
|
return ConflictProblem("选课批次开放后才能办理管理员代选。");
|
||||||
|
|
||||||
|
var source = db.Students.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == StudentStatus.Active &&
|
||||||
|
(offering.IsOpenToAll ||
|
||||||
|
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
|
||||||
|
!db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.CourseSelectionOfferingId == id &&
|
||||||
|
enrollment.StudentId == x.Id &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name,
|
||||||
|
ClassName = x.AdministrativeClass!.Name,
|
||||||
|
MajorName = x.AdministrativeClass.Major!.Name,
|
||||||
|
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("offerings/{id:guid}/admin-enrollments")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> AdminEnroll(
|
||||||
|
Guid id,
|
||||||
|
AdminEnrollmentRequest 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)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Classes)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (offering is null) return NotFound();
|
||||||
|
var round = offering.CourseSelectionRound!;
|
||||||
|
var task = offering.TeachingTask!;
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
|
||||||
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
|
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 inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
||||||
|
if (inactive is not null)
|
||||||
|
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
||||||
|
var outOfScope = students.FirstOrDefault(student =>
|
||||||
|
!offering.IsOpenToAll &&
|
||||||
|
!task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId));
|
||||||
|
if (outOfScope is not null)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingEnrollments = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
studentIds.Contains(x.StudentId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||||
|
if (alreadyEnrolled is not null)
|
||||||
|
{
|
||||||
|
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled,
|
||||||
|
cancellationToken);
|
||||||
|
if (enrolledCount + students.Count > offering.Capacity)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
studentIds.Contains(x.StudentId) &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||||
|
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateStudentIds.Count > 0)
|
||||||
|
{
|
||||||
|
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
|
round.AcademicTermId,
|
||||||
|
[task.Id],
|
||||||
|
cancellationToken);
|
||||||
|
if (candidateEntries.Count == 0)
|
||||||
|
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
|
||||||
|
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
var selectedCredits = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
|
||||||
|
.SumAsync(
|
||||||
|
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
|
||||||
|
cancellationToken) ?? 0;
|
||||||
|
if (selectedCredits + task.Course.Credits > round.MaxCredits)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId)
|
||||||
|
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var selectedEntries = await PublishedScheduleEntries(
|
||||||
|
round.AcademicTermId,
|
||||||
|
selectedTaskIds,
|
||||||
|
cancellationToken);
|
||||||
|
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
var enrollment = existingEnrollments.FirstOrDefault(x =>
|
||||||
|
x.StudentId == student.Id);
|
||||||
|
if (enrollment is null)
|
||||||
|
{
|
||||||
|
db.CourseEnrollments.Add(new CourseEnrollment
|
||||||
|
{
|
||||||
|
CourseSelectionOfferingId = id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
EnrolledAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||||
|
enrollment.EnrolledAt = now;
|
||||||
|
enrollment.WithdrawnAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Ok(new { EnrolledCount = students.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> AdminWithdraw(
|
||||||
|
Guid offeringId,
|
||||||
|
Guid enrollmentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var enrollment = await db.CourseEnrollments
|
||||||
|
.Include(x => x.CourseSelectionOffering)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x =>
|
||||||
|
x.Id == enrollmentId &&
|
||||||
|
x.CourseSelectionOfferingId == offeringId,
|
||||||
|
cancellationToken);
|
||||||
|
if (enrollment is null) return NotFound();
|
||||||
|
var offering = enrollment.CourseSelectionOffering!;
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(
|
||||||
|
offering.TeachingTask!.Course!.Nature))
|
||||||
|
return ConflictProblem("管理员名单调整仅适用于公共必修课。");
|
||||||
|
if (offering.TeachingTask.Status != TeachingTaskStatus.Published)
|
||||||
|
return ConflictProblem("该教学班当前不可调整名单。");
|
||||||
|
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
|
||||||
|
return ConflictProblem("该学生已不在教学班名单中。");
|
||||||
|
|
||||||
|
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
|
||||||
|
enrollment.WithdrawnAt = DateTime.UtcNow;
|
||||||
|
return await SaveAsync(enrollmentId, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("student/options")]
|
[HttpGet("student/options")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetStudentOptions(
|
public async Task<ActionResult> GetStudentOptions(
|
||||||
@@ -722,6 +995,9 @@ public sealed record CourseSelectionOfferingRequest(
|
|||||||
|
|
||||||
public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
||||||
|
|
||||||
|
public sealed record AdminEnrollmentRequest(
|
||||||
|
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
public sealed record StudentOfferingDto(
|
public sealed record StudentOfferingDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public static class CourseSelectionRules
|
|||||||
round.Status == CourseSelectionRoundStatus.Open &&
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
nowUtc <= round.WithdrawalEndsAt;
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
|
nature == CourseNature.GeneralRequired;
|
||||||
|
|
||||||
public static bool HasScheduleConflict(
|
public static bool HasScheduleConflict(
|
||||||
IEnumerable<ScheduleEntry> candidateEntries,
|
IEnumerable<ScheduleEntry> candidateEntries,
|
||||||
IEnumerable<ScheduleEntry> selectedEntries) =>
|
IEnumerable<ScheduleEntry> selectedEntries) =>
|
||||||
|
|||||||
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user