调课、停课、补课申请

This commit is contained in:
2026-07-25 15:36:14 +08:00 Unverified
parent 9685435da9
commit 13b61f191a
11 changed files with 1162 additions and 1 deletions
@@ -0,0 +1,440 @@
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)
await NotifyReviewersAsync(adj, 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);
await NotifyReviewersAsync(adj, 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)
.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;
await db.SaveChangesAsync(cancellationToken);
await NotifyApplicantAsync(adj, "已通过", cancellationToken);
return NoContent();
}
// ═══════════════ 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);
await NotifyApplicantAsync(adj,
$"已退回" + (adj.ReviewComment is not null ? $"{adj.ReviewComment}" : ""),
cancellationToken);
return NoContent();
}
// ═══════════════ Notifications ═══════════════
[HttpGet("notifications")]
[Authorize]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
return Ok(new
{
Items = await source.OrderByDescending(x => x.CreatedAt)
.Take(50)
.Select(x => new
{
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
})
.ToListAsync(cancellationToken),
UnreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken)
});
}
[HttpPost("notifications/{id:guid}/read")]
[Authorize]
public async Task<ActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var n = await db.Notifications
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
if (n is null) return NotFound();
n.IsRead = true;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("notifications/read-all")]
[Authorize]
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
await db.Notifications
.Where(x => x.UserId == userId && !x.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
cancellationToken);
return NoContent();
}
// ═══════════════ Helpers ═══════════════
private async Task NotifyReviewersAsync(
CourseAdjustment adj, CancellationToken ct)
{
var typeLabel = adj.Type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
var taskInfo = adj.TeachingTask is not null
? $"{adj.TeachingTask.Course!.Name}({adj.TeachingTask.TaskNumber})"
: "";
// Notify CollegeAdmin and AcademicAdmin of the course's college
var collegeId = adj.TeachingTask?.Course?.CollegeId;
if (!collegeId.HasValue) return;
var reviewerUserIds = await db.Users
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! })
.Where(x =>
(x.RoleName == SystemRoles.AcademicAdmin) ||
(x.RoleName == SystemRoles.CollegeAdmin &&
db.Teachers.Any(t =>
t.UserId == x.Id && t.CollegeId == collegeId)))
.Select(x => x.Id)
.Distinct()
.ToListAsync(ct);
foreach (var reviewerId in reviewerUserIds)
{
db.Notifications.Add(new Notification
{
UserId = reviewerId,
Title = $"新的{typeLabel}申请",
Content = $"{taskInfo} 提交了{typeLabel}申请,请及时审核。",
LinkUrl = "/course-adjustments"
});
}
await db.SaveChangesAsync(ct);
}
private async Task NotifyApplicantAsync(
CourseAdjustment adj, string result, CancellationToken ct)
{
var typeLabel = adj.Type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
db.Notifications.Add(new Notification
{
UserId = adj.ApplicantUserId,
Title = $"{typeLabel}申请{result}",
Content = adj.ReviewComment is not null
? $"您的{typeLabel}申请{result}。审核意见:{adj.ReviewComment}"
: $"您的{typeLabel}申请{result}。",
LinkUrl = "/course-adjustments"
});
await db.SaveChangesAsync(ct);
}
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);