Promote returning students, assign officers, and clear last season's data after an automatic SQLite backup, with Docker volume path docs fixed for /app/Data. Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
11 KiB
C#
295 lines
11 KiB
C#
using Core.Entities;
|
|
|
|
namespace Core.YearTransition;
|
|
|
|
/// <summary>
|
|
/// Inputs for building a year-transition plan.
|
|
/// </summary>
|
|
public sealed class YearTransitionRequest
|
|
{
|
|
public required IReadOnlyList<Student> Students { get; init; }
|
|
public required IReadOnlySet<int> ReturningStudentIds { get; init; }
|
|
public IReadOnlyDictionary<OfficerRole, int?> OfficerAssignments { get; init; }
|
|
= new Dictionary<OfficerRole, int?>();
|
|
public required int GraduatingGrade { get; init; }
|
|
public required string TargetCompetitionYear { get; init; }
|
|
public IReadOnlyList<string> PastedNames { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preview of a year transition before it is applied.
|
|
/// </summary>
|
|
public sealed class YearTransitionPlan
|
|
{
|
|
public required string TargetCompetitionYear { get; init; }
|
|
public required int GraduatingGrade { get; init; }
|
|
public required IReadOnlyList<StudentPromotion> Promotions { get; init; }
|
|
public required IReadOnlyList<Student> StudentsToRemove { get; init; }
|
|
public required IReadOnlyList<OfficerAssignmentChange> OfficerChanges { get; init; }
|
|
public required IReadOnlyList<string> UnmatchedPastedNames { get; init; }
|
|
public required IReadOnlyList<string> AmbiguousPastedNames { get; init; }
|
|
public required IReadOnlyList<string> Warnings { get; init; }
|
|
|
|
public int ReturningCount => Promotions.Count;
|
|
public int RemovalCount => StudentsToRemove.Count;
|
|
}
|
|
|
|
public sealed class StudentPromotion
|
|
{
|
|
public required Student Student { get; init; }
|
|
public required int PreviousGrade { get; init; }
|
|
public required int NewGrade { get; init; }
|
|
public required int PreviousTsaYear { get; init; }
|
|
public required int NewTsaYear { get; init; }
|
|
public OfficerRole? PreviousOfficerRole { get; init; }
|
|
public OfficerRole? NewOfficerRole { get; init; }
|
|
}
|
|
|
|
public sealed class OfficerAssignmentChange
|
|
{
|
|
public required OfficerRole Role { get; init; }
|
|
public Student? NewOfficer { get; init; }
|
|
public Student? PreviousOfficer { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure planner for year-end student promotion, graduation, and officer assignment.
|
|
/// </summary>
|
|
public static class YearTransitionPlanner
|
|
{
|
|
private const int AbsoluteMaxGrade = 12;
|
|
|
|
/// <summary>
|
|
/// Students at or above the graduating grade are suggested as non-returning.
|
|
/// </summary>
|
|
public static bool SuggestReturning(Student student, int graduatingGrade) =>
|
|
student.Grade < graduatingGrade;
|
|
|
|
/// <summary>
|
|
/// Parses pasted name lines and matches them to students.
|
|
/// </summary>
|
|
public static NameMatchResult MatchPastedNames(
|
|
IReadOnlyList<Student> students,
|
|
IEnumerable<string> pastedLines)
|
|
{
|
|
var matchedIds = new HashSet<int>();
|
|
var unmatched = new List<string>();
|
|
var ambiguous = new List<string>();
|
|
|
|
foreach (var rawLine in pastedLines)
|
|
{
|
|
var line = rawLine.Trim();
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
continue;
|
|
|
|
var matches = FindNameMatches(students, line).ToList();
|
|
if (matches.Count == 0)
|
|
{
|
|
unmatched.Add(line);
|
|
}
|
|
else if (matches.Count > 1)
|
|
{
|
|
ambiguous.Add(line);
|
|
foreach (var match in matches)
|
|
matchedIds.Add(match.Id);
|
|
}
|
|
else
|
|
{
|
|
matchedIds.Add(matches[0].Id);
|
|
}
|
|
}
|
|
|
|
return new NameMatchResult(matchedIds, unmatched, ambiguous);
|
|
}
|
|
|
|
public static YearTransitionPlan Build(YearTransitionRequest request)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade)
|
|
throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12.");
|
|
|
|
var studentsById = request.Students.ToDictionary(s => s.Id);
|
|
var warnings = new List<string>();
|
|
var promotions = new List<StudentPromotion>();
|
|
var toRemove = new List<Student>();
|
|
|
|
var nameMatch = MatchPastedNames(request.Students, request.PastedNames);
|
|
warnings.AddRange(nameMatch.AmbiguousNames.Select(n =>
|
|
$"Pasted name '{n}' matched more than one student."));
|
|
|
|
// Officer role -> student id from request (null = vacant)
|
|
var newOfficerByRole = Enum.GetValues<OfficerRole>()
|
|
.ToDictionary(
|
|
role => role,
|
|
role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null);
|
|
|
|
// Detect same student assigned to multiple offices
|
|
var assignedStudentIds = newOfficerByRole.Values
|
|
.Where(id => id.HasValue)
|
|
.Select(id => id!.Value)
|
|
.ToList();
|
|
var duplicateOfficerStudents = assignedStudentIds
|
|
.GroupBy(id => id)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => g.Key)
|
|
.ToHashSet();
|
|
|
|
foreach (var studentId in duplicateOfficerStudents)
|
|
{
|
|
if (studentsById.TryGetValue(studentId, out var student))
|
|
{
|
|
warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role.");
|
|
}
|
|
}
|
|
|
|
// Build new officer role lookup for returning students
|
|
var newRoleByStudentId = new Dictionary<int, OfficerRole>();
|
|
foreach (var (role, studentId) in newOfficerByRole)
|
|
{
|
|
if (!studentId.HasValue)
|
|
continue;
|
|
|
|
if (!request.ReturningStudentIds.Contains(studentId.Value))
|
|
{
|
|
var name = studentsById.TryGetValue(studentId.Value, out var s)
|
|
? s.LastNameFirstName
|
|
: $"Id {studentId.Value}";
|
|
warnings.Add($"{role} is assigned to {name}, who is not marked returning.");
|
|
continue;
|
|
}
|
|
|
|
if (duplicateOfficerStudents.Contains(studentId.Value))
|
|
continue;
|
|
|
|
newRoleByStudentId[studentId.Value] = role;
|
|
}
|
|
|
|
foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
|
{
|
|
if (request.ReturningStudentIds.Contains(student.Id))
|
|
{
|
|
var newGrade = Math.Min(AbsoluteMaxGrade,
|
|
Math.Min(request.GraduatingGrade, student.Grade + 1));
|
|
var newTsaYear = student.TsaYear + 1;
|
|
|
|
if (student.Grade >= request.GraduatingGrade)
|
|
{
|
|
warnings.Add(
|
|
$"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}.");
|
|
}
|
|
|
|
OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role)
|
|
? role
|
|
: null;
|
|
|
|
promotions.Add(new StudentPromotion
|
|
{
|
|
Student = student,
|
|
PreviousGrade = student.Grade,
|
|
NewGrade = newGrade,
|
|
PreviousTsaYear = student.TsaYear,
|
|
NewTsaYear = newTsaYear,
|
|
PreviousOfficerRole = student.OfficerRole,
|
|
NewOfficerRole = assignedRole
|
|
});
|
|
}
|
|
else
|
|
{
|
|
toRemove.Add(student);
|
|
}
|
|
}
|
|
|
|
var officerChanges = Enum.GetValues<OfficerRole>()
|
|
.Select(role =>
|
|
{
|
|
var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role);
|
|
Student? next = null;
|
|
if (newOfficerByRole.TryGetValue(role, out var nextId) &&
|
|
nextId.HasValue &&
|
|
studentsById.TryGetValue(nextId.Value, out var nextStudent) &&
|
|
request.ReturningStudentIds.Contains(nextId.Value) &&
|
|
!duplicateOfficerStudents.Contains(nextId.Value))
|
|
{
|
|
next = nextStudent;
|
|
}
|
|
|
|
return new OfficerAssignmentChange
|
|
{
|
|
Role = role,
|
|
PreviousOfficer = previous,
|
|
NewOfficer = next
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
return new YearTransitionPlan
|
|
{
|
|
TargetCompetitionYear = request.TargetCompetitionYear,
|
|
GraduatingGrade = request.GraduatingGrade,
|
|
Promotions = promotions,
|
|
StudentsToRemove = toRemove,
|
|
OfficerChanges = officerChanges,
|
|
UnmatchedPastedNames = nameMatch.UnmatchedNames,
|
|
AmbiguousPastedNames = nameMatch.AmbiguousNames,
|
|
Warnings = warnings
|
|
};
|
|
}
|
|
|
|
private static IEnumerable<Student> FindNameMatches(IReadOnlyList<Student> students, string line)
|
|
{
|
|
var comparer = StringComparer.OrdinalIgnoreCase;
|
|
|
|
// Exact full-name matches first
|
|
var fullMatches = students.Where(s =>
|
|
comparer.Equals(s.FirstNameLastName, line) ||
|
|
comparer.Equals(s.LastNameFirstName, line)).ToList();
|
|
if (fullMatches.Count > 0)
|
|
return fullMatches;
|
|
|
|
var (first, last) = ParseName(line);
|
|
if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last))
|
|
return [];
|
|
|
|
return students.Where(s =>
|
|
comparer.Equals(s.FirstName.Trim(), first) &&
|
|
comparer.Equals(s.LastName.Trim(), last));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a name line into (first, last), using <see cref="Student.ParseNameParts"/> for
|
|
/// "Last, First" and a last-space split for "First Last".
|
|
/// </summary>
|
|
private static (string First, string Last) ParseName(string fullName)
|
|
{
|
|
var trimmed = fullName.Trim();
|
|
if (trimmed.Contains(','))
|
|
{
|
|
var parts = Student.ParseNameParts(trimmed);
|
|
return (parts.Item1.Trim(), parts.Item2.Trim());
|
|
}
|
|
|
|
var lastSpace = trimmed.LastIndexOf(' ');
|
|
if (lastSpace <= 0)
|
|
return (trimmed, string.Empty);
|
|
|
|
return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim());
|
|
}
|
|
}
|
|
|
|
public sealed class NameMatchResult
|
|
{
|
|
public NameMatchResult(
|
|
IReadOnlySet<int> matchedStudentIds,
|
|
IReadOnlyList<string> unmatchedNames,
|
|
IReadOnlyList<string> ambiguousNames)
|
|
{
|
|
MatchedStudentIds = matchedStudentIds;
|
|
UnmatchedNames = unmatchedNames;
|
|
AmbiguousNames = ambiguousNames;
|
|
}
|
|
|
|
public IReadOnlySet<int> MatchedStudentIds { get; }
|
|
public IReadOnlyList<string> UnmatchedNames { get; }
|
|
public IReadOnlyList<string> AmbiguousNames { get; }
|
|
}
|