Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs
T
2026-07-27 08:59:02 +08:00

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);