选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
89 lines
3.5 KiB
C#
89 lines
3.5 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.CourseSelection;
|
|
|
|
public static class CourseSelectionRules
|
|
{
|
|
public static bool IsSelectionOpen(CourseSelectionRound round, DateTime nowUtc) =>
|
|
round.Status == CourseSelectionRoundStatus.Open &&
|
|
nowUtc >= round.StartsAt &&
|
|
nowUtc <= round.EndsAt;
|
|
|
|
public static bool CanWithdraw(CourseSelectionRound round, DateTime nowUtc) =>
|
|
round.Status == CourseSelectionRoundStatus.Open &&
|
|
nowUtc <= round.WithdrawalEndsAt;
|
|
|
|
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
|
|
round.Status == CourseSelectionRoundStatus.Open &&
|
|
nowUtc <= round.WithdrawalEndsAt;
|
|
|
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
|
nature == CourseNature.GeneralRequired;
|
|
|
|
public static bool IsGradeEligible(
|
|
IEnumerable<int> eligibleGrades,
|
|
int studentGrade) =>
|
|
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
|
|
|
|
public static bool HasReachedCourseLimit(
|
|
int? maxCourseCount,
|
|
int selectedCourseCount) =>
|
|
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
|
|
|
|
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
|
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
|
|
|
public static bool HasScheduleConflict(
|
|
IEnumerable<ScheduleEntry> candidateEntries,
|
|
IEnumerable<ScheduleEntry> selectedEntries) =>
|
|
candidateEntries.Any(candidate =>
|
|
selectedEntries.Any(selected =>
|
|
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
|
|
|
|
/// <summary>
|
|
/// Returns the overlap percentage of candidate schedule entries with selected entries.
|
|
/// 0 = no conflict, 100 = fully overlapping.
|
|
/// Used for retake enrollment where ≤50% overlap is allowed.
|
|
/// </summary>
|
|
public static double CalculateScheduleOverlap(
|
|
IReadOnlyCollection<ScheduleEntry> candidateEntries,
|
|
IReadOnlyCollection<ScheduleEntry> selectedEntries)
|
|
{
|
|
if (candidateEntries.Count == 0 || selectedEntries.Count == 0)
|
|
return 0;
|
|
|
|
int totalCandidatePeriods = 0;
|
|
int overlappedPeriods = 0;
|
|
|
|
foreach (var candidate in candidateEntries)
|
|
{
|
|
totalCandidatePeriods += candidate.PeriodCount;
|
|
foreach (var selected in selectedEntries)
|
|
{
|
|
if (!ScheduleConflictDetector.TimeOverlaps(candidate, selected))
|
|
continue;
|
|
// Calculate overlapping periods
|
|
var overlapStart = Math.Max(
|
|
candidate.StartPeriod, selected.StartPeriod);
|
|
var overlapEnd = Math.Min(
|
|
candidate.StartPeriod + candidate.PeriodCount,
|
|
selected.StartPeriod + selected.PeriodCount);
|
|
if (overlapEnd > overlapStart)
|
|
overlappedPeriods += overlapEnd - overlapStart;
|
|
}
|
|
}
|
|
|
|
return totalCandidatePeriods == 0
|
|
? 0
|
|
: (double)overlappedPeriods / totalCandidatePeriods * 100;
|
|
}
|
|
|
|
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
|
public static int RetakeCapacity(int originalCapacity) =>
|
|
(int)Math.Ceiling(originalCapacity * 1.15);
|
|
|
|
public static int EffectiveCapacity(int originalCapacity, bool isRetake) =>
|
|
isRetake ? RetakeCapacity(originalCapacity) : originalCapacity;
|
|
}
|