优化排课

This commit is contained in:
2026-07-24 19:35:49 +08:00 Unverified
parent 6538b6080a
commit e6d8eb0662
17 changed files with 4073 additions and 6 deletions
@@ -0,0 +1,243 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/teacher-course-applications")]
public sealed class TeacherCourseApplicationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string ReviewRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMine(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var teacher = await CurrentTeacherAsync(cancellationToken);
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
var source = db.TeacherCourseApplications.AsNoTracking()
.Where(x => x.TeacherId == teacher.Id);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.Select(x => new
{
x.Id,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.CourseId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeName = x.Course.College!.Name,
x.Status,
x.Statement,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("mine")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> Submit(
TeacherCourseApplicationRequest request,
CancellationToken cancellationToken)
{
var teacher = await CurrentTeacherAsync(cancellationToken);
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
if (!await db.Courses.AnyAsync(
x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
var application = await db.TeacherCourseApplications
.FirstOrDefaultAsync(x =>
x.AcademicTermId == request.AcademicTermId &&
x.TeacherId == teacher.Id &&
x.CourseId == request.CourseId,
cancellationToken);
if (application is not null &&
application.Status is TeacherCourseApplicationStatus.Pending or
TeacherCourseApplicationStatus.Approved)
return ConflictProblem("该学期的课程申报已提交或已审核通过。");
if (application is null)
{
application = new TeacherCourseApplication
{
AcademicTermId = request.AcademicTermId,
TeacherId = teacher.Id,
CourseId = request.CourseId
};
db.TeacherCourseApplications.Add(application);
}
application.Status = TeacherCourseApplicationStatus.Pending;
application.Statement = Normalize(request.Statement);
application.ReviewComment = null;
application.SubmittedAt = DateTime.UtcNow;
application.ReviewedAt = null;
application.ReviewedByUserId = null;
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { application.Id });
}
[HttpDelete("mine/{id:guid}")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> Withdraw(Guid id, CancellationToken cancellationToken)
{
var teacher = await CurrentTeacherAsync(cancellationToken);
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
var application = await db.TeacherCourseApplications.FirstOrDefaultAsync(
x => x.Id == id && x.TeacherId == teacher.Id,
cancellationToken);
if (application is null) return NotFound();
if (application.Status != TeacherCourseApplicationStatus.Pending)
return ConflictProblem("只有待审核申报可以撤回。");
application.Status = TeacherCourseApplicationStatus.Withdrawn;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("reviews")]
[Authorize(Roles = ReviewRoles)]
public async Task<ActionResult> GetReviews(
Guid? academicTermId,
TeacherCourseApplicationStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedApplications().AsNoTracking();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue) source = source.Where(x => x.Status == status);
return Ok(await source
.OrderBy(x => x.Status)
.ThenByDescending(x => x.SubmittedAt)
.Select(x => new
{
x.Id,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.TeacherId,
x.Teacher!.TeacherNumber,
TeacherName = x.Teacher.Name,
TeacherCollegeName = x.Teacher.College!.Name,
x.CourseId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CourseCollegeName = x.Course.College!.Name,
x.Status,
x.Statement,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("{id:guid}/review")]
[Authorize(Roles = ReviewRoles)]
public async Task<ActionResult> Review(
Guid id,
TeacherCourseReviewRequest request,
CancellationToken cancellationToken)
{
if (request.Status is not (
TeacherCourseApplicationStatus.Approved or
TeacherCourseApplicationStatus.Rejected))
return ValidationProblem("审核结论只能为通过或驳回。");
var application = await ScopedApplications()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (application is null) return NotFound();
if (application.Status != TeacherCourseApplicationStatus.Pending)
return ConflictProblem("只有待审核申报可以审核。");
application.Status = request.Status;
application.ReviewComment = Normalize(request.ReviewComment);
application.ReviewedAt = DateTime.UtcNow;
application.ReviewedByUserId = currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("eligible")]
[Authorize(Roles = ReviewRoles)]
public async Task<ActionResult> GetEligible(
Guid academicTermId,
Guid courseId,
CancellationToken cancellationToken)
{
var source = ScopedApplications().AsNoTracking()
.Where(x =>
x.AcademicTermId == academicTermId &&
x.CourseId == courseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active);
return Ok(await source
.OrderBy(x => x.Teacher!.TeacherNumber)
.Select(x => new
{
x.TeacherId,
x.Teacher!.TeacherNumber,
x.Teacher.Name,
x.Teacher.Title,
CollegeName = x.Teacher.College!.Name
})
.ToListAsync(cancellationToken));
}
private IQueryable<TeacherCourseApplication> ScopedApplications()
{
var source = db.TeacherCourseApplications.AsQueryable();
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
return collegeId.HasValue
? source.Where(x => x.Teacher!.CollegeId == collegeId.Value)
: source;
}
private Task<Teacher?> CurrentTeacherAsync(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return db.Teachers.FirstOrDefaultAsync(
x => x.UserId == userId && x.Status == TeacherStatus.Active,
cancellationToken);
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record TeacherCourseApplicationRequest(
Guid AcademicTermId,
Guid CourseId,
[MaxLength(500)] string? Statement);
public sealed record TeacherCourseReviewRequest(
TeacherCourseApplicationStatus Status,
[MaxLength(500)] string? ReviewComment);
@@ -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);