Files
h-school/src/HSchool.Schedule/TimetablePlanner.cs
T
Leonid Pershin 25ce26064d
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s
Update staffing management to enhance payroll and hiring functionalities
- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments.
- Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate.
- Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments.
- Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects.
- Improved localization strings to reflect changes in staffing and payroll terminology.
2026-08-19 12:51:38 +03:00

366 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using HSchool.Content;
namespace HSchool.Schedule;
/// <summary>
/// Lays the curriculum onto the week. Deterministic: same catalog, map, staff, locks and week
/// length always yield the same table. No clock, no world, no host.
/// </summary>
public static class TimetablePlanner
{
public static Timetable Build(
DefCatalog catalog,
MapLayout map,
IReadOnlyList<PlannerClass> classes,
IReadOnlyList<PlannerTeacher> teachers,
IReadOnlyList<LessonPlacement>? locked = null,
int weekDays = 5)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
ArgumentNullException.ThrowIfNull(classes);
ArgumentNullException.ThrowIfNull(teachers);
if (weekDays is < 5 or > 7)
{
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 57 days.");
}
var frame = catalog.DayFrame;
var lessonCount = frame?.LessonCount ?? 0;
var rooms = map.Rooms.ToDictionary(room => room.Id, StringComparer.Ordinal);
var classById = classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
var occupancy = new Occupancy();
var placed = new List<LessonPlacement>();
foreach (var lesson in Ordered(locked ?? []))
{
if (TryKeepLock(catalog, rooms, classById, teachers, occupancy, weekDays, lessonCount, lesson))
{
occupancy.Add(lesson);
placed.Add(lesson with { Locked = true });
}
}
var remaining = RemainingHours(catalog, classes, placed);
foreach (var demand in remaining)
{
var leftover = PlaceDemand(
catalog,
rooms,
classById[demand.ClassId],
teachers,
occupancy,
weekDays,
lessonCount,
demand.Subject,
demand.Hours,
placed);
demand.Hours = leftover;
}
var uncovered = remaining
.Where(row => row.Hours > 0)
.Select(row => new UncoveredDemand(row.ClassId, row.Subject, row.Hours))
.OrderBy(row => row.ClassId, StringComparer.Ordinal)
.ThenBy(row => row.Subject, StringComparer.Ordinal)
.ToArray();
var lessons = placed
.OrderBy(lesson => lesson.Day)
.ThenBy(lesson => lesson.Period)
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
.ToArray();
return new Timetable(lessons, uncovered);
}
private sealed class Remaining
{
public required string ClassId { get; init; }
public required string Subject { get; init; }
public int Hours { get; set; }
}
private static List<Remaining> RemainingHours(
DefCatalog catalog,
IReadOnlyList<PlannerClass> classes,
IReadOnlyList<LessonPlacement> placed)
{
var remaining = new List<Remaining>();
foreach (var schoolClass in classes.OrderBy(item => item.Year).ThenBy(item => item.Letter, StringComparer.Ordinal).ThenBy(item => item.Id, StringComparer.Ordinal))
{
foreach (var subject in catalog.Subjects.Values
.Where(def => !def.Abstract && def.HoursPerWeek > 0 && schoolClass.Year >= def.Grades.Min && schoolClass.Year <= def.Grades.Max)
.OrderBy(def => def.DefName, StringComparer.Ordinal))
{
var already = placed.Count(lesson =>
lesson.ClassId == schoolClass.Id && lesson.Subject == subject.DefName);
var hours = subject.HoursPerWeek - already;
if (hours > 0)
{
remaining.Add(new Remaining
{
ClassId = schoolClass.Id,
Subject = subject.DefName,
Hours = hours,
});
}
}
}
return remaining;
}
private static int PlaceDemand(
DefCatalog catalog,
IReadOnlyDictionary<string, RoomNode> rooms,
PlannerClass schoolClass,
IReadOnlyList<PlannerTeacher> teachers,
Occupancy occupancy,
int weekDays,
int lessonCount,
string subjectName,
int hours,
List<LessonPlacement> placed)
{
if (!catalog.Subjects.TryGetValue(subjectName, out var subject) || hours <= 0)
{
return hours;
}
var candidates = RoomsFor(catalog, rooms, schoolClass, subject);
if (candidates.Count == 0)
{
return hours;
}
var leftover = hours;
foreach (var teacher in TeachersFor(teachers, subjectName, placed))
{
while (leftover > 0 && TryPlaceOne(occupancy, schoolClass.Id, subjectName, teacher.Id, candidates, weekDays, lessonCount, placed))
{
leftover--;
}
if (leftover == 0)
{
break;
}
}
return leftover;
}
/// <summary>
/// Picks the slot, and the order days are tried in is the whole difference between a timetable
/// and a pile of lessons. Scanning day 0 upwards and taking the first free slot produced blocks
/// of the same subject and a week that ran out by Thursday: Monday held 77 lessons, Friday 16.
///
/// So days are ranked instead: first those that do not have this subject yet, then the emptiest
/// one for this class. Periods stay ascending inside the chosen day, so a day has no holes.
/// </summary>
private static bool TryPlaceOne(
Occupancy occupancy,
string classId,
string subject,
string teacherId,
IReadOnlyList<RoomNode> rooms,
int weekDays,
int lessonCount,
List<LessonPlacement> placed)
{
var days = new int[weekDays];
for (var day = 0; day < weekDays; day++)
{
days[day] = day;
}
Array.Sort(days, (left, right) =>
{
var bySubject = occupancy.SubjectLoad(classId, subject, left)
.CompareTo(occupancy.SubjectLoad(classId, subject, right));
if (bySubject != 0)
{
return bySubject;
}
var byDay = occupancy.DayLoad(classId, left).CompareTo(occupancy.DayLoad(classId, right));
return byDay != 0 ? byDay : left.CompareTo(right);
});
foreach (var day in days)
{
for (var period = 1; period <= lessonCount; period++)
{
foreach (var room in rooms)
{
if (!occupancy.IsFree(classId, teacherId, room.Id, day, period))
{
continue;
}
var lesson = new LessonPlacement(classId, subject, teacherId, room.Id, day, period);
occupancy.Add(lesson);
placed.Add(lesson);
return true;
}
}
}
return false;
}
private static bool TryKeepLock(
DefCatalog catalog,
IReadOnlyDictionary<string, RoomNode> rooms,
IReadOnlyDictionary<string, PlannerClass> classes,
IReadOnlyList<PlannerTeacher> teachers,
Occupancy occupancy,
int weekDays,
int lessonCount,
LessonPlacement lesson)
{
if (lesson.Day < 0 || lesson.Day >= weekDays || lesson.Period < 1 || lesson.Period > lessonCount)
{
return false;
}
if (!classes.TryGetValue(lesson.ClassId, out var schoolClass))
{
return false;
}
if (!catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
|| subject.Abstract
|| schoolClass.Year < subject.Grades.Min
|| schoolClass.Year > subject.Grades.Max)
{
return false;
}
if (!teachers.Any(teacher => teacher.Id == lesson.TeacherId && HasSubject(teacher, lesson.Subject)))
{
return false;
}
if (!rooms.TryGetValue(lesson.RoomId, out var room) || !RoomMatches(catalog, room, schoolClass, subject))
{
return false;
}
return occupancy.IsFree(lesson.ClassId, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
}
private static IReadOnlyList<PlannerTeacher> TeachersFor(
IReadOnlyList<PlannerTeacher> teachers,
string subject,
IReadOnlyList<LessonPlacement> placed)
{
return teachers
.Where(teacher => HasSubject(teacher, subject))
.OrderBy(teacher => placed.Count(lesson => lesson.TeacherId == teacher.Id))
.ThenBy(teacher => teacher.Id, StringComparer.Ordinal)
.ToArray();
}
private static bool HasSubject(PlannerTeacher teacher, string subject) =>
teacher.Subjects.Contains(subject, StringComparer.Ordinal);
private static IReadOnlyList<RoomNode> RoomsFor(
DefCatalog catalog,
IReadOnlyDictionary<string, RoomNode> rooms,
PlannerClass schoolClass,
SubjectDef subject)
{
if (!string.IsNullOrWhiteSpace(subject.Room))
{
return rooms.Values
.Where(room => RoomMatches(catalog, room, schoolClass, subject))
.OrderBy(room => room.Id, StringComparer.Ordinal)
.ToArray();
}
if (rooms.TryGetValue(schoolClass.HomeroomId, out var homeroom)
&& RoomMatches(catalog, homeroom, schoolClass, subject))
{
return [homeroom];
}
return [];
}
private static bool RoomMatches(DefCatalog catalog, RoomNode room, PlannerClass schoolClass, SubjectDef subject)
{
if (!string.IsNullOrWhiteSpace(subject.Room))
{
if (!room.Def.Equals(subject.Room, StringComparison.Ordinal))
{
return false;
}
}
else if (!room.Id.Equals(schoolClass.HomeroomId, StringComparison.Ordinal))
{
return false;
}
return ClassFits(catalog, room, schoolClass.PupilCount);
}
/// <summary>
/// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve
/// computers does not host sixteen pupils.
/// </summary>
private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount)
{
var seats = RoomOccupancy.PupilSlots(catalog, room);
return seats == 0 || pupilCount <= seats;
}
private static IEnumerable<LessonPlacement> Ordered(IEnumerable<LessonPlacement> lessons) =>
lessons
.OrderBy(lesson => lesson.Day)
.ThenBy(lesson => lesson.Period)
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
.ThenBy(lesson => lesson.RoomId, StringComparer.Ordinal);
private sealed class Occupancy
{
private readonly HashSet<(string Id, int Day, int Period)> _classes = [];
private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
private readonly HashSet<(string Id, int Day, int Period)> _rooms = [];
private readonly Dictionary<(string ClassId, int Day), int> _dayLoad = [];
private readonly Dictionary<(string ClassId, string Subject, int Day), int> _subjectLoad = [];
public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
!_classes.Contains((classId, day, period))
&& !_teachers.Contains((teacherId, day, period))
&& !_rooms.Contains((roomId, day, period));
/// <summary>How many lessons this class already has that day — counted, not recomputed.</summary>
public int DayLoad(string classId, int day) =>
_dayLoad.TryGetValue((classId, day), out var count) ? count : 0;
/// <summary>How many lessons of this subject this class already has that day.</summary>
public int SubjectLoad(string classId, string subject, int day) =>
_subjectLoad.TryGetValue((classId, subject, day), out var count) ? count : 0;
public void Add(LessonPlacement lesson)
{
_classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
_teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
_rooms.Add((lesson.RoomId, lesson.Day, lesson.Period));
var day = (lesson.ClassId, lesson.Day);
_dayLoad[day] = (_dayLoad.TryGetValue(day, out var lessons) ? lessons : 0) + 1;
var subject = (lesson.ClassId, lesson.Subject, lesson.Day);
_subjectLoad[subject] = (_subjectLoad.TryGetValue(subject, out var same) ? same : 0) + 1;
}
}
}