优化排课
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -72,6 +74,7 @@ public sealed class TeachingTasksController(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
@@ -108,6 +111,7 @@ public sealed class TeachingTasksController(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.PublishedAt,
|
||||
@@ -252,6 +256,163 @@ public sealed class TeachingTasksController(
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("generate-public-course")]
|
||||
public async Task<ActionResult> GeneratePublicCourseTasks(
|
||||
PublicCourseTaskGenerationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StartWeek > request.EndWeek)
|
||||
return ValidationProblem("开始周不能晚于结束周。");
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (term is null) return ValidationProblem("所选学期不存在或已停用。");
|
||||
var course = await db.Courses.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||
var scopedCollegeId = ScopedCollegeId();
|
||||
if (scopedCollegeId.HasValue && course.CollegeId != scopedCollegeId.Value)
|
||||
return Forbid();
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
|
||||
var classesQuery = db.AdministrativeClasses
|
||||
.Where(x => classIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Students)
|
||||
.AsQueryable();
|
||||
if (scopedCollegeId.HasValue)
|
||||
classesQuery = classesQuery.Where(x => x.Major!.CollegeId == scopedCollegeId.Value);
|
||||
var classes = await classesQuery
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (classes.Count != classIds.Length)
|
||||
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
classIds.Contains(x.AdministrativeClassId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (assignedClassIds.Count > 0)
|
||||
{
|
||||
var names = classes
|
||||
.Where(x => assignedClassIds.Contains(x.Id))
|
||||
.Select(x => x.Name);
|
||||
return ConflictProblem(
|
||||
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
|
||||
}
|
||||
|
||||
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved &&
|
||||
x.Teacher!.Status == TeacherStatus.Active)
|
||||
.Where(x => !scopedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == scopedCollegeId.Value)
|
||||
.Select(x => x.Teacher!)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (eligibleTeachers.Count == 0)
|
||||
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
|
||||
|
||||
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
|
||||
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
eligibleTeacherIds.Contains(x.TeacherId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.Status != TeachingTaskStatus.Closed)
|
||||
.GroupBy(x => x.TeacherId)
|
||||
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
|
||||
var existingNumbers = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == request.AcademicTermId)
|
||||
.Select(x => x.TaskNumber)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
var batchCode =
|
||||
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
|
||||
var groups = classes.Chunk(request.ClassesPerTask).ToList();
|
||||
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
|
||||
eligibleTeacherIds,
|
||||
existingLoads,
|
||||
groups.Count);
|
||||
var created = new List<TeachingTask>();
|
||||
for (var index = 0; index < groups.Count; index++)
|
||||
{
|
||||
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
|
||||
var group = groups[index];
|
||||
var taskNumber = NextTaskNumber(
|
||||
term.Code,
|
||||
course.Code,
|
||||
index + 1,
|
||||
existingNumbers);
|
||||
existingNumbers.Add(taskNumber);
|
||||
var studentCount = group.Sum(administrativeClass =>
|
||||
administrativeClass.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active));
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = taskNumber,
|
||||
Name = $"{course.Name}教学班 {index + 1:D2}",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = Math.Max(1, studentCount),
|
||||
StartWeek = request.StartWeek,
|
||||
EndWeek = request.EndWeek,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
GenerationBatchCode = batchCode,
|
||||
Notes = "公共课合班自动生成,发布前可继续调整。",
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
],
|
||||
Classes = group.Select(administrativeClass =>
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}).ToList()
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
CreatedCount = created.Count,
|
||||
Tasks = created.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
task.Name,
|
||||
TeacherName = eligibleTeachers
|
||||
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
|
||||
ClassNames = classes
|
||||
.Where(x => task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id))
|
||||
.Select(x => x.Name),
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> ScopedTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -285,6 +446,16 @@ public sealed class TeachingTasksController(
|
||||
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效或非在职授课教师。");
|
||||
if (teacherIds.Length > 0 &&
|
||||
course.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective &&
|
||||
await db.TeacherCourseApplications.CountAsync(
|
||||
x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("公共课授课教师必须已完成课程申报并经学院审核通过。");
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
var classes = db.AdministrativeClasses.AsNoTracking()
|
||||
@@ -344,6 +515,24 @@ public sealed class TeachingTasksController(
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NextTaskNumber(
|
||||
string termCode,
|
||||
string courseCode,
|
||||
int sequence,
|
||||
IReadOnlySet<string> existing)
|
||||
{
|
||||
var prefix = $"{termCode}-{courseCode}"
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
if (prefix.Length > 31) prefix = prefix[..31];
|
||||
var candidateSequence = sequence;
|
||||
while (true)
|
||||
{
|
||||
var candidate = $"{prefix}-A{candidateSequence:D2}";
|
||||
if (!existing.Contains(candidate)) return candidate;
|
||||
candidateSequence++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TeachingTaskRequest(
|
||||
@@ -359,3 +548,12 @@ public sealed record TeachingTaskRequest(
|
||||
Guid? PrimaryTeacherId,
|
||||
IReadOnlyCollection<Guid> ClassIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record PublicCourseTaskGenerationRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid CourseId,
|
||||
[Range(1, 10)] int ClassesPerTask,
|
||||
[Range(1, 30)] int StartWeek,
|
||||
[Range(1, 30)] int EndWeek,
|
||||
[Range(1, 40)] int WeeklyHours,
|
||||
IReadOnlyCollection<Guid> ClassIds);
|
||||
|
||||
Reference in New Issue
Block a user