494 lines
20 KiB
C#
494 lines
20 KiB
C#
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;
|
||
|
||
// ═══════════════ 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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
await NotificationService.SendToRoleAsync(db,
|
||
SystemRoles.AcademicAdmin,
|
||
$"新的{TypeLabel(adj.Type)}申请",
|
||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||
null, "/course-adjustments", cancellationToken);
|
||
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);
|
||
|
||
// 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);
|
||
}
|
||
|
||
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);
|
||
return NoContent();
|
||
}
|
||
|
||
// ═══════════════ Helpers ═══════════════
|
||
|
||
private async Task<ActionResult?> ValidateRequestAsync(
|
||
CourseAdjustmentRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var task = await db.TeachingTasks.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 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);
|