修复Mysql
This commit is contained in:
1 parent
634ef69a27
commit
72297aa23f
31 files changed
+1642
-1192
No files matched your search
@@ -178,13 +178,25 @@ public sealed class AttendanceController(
|
|||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
{
|
{
|
||||||
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
|
sheet.Id,
|
||||||
sheet.Status, sheet.Notes, sheet.SubmittedAt,
|
sheet.TeachingTaskId,
|
||||||
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
|
sheet.Name,
|
||||||
|
sheet.AttendanceDate,
|
||||||
|
sheet.Status,
|
||||||
|
sheet.Notes,
|
||||||
|
sheet.SubmittedAt,
|
||||||
|
sheet.TaskNumber,
|
||||||
|
sheet.TaskName,
|
||||||
|
sheet.CourseCode,
|
||||||
|
sheet.CourseName,
|
||||||
Records = sheet.Records.Select(r => new
|
Records = sheet.Records.Select(r => new
|
||||||
{
|
{
|
||||||
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
|
r.StudentId,
|
||||||
r.Status, r.Notes,
|
r.StudentNumber,
|
||||||
|
r.Name,
|
||||||
|
r.ClassName,
|
||||||
|
r.Status,
|
||||||
|
r.Notes,
|
||||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||||
})
|
})
|
||||||
@@ -504,8 +516,8 @@ public sealed class AttendanceController(
|
|||||||
|
|
||||||
var source = db.AttendanceRecords.AsNoTracking()
|
var source = db.AttendanceRecords.AsNoTracking()
|
||||||
.Where(r =>
|
.Where(r =>
|
||||||
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
|
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
|
||||||
targetClassIds.Contains(r.Student!.AdministrativeClassId));
|
.WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(r =>
|
source = source.Where(r =>
|
||||||
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
|
|||||||
@@ -77,45 +77,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||||
var errors = new List<string>();
|
return await db.ExecuteInRetriableTransactionAsync<
|
||||||
ExcelImportResult result;
|
ActionResult<ExcelImportResult>>(
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
async transaction =>
|
||||||
try
|
|
||||||
{
|
|
||||||
result = kind.ToLowerInvariant() switch
|
|
||||||
{
|
{
|
||||||
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken),
|
var errors = new List<string>();
|
||||||
"colleges" => await ImportCollegesAsync(rows, errors, cancellationToken),
|
ExcelImportResult result;
|
||||||
"majors" => await ImportMajorsAsync(rows, errors, cancellationToken),
|
try
|
||||||
"classes" => await ImportClassesAsync(rows, errors, cancellationToken),
|
{
|
||||||
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
|
result = kind.ToLowerInvariant() switch
|
||||||
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
|
{
|
||||||
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
|
"campuses" => await ImportCampusesAsync(
|
||||||
"course-categories" => await ImportCourseCategoriesAsync(
|
rows, errors, cancellationToken),
|
||||||
rows, errors, cancellationToken),
|
"colleges" => await ImportCollegesAsync(
|
||||||
_ => throw new InvalidOperationException()
|
rows, errors, cancellationToken),
|
||||||
};
|
"majors" => await ImportMajorsAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
"classes" => await ImportClassesAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
"terms" => await ImportTermsAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
"buildings" => await ImportBuildingsAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
"classrooms" => await ImportClassroomsAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
"course-categories" => await ImportCourseCategoriesAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
return Conflict(new ProblemDetails
|
return Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
Title = "导入失败",
|
Title = "导入失败",
|
||||||
Detail = "存在重复编码或无效关联,未写入任何数据。",
|
Detail = "存在重复编码或无效关联,未写入任何数据。",
|
||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
|
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
|
||||||
@@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
entity = new Major
|
entity = new Major
|
||||||
{
|
{
|
||||||
Code = code, Name = name, CollegeId = college.Id,
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
CollegeId = college.Id,
|
||||||
DegreeType = degreeType
|
DegreeType = degreeType
|
||||||
};
|
};
|
||||||
db.Majors.Add(entity);
|
db.Majors.Add(entity);
|
||||||
@@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
entity = new AdministrativeClass
|
entity = new AdministrativeClass
|
||||||
{
|
{
|
||||||
Code = code, Name = name, MajorId = major.Id, Grade = grade.Value
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = grade.Value
|
||||||
};
|
};
|
||||||
db.AdministrativeClasses.Add(entity);
|
db.AdministrativeClasses.Add(entity);
|
||||||
existing[code] = entity;
|
existing[code] = entity;
|
||||||
@@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
entity = new AcademicTerm
|
entity = new AcademicTerm
|
||||||
{
|
{
|
||||||
Code = code, Name = name, AcademicYear = academicYear,
|
Code = code,
|
||||||
Season = season.Value, StartDate = startDate.Value,
|
Name = name,
|
||||||
|
AcademicYear = academicYear,
|
||||||
|
Season = season.Value,
|
||||||
|
StartDate = startDate.Value,
|
||||||
EndDate = endDate.Value
|
EndDate = endDate.Value
|
||||||
};
|
};
|
||||||
db.AcademicTerms.Add(entity);
|
db.AcademicTerms.Add(entity);
|
||||||
@@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
{
|
{
|
||||||
entity = new Classroom
|
entity = new Classroom
|
||||||
{
|
{
|
||||||
Code = code, Name = name, BuildingId = building.Id,
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
BuildingId = building.Id,
|
||||||
RoomType = roomType
|
RoomType = roomType
|
||||||
};
|
};
|
||||||
db.Classrooms.Add(entity);
|
db.Classrooms.Add(entity);
|
||||||
|
|||||||
@@ -369,12 +369,12 @@ public sealed class CourseSelectionsController(
|
|||||||
var source = db.Students.AsNoTracking()
|
var source = db.Students.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status == StudentStatus.Active &&
|
x.Status == StudentStatus.Active &&
|
||||||
(offering.IsOpenToAll ||
|
|
||||||
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
|
|
||||||
!db.CourseEnrollments.Any(enrollment =>
|
!db.CourseEnrollments.Any(enrollment =>
|
||||||
enrollment.CourseSelectionOfferingId == id &&
|
enrollment.CourseSelectionOfferingId == id &&
|
||||||
enrollment.StudentId == x.Id &&
|
enrollment.StudentId == x.Id &&
|
||||||
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||||
|
if (!offering.IsOpenToAll)
|
||||||
|
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
keyword = keyword.Trim();
|
keyword = keyword.Trim();
|
||||||
@@ -415,157 +415,160 @@ public sealed class CourseSelectionsController(
|
|||||||
if (studentIds.Length > 100)
|
if (studentIds.Length > 100)
|
||||||
return ValidationProblem("单次最多可为 100 名学生代选。");
|
return ValidationProblem("单次最多可为 100 名学生代选。");
|
||||||
|
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
IsolationLevel.Serializable,
|
async transaction =>
|
||||||
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 (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
|
||||||
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(
|
db.ChangeTracker.Clear();
|
||||||
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
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 selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
var students = await db.Students
|
||||||
.Where(x =>
|
.Include(x => x.AdministrativeClass)
|
||||||
x.StudentId == student.Id &&
|
.WhereIn(studentIds, x => x.Id)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
.OrderBy(x => x.StudentNumber)
|
||||||
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
|
.ToListAsync(cancellationToken);
|
||||||
round.AcademicTermId)
|
if (students.Count != studentIds.Length)
|
||||||
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
|
return ValidationProblem("存在无效的学生档案。");
|
||||||
.Distinct()
|
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
||||||
.ToArrayAsync(cancellationToken);
|
if (inactive is not null)
|
||||||
var selectedEntries = await PublishedScheduleEntries(
|
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
||||||
round.AcademicTermId,
|
var outOfScope = students.FirstOrDefault(student =>
|
||||||
selectedTaskIds,
|
!offering.IsOpenToAll &&
|
||||||
cancellationToken);
|
!task.Classes.Any(item =>
|
||||||
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
item.AdministrativeClassId == student.AdministrativeClassId));
|
||||||
{
|
if (outOfScope is not null)
|
||||||
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,
|
return ConflictProblem(
|
||||||
StudentId = student.Id,
|
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
|
||||||
EnrolledAt = now
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
|
||||||
enrollment.EnrolledAt = now;
|
|
||||||
enrollment.WithdrawnAt = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
var existingEnrollments = await db.CourseEnrollments
|
||||||
await transaction.CommitAsync(cancellationToken);
|
.Where(x => x.CourseSelectionOfferingId == id)
|
||||||
return Ok(new { EnrolledCount = students.Count });
|
.WhereIn(studentIds, x => 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 =>
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||||
|
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId)
|
||||||
|
.WhereIn(studentIds, x => x.StudentId)
|
||||||
|
.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 (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
||||||
@@ -581,73 +584,77 @@ public sealed class CourseSelectionsController(
|
|||||||
if (studentIds.Length > 100)
|
if (studentIds.Length > 100)
|
||||||
return ValidationProblem("单次最多可为 100 名学生强制选课。");
|
return ValidationProblem("单次最多可为 100 名学生强制选课。");
|
||||||
|
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
IsolationLevel.Serializable, cancellationToken);
|
async transaction =>
|
||||||
var offering = await db.CourseSelectionOfferings
|
|
||||||
.Include(x => x.CourseSelectionRound)
|
|
||||||
.Include(x => x.TeachingTask)
|
|
||||||
.ThenInclude(x => x!.Course)
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken);
|
|
||||||
if (offering is null) return NotFound();
|
|
||||||
var round = offering.CourseSelectionRound!;
|
|
||||||
var task = offering.TeachingTask!;
|
|
||||||
|
|
||||||
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 existingEnrollments = await db.CourseEnrollments
|
|
||||||
.Where(x =>
|
|
||||||
x.CourseSelectionOfferingId == offeringId &&
|
|
||||||
studentIds.Contains(x.StudentId))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var alreadyEnrolled = existingEnrollments
|
|
||||||
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
|
|
||||||
if (alreadyEnrolled is not null)
|
|
||||||
{
|
|
||||||
var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
|
||||||
return ConflictProblem(
|
|
||||||
$"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var enrolled = 0;
|
|
||||||
foreach (var student in students)
|
|
||||||
{
|
|
||||||
var enrollment = existingEnrollments
|
|
||||||
.FirstOrDefault(x => x.StudentId == student.Id);
|
|
||||||
if (enrollment is null)
|
|
||||||
{
|
{
|
||||||
db.CourseEnrollments.Add(new CourseEnrollment
|
db.ChangeTracker.Clear();
|
||||||
|
var offering = await db.CourseSelectionOfferings
|
||||||
|
.Include(x => x.CourseSelectionRound)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken);
|
||||||
|
if (offering is null) return NotFound();
|
||||||
|
var round = offering.CourseSelectionRound!;
|
||||||
|
var task = offering.TeachingTask!;
|
||||||
|
|
||||||
|
if (round.Status == CourseSelectionRoundStatus.Draft)
|
||||||
|
return ConflictProblem("选课批次开放后才能办理强制选课。");
|
||||||
|
if (task.Status != TeachingTaskStatus.Published)
|
||||||
|
return ConflictProblem("该教学班当前不可选。");
|
||||||
|
|
||||||
|
var students = await db.Students
|
||||||
|
.Include(x => x.AdministrativeClass)
|
||||||
|
.WhereIn(studentIds, x => x.Id)
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (students.Count != studentIds.Length)
|
||||||
|
return ValidationProblem("存在无效的学生档案。");
|
||||||
|
|
||||||
|
var existingEnrollments = await db.CourseEnrollments
|
||||||
|
.Where(x => x.CourseSelectionOfferingId == offeringId)
|
||||||
|
.WhereIn(studentIds, x => x.StudentId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var alreadyEnrolled = existingEnrollments
|
||||||
|
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
|
||||||
|
if (alreadyEnrolled is not null)
|
||||||
{
|
{
|
||||||
CourseSelectionOfferingId = offeringId,
|
var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
||||||
StudentId = student.Id,
|
return ConflictProblem(
|
||||||
EnrollmentType = EnrollmentType.Retake,
|
$"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
|
||||||
EnrolledAt = now
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
|
||||||
enrollment.EnrolledAt = now;
|
|
||||||
enrollment.WithdrawnAt = null;
|
|
||||||
enrollment.EnrollmentType = EnrollmentType.Retake;
|
|
||||||
}
|
|
||||||
enrolled++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
var now = DateTime.UtcNow;
|
||||||
await transaction.CommitAsync(cancellationToken);
|
var enrolled = 0;
|
||||||
return Ok(new { EnrolledCount = enrolled });
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
var enrollment = existingEnrollments
|
||||||
|
.FirstOrDefault(x => x.StudentId == student.Id);
|
||||||
|
if (enrollment is null)
|
||||||
|
{
|
||||||
|
db.CourseEnrollments.Add(new CourseEnrollment
|
||||||
|
{
|
||||||
|
CourseSelectionOfferingId = offeringId,
|
||||||
|
StudentId = student.Id,
|
||||||
|
EnrollmentType = EnrollmentType.Retake,
|
||||||
|
EnrolledAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||||
|
enrollment.EnrolledAt = now;
|
||||||
|
enrollment.WithdrawnAt = null;
|
||||||
|
enrollment.EnrollmentType = EnrollmentType.Retake;
|
||||||
|
}
|
||||||
|
enrolled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Ok(new { EnrolledCount = enrolled });
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||||
@@ -824,145 +831,149 @@ public sealed class CourseSelectionsController(
|
|||||||
StudentEnrollmentRequest request,
|
StudentEnrollmentRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var student = await CurrentStudentAsync(cancellationToken);
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
if (student is null) return ProfileNotFound();
|
async transaction =>
|
||||||
if (student.Status != StudentStatus.Active)
|
|
||||||
return ConflictProblem("只有在籍学生可以选课。");
|
|
||||||
|
|
||||||
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 == request.OfferingId, cancellationToken);
|
|
||||||
if (offering is null) return NotFound();
|
|
||||||
var round = offering.CourseSelectionRound!;
|
|
||||||
var task = offering.TeachingTask!;
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
if (!CourseSelectionRules.IsSelectionOpen(round, now))
|
|
||||||
return ConflictProblem("当前不在该选课批次的开放时间内。");
|
|
||||||
if (task.Status != TeachingTaskStatus.Published)
|
|
||||||
return ConflictProblem("该教学班当前不可选。");
|
|
||||||
if (!offering.IsOpenToAll &&
|
|
||||||
!task.Classes.Any(x =>
|
|
||||||
x.AdministrativeClassId == student.AdministrativeClassId))
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
// Detect retake: student previously took the same course in any term
|
|
||||||
var isRetake = await db.CourseEnrollments.AnyAsync(
|
|
||||||
x =>
|
|
||||||
x.StudentId == student.Id &&
|
|
||||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
|
||||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
|
|
||||||
round.AcademicTermId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
|
|
||||||
x =>
|
|
||||||
x.CourseSelectionOfferingId == offering.Id &&
|
|
||||||
x.StudentId == student.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
|
|
||||||
return ConflictProblem("你已经选择了该教学班。");
|
|
||||||
|
|
||||||
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
|
||||||
x =>
|
|
||||||
x.CourseSelectionOfferingId == offering.Id &&
|
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled,
|
|
||||||
cancellationToken);
|
|
||||||
var effectiveCapacity = isRetake
|
|
||||||
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
|
|
||||||
: offering.Capacity;
|
|
||||||
if (enrolledCount >= effectiveCapacity)
|
|
||||||
return ConflictProblem("该教学班名额已满。");
|
|
||||||
|
|
||||||
// Normal enrollment: no duplicate course in same term
|
|
||||||
if (!isRetake)
|
|
||||||
{
|
|
||||||
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
|
||||||
x =>
|
|
||||||
x.StudentId == student.Id &&
|
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
|
||||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
|
||||||
round.AcademicTermId,
|
|
||||||
cancellationToken);
|
|
||||||
if (duplicateCourse)
|
|
||||||
return ConflictProblem("同一学期不能重复选择相同课程。");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credit limit check
|
|
||||||
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(
|
|
||||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
|
||||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule conflict check
|
|
||||||
var candidateEntries = await PublishedScheduleEntries(
|
|
||||||
round.AcademicTermId, [task.Id], cancellationToken);
|
|
||||||
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
|
||||||
candidateEntries.Count == 0)
|
|
||||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
|
||||||
|
|
||||||
var selectedTaskIds = await db.CourseEnrollments
|
|
||||||
.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))
|
|
||||||
{
|
|
||||||
if (!isRetake)
|
|
||||||
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
|
||||||
|
|
||||||
// Retake: allow ≤50% overlap
|
|
||||||
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
|
|
||||||
candidateEntries, selectedEntries);
|
|
||||||
if (overlap > 50)
|
|
||||||
return ConflictProblem(
|
|
||||||
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing is null)
|
|
||||||
{
|
|
||||||
existing = new CourseEnrollment
|
|
||||||
{
|
{
|
||||||
CourseSelectionOfferingId = offering.Id,
|
db.ChangeTracker.Clear();
|
||||||
StudentId = student.Id,
|
var student = await CurrentStudentAsync(cancellationToken);
|
||||||
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
|
if (student is null) return ProfileNotFound();
|
||||||
};
|
if (student.Status != StudentStatus.Active)
|
||||||
db.CourseEnrollments.Add(existing);
|
return ConflictProblem("只有在籍学生可以选课。");
|
||||||
}
|
|
||||||
else
|
var offering = await db.CourseSelectionOfferings
|
||||||
{
|
.Include(x => x.CourseSelectionRound)
|
||||||
existing.Status = CourseEnrollmentStatus.Enrolled;
|
.Include(x => x.TeachingTask)
|
||||||
existing.EnrolledAt = now;
|
.ThenInclude(x => x!.Course)
|
||||||
existing.WithdrawnAt = null;
|
.Include(x => x.TeachingTask)
|
||||||
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal;
|
.ThenInclude(x => x!.Classes)
|
||||||
}
|
.FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
if (offering is null) return NotFound();
|
||||||
await transaction.CommitAsync(cancellationToken);
|
var round = offering.CourseSelectionRound!;
|
||||||
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
|
var task = offering.TeachingTask!;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (!CourseSelectionRules.IsSelectionOpen(round, now))
|
||||||
|
return ConflictProblem("当前不在该选课批次的开放时间内。");
|
||||||
|
if (task.Status != TeachingTaskStatus.Published)
|
||||||
|
return ConflictProblem("该教学班当前不可选。");
|
||||||
|
if (!offering.IsOpenToAll &&
|
||||||
|
!task.Classes.Any(x =>
|
||||||
|
x.AdministrativeClassId == student.AdministrativeClassId))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
// Detect retake: student previously took the same course in any term
|
||||||
|
var isRetake = await db.CourseEnrollments.AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||||
|
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
|
||||||
|
round.AcademicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
|
||||||
|
x =>
|
||||||
|
x.CourseSelectionOfferingId == offering.Id &&
|
||||||
|
x.StudentId == student.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
return ConflictProblem("你已经选择了该教学班。");
|
||||||
|
|
||||||
|
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.CourseSelectionOfferingId == offering.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled,
|
||||||
|
cancellationToken);
|
||||||
|
var effectiveCapacity = isRetake
|
||||||
|
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
|
||||||
|
: offering.Capacity;
|
||||||
|
if (enrolledCount >= effectiveCapacity)
|
||||||
|
return ConflictProblem("该教学班名额已满。");
|
||||||
|
|
||||||
|
// Normal enrollment: no duplicate course in same term
|
||||||
|
if (!isRetake)
|
||||||
|
{
|
||||||
|
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||||
|
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
if (duplicateCourse)
|
||||||
|
return ConflictProblem("同一学期不能重复选择相同课程。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit limit check
|
||||||
|
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(
|
||||||
|
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||||
|
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule conflict check
|
||||||
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
|
round.AcademicTermId, [task.Id], cancellationToken);
|
||||||
|
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||||
|
candidateEntries.Count == 0)
|
||||||
|
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||||
|
|
||||||
|
var selectedTaskIds = await db.CourseEnrollments
|
||||||
|
.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))
|
||||||
|
{
|
||||||
|
if (!isRetake)
|
||||||
|
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
||||||
|
|
||||||
|
// Retake: allow ≤50% overlap
|
||||||
|
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
|
||||||
|
candidateEntries, selectedEntries);
|
||||||
|
if (overlap > 50)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = new CourseEnrollment
|
||||||
|
{
|
||||||
|
CourseSelectionOfferingId = offering.Id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
|
||||||
|
};
|
||||||
|
db.CourseEnrollments.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Status = CourseEnrollmentStatus.Enrolled;
|
||||||
|
existing.EnrolledAt = now;
|
||||||
|
existing.WithdrawnAt = null;
|
||||||
|
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("student/enrollments/{id:guid}")]
|
[HttpDelete("student/enrollments/{id:guid}")]
|
||||||
@@ -1159,9 +1170,9 @@ public sealed class CourseSelectionsController(
|
|||||||
if (taskIds.Count == 0) return [];
|
if (taskIds.Count == 0) return [];
|
||||||
return await db.ScheduleEntries.AsNoTracking()
|
return await db.ScheduleEntries.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
taskIds.Contains(x.TeachingTaskId) &&
|
|
||||||
x.SchedulePlan!.AcademicTermId == academicTermId &&
|
x.SchedulePlan!.AcademicTermId == academicTermId &&
|
||||||
x.SchedulePlan.Status == SchedulePlanStatus.Published)
|
x.SchedulePlan.Status == SchedulePlanStatus.Published)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 3 of 31 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user