Update staffing management to enhance payroll and hiring functionalities
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s

- 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.
This commit is contained in:
Leonid Pershin
2026-08-19 12:51:38 +03:00
parent d61240d5c1
commit 25ce26064d
20 changed files with 376 additions and 112 deletions
@@ -26,9 +26,9 @@ public class StaffingApiTests(AppHostFixture fixture)
var staffing = await GetStaffingAsync(client, school.Id);
Assert.Equal(10_000f, staffing.Allocated);
Assert.Equal(40_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(10_000f, staffing.Remaining);
Assert.Equal(40_000f, staffing.Remaining);
Assert.Equal(12, staffing.Applicants.Count);
Assert.Empty(staffing.Staff);
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
@@ -148,9 +148,9 @@ public class StaffingApiTests(AppHostFixture fixture)
}
Assert.Equal("payroll-exceeded", problem?.Code);
Assert.Equal(10_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 10_000f);
Assert.True(problem?.Attempted > 10_000f);
Assert.Equal(40_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 40_000f);
Assert.True(problem?.Attempted > 40_000f);
Assert.True(staffing.Staff.Count >= 1);
}
@@ -165,7 +165,8 @@ public class StaffingApiTests(AppHostFixture fixture)
var teacher = hired.Staff.Single();
var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics");
Assert.Equal(hired.Payroll, assigned.Payroll);
// The first subject already costs: 35 curriculum hours is well past one full rate.
Assert.True(assigned.Payroll > hired.Payroll);
assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage");
Assert.True(assigned.Payroll > hired.Payroll);
@@ -28,7 +28,7 @@ public class PeopleDefTests
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
Assert.NotNull(catalog.DayFrame);
@@ -44,7 +44,7 @@ public class VanillaCoreTests
Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.NotNull(catalog.DayFrame);
Assert.Equal("08:30", catalog.DayFrame.FirstLesson);
Assert.Equal(7, catalog.DayFrame.LessonCount);
+87 -22
View File
@@ -4,16 +4,22 @@ public class StaffingTests
{
private const float Cap = 10_000f;
/// <summary>
/// Price of an hour times the hours carried, with one full rate as the floor: a hired person
/// costs something even idle, and loading them past the rate costs more. The number of
/// subjects does not enter into it — twenty hours are twenty hours.
/// </summary>
[Fact]
public void MonthlyPay_AddsSurchargeAfterTheFirstSubject()
public void MonthlyPay_IsHoursTimesTheHourlyAsk_WithAFullRateAsTheFloor()
{
var rules = Fixtures.Catalog().StaffingRules!;
Assert.Equal(4_000f, Staffing.MonthlyBase(rules, 50f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 0));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 1));
Assert.Equal(5_000f, Staffing.MonthlyPay(rules, 50f, 2));
Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 3));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 0f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 12f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 20f));
Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 30f));
Assert.Equal(7_200f, Staffing.MonthlyPay(rules, 50f, 36f));
}
[Fact]
@@ -53,25 +59,85 @@ public class StaffingTests
var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, hired.Error);
// Mathematics is 35 curriculum hours a week, Literature 21. At 37.5 an hour that is
// 37.5 × 56 × 4 = 8 400 a month for one person — and no room left for a second.
var loaded = hired;
foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "ForeignLanguage" })
foreach (var subject in new[] { "Mathematics", "Literature" })
{
loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, subject, Cap);
Assert.Equal(StaffingError.None, loaded.Error);
}
Assert.Equal(6_750f, loaded.Payroll);
Assert.Equal(8_400f, loaded.Payroll);
var blocked = Staffing.Hire(catalog, map, loaded.Roster, loaded.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.PayrollExceeded, blocked.Error);
var dropped = Staffing.UnassignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, "Biology", Cap);
var dropped = Staffing.UnassignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, "Literature", Cap);
Assert.Equal(StaffingError.None, dropped.Error);
Assert.Equal(6_000f, dropped.Payroll);
Assert.Equal(5_250f, dropped.Payroll);
var fitted = Staffing.Hire(catalog, map, dropped.Roster, dropped.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, fitted.Error);
Assert.Equal(10_000f, fitted.Payroll);
Assert.Equal(9_250f, fitted.Payroll);
}
/// <summary>
/// Two teachers of one subject split its hours, so the second one costs a full rate and makes
/// the first cheaper. That is the whole reason hiring another is not simply worse than loading
/// the one you have.
/// </summary>
[Fact]
public void TwoTeachersOfASubject_ShareItsHours()
{
var (catalog, map, roster, pool) = Fresh();
var first = WithAsk(pool.Applicants[0], 50f);
var second = WithAsk(pool.Applicants[1], 50f);
pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] };
var one = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, 50_000f);
one = Staffing.AssignSubject(catalog, one.Roster, one.Pool, first.Person.Id, "Mathematics", 50_000f);
// All 35 hours on one person: 50 × 35 × 4.
Assert.Equal(7_000f, one.Payroll);
var two = Staffing.Hire(catalog, map, one.Roster, one.Pool, second.Person.Id, Staffing.TeacherPosition, 50_000f);
two = Staffing.AssignSubject(catalog, two.Roster, two.Pool, second.Person.Id, "Mathematics", 50_000f);
// 17.5 hours each, which is under a full rate, so both are paid the rate: 4 000 × 2.
Assert.Equal(8_000f, two.Payroll);
Assert.All(
two.Roster.People.Where(person => person.IsStaff),
person => Assert.Equal(17.5f, Staffing.WeeklyHours(catalog, two.Roster, person)));
}
/// <summary>
/// Primary school is twenty hours a week for each of four classes. One teacher cannot carry
/// eighty hours whatever the budget says, so the subject stays on the uncovered list until
/// enough people share it.
/// </summary>
[Fact]
public void SubjectNeedingMoreHoursThanOnePersonCarries_StaysUncovered()
{
var (catalog, map, roster, pool) = Fresh();
var rules = catalog.StaffingRules!;
Assert.Equal(80f, Staffing.SubjectHours(catalog, roster, "PrimarySchool"));
Assert.True(80f > rules.MaxWeeklyHours);
var outcome = new StaffingOutcome(StaffingError.None, roster, pool, 0, 0, 0, 0);
for (var i = 0; i < 3; i++)
{
var applicant = outcome.Pool.Applicants[0];
outcome = Staffing.Hire(catalog, map, outcome.Roster, outcome.Pool, applicant.Person.Id, Staffing.TeacherPosition, 500_000f);
Assert.Equal(StaffingError.None, outcome.Error);
outcome = Staffing.AssignSubject(catalog, outcome.Roster, outcome.Pool, applicant.Person.Id, "PrimarySchool", 500_000f);
Assert.Equal(StaffingError.None, outcome.Error);
var stillShort = 80f > (i + 1) * rules.MaxWeeklyHours;
Assert.Equal(
stillShort,
Staffing.Uncovered(catalog, outcome.Roster).Any(subject => subject.DefName == "PrimarySchool"));
}
}
[Fact]
@@ -82,23 +148,22 @@ public class StaffingTests
pool = pool with { Applicants = [applicant, .. pool.Applicants.Skip(1)] };
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
var loaded = hired;
foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "Physics", "Chemistry" })
{
loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, subject, Cap);
Assert.Equal(StaffingError.None, loaded.Error);
}
Assert.Equal(4_000f, hired.Payroll);
Assert.Equal(10_000f, loaded.Payroll);
// Mathematics is 35 hours: 50 × 35 × 4 = 7 000.
var loaded = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
Assert.Equal(StaffingError.None, loaded.Error);
Assert.Equal(7_000f, loaded.Payroll);
var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "Geography", Cap);
// Russian adds 28 more. 63 hours at 50 is 12 600 a month — past the 10 000 allocation.
var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "RussianLanguage", Cap);
Assert.Equal(StaffingError.PayrollExceeded, rejected.Error);
Assert.Equal(10_000f, rejected.Payroll);
Assert.Equal(11_000f, rejected.Attempted);
Assert.Equal(0f, rejected.Remaining);
Assert.Equal(7_000f, rejected.Payroll);
Assert.Equal(12_600f, rejected.Attempted);
Assert.Equal(3_000f, rejected.Remaining);
Assert.DoesNotContain(
rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects,
name => name == "Geography");
name => name == "RussianLanguage");
}
[Fact]
@@ -146,6 +146,50 @@ public class TimetablePlannerTests
}
}
/// <summary>
/// The four bans alone allowed a legal but unusable week. Taking the first free slot day by
/// day gave a full school 77 lessons on Monday and 16 on Friday, with three foreign-language
/// lessons back to back. These are the numbers a timetable is judged by, so they are asserted.
/// </summary>
[Fact]
public void AWeek_IsSpreadAcrossDaysAndSubjectsDoNotRunInBlocks()
{
var catalog = Fixtures.Catalog();
var map = Fixtures.ClassroomsAndGym(11);
var classes = Enumerable.Range(0, 11).Select(index => Fixtures.Class(index, index + 1, 16)).ToArray();
var teachers = catalog.Subjects.Values
.Where(subject => !subject.Abstract)
.SelectMany(subject => Enumerable
.Range(0, 3)
.Select(copy => Fixtures.Teacher($"t{subject.DefName}{copy}", subject.DefName)))
.ToArray();
var table = TimetablePlanner.Build(catalog, map, classes, teachers);
var perDay = Enumerable.Range(0, 5).Select(day => table.Lessons.Count(lesson => lesson.Day == day)).ToArray();
Assert.All(perDay, count => Assert.True(count > 0, $"a school day is empty: {string.Join("/", perDay)}"));
Assert.True(
perDay.Max() <= perDay.Min() * 1.5,
$"the week is lopsided: {string.Join("/", perDay)}");
// Spread as evenly as the curriculum allows, not "never twice a day": primary school is
// twenty hours over five days, so four a day is the best that exists.
foreach (var group in table.Lessons.GroupBy(lesson => (lesson.ClassId, lesson.Subject)))
{
var bySubjectDay = Enumerable
.Range(0, 5)
.Select(day => group.Count(lesson => lesson.Day == day))
.ToArray();
// Two, not one: the day order is a preference, and a busy room or teacher can still
// push an hour onto a day that already has the subject. Before the fix the same
// measurement read 5/5/5/0/0.
Assert.True(
bySubjectDay.Max() - bySubjectDay.Min() <= 2,
$"{group.Key.ClassId} has {group.Key.Subject} bunched into days: {string.Join("/", bySubjectDay)}");
}
}
private static string Snapshot(Timetable table) =>
string.Join(
'\n',