修复Mysql
This commit is contained in:
@@ -178,13 +178,25 @@ public sealed class AttendanceController(
|
||||
{
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
|
||||
sheet.Status, sheet.Notes, sheet.SubmittedAt,
|
||||
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
|
||||
sheet.Id,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.Name,
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.Notes,
|
||||
sheet.SubmittedAt,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
Records = sheet.Records.Select(r => new
|
||||
{
|
||||
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
|
||||
r.Status, r.Notes,
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||
})
|
||||
@@ -504,8 +516,8 @@ public sealed class AttendanceController(
|
||||
|
||||
var source = db.AttendanceRecords.AsNoTracking()
|
||||
.Where(r =>
|
||||
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
|
||||
targetClassIds.Contains(r.Student!.AdministrativeClassId));
|
||||
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
|
||||
.WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(r =>
|
||||
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
||||
|
||||
@@ -77,45 +77,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
var errors = new List<string>();
|
||||
ExcelImportResult result;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
result = kind.ToLowerInvariant() switch
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken),
|
||||
"colleges" => await ImportCollegesAsync(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()
|
||||
};
|
||||
var errors = new List<string>();
|
||||
ExcelImportResult result;
|
||||
try
|
||||
{
|
||||
result = kind.ToLowerInvariant() switch
|
||||
{
|
||||
"campuses" => await ImportCampusesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"colleges" => await ImportCollegesAsync(
|
||||
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)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复编码或无效关联,未写入任何数据。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复编码或无效关联,未写入任何数据。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
|
||||
@@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new Major
|
||||
{
|
||||
Code = code, Name = name, CollegeId = college.Id,
|
||||
Code = code,
|
||||
Name = name,
|
||||
CollegeId = college.Id,
|
||||
DegreeType = degreeType
|
||||
};
|
||||
db.Majors.Add(entity);
|
||||
@@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
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);
|
||||
existing[code] = entity;
|
||||
@@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new AcademicTerm
|
||||
{
|
||||
Code = code, Name = name, AcademicYear = academicYear,
|
||||
Season = season.Value, StartDate = startDate.Value,
|
||||
Code = code,
|
||||
Name = name,
|
||||
AcademicYear = academicYear,
|
||||
Season = season.Value,
|
||||
StartDate = startDate.Value,
|
||||
EndDate = endDate.Value
|
||||
};
|
||||
db.AcademicTerms.Add(entity);
|
||||
@@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new Classroom
|
||||
{
|
||||
Code = code, Name = name, BuildingId = building.Id,
|
||||
Code = code,
|
||||
Name = name,
|
||||
BuildingId = building.Id,
|
||||
RoomType = roomType
|
||||
};
|
||||
db.Classrooms.Add(entity);
|
||||
|
||||
@@ -369,12 +369,12 @@ public sealed class CourseSelectionsController(
|
||||
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 (!offering.IsOpenToAll)
|
||||
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
@@ -415,157 +415,160 @@ public sealed class CourseSelectionsController(
|
||||
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 (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 await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
}
|
||||
db.ChangeTracker.Clear();
|
||||
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()
|
||||
.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
|
||||
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 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)
|
||||
{
|
||||
CourseSelectionOfferingId = id,
|
||||
StudentId = student.Id,
|
||||
EnrolledAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||
enrollment.EnrolledAt = now;
|
||||
enrollment.WithdrawnAt = null;
|
||||
}
|
||||
}
|
||||
return ConflictProblem(
|
||||
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = students.Count });
|
||||
var existingEnrollments = await db.CourseEnrollments
|
||||
.Where(x => x.CourseSelectionOfferingId == id)
|
||||
.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")]
|
||||
@@ -581,73 +584,77 @@ public sealed class CourseSelectionsController(
|
||||
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)
|
||||
.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)
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
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,
|
||||
StudentId = student.Id,
|
||||
EnrollmentType = EnrollmentType.Retake,
|
||||
EnrolledAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||
enrollment.EnrolledAt = now;
|
||||
enrollment.WithdrawnAt = null;
|
||||
enrollment.EnrollmentType = EnrollmentType.Retake;
|
||||
}
|
||||
enrolled++;
|
||||
}
|
||||
var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
||||
return ConflictProblem(
|
||||
$"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = enrolled });
|
||||
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
|
||||
{
|
||||
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}")]
|
||||
@@ -824,145 +831,149 @@ public sealed class CourseSelectionsController(
|
||||
StudentEnrollmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null) return ProfileNotFound();
|
||||
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
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
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 });
|
||||
db.ChangeTracker.Clear();
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null) return ProfileNotFound();
|
||||
if (student.Status != StudentStatus.Active)
|
||||
return ConflictProblem("只有在籍学生可以选课。");
|
||||
|
||||
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,
|
||||
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}")]
|
||||
@@ -1159,9 +1170,9 @@ public sealed class CourseSelectionsController(
|
||||
if (taskIds.Count == 0) return [];
|
||||
return await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId) &&
|
||||
x.SchedulePlan!.AcademicTermId == academicTermId &&
|
||||
x.SchedulePlan.Status == SchedulePlanStatus.Published)
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,32 +122,39 @@ public sealed class CoursesExcelController(
|
||||
if (rows.Count == 0)
|
||||
return ValidationProblem("Excel 中没有可导入的课程数据。");
|
||||
|
||||
var errors = new List<string>();
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = await ImportRowsAsync(rows, errors, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
var errors = new List<string>();
|
||||
try
|
||||
{
|
||||
var result = await ImportRowsAsync(
|
||||
rows,
|
||||
errors,
|
||||
cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportRowsAsync(
|
||||
|
||||
@@ -103,12 +103,14 @@ public sealed class CurriculumPlansController(
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var accessibleCollegeIds = majorOptions
|
||||
.Select(x => x.CollegeId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
|
||||
if (collegeId.HasValue)
|
||||
accessibleMajors = accessibleMajors.Where(
|
||||
x => x.CollegeId == collegeId.Value);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id))
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
accessibleMajors.Any(major => major.CollegeId == x.Id))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
@@ -288,46 +290,57 @@ public sealed class CurriculumPlansController(
|
||||
[HttpPost("{id:guid}/publish")]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿方案可以发布。");
|
||||
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
|
||||
return ConflictProblem("发布前每个课程模块都必须配置课程。");
|
||||
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
|
||||
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
|
||||
if (plan.Modules.Any(module =>
|
||||
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
|
||||
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
|
||||
return ConflictProblem("同一门课程不能重复加入多个模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
|
||||
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
|
||||
return ConflictProblem("建议学期超出了该专业学制。");
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var plan = await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿方案可以发布。");
|
||||
if (plan.Modules.Count == 0 ||
|
||||
plan.Modules.Any(x => x.Courses.Count == 0))
|
||||
return ConflictProblem("发布前每个课程模块都必须配置课程。");
|
||||
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
|
||||
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
|
||||
if (plan.Modules.Any(module =>
|
||||
module.Courses.Sum(x => x.Course!.Credits) <
|
||||
module.RequiredCredits))
|
||||
return ConflictProblem(
|
||||
"存在课程学分合计低于最低学分要求的模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses)
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Any(x => x.Count() > 1))
|
||||
return ConflictProblem("同一门课程不能重复加入多个模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
|
||||
x.RecommendedSemester >
|
||||
plan.Major!.SchoolingYears * 2))
|
||||
return ConflictProblem("建议学期超出了该专业学制。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var previousPlans = await ScopedPlans()
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
x.MajorId == plan.MajorId &&
|
||||
x.EffectiveGrade == plan.EffectiveGrade &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var previous in previousPlans)
|
||||
{
|
||||
previous.Status = CurriculumPlanStatus.Archived;
|
||||
}
|
||||
var previousPlans = await ScopedPlans()
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
x.MajorId == plan.MajorId &&
|
||||
x.EffectiveGrade == plan.EffectiveGrade &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var previous in previousPlans)
|
||||
{
|
||||
previous.Status = CurriculumPlanStatus.Archived;
|
||||
}
|
||||
|
||||
plan.Status = CurriculumPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
plan.Status = CurriculumPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{planId:guid}/modules")]
|
||||
|
||||
@@ -31,9 +31,15 @@ public sealed class DegreeAwardsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.GraduationYear, x.DegreeName,
|
||||
x.MinimumGradePoint, x.Status, x.Notes,
|
||||
x.CalculatedAt, x.PublishedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.GraduationYear,
|
||||
x.DegreeName,
|
||||
x.MinimumGradePoint,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.CalculatedAt,
|
||||
x.PublishedAt,
|
||||
ResultCount = x.Results.Count(result =>
|
||||
!collegeId.HasValue ||
|
||||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
||||
@@ -67,19 +73,32 @@ public sealed class DegreeAwardsController(
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
|
||||
batch.MinimumGradePoint, batch.Status, batch.Notes,
|
||||
batch.CalculatedAt, batch.PublishedAt,
|
||||
batch.Id,
|
||||
batch.Name,
|
||||
batch.GraduationYear,
|
||||
batch.DegreeName,
|
||||
batch.MinimumGradePoint,
|
||||
batch.Status,
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
|
||||
x.ExceptionReason, x.IsOverridden,
|
||||
x.ReviewComment, x.ReviewedAt
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
});
|
||||
}
|
||||
@@ -130,9 +149,9 @@ public sealed class DegreeAwardsController(
|
||||
.ToList();
|
||||
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
||||
var gradePoints = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => studentIds.Contains(x.StudentId) &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradePoint.HasValue)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -241,10 +260,14 @@ public sealed class DegreeAwardsController(
|
||||
x.DegreeAwardBatch.GraduationYear,
|
||||
x.DegreeAwardBatch.DegreeName,
|
||||
x.DegreeAwardBatch.MinimumGradePoint,
|
||||
x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
||||
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
|
||||
x.IsOverridden, x.ReviewComment,
|
||||
x.AverageGradePoint,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.DegreeAwardBatch.PublishedAt
|
||||
}).FirstOrDefaultAsync(token);
|
||||
return Ok(result);
|
||||
|
||||
@@ -39,12 +39,19 @@ public sealed class EvaluationsController(
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.Status,
|
||||
x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.StartsAt, x.EndsAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Status,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
Dimensions = x.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
}),
|
||||
RecordCount = x.Records.Count,
|
||||
x.CreatedAt
|
||||
@@ -194,8 +201,10 @@ public sealed class EvaluationsController(
|
||||
var enrollments = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.StudentId == studentId.Value &&
|
||||
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId))
|
||||
e.StudentId == studentId.Value)
|
||||
.WhereIn(
|
||||
termIds,
|
||||
e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)
|
||||
.Select(e => new
|
||||
{
|
||||
e.CourseSelectionOffering!.TeachingTaskId,
|
||||
@@ -211,9 +220,10 @@ public sealed class EvaluationsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Check which tasks have already been evaluated
|
||||
var setupIds = openSetups.Select(s => s.Id).ToArray();
|
||||
var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.StudentId == studentId.Value &&
|
||||
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId))
|
||||
.Where(r => r.StudentId == studentId.Value)
|
||||
.WhereIn(setupIds, r => r.EvaluationSetupId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
|
||||
@@ -251,7 +261,9 @@ public sealed class EvaluationsController(
|
||||
.Where(x => x.Id == teachingTaskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.TaskNumber, x.Name,
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
@@ -283,7 +295,10 @@ public sealed class EvaluationsController(
|
||||
setup.Name,
|
||||
Dimensions = setup.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
}),
|
||||
Task = task
|
||||
});
|
||||
@@ -384,8 +399,8 @@ public sealed class EvaluationsController(
|
||||
foreach (var setup in setups)
|
||||
{
|
||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.EvaluationSetupId == setup.Id &&
|
||||
taskIds.Contains(r.TeachingTaskId))
|
||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -396,7 +411,9 @@ public sealed class EvaluationsController(
|
||||
.Where(x => x.Id == taskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.TaskNumber, x.Name,
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name
|
||||
@@ -525,12 +542,18 @@ public sealed class EvaluationsController(
|
||||
{
|
||||
Setup = new
|
||||
{
|
||||
setup.Id, setup.Name, setup.Status,
|
||||
setup.Id,
|
||||
setup.Name,
|
||||
setup.Status,
|
||||
TermName = setup.AcademicTerm!.Name,
|
||||
setup.StartsAt, setup.EndsAt,
|
||||
setup.StartsAt,
|
||||
setup.EndsAt,
|
||||
Dimensions = setup.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
})
|
||||
},
|
||||
ByTask = byTask,
|
||||
|
||||
@@ -39,8 +39,14 @@ public sealed class ExamsController(
|
||||
.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
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
x.PublishedAt
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -72,37 +78,42 @@ public sealed class ExamsController(
|
||||
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, x.Notes, x.PublishedAt,
|
||||
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.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),
|
||||
StudentCount = db.CourseEnrollments.Count(e =>
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
|
||||
})
|
||||
item.RequiredInvigilatorCount,
|
||||
item.Notes,
|
||||
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
||||
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
|
||||
StudentCount = db.CourseEnrollments.Count(e =>
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
|
||||
})
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return plan is null ? NotFound() : Ok(plan);
|
||||
}
|
||||
@@ -270,7 +281,7 @@ public sealed class ExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedIds, x => x.Id);
|
||||
|
||||
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Capacity)
|
||||
@@ -313,8 +324,8 @@ public sealed class ExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyIds, x => x.Id)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -547,8 +558,10 @@ public sealed class ExamsController(
|
||||
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)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效监考教师。");
|
||||
}
|
||||
|
||||
@@ -564,9 +577,10 @@ public sealed class ExamsController(
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await overlaps.AnyAsync(x =>
|
||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||
cancellationToken))
|
||||
if (await db.ExamSessionInvigilators
|
||||
.Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有考试任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,15 @@ public sealed class GraduationAuditsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
|
||||
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.GraduationYear,
|
||||
x.EnrollmentYear,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.CalculatedAt,
|
||||
x.PublishedAt,
|
||||
x.CreatedAt,
|
||||
ResultCount = x.Results.Count(result =>
|
||||
!collegeId.HasValue ||
|
||||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
||||
@@ -68,23 +75,38 @@ public sealed class GraduationAuditsController(
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
|
||||
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
|
||||
batch.Id,
|
||||
batch.Name,
|
||||
batch.GraduationYear,
|
||||
batch.EnrollmentYear,
|
||||
batch.Status,
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits, x.EarnedCredits,
|
||||
x.RequiredCourseCount, x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount, x.MissingCourseNames,
|
||||
x.CalculatedConclusion, x.Conclusion, x.IsOverridden,
|
||||
x.ReviewComment, x.ReviewedAt
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
});
|
||||
}
|
||||
@@ -136,8 +158,8 @@ public sealed class GraduationAuditsController(
|
||||
.ToListAsync(token);
|
||||
var studentIds = students.Select(x => x.Id).ToArray();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => studentIds.Contains(x.StudentId) &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => new GradeSnapshot(
|
||||
x.StudentId,
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
@@ -275,13 +297,19 @@ public sealed class GraduationAuditsController(
|
||||
x.Id,
|
||||
BatchName = x.GraduationAuditBatch!.Name,
|
||||
x.GraduationAuditBatch.GraduationYear,
|
||||
x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits, x.EarnedCredits,
|
||||
x.RequiredCourseCount, x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount, x.MissingCourseNames,
|
||||
x.Conclusion, x.IsOverridden, x.ReviewComment,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.GraduationAuditBatch.PublishedAt
|
||||
}).FirstOrDefaultAsync(token);
|
||||
return Ok(result);
|
||||
|
||||
@@ -44,8 +44,14 @@ public sealed class MakeupExamsController(
|
||||
.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
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
x.PublishedAt
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -77,46 +83,51 @@ public sealed class MakeupExamsController(
|
||||
.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,
|
||||
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
|
||||
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);
|
||||
}
|
||||
@@ -311,8 +322,12 @@ public sealed class MakeupExamsController(
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
|
||||
message = "该计划已有正在执行的任务。" });
|
||||
return Ok(new
|
||||
{
|
||||
jobId = existing.Id,
|
||||
status = existing.Status.ToString(),
|
||||
message = "该计划已有正在执行的任务。"
|
||||
});
|
||||
|
||||
var job = new MakeupExamAutoJob
|
||||
{
|
||||
@@ -404,9 +419,9 @@ public sealed class MakeupExamsController(
|
||||
|
||||
// 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 &&
|
||||
.Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.MakeupExamSession.EndsAt)
|
||||
.WhereIn(validIds, x => x.StudentId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -414,7 +429,7 @@ public sealed class MakeupExamsController(
|
||||
if (conflictIds.Count > 0)
|
||||
{
|
||||
var conflictNumbers = await db.Students
|
||||
.Where(s => conflictIds.Contains(s.Id))
|
||||
.WhereIn(conflictIds, s => s.Id)
|
||||
.Select(s => s.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ConflictProblem(
|
||||
@@ -590,7 +605,7 @@ public sealed class MakeupExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedIds, x => x.Id);
|
||||
|
||||
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Capacity)
|
||||
@@ -634,8 +649,8 @@ public sealed class MakeupExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyIds, x => x.Id)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -845,8 +860,10 @@ public sealed class MakeupExamsController(
|
||||
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)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效监考教师。");
|
||||
}
|
||||
|
||||
@@ -862,9 +879,10 @@ public sealed class MakeupExamsController(
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await overlaps.AnyAsync(x =>
|
||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||
cancellationToken))
|
||||
if (await db.MakeupExamSessionInvigilators
|
||||
.Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有补考任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -158,52 +158,60 @@ public sealed class PersonnelController(
|
||||
TeacherAccountActivationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
|
||||
if (teacher is null) return NotFound();
|
||||
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
|
||||
if (teacher.Status != TeacherStatus.Active)
|
||||
{
|
||||
return ConflictProblem("仅在职教师可以激活登录账号。");
|
||||
}
|
||||
if (teacher.UserId.HasValue)
|
||||
{
|
||||
return ConflictProblem("该教师档案已经关联登录账号。");
|
||||
}
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
|
||||
if (teacher is null) return NotFound();
|
||||
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
|
||||
if (teacher.Status != TeacherStatus.Active)
|
||||
{
|
||||
return ConflictProblem("仅在职教师可以激活登录账号。");
|
||||
}
|
||||
if (teacher.UserId.HasValue)
|
||||
{
|
||||
return ConflictProblem("该教师档案已经关联登录账号。");
|
||||
}
|
||||
|
||||
var userName = teacher.TeacherNumber.Trim();
|
||||
if (await userManager.FindByNameAsync(userName) is not null)
|
||||
{
|
||||
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
}
|
||||
var userName = teacher.TeacherNumber.Trim();
|
||||
if (await userManager.FindByNameAsync(userName) is not null)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = userName,
|
||||
DisplayName = teacher.Name,
|
||||
StaffNumber = userName,
|
||||
CollegeId = teacher.CollegeId,
|
||||
IsEnabled = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = userName,
|
||||
DisplayName = teacher.Name,
|
||||
StaffNumber = userName,
|
||||
CollegeId = teacher.CollegeId,
|
||||
IsEnabled = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
|
||||
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
result = await userManager.AddToRoleAsync(
|
||||
user,
|
||||
SystemRoles.Teacher);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
|
||||
teacher.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
teacher.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("students")]
|
||||
|
||||
@@ -123,32 +123,39 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
|
||||
var errors = new List<string>();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
|
||||
? await ImportTeachersAsync(rows, errors, cancellationToken)
|
||||
: await ImportStudentsAsync(rows, errors, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复编号或无效关联,未写入任何档案。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
var errors = new List<string>();
|
||||
try
|
||||
{
|
||||
var result = kind.Equals(
|
||||
"teachers",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? await ImportTeachersAsync(rows, errors, cancellationToken)
|
||||
: await ImportStudentsAsync(rows, errors, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return ImportValidationProblem(errors);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "导入失败",
|
||||
Detail = "存在重复编号或无效关联,未写入任何档案。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportTeachersAsync(
|
||||
|
||||
@@ -82,24 +82,24 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course!.Name,
|
||||
CollegeId = x.Course.CollegeId,
|
||||
CollegeName = x.Course.College!.Name,
|
||||
TeacherNames = x.Teachers
|
||||
x.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course!.Name,
|
||||
CollegeId = x.Course.CollegeId,
|
||||
CollegeName = x.Course.College!.Name,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.Capacity,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode
|
||||
x.Capacity,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var taskIds = tasks.Select(x => x.Id).ToList();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
return Ok(tasks.Select(task =>
|
||||
@@ -109,19 +109,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
task.Name,
|
||||
task.CourseCode,
|
||||
task.CourseName,
|
||||
task.CollegeId,
|
||||
task.CollegeName,
|
||||
task.TeacherNames,
|
||||
task.Capacity,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
task.WeeklyHours,
|
||||
task.SchedulingMode,
|
||||
HasCustomConstraint = constraint is not null,
|
||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||
task.Name,
|
||||
task.CourseCode,
|
||||
task.CourseName,
|
||||
task.CollegeId,
|
||||
task.CollegeName,
|
||||
task.TeacherNames,
|
||||
task.Capacity,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
task.WeeklyHours,
|
||||
task.SchedulingMode,
|
||||
HasCustomConstraint = constraint is not null,
|
||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||
? false
|
||||
: constraint?.RequiresClassroom ?? true,
|
||||
constraint?.RequiredCampusId,
|
||||
@@ -143,34 +143,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
|
||||
return ValidationProblem("允许上课日必须位于星期一至星期日。");
|
||||
if (request.EarliestPeriod.HasValue &&
|
||||
request.LatestPeriod.HasValue &&
|
||||
request.EarliestPeriod > request.LatestPeriod)
|
||||
return ValidationProblem("最早节次不能晚于最晚节次。");
|
||||
if (!Enum.IsDefined(request.SchedulingMode))
|
||||
return ValidationProblem("授课方式无效。");
|
||||
var task = await db.TeachingTasks
|
||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
|
||||
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
|
||||
if (request.EarliestPeriod.HasValue &&
|
||||
request.LatestPeriod.HasValue &&
|
||||
request.EarliestPeriod > request.LatestPeriod)
|
||||
return ValidationProblem("最早节次不能晚于最晚节次。");
|
||||
if (!Enum.IsDefined(request.SchedulingMode))
|
||||
return ValidationProblem("授课方式无效。");
|
||||
var task = await db.TeachingTasks
|
||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
|
||||
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
|
||||
|
||||
task.SchedulingMode = request.SchedulingMode;
|
||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
{
|
||||
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||
if (flexibleConstraint is not null)
|
||||
{
|
||||
ClearConstraint(flexibleConstraint);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
task.SchedulingMode = request.SchedulingMode;
|
||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
{
|
||||
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||
if (flexibleConstraint is not null)
|
||||
{
|
||||
ClearConstraint(flexibleConstraint);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
Building? building = null;
|
||||
Building? building = null;
|
||||
if (request.RequiredBuildingId.HasValue)
|
||||
{
|
||||
building = await db.Buildings.AsNoTracking()
|
||||
@@ -189,7 +189,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
return ValidationProblem("指定校区不存在或已停用。");
|
||||
|
||||
var allowedRooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(request.AllowedClassroomIds, x => x.Id)
|
||||
.Include(x => x.Building)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
|
||||
@@ -225,9 +226,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
||||
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
|
||||
: [];
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("constraints/batch")]
|
||||
public async Task<ActionResult> SaveConstraintsBatch(
|
||||
@@ -246,22 +247,22 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
if (request.SchedulingMode.HasValue &&
|
||||
!Enum.IsDefined(request.SchedulingMode.Value))
|
||||
return ValidationProblem("授课方式无效。");
|
||||
if (!request.SchedulingMode.HasValue &&
|
||||
!request.RequiresClassroom.HasValue &&
|
||||
request.AllowedDayOfWeeks is null &&
|
||||
!request.UpdateClassroomScope &&
|
||||
!request.UpdatePeriodRange &&
|
||||
!request.EarliestPeriod.HasValue &&
|
||||
!request.LatestPeriod.HasValue)
|
||||
if (!request.SchedulingMode.HasValue &&
|
||||
!request.RequiresClassroom.HasValue &&
|
||||
request.AllowedDayOfWeeks is null &&
|
||||
!request.UpdateClassroomScope &&
|
||||
!request.UpdatePeriodRange &&
|
||||
!request.EarliestPeriod.HasValue &&
|
||||
!request.LatestPeriod.HasValue)
|
||||
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
||||
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
||||
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
||||
|
||||
var tasks = await db.TeachingTasks
|
||||
.Where(x =>
|
||||
taskIds.Contains(x.Id) &&
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != taskIds.Length)
|
||||
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
|
||||
@@ -297,7 +298,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
|
||||
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
|
||||
allowedRooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(roomIds, x => x.Id)
|
||||
.Include(x => x.Building)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedRooms.Count != roomIds.Length)
|
||||
@@ -311,12 +313,12 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
return ValidationProblem("指定教室必须位于所选校区。");
|
||||
}
|
||||
|
||||
var constraints = await db.TeachingTaskScheduleConstraints
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var constraints = await db.TeachingTaskScheduleConstraints
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (request.SchedulingMode.HasValue)
|
||||
task.SchedulingMode = request.SchedulingMode.Value;
|
||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
@@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IReadOnlyCollection<Guid> taskIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ScheduleEntries.AsNoTracking()
|
||||
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken);
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.AnyAsync(cancellationToken);
|
||||
|
||||
private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
|
||||
{
|
||||
|
||||
@@ -95,7 +95,12 @@ public sealed class StatisticsController(
|
||||
|
||||
return new
|
||||
{
|
||||
byCollege, byMajor, byClass, byGrade, byStatus, byGender,
|
||||
byCollege,
|
||||
byMajor,
|
||||
byClass,
|
||||
byGrade,
|
||||
byStatus,
|
||||
byGender,
|
||||
enrollmentTrend,
|
||||
totals = new { totalStudents }
|
||||
};
|
||||
@@ -255,7 +260,8 @@ public sealed class StatisticsController(
|
||||
|
||||
var passRateByCollegeResult = passRateByCollege.Select(x => new
|
||||
{
|
||||
x.collegeName, x.totalRecords,
|
||||
x.collegeName,
|
||||
x.totalRecords,
|
||||
passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0,
|
||||
averageScore = Math.Round(x.averageScore, 1)
|
||||
}).ToList();
|
||||
@@ -336,7 +342,8 @@ public sealed class StatisticsController(
|
||||
|
||||
var byCollegeResult = byCollege.Select(x => new
|
||||
{
|
||||
x.collegeName, x.total,
|
||||
x.collegeName,
|
||||
x.total,
|
||||
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
|
||||
}).OrderByDescending(x => x.passRate).ToList();
|
||||
|
||||
@@ -353,7 +360,9 @@ public sealed class StatisticsController(
|
||||
|
||||
var byCourseResult = byCourse.Select(x => new
|
||||
{
|
||||
x.courseCode, x.courseName, x.total,
|
||||
x.courseCode,
|
||||
x.courseName,
|
||||
x.total,
|
||||
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
|
||||
}).OrderByDescending(x => x.passRate).ToList();
|
||||
|
||||
@@ -567,7 +576,8 @@ public sealed class StatisticsController(
|
||||
// get entries
|
||||
var entries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.SchedulePlanId == plan.Id)
|
||||
.Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value))
|
||||
.Where(e => e.ClassroomId != null)
|
||||
.WhereIn(classroomIds, e => e.ClassroomId!.Value)
|
||||
.Select(e => new
|
||||
{
|
||||
e.ClassroomId,
|
||||
@@ -633,7 +643,8 @@ public sealed class StatisticsController(
|
||||
var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount);
|
||||
return new
|
||||
{
|
||||
day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
|
||||
day,
|
||||
dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
|
||||
utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -74,8 +74,8 @@ public sealed class StudentCurriculumController(
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) &&
|
||||
x.GradeSheet.Status == GradeSheetStatus.Published)
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId)
|
||||
.Select(x => new StudentGradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.TotalScore,
|
||||
@@ -88,15 +88,15 @@ public sealed class StudentCurriculumController(
|
||||
|
||||
var currentCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
planCourseIds.Contains(task.CourseId) &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.AcademicTerm!.IsCurrent &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||
.WhereIn(planCourseIds, task => task.CourseId)
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -76,11 +76,11 @@ public sealed class TeachingTasksController(
|
||||
CollegeName = x.Course.College!.Name,
|
||||
x.Capacity,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
x.GenerationBatchCode,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
@@ -157,11 +157,11 @@ public sealed class TeachingTasksController(
|
||||
CollegeName = x.Course.College!.Name,
|
||||
x.Capacity,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
x.GenerationBatchCode,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.PublishedAt,
|
||||
@@ -215,10 +215,10 @@ public sealed class TeachingTasksController(
|
||||
CourseId = request.CourseId,
|
||||
Capacity = request.Capacity,
|
||||
StartWeek = request.StartWeek,
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
SchedulingMode = request.SchedulingMode,
|
||||
Notes = Normalize(request.Notes)
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
SchedulingMode = request.SchedulingMode,
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
SetAssignments(task, request);
|
||||
db.TeachingTasks.Add(task);
|
||||
@@ -249,10 +249,10 @@ public sealed class TeachingTasksController(
|
||||
task.CourseId = request.CourseId;
|
||||
task.Capacity = request.Capacity;
|
||||
task.StartWeek = request.StartWeek;
|
||||
task.EndWeek = request.EndWeek;
|
||||
task.WeeklyHours = request.WeeklyHours;
|
||||
task.SchedulingMode = request.SchedulingMode;
|
||||
task.Notes = Normalize(request.Notes);
|
||||
task.EndWeek = request.EndWeek;
|
||||
task.WeeklyHours = request.WeeklyHours;
|
||||
task.SchedulingMode = request.SchedulingMode;
|
||||
task.Notes = Normalize(request.Notes);
|
||||
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
|
||||
db.TeachingTaskClasses.RemoveRange(task.Classes);
|
||||
task.Teachers = [];
|
||||
@@ -342,7 +342,7 @@ public sealed class TeachingTasksController(
|
||||
.Include(x => x.Classes)
|
||||
.ThenInclude(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Students)
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
.WhereIn(ids, x => x.Id)
|
||||
.OrderBy(x => x.TaskNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != ids.Length)
|
||||
@@ -352,17 +352,17 @@ public sealed class TeachingTasksController(
|
||||
switch (request.Operation)
|
||||
{
|
||||
case TeachingTaskBatchOperation.Publish:
|
||||
{
|
||||
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
|
||||
if (validation is not null) return ConflictProblem(validation);
|
||||
var publishedAt = DateTime.UtcNow;
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
task.Status = TeachingTaskStatus.Published;
|
||||
task.PublishedAt = publishedAt;
|
||||
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
|
||||
if (validation is not null) return ConflictProblem(validation);
|
||||
var publishedAt = DateTime.UtcNow;
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
task.Status = TeachingTaskStatus.Published;
|
||||
task.PublishedAt = publishedAt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TeachingTaskBatchOperation.Delete:
|
||||
if (tasks.Any(x =>
|
||||
x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)))
|
||||
@@ -414,14 +414,14 @@ public sealed class TeachingTasksController(
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (!CanManage(course)) return Forbid();
|
||||
var hoursProblem = TeachingTaskHours.Validate(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (!CanManage(course)) return Forbid();
|
||||
var hoursProblem = TeachingTaskHours.Validate(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||
var scopedCollegeId = ScopedCollegeId();
|
||||
@@ -431,7 +431,8 @@ public sealed class TeachingTasksController(
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
|
||||
var classesQuery = db.AdministrativeClasses
|
||||
.Where(x => classIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(classIds, x => x.Id)
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Students)
|
||||
.AsQueryable();
|
||||
@@ -443,123 +444,127 @@ public sealed class TeachingTasksController(
|
||||
if (classes.Count != classIds.Length)
|
||||
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
classIds.Contains(x.AdministrativeClassId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (assignedClassIds.Count > 0)
|
||||
{
|
||||
var names = classes
|
||||
.Where(x => assignedClassIds.Contains(x.Id))
|
||||
.Select(x => x.Name);
|
||||
return ConflictProblem(
|
||||
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
|
||||
}
|
||||
|
||||
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved &&
|
||||
x.Teacher!.Status == TeacherStatus.Active)
|
||||
.Where(x => !scopedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == scopedCollegeId.Value)
|
||||
.Select(x => x.Teacher!)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (eligibleTeachers.Count == 0)
|
||||
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
|
||||
|
||||
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
|
||||
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
eligibleTeacherIds.Contains(x.TeacherId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.Status != TeachingTaskStatus.Closed)
|
||||
.GroupBy(x => x.TeacherId)
|
||||
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
|
||||
var existingNumbers = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == request.AcademicTermId)
|
||||
.Select(x => x.TaskNumber)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
var batchCode =
|
||||
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
|
||||
var groups = classes.Chunk(request.ClassesPerTask).ToList();
|
||||
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
eligibleTeacherIds,
|
||||
existingLoads,
|
||||
groups.Count);
|
||||
var created = new List<TeachingTask>();
|
||||
for (var index = 0; index < groups.Count; index++)
|
||||
{
|
||||
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
|
||||
var group = groups[index];
|
||||
var taskNumber = NextTaskNumber(
|
||||
term.Code,
|
||||
course.Code,
|
||||
index + 1,
|
||||
existingNumbers);
|
||||
existingNumbers.Add(taskNumber);
|
||||
var studentCount = group.Sum(administrativeClass =>
|
||||
administrativeClass.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active));
|
||||
var task = new TeachingTask
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
TaskNumber = taskNumber,
|
||||
Name = $"{course.Name}教学班 {index + 1:D2}",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = Math.Max(1, studentCount),
|
||||
StartWeek = request.StartWeek,
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
GenerationBatchCode = batchCode,
|
||||
Notes = "公共课合班自动生成,发布前可继续调整。",
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
db.ChangeTracker.Clear();
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (assignedClassIds.Count > 0)
|
||||
{
|
||||
var names = classes
|
||||
.Where(x => assignedClassIds.Contains(x.Id))
|
||||
.Select(x => x.Name);
|
||||
return ConflictProblem(
|
||||
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
|
||||
}
|
||||
|
||||
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved &&
|
||||
x.Teacher!.Status == TeacherStatus.Active)
|
||||
.Where(x => !scopedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == scopedCollegeId.Value)
|
||||
.Select(x => x.Teacher!)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (eligibleTeachers.Count == 0)
|
||||
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
|
||||
|
||||
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
|
||||
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.Status != TeachingTaskStatus.Closed)
|
||||
.WhereIn(eligibleTeacherIds, x => x.TeacherId)
|
||||
.GroupBy(x => x.TeacherId)
|
||||
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
|
||||
var existingNumbers = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == request.AcademicTermId)
|
||||
.Select(x => x.TaskNumber)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
var batchCode =
|
||||
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
|
||||
var groups = classes.Chunk(request.ClassesPerTask).ToList();
|
||||
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
eligibleTeacherIds,
|
||||
existingLoads,
|
||||
groups.Count);
|
||||
var created = new List<TeachingTask>();
|
||||
for (var index = 0; index < groups.Count; index++)
|
||||
{
|
||||
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
|
||||
var group = groups[index];
|
||||
var taskNumber = NextTaskNumber(
|
||||
term.Code,
|
||||
course.Code,
|
||||
index + 1,
|
||||
existingNumbers);
|
||||
existingNumbers.Add(taskNumber);
|
||||
var studentCount = group.Sum(administrativeClass =>
|
||||
administrativeClass.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active));
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = taskNumber,
|
||||
Name = $"{course.Name}教学班 {index + 1:D2}",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = Math.Max(1, studentCount),
|
||||
StartWeek = request.StartWeek,
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
GenerationBatchCode = batchCode,
|
||||
Notes = "公共课合班自动生成,发布前可继续调整。",
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
],
|
||||
Classes = group.Select(administrativeClass =>
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}).ToList()
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
],
|
||||
Classes = group.Select(administrativeClass =>
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}).ToList()
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
CreatedCount = created.Count,
|
||||
Tasks = created.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
task.Name,
|
||||
TeacherName = eligibleTeachers
|
||||
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
|
||||
ClassNames = classes
|
||||
.Where(x => task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id))
|
||||
.Select(x => x.Name),
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
CreatedCount = created.Count,
|
||||
Tasks = created.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
task.Name,
|
||||
TeacherName = eligibleTeachers
|
||||
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
|
||||
ClassNames = classes
|
||||
.Where(x => task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id))
|
||||
.Select(x => x.Name),
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> ScopedTasks()
|
||||
@@ -579,8 +584,8 @@ public sealed class TeachingTasksController(
|
||||
return ValidationProblem("开始周不能晚于结束周。");
|
||||
var course = await db.Courses.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (!CanManage(course)) return Forbid();
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (!CanManage(course)) return Forbid();
|
||||
if (!Enum.IsDefined(request.SchedulingMode))
|
||||
return ValidationProblem("授课方式无效。");
|
||||
var hoursProblem = TeachingTaskHours.Validate(
|
||||
@@ -589,7 +594,7 @@ public sealed class TeachingTasksController(
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
var collegeId = ScopedCollegeId();
|
||||
var collegeId = ScopedCollegeId();
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
@@ -599,25 +604,26 @@ public sealed class TeachingTasksController(
|
||||
if (request.PrimaryTeacherId.HasValue &&
|
||||
!teacherIds.Contains(request.PrimaryTeacherId.Value))
|
||||
return ValidationProblem("主讲教师必须包含在授课教师中。");
|
||||
if (await db.Teachers.CountAsync(
|
||||
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效或非在职授课教师。");
|
||||
if (teacherIds.Length > 0 &&
|
||||
await db.TeacherCourseApplications.CountAsync(
|
||||
x =>
|
||||
await db.TeacherCourseApplications
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length > 0)
|
||||
{
|
||||
var classes = db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => classIds.Contains(x.Id));
|
||||
.WhereIn(classIds, x => x.Id);
|
||||
if (collegeId.HasValue)
|
||||
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
|
||||
if (await classes.CountAsync(cancellationToken) != classIds.Length)
|
||||
@@ -673,11 +679,10 @@ public sealed class TeachingTasksController(
|
||||
.ToArray();
|
||||
var approvedApplications = await db.TeacherCourseApplications
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
termIds.Contains(x.AcademicTermId) &&
|
||||
courseIds.Contains(x.CourseId) &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.WhereIn(termIds, x => x.AcademicTermId)
|
||||
.WhereIn(courseIds, x => x.CourseId)
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var approvedAssignments = approvedApplications
|
||||
|
||||
@@ -317,11 +317,12 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken);
|
||||
var activePeriodCount = configuredPeriodCount == 0
|
||||
? requestedPeriods.Length
|
||||
: await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x =>
|
||||
x.AcademicTermId == academicTermId &&
|
||||
x.IsEnabled &&
|
||||
requestedPeriods.Contains(x.PeriodNumber),
|
||||
cancellationToken);
|
||||
: await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == academicTermId &&
|
||||
x.IsEnabled)
|
||||
.WhereIn(requestedPeriods, x => x.PeriodNumber)
|
||||
.CountAsync(cancellationToken);
|
||||
if (activePeriodCount != requestedPeriods.Length)
|
||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||
|
||||
@@ -357,8 +358,8 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Building!.IsEnabled &&
|
||||
x.Building.Campus!.IsEnabled &&
|
||||
!occupiedIds.Contains(x.Id));
|
||||
x.Building.Campus!.IsEnabled)
|
||||
.WhereNotIn(occupiedIds, x => x.Id);
|
||||
if (campusId.HasValue)
|
||||
rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value);
|
||||
if (buildingId.HasValue)
|
||||
|
||||
@@ -25,8 +25,14 @@ public sealed class UsersController(
|
||||
.OrderBy(x => x.UserName)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.UserName, x.DisplayName, x.StaffNumber,
|
||||
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt
|
||||
x.Id,
|
||||
x.UserName,
|
||||
x.DisplayName,
|
||||
x.StaffNumber,
|
||||
x.CollegeId,
|
||||
x.IsEnabled,
|
||||
x.LastLoginAt,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -36,8 +42,14 @@ public sealed class UsersController(
|
||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
||||
result.Add(new
|
||||
{
|
||||
user.Id, user.UserName, user.DisplayName, user.StaffNumber,
|
||||
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt,
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.DisplayName,
|
||||
user.StaffNumber,
|
||||
user.CollegeId,
|
||||
user.IsEnabled,
|
||||
user.LastLoginAt,
|
||||
user.CreatedAt,
|
||||
Roles = identityUser is null
|
||||
? []
|
||||
: await userManager.GetRolesAsync(identityUser)
|
||||
@@ -54,38 +66,50 @@ public sealed class UsersController(
|
||||
.ToListAsync(cancellationToken));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(CreateUserRequest request)
|
||||
public async Task<ActionResult> Create(
|
||||
CreateUserRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = request.UserName.Trim(),
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
StaffNumber = staffNumber,
|
||||
CollegeId = request.CollegeId,
|
||||
LockoutEnabled = true,
|
||||
IsEnabled = true
|
||||
};
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = request.UserName.Trim(),
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
StaffNumber = staffNumber,
|
||||
CollegeId = request.CollegeId,
|
||||
LockoutEnabled = true,
|
||||
IsEnabled = true
|
||||
};
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
|
||||
result = await userManager.AddToRolesAsync(user, roles);
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
result = await userManager.AddToRolesAsync(user, roles);
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
|
||||
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id;
|
||||
if (profiles.Student is not null) profiles.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
|
||||
if (profiles.Teacher is not null)
|
||||
profiles.Teacher.UserId = user.Id;
|
||||
if (profiles.Student is not null)
|
||||
profiles.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return CreatedAtAction(
|
||||
nameof(GetUsers),
|
||||
new { id = user.Id },
|
||||
new { user.Id });
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/status")]
|
||||
@@ -103,51 +127,79 @@ public sealed class UsersController(
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/roles")]
|
||||
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request)
|
||||
public async Task<IActionResult> SetRoles(
|
||||
Guid id,
|
||||
SetRolesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(id.ToString());
|
||||
if (user is null) return NotFound();
|
||||
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var user = await userManager.FindByIdAsync(id.ToString());
|
||||
if (user is null) return NotFound();
|
||||
var profiles = await ResolveProfilesAsync(
|
||||
staffNumber,
|
||||
roles,
|
||||
user.Id);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
|
||||
var existing = await userManager.GetRolesAsync(user);
|
||||
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
|
||||
existing.Contains(SystemRoles.SuperAdmin) &&
|
||||
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidationProblem("不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
var existing = await userManager.GetRolesAsync(user);
|
||||
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
|
||||
existing.Contains(SystemRoles.SuperAdmin) &&
|
||||
!roles.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidationProblem(
|
||||
"不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
user.StaffNumber = staffNumber;
|
||||
user.CollegeId = request.CollegeId;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded) return IdentityValidationProblem(addResult);
|
||||
user.StaffNumber = staffNumber;
|
||||
user.CollegeId = request.CollegeId;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return IdentityValidationProblem(updateResult);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded)
|
||||
return IdentityValidationProblem(removeResult);
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded)
|
||||
return IdentityValidationProblem(addResult);
|
||||
|
||||
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync();
|
||||
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync();
|
||||
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var teacher in linkedTeachers) teacher.UserId = null;
|
||||
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var student in linkedStudents) student.UserId = null;
|
||||
if (profiles.Teacher is not null) profiles.Teacher.UserId = id;
|
||||
if (profiles.Student is not null) profiles.Student.UserId = id;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
return NoContent();
|
||||
var linkedTeachers = await db.Teachers
|
||||
.Where(x => x.UserId == id)
|
||||
.ToListAsync();
|
||||
var linkedStudents = await db.Students
|
||||
.Where(x => x.UserId == id)
|
||||
.ToListAsync();
|
||||
if (!roles.Contains(
|
||||
SystemRoles.Teacher,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var teacher in linkedTeachers)
|
||||
teacher.UserId = null;
|
||||
if (!roles.Contains(
|
||||
SystemRoles.Student,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var student in linkedStudents)
|
||||
student.UserId = null;
|
||||
if (profiles.Teacher is not null)
|
||||
profiles.Teacher.UserId = id;
|
||||
if (profiles.Student is not null)
|
||||
profiles.Student.UserId = id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
|
||||
|
||||
@@ -83,169 +83,212 @@ public sealed class UsersExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
|
||||
|
||||
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
|
||||
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var users = await userManager.Users.ToDictionaryAsync(
|
||||
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var teachers = await db.Teachers.ToDictionaryAsync(
|
||||
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var students = await db.Students.ToDictionaryAsync(
|
||||
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var errors = new List<string>();
|
||||
var plans = new List<UserImportPlan>();
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var userName = Required(row, "登录账号", errors);
|
||||
var displayName = Required(row, "姓名", errors);
|
||||
if (userName is null || displayName is null) continue;
|
||||
if (!seenNames.Add(userName))
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
|
||||
continue;
|
||||
}
|
||||
db.ChangeTracker.Clear();
|
||||
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
|
||||
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var users = await userManager.Users.ToDictionaryAsync(
|
||||
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var teachers = await db.Teachers.ToDictionaryAsync(
|
||||
x => x.TeacherNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var students = await db.Students.ToDictionaryAsync(
|
||||
x => x.StudentNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var errors = new List<string>();
|
||||
var plans = new List<UserImportPlan>();
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existing = users.GetValueOrDefault(userName);
|
||||
var password = Optional(row, "初始密码");
|
||||
if (existing is null && (password?.Length ?? 0) < 8)
|
||||
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
|
||||
if (password is { Length: > 0 and < 8 })
|
||||
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
|
||||
|
||||
var roleNames = ParseRoles(row, errors);
|
||||
var enabled = ParseEnabled(row, errors);
|
||||
var staffNumber = Optional(row, "工号/学号");
|
||||
Guid? collegeId = null;
|
||||
var collegeCode = Optional(row, "学院编码");
|
||||
if (collegeCode is not null)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeCode, out var college))
|
||||
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
else collegeId = college.Id;
|
||||
}
|
||||
|
||||
Teacher? teacher = null;
|
||||
Student? student = null;
|
||||
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
|
||||
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
|
||||
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
|
||||
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedTeachers.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
|
||||
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
|
||||
else if (student.UserId.HasValue && student.UserId != existing?.Id)
|
||||
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedStudents.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
|
||||
if (existing?.Id.ToString() == currentUserId)
|
||||
{
|
||||
if (!enabled)
|
||||
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
|
||||
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
plans.Add(new UserImportPlan(
|
||||
row.RowNumber, existing, userName, displayName, password,
|
||||
staffNumber, collegeId, roleNames, enabled, teacher, student));
|
||||
}
|
||||
|
||||
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var user = plan.Existing;
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
foreach (var row in rows)
|
||||
{
|
||||
UserName = plan.UserName,
|
||||
DisplayName = plan.DisplayName,
|
||||
StaffNumber = plan.StaffNumber,
|
||||
CollegeId = plan.CollegeId,
|
||||
IsEnabled = plan.IsEnabled,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var createResult = await userManager.CreateAsync(user, plan.Password!);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(createResult, plan.RowNumber);
|
||||
var userName = Required(row, "登录账号", errors);
|
||||
var displayName = Required(row, "姓名", errors);
|
||||
if (userName is null || displayName is null) continue;
|
||||
if (!seenNames.Add(userName))
|
||||
{
|
||||
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = users.GetValueOrDefault(userName);
|
||||
var password = Optional(row, "初始密码");
|
||||
if (existing is null && (password?.Length ?? 0) < 8)
|
||||
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
|
||||
if (password is { Length: > 0 and < 8 })
|
||||
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
|
||||
|
||||
var roleNames = ParseRoles(row, errors);
|
||||
var enabled = ParseEnabled(row, errors);
|
||||
var staffNumber = Optional(row, "工号/学号");
|
||||
Guid? collegeId = null;
|
||||
var collegeCode = Optional(row, "学院编码");
|
||||
if (collegeCode is not null)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeCode, out var college))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
else collegeId = college.Id;
|
||||
}
|
||||
|
||||
Teacher? teacher = null;
|
||||
Student? student = null;
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Teacher,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null ||
|
||||
!teachers.TryGetValue(staffNumber, out teacher))
|
||||
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
|
||||
else if (teacher.UserId.HasValue &&
|
||||
teacher.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedTeachers.Add(staffNumber))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Student,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null ||
|
||||
!students.TryGetValue(staffNumber, out student))
|
||||
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
|
||||
else if (student.UserId.HasValue &&
|
||||
student.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedStudents.Add(staffNumber))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
|
||||
if (existing?.Id.ToString() == currentUserId)
|
||||
{
|
||||
if (!enabled)
|
||||
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
|
||||
if (!roleNames.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
plans.Add(new UserImportPlan(
|
||||
row.RowNumber, existing, userName, displayName, password,
|
||||
staffNumber, collegeId, roleNames, enabled, teacher, student));
|
||||
}
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
user.DisplayName = plan.DisplayName;
|
||||
user.StaffNumber = plan.StaffNumber;
|
||||
user.CollegeId = plan.CollegeId;
|
||||
user.IsEnabled = plan.IsEnabled;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
|
||||
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(updateResult, plan.RowNumber);
|
||||
}
|
||||
if (plan.Password is not null)
|
||||
{
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var passwordResult = await userManager.ResetPasswordAsync(
|
||||
user, token, plan.Password);
|
||||
if (!passwordResult.Succeeded)
|
||||
var user = plan.Existing;
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = plan.UserName,
|
||||
DisplayName = plan.DisplayName,
|
||||
StaffNumber = plan.StaffNumber,
|
||||
CollegeId = plan.CollegeId,
|
||||
IsEnabled = plan.IsEnabled,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var createResult = await userManager.CreateAsync(
|
||||
user,
|
||||
plan.Password!);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(
|
||||
createResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
user.DisplayName = plan.DisplayName;
|
||||
user.StaffNumber = plan.StaffNumber;
|
||||
user.CollegeId = plan.CollegeId;
|
||||
user.IsEnabled = plan.IsEnabled;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(
|
||||
updateResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
if (plan.Password is not null)
|
||||
{
|
||||
var token = await userManager
|
||||
.GeneratePasswordResetTokenAsync(user);
|
||||
var passwordResult = await userManager.ResetPasswordAsync(
|
||||
user,
|
||||
token,
|
||||
plan.Password);
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(
|
||||
passwordResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
var existingRoles = await userManager.GetRolesAsync(user);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existingRoles.Except(
|
||||
plan.Roles,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(passwordResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(removeResult, plan.RowNumber);
|
||||
}
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
plan.Roles.Except(
|
||||
existingRoles,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(addResult, plan.RowNumber);
|
||||
}
|
||||
|
||||
foreach (var linked in await db.Teachers
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Teacher) linked.UserId = null;
|
||||
foreach (var linked in await db.Students
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Student) linked.UserId = null;
|
||||
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
|
||||
if (plan.Student is not null) plan.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
var existingRoles = await userManager.GetRolesAsync(user);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(removeResult, plan.RowNumber);
|
||||
}
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(addResult, plan.RowNumber);
|
||||
}
|
||||
|
||||
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Teacher) linked.UserId = null;
|
||||
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Student) linked.UserId = null;
|
||||
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
|
||||
if (plan.Student is not null) plan.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new ExcelImportResult(created, updated, plans.Count));
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new ExcelImportResult(created, updated, plans.Count));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/password")]
|
||||
|
||||
@@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
{
|
||||
db.WarningRules.Add(new WarningRule
|
||||
{
|
||||
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold,
|
||||
IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent,
|
||||
NotifyCounselor = r.NotifyCounselor,
|
||||
Description = r.Description?.Trim(),
|
||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
||||
@@ -110,7 +114,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
{
|
||||
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
|
||||
if (classIds.Count > 0)
|
||||
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId));
|
||||
q = q.WhereIn(classIds, x => x.Student!.AdministrativeClassId);
|
||||
else if (scope.Current.RestrictedCollegeId.HasValue)
|
||||
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
||||
@@ -181,7 +181,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
@@ -213,8 +213,8 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyTeacherIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -164,7 +164,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
|
||||
@@ -176,7 +176,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
@@ -208,8 +208,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyTeacherIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
// 1. Approved deferred exams (highest priority)
|
||||
var deferredStudents = await db.DeferredExams.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId &&
|
||||
x.Status == ApprovalStatus.Approved &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.Status == ApprovalStatus.Approved)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
// 2. Absent students
|
||||
var absentStudents = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||
x.ExamStatus == GradeExamStatus.Absent &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.ExamStatus == GradeExamStatus.Absent)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
var failedStudents = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||
x.ExamStatus == GradeExamStatus.Normal &&
|
||||
x.TotalScore < 60 &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.TotalScore < 60)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class QueryableCollectionExtensions
|
||||
{
|
||||
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector,
|
||||
Func<Expression, Expression, BinaryExpression> combine,
|
||||
bool negate)
|
||||
{
|
||||
Expression? body = null;
|
||||
foreach (var value in values.Distinct())
|
||||
{
|
||||
Expression comparison = Expression.Equal(
|
||||
valueSelector.Body,
|
||||
Expression.Constant(value, typeof(TValue)));
|
||||
if (negate)
|
||||
comparison = Expression.Not(comparison);
|
||||
body = body is null ? comparison : combine(body, comparison);
|
||||
}
|
||||
|
||||
body ??= Expression.Constant(negate);
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
body,
|
||||
valueSelector.Parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class RetriableTransactionExtensions
|
||||
{
|
||||
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
return await operation(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task ExecuteInRetriableTransactionAsync(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await operation(transaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
.ThenByDescending(x => x.Capacity)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
var taskIds = tasks.Select(task => task.Id).ToArray();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
|
||||
@@ -169,10 +169,7 @@ public sealed class AutomaticScheduleJobProcessor(
|
||||
job.CompletedTasks = result.CompletedTasks;
|
||||
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
|
||||
|
||||
@@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor(
|
||||
ReportProgress,
|
||||
stoppingToken);
|
||||
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
throw new SchedulePublishValidationException(
|
||||
"排课草稿状态已发生变化,请刷新后重试。");
|
||||
var publishedPlanId = plan.Id;
|
||||
await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var publishJob = await db.SchedulePublishJobs
|
||||
.FirstAsync(x => x.Id == jobId, stoppingToken);
|
||||
var publishPlan = await db.SchedulePlans
|
||||
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
|
||||
if (publishPlan.Status != SchedulePlanStatus.Draft)
|
||||
{
|
||||
throw new SchedulePublishValidationException(
|
||||
"排课草稿状态已发生变化,请刷新后重试。");
|
||||
}
|
||||
|
||||
var previous = await db.SchedulePlans
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == SchedulePlanStatus.Published)
|
||||
.ToListAsync(stoppingToken);
|
||||
foreach (var oldPlan in previous)
|
||||
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||
var previous = await db.SchedulePlans
|
||||
.Where(x =>
|
||||
x.Id != publishPlan.Id &&
|
||||
x.AcademicTermId == publishPlan.AcademicTermId &&
|
||||
x.Status == SchedulePlanStatus.Published)
|
||||
.ToListAsync(stoppingToken);
|
||||
foreach (var oldPlan in previous)
|
||||
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||
|
||||
plan.Status = SchedulePlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
job.Status = SchedulePublishJobStatus.Succeeded;
|
||||
job.ActiveAcademicTermId = null;
|
||||
job.CompletedSteps = job.TotalSteps;
|
||||
job.CurrentStep = "课表已发布";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
publishPlan.Status = SchedulePlanStatus.Published;
|
||||
publishPlan.PublishedAt = DateTime.UtcNow;
|
||||
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
||||
publishJob.ActiveAcademicTermId = null;
|
||||
publishJob.CompletedSteps = publishJob.TotalSteps;
|
||||
publishJob.CurrentStep = "课表已发布";
|
||||
publishJob.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
},
|
||||
stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||
job.Id,
|
||||
plan.Id);
|
||||
jobId,
|
||||
publishedPlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -245,7 +256,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
|
||||
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
|
||||
|
||||
@@ -14,6 +14,31 @@ namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AuthControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetriableTransaction_WorksWithRetryingExecutionStrategy()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, await db.Colleges.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
|
||||
{
|
||||
|
||||
@@ -52,6 +52,25 @@ public sealed class MySqlMigrationTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_guid_collections_use_provider_safe_predicates()
|
||||
{
|
||||
using var db = new AppDbContext(CreateMySqlOptions());
|
||||
var ids = new[]
|
||||
{
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Guid.Parse("22222222-2222-2222-2222-222222222222")
|
||||
};
|
||||
|
||||
var sql = db.Colleges
|
||||
.WhereIn(ids, x => x.Id)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DbContextOptions<AppDbContext> CreateMySqlOptions() =>
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(
|
||||
|
||||
@@ -74,8 +74,9 @@ public sealed class PersonnelControllerTests
|
||||
Assert.Equal(teacher.Name, user.DisplayName);
|
||||
Assert.Equal(teacher.CollegeId, user.CollegeId);
|
||||
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher));
|
||||
await db.Entry(teacher).ReloadAsync();
|
||||
Assert.Equal(user.Id, teacher.UserId);
|
||||
db.ChangeTracker.Clear();
|
||||
var linkedTeacher = await db.Teachers.SingleAsync(x => x.Id == teacher.Id);
|
||||
Assert.Equal(user.Id, linkedTeacher.UserId);
|
||||
}
|
||||
|
||||
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||
|
||||
Reference in New Issue
Block a user