Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs
T
biss 22321abe23 点名、成绩册统一读取“行政班在籍学生 ∪ 已选课学生”,不再错误显示 0 人。
教师选课名单改为展示全部本人教学班及完整学生名单,支持 Excel 导出。
调停课改用教师专用教学班接口,并在服务端限制只能操作本人教学班。
2026-07-27 08:05:42 +08:00

545 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/course-adjustments")]
public sealed class CourseAdjustmentsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Applicants =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet("task-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetTaskOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
ClassNames = x.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.ToListAsync(cancellationToken));
}
// ═══════════════ My adjustments ═══════════════
[HttpGet("mine")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetMine(
Guid? academicTermId,
CourseAdjustmentStatus? status,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.ApplicantUserId == userId);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x => x.Status == status);
return Ok(await source
.OrderByDescending(x => x.CreatedAt)
.Select(AdjustmentProjection())
.ToListAsync(cancellationToken));
}
// ═══════════════ Pending reviews ═══════════════
[HttpGet("pending-reviews")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetPendingReviews(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
if (scope.Scope == DataScope.College)
source = source.Where(x =>
x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.SubmittedAt)
.Select(AdjustmentProjection())
.ToListAsync(cancellationToken));
}
// ═══════════════ Detail ═══════════════
[HttpGet("{id:guid}")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetDetail(Guid id, CancellationToken cancellationToken)
{
var adj = await db.CourseAdjustments.AsNoTracking()
.Where(x => x.Id == id)
.Select(AdjustmentProjection())
.FirstOrDefaultAsync(cancellationToken);
return adj is null ? NotFound() : Ok(adj);
}
// ═══════════════ Create ═══════════════
[HttpPost]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> Create(
CourseAdjustmentRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var validation = await ValidateRequestAsync(request, cancellationToken);
if (validation is not null) return validation;
var adj = new CourseAdjustment
{
TeachingTaskId = request.TeachingTaskId,
Type = request.Type,
ApplicantUserId = userId,
Reason = request.Reason.Trim(),
TargetDate = request.TargetDate,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
ClassroomId = request.ClassroomId,
SubstituteTeacherId = request.SubstituteTeacherId,
CancelWeek = request.CancelWeek,
CancelDate = request.CancelDate
};
if (request.Submit)
{
adj.Status = CourseAdjustmentStatus.Submitted;
adj.SubmittedAt = DateTime.UtcNow;
}
db.CourseAdjustments.Add(adj);
await db.SaveChangesAsync(cancellationToken);
if (request.Submit)
{
var taskInfo = await db.TeachingTasks
.Where(x => x.Id == request.TeachingTaskId)
.Select(x => new { x.Course!.Name, x.Course.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (taskInfo is not null)
{
var tl = TypeLabel(request.Type);
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
$"新的{tl}申请",
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
}
}
return Created(string.Empty, new { adj.Id });
}
// ═══════════════ Submit ═══════════════
[HttpPost("{id:guid}/submit")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var adj = await db.CourseAdjustments
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x =>
x.Id == id && x.ApplicantUserId == userId, cancellationToken);
if (adj is null) return NotFound();
if (adj.Status != CourseAdjustmentStatus.Draft)
return ConflictProblem("只有草稿可以提交。");
adj.Status = CourseAdjustmentStatus.Submitted;
adj.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var courseName = adj.TeachingTask!.Course!.Name;
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
adj.TeachingTask.Course.CollegeId,
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
await NotificationService.SendToRoleAsync(db,
SystemRoles.AcademicAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
null, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
// ═══════════════ Approve ═══════════════
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
{
var adj = await db.CourseAdjustments
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (adj is null) return NotFound();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem("只能审核本学院课程的调停课申请。");
if (adj.Status != CourseAdjustmentStatus.Submitted)
return ConflictProblem("只有待审核的申请可以审批。");
adj.Status = CourseAdjustmentStatus.Approved;
adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
// Apply schedule changes
await ApplyScheduleChangesAsync(adj, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
// Notify applicant
await NotificationService.SendAsync(db, adj.ApplicantUserId,
"调停课申请已通过",
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
// Notify affected students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.Student!.UserId)
.Where(uid => uid != null)
.Select(uid => uid!.Value)
.Distinct()
.ToListAsync(cancellationToken);
if (studentUserIds.Count > 0)
{
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"课程变动通知",
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
"/my-timetable", cancellationToken,
NotificationCategory.Schedule);
}
return NoContent();
}
private async Task ApplyScheduleChangesAsync(
CourseAdjustment adj, CancellationToken ct)
{
switch (adj.Type)
{
case CourseAdjustmentType.Reschedule:
// Update existing schedule entries for this teaching task
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
.ToListAsync(ct);
foreach (var entry in entries)
{
entry.DayOfWeek = adj.DayOfWeek.Value;
entry.StartPeriod = adj.StartPeriod.Value;
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
}
if (adj.ClassroomId.HasValue)
{
foreach (var entry in entries)
entry.ClassroomId = adj.ClassroomId.Value;
}
}
break;
case CourseAdjustmentType.Cancel:
// Cancel: remove schedule entries for the specified week
if (adj.CancelWeek.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
x.StartWeek <= adj.CancelWeek.Value &&
x.EndWeek >= adj.CancelWeek.Value)
.ToListAsync(ct);
foreach (var entry in entries)
{
// Split the entry to exclude the cancelled week
if (entry.StartWeek == adj.CancelWeek.Value &&
entry.EndWeek == adj.CancelWeek.Value)
{
db.ScheduleEntries.Remove(entry);
}
else if (entry.StartWeek == adj.CancelWeek.Value)
{
entry.StartWeek = adj.CancelWeek.Value + 1;
}
else if (entry.EndWeek == adj.CancelWeek.Value)
{
entry.EndWeek = adj.CancelWeek.Value - 1;
}
}
}
break;
case CourseAdjustmentType.Makeup:
// Makeup: add a temp schedule entry for the makeup date
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
adj.StartPeriod.HasValue)
{
var publishedPlan = await db.SchedulePlans
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.FirstOrDefaultAsync(ct);
if (publishedPlan is not null)
{
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = publishedPlan.Id,
TeachingTaskId = adj.TeachingTaskId,
ClassroomId = adj.ClassroomId,
DayOfWeek = adj.DayOfWeek.Value,
StartPeriod = adj.StartPeriod.Value,
PeriodCount = adj.PeriodCount ?? 2,
StartWeek = 1,
EndWeek = 1,
WeekPattern = WeekPattern.All,
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd}"
});
}
}
break;
case CourseAdjustmentType.Substitute:
// Substitute: add substitute teacher to the teaching task
if (adj.SubstituteTeacherId.HasValue)
{
var alreadyExists = await db.TeachingTaskTeachers
.AnyAsync(x =>
x.TeachingTaskId == adj.TeachingTaskId &&
x.TeacherId == adj.SubstituteTeacherId.Value, ct);
if (!alreadyExists)
{
db.TeachingTaskTeachers.Add(new TeachingTaskTeacher
{
TeachingTaskId = adj.TeachingTaskId,
TeacherId = adj.SubstituteTeacherId.Value,
IsPrimary = false
});
}
}
break;
}
}
private static string TypeLabel(CourseAdjustmentType type) => type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
// ═══════════════ Reject ═══════════════
[HttpPost("{id:guid}/reject")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Reject(
Guid id,
RejectionRequest request,
CancellationToken cancellationToken)
{
var adj = await db.CourseAdjustments
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (adj is null) return NotFound();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem("只能审核本学院课程的调停课申请。");
if (adj.Status != CourseAdjustmentStatus.Submitted)
return ConflictProblem("只有待审核的申请可以退回。");
adj.Status = CourseAdjustmentStatus.Rejected;
adj.ReviewComment = request.Comment?.Trim();
adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(cancellationToken);
var msg = adj.ReviewComment is not null
? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}"
: $"您的{TypeLabel(adj.Type)}申请已退回。";
await NotificationService.SendAsync(db, adj.ApplicantUserId,
$"{TypeLabel(adj.Type)}申请已退回", msg,
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
// ═══════════════ Helpers ═══════════════
private async Task<ActionResult?> ValidateRequestAsync(
CourseAdjustmentRequest request,
CancellationToken cancellationToken)
{
var task = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.Course)
.FirstOrDefaultAsync(x =>
x.Id == request.TeachingTaskId &&
x.Status == TeachingTaskStatus.Published,
cancellationToken);
if (task is null) return ValidationProblem("教学班不存在或未发布。");
switch (request.Type)
{
case CourseAdjustmentType.Reschedule:
case CourseAdjustmentType.Makeup:
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
return ValidationProblem("请选择有效的上课日。");
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
return ValidationProblem("请选择起始节次。");
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
return ValidationProblem("请选择持续节数。");
if (request.Type == CourseAdjustmentType.Makeup &&
!request.TargetDate.HasValue)
return ValidationProblem("补课必须指定日期。");
break;
case CourseAdjustmentType.Substitute:
if (!request.SubstituteTeacherId.HasValue)
return ValidationProblem("请选择代课教师。");
if (!await db.Teachers.AnyAsync(x =>
x.Id == request.SubstituteTeacherId &&
x.Status == TeacherStatus.Active, cancellationToken))
return ValidationProblem("代课教师不存在或已离职。");
break;
case CourseAdjustmentType.Cancel:
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
return ValidationProblem("停课需指定周次或日期。");
break;
}
return null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private static System.Linq.Expressions.Expression<
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name),
x.Type,
x.Status,
x.Reason,
x.ReviewComment,
x.TargetDate,
x.DayOfWeek,
x.StartPeriod,
x.PeriodCount,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
SubstituteTeacherName = x.SubstituteTeacher != null ? x.SubstituteTeacher.Name : null,
x.CancelWeek,
x.CancelDate,
x.ApplicantUserId,
x.SubmittedAt,
x.ReviewedAt,
x.CreatedAt
};
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
// ═══════════════ Request records ═══════════════
public sealed record CourseAdjustmentRequest(
Guid TeachingTaskId,
CourseAdjustmentType Type,
[Required, MaxLength(500)] string Reason,
bool Submit,
DateOnly? TargetDate,
[Range(1, 7)] int? DayOfWeek,
[Range(1, 30)] int? StartPeriod,
[Range(1, 6)] int? PeriodCount,
Guid? ClassroomId,
Guid? SubstituteTeacherId,
int? CancelWeek,
DateOnly? CancelDate);
public sealed record RejectionRequest(
[MaxLength(500)] string? Comment);