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