优化排课
This commit is contained in:
1 parent
6538b6080a
commit
e6d8eb0662
17 files changed
+4073
-6
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
|
|||||||
@@ -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.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -72,6 +74,7 @@ public sealed class TeachingTasksController(
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
TeacherNames = x.Teachers
|
TeacherNames = x.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
@@ -108,6 +111,7 @@ public sealed class TeachingTasksController(
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
@@ -252,6 +256,163 @@ public sealed class TeachingTasksController(
|
|||||||
return await SaveAsync(id, false, cancellationToken);
|
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()
|
private IQueryable<TeachingTask> ScopedTasks()
|
||||||
{
|
{
|
||||||
var source = db.TeachingTasks.AsQueryable();
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
@@ -285,6 +446,16 @@ public sealed class TeachingTasksController(
|
|||||||
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
||||||
cancellationToken) != teacherIds.Length)
|
cancellationToken) != teacherIds.Length)
|
||||||
return ValidationProblem("存在无效或非在职授课教师。");
|
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 classIds = request.ClassIds.Distinct().ToArray();
|
||||||
var classes = db.AdministrativeClasses.AsNoTracking()
|
var classes = db.AdministrativeClasses.AsNoTracking()
|
||||||
@@ -344,6 +515,24 @@ public sealed class TeachingTasksController(
|
|||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
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(
|
public sealed record TeachingTaskRequest(
|
||||||
@@ -359,3 +548,12 @@ public sealed record TeachingTaskRequest(
|
|||||||
Guid? PrimaryTeacherId,
|
Guid? PrimaryTeacherId,
|
||||||
IReadOnlyCollection<Guid> ClassIds,
|
IReadOnlyCollection<Guid> ClassIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[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);
|
||||||
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user