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>> 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(items, total, page, pageSize)); } [HttpGet("options")] public async Task 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 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 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 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 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 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 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 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 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 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( 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(); 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 ScopedTasks() { var source = db.TeachingTasks.AsQueryable(); var collegeId = ScopedCollegeId(); return collegeId.HasValue ? source.Where(x => x.Course!.CollegeId == collegeId.Value) : source; } private async Task 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 ValidatePublishingTasksAsync( IReadOnlyCollection 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 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 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 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 TeacherIds, Guid? PrimaryTeacherId, IReadOnlyCollection 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 ClassIds); public sealed record TeachingTaskBatchRequest( TeachingTaskBatchOperation Operation, [MinLength(1), MaxLength(100)] IReadOnlyCollection Ids); public enum TeachingTaskBatchOperation { Publish = 1, Delete = 2, Archive = 3, Unarchive = 4 }