feat: add locked new-year rollover wizard for season transitions
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>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user