普通课表学时 = 总学时 − 实践学时。 自动排课、手工排课、课表发布都会阻止把实践学时重复排入。 全部为实践学时的课程应设为“非排时课程”,再由实验管理安排。 教学任务和排课页面会显示“常规 / 实践学时”。 已有教学任务即使仍保存旧周学时,排课时也会按拆分后的课程学时计算。 无需数据库迁移。
832 lines
35 KiB
C#
832 lines
35 KiB
C#
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.Caching;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Teaching;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize(Roles = ManagementRoles)]
|
|
[Route("api/teaching-tasks")]
|
|
public sealed class TeachingTasksController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope,
|
|
IAppCache cache) : ControllerBase
|
|
{
|
|
private const string ManagementRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin;
|
|
private const string SchoolManagementRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin;
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<PagedResult<object>>> Get(
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
string? keyword = null,
|
|
Guid? academicTermId = null,
|
|
Guid? collegeId = null,
|
|
TeachingTaskStatus? status = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
|
var source = ScopedTasks().AsNoTracking();
|
|
if (academicTermId.HasValue)
|
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
|
if (collegeId.HasValue)
|
|
source = source.Where(x => x.Course!.CollegeId == collegeId);
|
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
|
if (!string.IsNullOrWhiteSpace(keyword))
|
|
{
|
|
keyword = keyword.Trim();
|
|
source = source.Where(x =>
|
|
x.TaskNumber.Contains(keyword) ||
|
|
x.Name.Contains(keyword) ||
|
|
x.Course!.Code.Contains(keyword) ||
|
|
x.Course.Name.Contains(keyword));
|
|
}
|
|
|
|
var total = await source.CountAsync(cancellationToken);
|
|
var items = await source
|
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
|
.ThenBy(x => x.TaskNumber)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
TermName = x.AcademicTerm!.Name,
|
|
x.CourseId,
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
CourseNature = x.Course.Nature,
|
|
CollegeId = x.Course.CollegeId,
|
|
CollegeName = x.Course.College!.Name,
|
|
x.Capacity,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.SchedulingMode,
|
|
CourseTotalHours = x.Course.TotalHours,
|
|
CoursePracticeHours = x.Course.PracticeHours,
|
|
CourseRegularScheduleHours =
|
|
x.Course.TotalHours - x.Course.PracticeHours,
|
|
x.GenerationBatchCode,
|
|
x.Status,
|
|
TeacherNames = x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.Select(item => item.Teacher!.Name),
|
|
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
|
|
StudentCount = x.Classes.Sum(item =>
|
|
item.AdministrativeClass!.Students.Count(student =>
|
|
student.Status == StudentStatus.Active)),
|
|
x.UpdatedAt
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpGet("options")]
|
|
public async Task<ActionResult> GetOptions(
|
|
Guid academicTermId,
|
|
TeachingTaskStatus status = TeachingTaskStatus.Published,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var items = await ScopedTasks()
|
|
.AsNoTracking()
|
|
.Where(x =>
|
|
x.AcademicTermId == academicTermId &&
|
|
x.Status == status)
|
|
.OrderBy(x => x.Course!.Code)
|
|
.ThenBy(x => x.TaskNumber)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
x.CourseId,
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
CourseNature = x.Course.Nature,
|
|
CollegeId = x.Course.CollegeId,
|
|
CollegeName = x.Course.College!.Name,
|
|
x.Capacity,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.SchedulingMode,
|
|
CourseTotalHours = x.Course!.TotalHours,
|
|
CoursePracticeHours = x.Course.PracticeHours,
|
|
CourseRegularScheduleHours =
|
|
x.Course.TotalHours - x.Course.PracticeHours,
|
|
TeacherNames = x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.Select(item => item.Teacher!.Name),
|
|
ClassNames = x.Classes
|
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
|
.Select(item => item.AdministrativeClass!.Name)
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
return Ok(items);
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.AsNoTracking()
|
|
.Where(x => x.Id == id)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
TermName = x.AcademicTerm!.Name,
|
|
x.CourseId,
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
CourseNature = x.Course.Nature,
|
|
CollegeId = x.Course.CollegeId,
|
|
CollegeName = x.Course.College!.Name,
|
|
x.Capacity,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.SchedulingMode,
|
|
CourseTotalHours = x.Course.TotalHours,
|
|
CoursePracticeHours = x.Course.PracticeHours,
|
|
CourseRegularScheduleHours =
|
|
x.Course.TotalHours - x.Course.PracticeHours,
|
|
x.GenerationBatchCode,
|
|
x.Status,
|
|
x.Notes,
|
|
x.PublishedAt,
|
|
TeacherIds = x.Teachers.Select(item => item.TeacherId),
|
|
PrimaryTeacherId = x.Teachers
|
|
.Where(item => item.IsPrimary)
|
|
.Select(item => (Guid?)item.TeacherId)
|
|
.FirstOrDefault(),
|
|
Teachers = x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.ThenBy(item => item.Teacher!.TeacherNumber)
|
|
.Select(item => new
|
|
{
|
|
item.TeacherId,
|
|
item.Teacher!.TeacherNumber,
|
|
item.Teacher.Name,
|
|
item.Teacher.Title,
|
|
item.IsPrimary
|
|
}),
|
|
ClassIds = x.Classes.Select(item => item.AdministrativeClassId),
|
|
Classes = x.Classes
|
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
|
.Select(item => new
|
|
{
|
|
item.AdministrativeClassId,
|
|
item.AdministrativeClass!.Code,
|
|
item.AdministrativeClass.Name,
|
|
item.AdministrativeClass.Grade,
|
|
StudentCount = item.AdministrativeClass.Students.Count(student =>
|
|
student.Status == StudentStatus.Active)
|
|
}),
|
|
x.CreatedAt,
|
|
x.UpdatedAt
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
return task is null ? NotFound() : Ok(task);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult> Create(
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = request.TaskNumber.Trim(),
|
|
Name = request.Name.Trim(),
|
|
AcademicTermId = request.AcademicTermId,
|
|
CourseId = request.CourseId,
|
|
Capacity = request.Capacity,
|
|
StartWeek = request.StartWeek,
|
|
EndWeek = request.EndWeek,
|
|
WeeklyHours = request.WeeklyHours,
|
|
SchedulingMode = request.SchedulingMode,
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
SetAssignments(task, request);
|
|
db.TeachingTasks.Add(task);
|
|
return await SaveAsync(task.Id, true, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("{id:guid}")]
|
|
public async Task<ActionResult> Update(
|
|
Guid id,
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.Include(x => x.Teachers)
|
|
.Include(x => x.Classes)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (!CanManage(task.Course!)) return Forbid();
|
|
if (task.Status != TeachingTaskStatus.Draft)
|
|
return ConflictProblem("已发布或已结课的教学任务不可直接修改。");
|
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
|
|
task.TaskNumber = request.TaskNumber.Trim();
|
|
task.Name = request.Name.Trim();
|
|
task.AcademicTermId = request.AcademicTermId;
|
|
task.CourseId = request.CourseId;
|
|
task.Capacity = request.Capacity;
|
|
task.StartWeek = request.StartWeek;
|
|
task.EndWeek = request.EndWeek;
|
|
task.WeeklyHours = request.WeeklyHours;
|
|
task.SchedulingMode = request.SchedulingMode;
|
|
task.Notes = Normalize(request.Notes);
|
|
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
|
|
db.TeachingTaskClasses.RemoveRange(task.Classes);
|
|
task.Teachers = [];
|
|
task.Classes = [];
|
|
SetAssignments(task, request);
|
|
return await SaveAsync(task.Id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{id:guid}")]
|
|
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (!CanManage(task.Course!)) return Forbid();
|
|
if (task.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed))
|
|
return ConflictProblem("仅草稿或已归档的教学任务可以删除。");
|
|
db.TeachingTasks.Remove(task);
|
|
return await SaveAsync(
|
|
id,
|
|
false,
|
|
cancellationToken,
|
|
"教学任务已被排课、选课、成绩或考试等业务引用,不能删除;如需继续使用,可撤销归档。");
|
|
}
|
|
|
|
[HttpPost("{id:guid}/publish")]
|
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.Include(x => x.Teachers)
|
|
.Include(x => x.Classes)
|
|
.ThenInclude(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Students)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (!CanManage(task.Course!)) return Forbid();
|
|
var validation = await ValidatePublishingTasksAsync([task], cancellationToken);
|
|
if (validation is not null) return ConflictProblem(validation);
|
|
|
|
task.Status = TeachingTaskStatus.Published;
|
|
task.PublishedAt = DateTime.UtcNow;
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/close")]
|
|
public async Task<ActionResult> Close(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (!CanManage(task.Course!)) return Forbid();
|
|
if (task.Status != TeachingTaskStatus.Published)
|
|
return ConflictProblem("只有已发布的教学任务可以归档。");
|
|
task.Status = TeachingTaskStatus.Closed;
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/unarchive")]
|
|
public async Task<ActionResult> Unarchive(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (!CanManage(task.Course!)) return Forbid();
|
|
if (task.Status != TeachingTaskStatus.Closed)
|
|
return ConflictProblem("只有已归档的教学任务可以撤销归档。");
|
|
task.Status = TeachingTaskStatus.Published;
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("batch")]
|
|
public async Task<ActionResult> Batch(
|
|
TeachingTaskBatchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var ids = request.Ids.Distinct().ToArray();
|
|
if (ids.Length == 0) return ValidationProblem("请至少选择一个教学任务。");
|
|
if (ids.Length > 100) return ValidationProblem("每次最多处理 100 个教学任务。");
|
|
|
|
var tasks = await ScopedTasks()
|
|
.Include(x => x.Course)
|
|
.Include(x => x.Teachers)
|
|
.Include(x => x.Classes)
|
|
.ThenInclude(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Students)
|
|
.WhereIn(ids, x => x.Id)
|
|
.OrderBy(x => x.TaskNumber)
|
|
.ToListAsync(cancellationToken);
|
|
if (tasks.Count != ids.Length)
|
|
return ConflictProblem("部分教学任务不存在,或已超出当前账号的数据范围。");
|
|
if (tasks.Any(x => !CanManage(x.Course!))) return Forbid();
|
|
|
|
switch (request.Operation)
|
|
{
|
|
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;
|
|
task.PublishedAt = publishedAt;
|
|
}
|
|
break;
|
|
}
|
|
case TeachingTaskBatchOperation.Delete:
|
|
if (tasks.Any(x =>
|
|
x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)))
|
|
return ConflictProblem(
|
|
$"教学任务“{tasks.First(x => x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)).Name}”不是草稿或已归档状态,整批未删除。");
|
|
db.TeachingTasks.RemoveRange(tasks);
|
|
break;
|
|
case TeachingTaskBatchOperation.Archive:
|
|
if (tasks.Any(x => x.Status != TeachingTaskStatus.Published))
|
|
return ConflictProblem(
|
|
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Published).Name}”不是已发布状态,整批未归档。");
|
|
foreach (var task in tasks) task.Status = TeachingTaskStatus.Closed;
|
|
break;
|
|
case TeachingTaskBatchOperation.Unarchive:
|
|
if (tasks.Any(x => x.Status != TeachingTaskStatus.Closed))
|
|
return ConflictProblem(
|
|
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Closed).Name}”不是已归档状态,整批未撤销归档。");
|
|
foreach (var task in tasks) task.Status = TeachingTaskStatus.Published;
|
|
break;
|
|
default:
|
|
return ValidationProblem("不支持的批量操作。");
|
|
}
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
|
return Ok(new { AffectedCount = tasks.Count });
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("批量操作未执行:教学任务仍被其他业务引用,或关联数据已失效。");
|
|
}
|
|
}
|
|
|
|
[HttpPost("generate-public-course")]
|
|
[Authorize(Roles = SchoolManagementRoles)]
|
|
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 (!CanManage(course)) return Forbid();
|
|
var hoursProblem = TeachingTaskHours.Validate(
|
|
course,
|
|
request.StartWeek,
|
|
request.EndWeek,
|
|
request.WeeklyHours,
|
|
TeachingTaskSchedulingMode.Standard);
|
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
|
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 => x.IsEnabled)
|
|
.WhereIn(classIds, x => x.Id)
|
|
.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("存在无效或不在当前数据范围内的行政班。");
|
|
|
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
|
async transaction =>
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
|
.Where(x =>
|
|
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
|
x.TeachingTask.CourseId == request.CourseId)
|
|
.WhereIn(classIds, x => x.AdministrativeClassId)
|
|
.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 =>
|
|
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,
|
|
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);
|
|
await cache.RemoveByTagAsync(
|
|
AppCacheTags.Timetables,
|
|
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
|
|
})
|
|
});
|
|
},
|
|
cancellationToken,
|
|
IsolationLevel.Serializable);
|
|
}
|
|
|
|
private IQueryable<TeachingTask> ScopedTasks()
|
|
{
|
|
var source = db.TeachingTasks.AsQueryable();
|
|
var collegeId = ScopedCollegeId();
|
|
return collegeId.HasValue
|
|
? source.Where(x => x.Course!.CollegeId == collegeId.Value)
|
|
: source;
|
|
}
|
|
|
|
private async Task<ActionResult?> ValidateRequestAsync(
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request.StartWeek > request.EndWeek)
|
|
return ValidationProblem("开始周不能晚于结束周。");
|
|
var course = await db.Courses.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
|
|
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
|
if (!CanManage(course)) return Forbid();
|
|
if (!Enum.IsDefined(request.SchedulingMode))
|
|
return ValidationProblem("授课方式无效。");
|
|
var hoursProblem = TeachingTaskHours.Validate(
|
|
course,
|
|
request.StartWeek,
|
|
request.EndWeek,
|
|
request.WeeklyHours,
|
|
request.SchedulingMode);
|
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
|
var collegeId = ScopedCollegeId();
|
|
if (!await db.AcademicTerms.AnyAsync(
|
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("所选学期不存在或已停用。");
|
|
|
|
var teacherIds = request.TeacherIds.Distinct().ToArray();
|
|
if (request.PrimaryTeacherId.HasValue &&
|
|
!teacherIds.Contains(request.PrimaryTeacherId.Value))
|
|
return ValidationProblem("主讲教师必须包含在授课教师中。");
|
|
if (await db.Teachers
|
|
.Where(x => x.Status == TeacherStatus.Active)
|
|
.WhereIn(teacherIds, x => x.Id)
|
|
.CountAsync(cancellationToken) != teacherIds.Length)
|
|
return ValidationProblem("存在无效或非在职授课教师。");
|
|
if (teacherIds.Length > 0 &&
|
|
await db.TeacherCourseApplications
|
|
.Where(x =>
|
|
x.AcademicTermId == request.AcademicTermId &&
|
|
x.CourseId == request.CourseId &&
|
|
x.Status == TeacherCourseApplicationStatus.Approved)
|
|
.WhereIn(teacherIds, x => x.TeacherId)
|
|
.CountAsync(cancellationToken) != teacherIds.Length)
|
|
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
|
|
|
|
var classIds = request.ClassIds.Distinct().ToArray();
|
|
if (classIds.Length > 0)
|
|
{
|
|
var classes = db.AdministrativeClasses.AsNoTracking()
|
|
.WhereIn(classIds, x => x.Id);
|
|
if (collegeId.HasValue)
|
|
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
|
|
if (await classes.CountAsync(cancellationToken) != classIds.Length)
|
|
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void SetAssignments(TeachingTask task, TeachingTaskRequest request)
|
|
{
|
|
foreach (var teacherId in request.TeacherIds.Distinct())
|
|
{
|
|
task.Teachers.Add(new TeachingTaskTeacher
|
|
{
|
|
TeacherId = teacherId,
|
|
IsPrimary = teacherId == request.PrimaryTeacherId
|
|
});
|
|
}
|
|
foreach (var classId in request.ClassIds.Distinct())
|
|
{
|
|
task.Classes.Add(new TeachingTaskClass
|
|
{
|
|
AdministrativeClassId = classId
|
|
});
|
|
}
|
|
}
|
|
|
|
private async Task<string?> ValidatePublishingTasksAsync(
|
|
IReadOnlyCollection<TeachingTask> tasks,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var invalidStatus = tasks.FirstOrDefault(x => x.Status != TeachingTaskStatus.Draft);
|
|
if (invalidStatus is not null)
|
|
return PublishingProblem(
|
|
tasks,
|
|
invalidStatus,
|
|
"只有草稿教学任务可以发布。");
|
|
|
|
var invalidPrimaryTeacher = tasks.FirstOrDefault(x =>
|
|
x.Teachers.Count == 0 || x.Teachers.Count(item => item.IsPrimary) != 1);
|
|
if (invalidPrimaryTeacher is not null)
|
|
return PublishingProblem(
|
|
tasks,
|
|
invalidPrimaryTeacher,
|
|
"发布前必须指定且只能指定一名主讲教师。");
|
|
|
|
var termIds = tasks.Select(x => x.AcademicTermId).Distinct().ToArray();
|
|
var courseIds = tasks.Select(x => x.CourseId).Distinct().ToArray();
|
|
var teacherIds = tasks
|
|
.SelectMany(x => x.Teachers)
|
|
.Select(x => x.TeacherId)
|
|
.Distinct()
|
|
.ToArray();
|
|
var approvedApplications = await db.TeacherCourseApplications
|
|
.AsNoTracking()
|
|
.Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
|
|
.WhereIn(termIds, x => x.AcademicTermId)
|
|
.WhereIn(courseIds, x => x.CourseId)
|
|
.WhereIn(teacherIds, x => x.TeacherId)
|
|
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
|
|
.ToListAsync(cancellationToken);
|
|
var approvedAssignments = approvedApplications
|
|
.Select(x => (x.AcademicTermId, x.CourseId, x.TeacherId))
|
|
.ToHashSet();
|
|
var unapprovedTeacher = tasks.FirstOrDefault(task =>
|
|
task.Teachers.Any(teacher =>
|
|
!approvedAssignments.Contains(
|
|
(task.AcademicTermId, task.CourseId, teacher.TeacherId))));
|
|
if (unapprovedTeacher is not null)
|
|
return PublishingProblem(
|
|
tasks,
|
|
unapprovedTeacher,
|
|
"授课教师尚未完成该课程申报,或学院审核尚未通过。");
|
|
|
|
foreach (var task in tasks)
|
|
{
|
|
var studentCount = task.Classes.Sum(x =>
|
|
x.AdministrativeClass!.Students.Count(student =>
|
|
student.Status == StudentStatus.Active));
|
|
if (studentCount > task.Capacity)
|
|
return PublishingProblem(
|
|
tasks,
|
|
task,
|
|
$"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string PublishingProblem(
|
|
IReadOnlyCollection<TeachingTask> tasks,
|
|
TeachingTask task,
|
|
string detail) =>
|
|
tasks.Count == 1 ? detail : $"教学任务“{task.Name}”:{detail}整批未发布。";
|
|
|
|
private Guid? ScopedCollegeId()
|
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
|
|
|
private bool CanManage(Course course) =>
|
|
TeachingTaskMaintenancePolicy.CanManage(
|
|
currentUserDataScope.Current,
|
|
course.CollegeId,
|
|
course.Nature);
|
|
|
|
private async Task<ActionResult> SaveAsync(
|
|
Guid id,
|
|
bool created,
|
|
CancellationToken cancellationToken,
|
|
string conflictMessage = "教学任务编号重复,或关联数据已失效。")
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem(conflictMessage);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
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(
|
|
[Required, MaxLength(40)] string TaskNumber,
|
|
[Required, MaxLength(120)] string Name,
|
|
Guid AcademicTermId,
|
|
Guid CourseId,
|
|
[Range(1, 10000)] int Capacity,
|
|
[Range(1, 30)] int StartWeek,
|
|
[Range(1, 30)] int EndWeek,
|
|
[Range(1, 40)] int WeeklyHours,
|
|
IReadOnlyCollection<Guid> TeacherIds,
|
|
Guid? PrimaryTeacherId,
|
|
IReadOnlyCollection<Guid> ClassIds,
|
|
[MaxLength(500)] string? Notes,
|
|
TeachingTaskSchedulingMode SchedulingMode = TeachingTaskSchedulingMode.Standard);
|
|
|
|
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);
|
|
|
|
public sealed record TeachingTaskBatchRequest(
|
|
TeachingTaskBatchOperation Operation,
|
|
[MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> Ids);
|
|
|
|
public enum TeachingTaskBatchOperation
|
|
{
|
|
Publish = 1,
|
|
Delete = 2,
|
|
Archive = 3,
|
|
Unarchive = 4
|
|
}
|