872 lines
34 KiB
C#
872 lines
34 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 Jiaowu.Api.Infrastructure.Scheduling;
|
|
using Jiaowu.Api.Infrastructure.Teaching;
|
|
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));
|
|
}
|
|
|
|
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
|
|
[Authorize(Roles = Applicants)]
|
|
public async Task<ActionResult> GetSessionOptions(
|
|
Guid teachingTaskId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var task = await AccessibleTeachingTasks().AsNoTracking()
|
|
.Where(x =>
|
|
x.Id == teachingTaskId &&
|
|
x.Status == TeachingTaskStatus.Published)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
CourseName = x.Course!.Name,
|
|
x.AcademicTermId,
|
|
TermStartDate = x.AcademicTerm!.StartDate,
|
|
TermEndDate = x.AcademicTerm.EndDate
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (task is null) return NotFound();
|
|
|
|
var entries = await db.ScheduleEntries.AsNoTracking()
|
|
.Where(x =>
|
|
x.TeachingTaskId == teachingTaskId &&
|
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
|
|
.OrderBy(x => x.DayOfWeek)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.DayOfWeek,
|
|
x.StartPeriod,
|
|
x.PeriodCount,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeekPattern,
|
|
x.ClassroomId,
|
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var occupied = await db.CourseAdjustments.AsNoTracking()
|
|
.Where(x =>
|
|
x.TeachingTaskId == teachingTaskId &&
|
|
x.SourceScheduleEntryId != null &&
|
|
x.SourceWeek != null &&
|
|
(x.Status == CourseAdjustmentStatus.Submitted ||
|
|
x.Status == CourseAdjustmentStatus.Approved))
|
|
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
|
|
.ToListAsync(cancellationToken);
|
|
var occupiedOccurrences = occupied
|
|
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
|
|
.ToHashSet();
|
|
|
|
var sessions = entries
|
|
.SelectMany(entry => Enumerable.Range(
|
|
entry.StartWeek,
|
|
entry.EndWeek - entry.StartWeek + 1)
|
|
.Where(week => IncludesWeek(entry.WeekPattern, week))
|
|
.Select(week => new
|
|
{
|
|
ScheduleEntryId = entry.Id,
|
|
Week = week,
|
|
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
|
|
entry.DayOfWeek,
|
|
entry.StartPeriod,
|
|
entry.PeriodCount,
|
|
entry.ClassroomId,
|
|
entry.ClassroomName,
|
|
entry.BuildingName,
|
|
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
|
|
}))
|
|
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
|
|
.OrderBy(x => x.Date)
|
|
.ThenBy(x => x.StartPeriod)
|
|
.ToList();
|
|
|
|
var classrooms = await db.Classrooms.AsNoTracking()
|
|
.Where(x => x.IsEnabled)
|
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
|
.ThenBy(x => x.Building!.SortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Name,
|
|
BuildingName = x.Building!.Name,
|
|
CampusName = x.Building.Campus!.Name,
|
|
x.Capacity
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var periods = await db.ScheduleTimeSlots.AsNoTracking()
|
|
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
|
|
.OrderBy(x => x.PeriodNumber)
|
|
.Select(x => new
|
|
{
|
|
x.PeriodNumber,
|
|
x.Name,
|
|
StartsAt = x.StartsAt.ToString("HH:mm"),
|
|
EndsAt = x.EndsAt.ToString("HH:mm")
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
|
|
}
|
|
|
|
// ═══════════════ 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 source = RequiresSourceOccurrence(request.Type)
|
|
? await LoadSourceOccurrenceAsync(
|
|
request.TeachingTaskId,
|
|
request.SourceScheduleEntryId,
|
|
request.SourceWeek,
|
|
cancellationToken)
|
|
: null;
|
|
var targetDayOfWeek = request.TargetDate.HasValue
|
|
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
|
|
: request.DayOfWeek;
|
|
|
|
var adj = new CourseAdjustment
|
|
{
|
|
TeachingTaskId = request.TeachingTaskId,
|
|
Type = request.Type,
|
|
ApplicantUserId = userId,
|
|
Reason = request.Reason.Trim(),
|
|
SourceScheduleEntryId = source?.Entry.Id,
|
|
SourceWeek = source?.Week,
|
|
SourceDate = source?.Date,
|
|
SourceStartPeriod = source?.Entry.StartPeriod,
|
|
SourcePeriodCount = source?.Entry.PeriodCount,
|
|
SourceClassroomId = source?.Entry.ClassroomId,
|
|
TargetDate = request.TargetDate,
|
|
DayOfWeek = targetDayOfWeek,
|
|
StartPeriod = request.StartPeriod,
|
|
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
|
|
ClassroomId = request.ClassroomId,
|
|
SubstituteTeacherId = request.SubstituteTeacherId,
|
|
CancelWeek = request.Type == CourseAdjustmentType.Cancel
|
|
? source?.Week
|
|
: request.CancelWeek,
|
|
CancelDate = request.Type == CourseAdjustmentType.Cancel
|
|
? source?.Date
|
|
: 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("只有草稿可以提交。");
|
|
|
|
var validation = await ValidateRequestAsync(
|
|
new CourseAdjustmentRequest(
|
|
adj.TeachingTaskId,
|
|
adj.Type,
|
|
adj.Reason,
|
|
true,
|
|
adj.TargetDate,
|
|
adj.DayOfWeek,
|
|
adj.StartPeriod,
|
|
adj.PeriodCount,
|
|
adj.ClassroomId,
|
|
adj.SubstituteTeacherId,
|
|
adj.CancelWeek,
|
|
adj.CancelDate,
|
|
adj.SourceScheduleEntryId,
|
|
adj.SourceWeek),
|
|
cancellationToken);
|
|
if (validation is not null) return validation;
|
|
|
|
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)
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Teachers)
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Classes)
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.AcademicTerm)
|
|
.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("只有待审核的申请可以审批。");
|
|
|
|
var scheduleProblem = await ValidateApprovalScheduleAsync(
|
|
adj,
|
|
cancellationToken);
|
|
if (scheduleProblem is not null)
|
|
return ConflictProblem(scheduleProblem);
|
|
|
|
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 TeachingTaskRosterQuery
|
|
.ForTask(db, adj.TeachingTaskId)
|
|
.Select(x => x.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:
|
|
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
|
|
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
|
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
|
|
break;
|
|
|
|
case CourseAdjustmentType.Cancel:
|
|
var cancelSource = await LoadSourceEntryAsync(adj, ct);
|
|
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
|
|
break;
|
|
|
|
case CourseAdjustmentType.Makeup:
|
|
var makeupSource = await LoadSourceEntryAsync(adj, ct);
|
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
|
|
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.TargetDate.HasValue)
|
|
return ValidationProblem(
|
|
request.Type == CourseAdjustmentType.Reschedule
|
|
? "请选择调课后的日期。"
|
|
: "请选择补课日期。");
|
|
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
|
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:
|
|
break;
|
|
}
|
|
|
|
if (RequiresSourceOccurrence(request.Type))
|
|
{
|
|
var source = await LoadSourceOccurrenceAsync(
|
|
request.TeachingTaskId,
|
|
request.SourceScheduleEntryId,
|
|
request.SourceWeek,
|
|
cancellationToken);
|
|
if (source is null)
|
|
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
|
|
|
|
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
|
|
x.SourceScheduleEntryId == source.Entry.Id &&
|
|
x.SourceWeek == source.Week &&
|
|
(x.Status == CourseAdjustmentStatus.Submitted ||
|
|
x.Status == CourseAdjustmentStatus.Approved),
|
|
cancellationToken);
|
|
if (duplicate)
|
|
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
|
|
|
|
if (request.TargetDate.HasValue &&
|
|
(request.TargetDate < source.Term.StartDate ||
|
|
request.TargetDate > source.Term.EndDate))
|
|
return ValidationProblem("目标日期必须在本学期起止日期内。");
|
|
}
|
|
|
|
if (request.ClassroomId.HasValue &&
|
|
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
|
x.Id == request.ClassroomId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("目标教室不存在或已停用。");
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<string?> ValidateApprovalScheduleAsync(
|
|
CourseAdjustment adjustment,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
|
|
|
|
var source = await LoadSourceOccurrenceAsync(
|
|
adjustment.TeachingTaskId,
|
|
adjustment.SourceScheduleEntryId,
|
|
adjustment.SourceWeek,
|
|
cancellationToken);
|
|
if (source is null)
|
|
return "原课次已不在当前发布课表中,请退回后重新选择。";
|
|
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
|
|
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
|
|
return "目标上课日期或节次不完整。";
|
|
|
|
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
|
|
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
|
|
var target = new ScheduleEntry
|
|
{
|
|
TeachingTaskId = adjustment.TeachingTaskId,
|
|
TeachingTask = adjustment.TeachingTask,
|
|
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
|
DayOfWeek = targetDay,
|
|
StartPeriod = adjustment.StartPeriod.Value,
|
|
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
|
|
StartWeek = targetWeek,
|
|
EndWeek = targetWeek,
|
|
WeekPattern = WeekPattern.All
|
|
};
|
|
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Teachers)
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.Classes)
|
|
.Where(x =>
|
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
|
x.Id != adjustment.SourceScheduleEntryId &&
|
|
x.DayOfWeek == targetDay &&
|
|
x.StartWeek <= targetWeek &&
|
|
x.EndWeek >= targetWeek &&
|
|
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
|
target.StartPeriod < x.StartPeriod + x.PeriodCount)
|
|
.ToListAsync(cancellationToken);
|
|
foreach (var existing in possibleConflicts.Where(x =>
|
|
IncludesWeek(x.WeekPattern, targetWeek)))
|
|
{
|
|
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
|
|
if (reason is not null)
|
|
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
|
|
Guid teachingTaskId,
|
|
Guid? scheduleEntryId,
|
|
int? week,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
|
|
var entry = await db.ScheduleEntries.AsNoTracking()
|
|
.Include(x => x.TeachingTask)
|
|
.ThenInclude(x => x!.AcademicTerm)
|
|
.FirstOrDefaultAsync(x =>
|
|
x.Id == scheduleEntryId.Value &&
|
|
x.TeachingTaskId == teachingTaskId &&
|
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
|
cancellationToken);
|
|
if (entry?.TeachingTask?.AcademicTerm is null ||
|
|
week < entry.StartWeek || week > entry.EndWeek ||
|
|
!IncludesWeek(entry.WeekPattern, week.Value))
|
|
return null;
|
|
var date = ResolveDate(
|
|
entry.TeachingTask.AcademicTerm.StartDate,
|
|
week.Value,
|
|
entry.DayOfWeek);
|
|
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
|
|
date > entry.TeachingTask.AcademicTerm.EndDate)
|
|
return null;
|
|
return new SourceOccurrence(
|
|
entry,
|
|
entry.TeachingTask.AcademicTerm,
|
|
week.Value,
|
|
date);
|
|
}
|
|
|
|
private Task<ScheduleEntry> LoadSourceEntryAsync(
|
|
CourseAdjustment adjustment,
|
|
CancellationToken cancellationToken) =>
|
|
db.ScheduleEntries.SingleAsync(x =>
|
|
x.Id == adjustment.SourceScheduleEntryId &&
|
|
x.TeachingTaskId == adjustment.TeachingTaskId &&
|
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
|
cancellationToken);
|
|
|
|
private void ExcludeWeek(ScheduleEntry entry, int week)
|
|
{
|
|
db.ScheduleEntries.Remove(entry);
|
|
if (entry.StartWeek <= week - 1 &&
|
|
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
|
|
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
|
|
if (week + 1 <= entry.EndWeek &&
|
|
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
|
|
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
|
|
}
|
|
|
|
private ScheduleEntry CreateTargetEntry(
|
|
CourseAdjustment adjustment,
|
|
Guid schedulePlanId)
|
|
{
|
|
var targetWeek = ResolveWeek(
|
|
adjustment.TeachingTask!.AcademicTerm!.StartDate,
|
|
adjustment.TargetDate!.Value);
|
|
return new ScheduleEntry
|
|
{
|
|
SchedulePlanId = schedulePlanId,
|
|
TeachingTaskId = adjustment.TeachingTaskId,
|
|
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
|
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
|
|
StartPeriod = adjustment.StartPeriod!.Value,
|
|
PeriodCount = adjustment.SourcePeriodCount!.Value,
|
|
StartWeek = targetWeek,
|
|
EndWeek = targetWeek,
|
|
WeekPattern = WeekPattern.All,
|
|
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
|
|
? $"调课(原第 {adjustment.SourceWeek} 周)"
|
|
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
|
|
};
|
|
}
|
|
|
|
private static ScheduleEntry CloneEntry(
|
|
ScheduleEntry source,
|
|
int startWeek,
|
|
int endWeek) => new()
|
|
{
|
|
SchedulePlanId = source.SchedulePlanId,
|
|
TeachingTaskId = source.TeachingTaskId,
|
|
ClassroomId = source.ClassroomId,
|
|
DayOfWeek = source.DayOfWeek,
|
|
StartPeriod = source.StartPeriod,
|
|
PeriodCount = source.PeriodCount,
|
|
StartWeek = startWeek,
|
|
EndWeek = endWeek,
|
|
WeekPattern = source.WeekPattern,
|
|
Notes = source.Notes
|
|
};
|
|
|
|
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
|
|
type is CourseAdjustmentType.Reschedule or
|
|
CourseAdjustmentType.Cancel or
|
|
CourseAdjustmentType.Makeup;
|
|
|
|
private static bool IncludesWeek(WeekPattern pattern, int week) =>
|
|
pattern == WeekPattern.All ||
|
|
pattern == WeekPattern.Odd && week % 2 == 1 ||
|
|
pattern == WeekPattern.Even && week % 2 == 0;
|
|
|
|
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
|
|
Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
|
.Any(week => IncludesWeek(pattern, week));
|
|
|
|
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
|
|
{
|
|
var startDay = (int)termStartDate.DayOfWeek;
|
|
var daysSinceMonday = (startDay + 6) % 7;
|
|
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
|
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
|
}
|
|
|
|
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
|
|
{
|
|
var startDay = (int)termStartDate.DayOfWeek;
|
|
var daysSinceMonday = (startDay + 6) % 7;
|
|
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
|
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
|
}
|
|
|
|
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
|
|
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
|
|
|
|
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 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.SourceScheduleEntryId,
|
|
x.SourceWeek,
|
|
x.SourceDate,
|
|
x.SourceStartPeriod,
|
|
x.SourcePeriodCount,
|
|
SourceClassroomName = x.SourceClassroomId == null
|
|
? null
|
|
: db.Classrooms
|
|
.Where(room => room.Id == x.SourceClassroomId)
|
|
.Select(room => room.Building!.Name + " " + room.Name)
|
|
.FirstOrDefault(),
|
|
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 sealed record SourceOccurrence(
|
|
ScheduleEntry Entry,
|
|
AcademicTerm Term,
|
|
int Week,
|
|
DateOnly Date);
|
|
|
|
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,
|
|
Guid? SourceScheduleEntryId = null,
|
|
int? SourceWeek = null);
|
|
|
|
public sealed record RejectionRequest(
|
|
[MaxLength(500)] string? Comment);
|