优化排课

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);
@@ -15,12 +15,30 @@ public sealed class TeachingTask : EntityBase
public int EndWeek { get; set; } = 16;
public int WeeklyHours { get; set; } = 2;
public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft;
public string? GenerationBatchCode { get; set; }
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<TeachingTaskTeacher> Teachers { get; set; } = [];
public ICollection<TeachingTaskClass> Classes { get; set; } = [];
}
public sealed class TeacherCourseApplication : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public TeacherCourseApplicationStatus Status { get; set; } =
TeacherCourseApplicationStatus.Pending;
public string? Statement { get; set; }
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
public sealed class TeachingTaskTeacher
{
public Guid TeachingTaskId { get; set; }
@@ -44,3 +62,11 @@ public enum TeachingTaskStatus
Published = 2,
Closed = 3
}
public enum TeacherCourseApplicationStatus
{
Pending = 1,
Approved = 2,
Rejected = 3,
Withdrawn = 4
}
@@ -27,6 +27,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<TeacherCourseApplication> TeacherCourseApplications =>
Set<TeacherCourseApplication>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
@@ -229,6 +231,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.TaskNumber).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.GenerationBatchCode).HasMaxLength(40);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.TaskNumber).IsUnique();
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
@@ -268,6 +271,35 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeacherCourseApplication>(entity =>
{
entity.Property(x => x.Statement).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new
{
x.AcademicTermId,
x.TeacherId,
x.CourseId
}).IsUnique();
entity.HasIndex(x => new { x.Status, x.AcademicTermId });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Teacher)
.WithMany()
.HasForeignKey(x => x.TeacherId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.ReviewedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<SchedulePlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -21,6 +21,8 @@ public sealed class DevelopmentSqliteMigrator(
private const string CourseCategoriesMigration = "20260724_13_course_categories";
private const string SchedulingOptimizationMigration =
"20260724_14_scheduling_optimization";
private const string TeacherCourseApplicationsMigration =
"20260724_15_teacher_course_applications";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -121,6 +123,18 @@ public sealed class DevelopmentSqliteMigrator(
SchedulingOptimizationMigration,
schedulingOptimizationExists ? [] : SchedulingOptimizationStatements,
cancellationToken);
var teacherCourseApplicationsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'TeacherCourseApplications'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
TeacherCourseApplicationsMigration,
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -953,4 +967,59 @@ public sealed class DevelopmentSqliteMigrator(
ON "TeachingTaskAllowedClassrooms" ("ClassroomId");
"""
];
private static readonly string[] TeacherCourseApplicationStatements =
[
"""
ALTER TABLE "TeachingTasks" ADD COLUMN "GenerationBatchCode" TEXT NULL;
""",
"""
CREATE TABLE "TeacherCourseApplications" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TeacherCourseApplications" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"TeacherId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Statement" TEXT NULL,
"ReviewComment" TEXT NULL,
"SubmittedAt" TEXT NOT NULL,
"ReviewedAt" TEXT NULL,
"ReviewedByUserId" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_TeacherCourseApplications_AcademicTerms"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_TeacherCourseApplications_Teachers"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_TeacherCourseApplications_Courses"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_TeacherCourseApplications_ReviewedBy"
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_TeacherCourseApplications_Term_Teacher_Course"
ON "TeacherCourseApplications" ("AcademicTermId", "TeacherId", "CourseId");
""",
"""
CREATE INDEX "IX_TeacherCourseApplications_Status_AcademicTermId"
ON "TeacherCourseApplications" ("Status", "AcademicTermId");
""",
"""
CREATE INDEX "IX_TeacherCourseApplications_TeacherId"
ON "TeacherCourseApplications" ("TeacherId");
""",
"""
CREATE INDEX "IX_TeacherCourseApplications_CourseId"
ON "TeacherCourseApplications" ("CourseId");
""",
"""
CREATE INDEX "IX_TeacherCourseApplications_ReviewedByUserId"
ON "TeacherCourseApplications" ("ReviewedByUserId");
"""
];
}
@@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeacherCourseApplications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GenerationBatchCode",
table: "TeachingTasks",
type: "varchar(40)",
maxLength: 40,
nullable: true);
migrationBuilder.CreateTable(
name: "TeacherCourseApplications",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Statement = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeacherCourseApplications", x => x.Id);
table.ForeignKey(
name: "FK_TeacherCourseApplications_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeacherCourseApplications_AspNetUsers_ReviewedByUserId",
column: x => x.ReviewedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_TeacherCourseApplications_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeacherCourseApplications_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeacherCourseApplications_AcademicTermId_TeacherId_CourseId",
table: "TeacherCourseApplications",
columns: new[] { "AcademicTermId", "TeacherId", "CourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeacherCourseApplications_CourseId",
table: "TeacherCourseApplications",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_TeacherCourseApplications_ReviewedByUserId",
table: "TeacherCourseApplications",
column: "ReviewedByUserId");
migrationBuilder.CreateIndex(
name: "IX_TeacherCourseApplications_Status_AcademicTermId",
table: "TeacherCourseApplications",
columns: new[] { "Status", "AcademicTermId" });
migrationBuilder.CreateIndex(
name: "IX_TeacherCourseApplications_TeacherId",
table: "TeacherCourseApplications",
column: "TeacherId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeacherCourseApplications");
migrationBuilder.DropColumn(
name: "GenerationBatchCode",
table: "TeachingTasks");
}
}
}
@@ -1614,6 +1614,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ReviewedByUserId")
.HasColumnType("char(36)");
b.Property<string>("Statement")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("ReviewedByUserId");
b.HasIndex("TeacherId");
b.HasIndex("Status", "AcademicTermId");
b.HasIndex("AcademicTermId", "TeacherId", "CourseId")
.IsUnique();
b.ToTable("TeacherCourseApplications");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.Property<Guid>("Id")
@@ -1635,6 +1692,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<string>("GenerationBatchCode")
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -2464,6 +2525,38 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AcademicTerm");
b.Navigation("Course");
b.Navigation("Teacher");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
@@ -0,0 +1,33 @@
namespace Jiaowu.Api.Infrastructure.Teaching;
public static class PublicCourseTaskAssignmentPlanner
{
public static IReadOnlyList<Guid> AssignTeachers(
IReadOnlyCollection<Guid> eligibleTeacherIds,
IReadOnlyDictionary<Guid, int> existingLoads,
int groupCount,
Random? random = null)
{
if (eligibleTeacherIds.Count == 0)
throw new ArgumentException("至少需要一名可分配教师。", nameof(eligibleTeacherIds));
if (groupCount < 1)
throw new ArgumentOutOfRangeException(nameof(groupCount));
random ??= Random.Shared;
var teachers = eligibleTeacherIds.Distinct().ToArray();
var loads = teachers.ToDictionary(
teacherId => teacherId,
teacherId => existingLoads.GetValueOrDefault(teacherId));
var assignments = new List<Guid>(groupCount);
for (var index = 0; index < groupCount; index++)
{
var minimumLoad = loads.Values.Min();
var candidates = teachers
.Where(teacherId => loads[teacherId] == minimumLoad)
.ToArray();
var selected = candidates[random.Next(candidates.Length)];
assignments.Add(selected);
loads[selected]++;
}
return assignments;
}
}