Enhance staffing management in school simulation by introducing API endpoints for hiring staff and assigning subjects. Implement payroll cap validation to ensure hiring and subject assignments do not exceed the allocated budget. Update the simulation options to include a monthly payroll cap and revise related classes to support new staffing functionalities. Enhance documentation to reflect these changes and update tests to validate the new features.
This commit is contained in:
@@ -48,6 +48,14 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
||||
return pool;
|
||||
}
|
||||
|
||||
public ApplicantPool Without(string personId) =>
|
||||
this with
|
||||
{
|
||||
Applicants = Applicants
|
||||
.Where(applicant => !applicant.Person.Id.Equals(personId, StringComparison.Ordinal))
|
||||
.ToArray(),
|
||||
};
|
||||
|
||||
public static float HourlyAsk(DefCatalog catalog, Person person)
|
||||
{
|
||||
var rules = catalog.StaffingRules
|
||||
|
||||
@@ -40,6 +40,12 @@ public sealed record Person
|
||||
|
||||
public required IReadOnlyDictionary<string, float> Needs { get; init; }
|
||||
|
||||
/// <summary>SubjectDef names this staff member is assigned. Empty for everyone else.</summary>
|
||||
public IReadOnlyList<string> Subjects { get; init; } = [];
|
||||
|
||||
/// <summary>Hourly rate frozen at hire. Null until the person is staff.</summary>
|
||||
public float? HourlyWageAsk { get; init; }
|
||||
|
||||
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.People;
|
||||
|
||||
public enum StaffingError
|
||||
{
|
||||
None,
|
||||
UnknownSchool,
|
||||
UnknownApplicant,
|
||||
AlreadyHired,
|
||||
UnknownPosition,
|
||||
UnknownSubject,
|
||||
NotStaff,
|
||||
NotTeacher,
|
||||
AlreadyAssigned,
|
||||
SubjectNotAssigned,
|
||||
PayrollExceeded,
|
||||
NoOpening,
|
||||
}
|
||||
|
||||
public sealed record StaffingOutcome(
|
||||
StaffingError Error,
|
||||
Roster Roster,
|
||||
ApplicantPool Pool,
|
||||
float Allocated,
|
||||
float Payroll,
|
||||
float Remaining,
|
||||
float Attempted);
|
||||
|
||||
/// <summary>
|
||||
/// Hire, subject assignment and the monthly payroll cap. Scale lives on <see cref="StaffingDef"/>;
|
||||
/// the cap is supplied by the caller because it is a simulation option, not catalog data.
|
||||
/// </summary>
|
||||
public static class Staffing
|
||||
{
|
||||
public const string TeacherPosition = "Teacher";
|
||||
|
||||
public static StaffingOutcome UnknownSchool() =>
|
||||
Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0);
|
||||
|
||||
public static float MonthlyBase(StaffingDef rules, float hourlyAsk) =>
|
||||
Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth);
|
||||
|
||||
public static float MonthlyPay(StaffingDef rules, float hourlyAsk, int subjectCount)
|
||||
{
|
||||
var extras = Math.Max(0, subjectCount - 1);
|
||||
return Round(MonthlyBase(rules, hourlyAsk) * (1f + (rules.ExtraSubjectSurcharge * extras)));
|
||||
}
|
||||
|
||||
public static float Payroll(StaffingDef rules, IEnumerable<Person> people)
|
||||
{
|
||||
var total = 0f;
|
||||
foreach (var person in people)
|
||||
{
|
||||
if (!person.IsStaff || person.HourlyWageAsk is not { } ask)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
total += MonthlyPay(rules, ask, person.Subjects.Count);
|
||||
}
|
||||
|
||||
return Round(total);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SubjectDef> Uncovered(DefCatalog catalog, Roster roster)
|
||||
{
|
||||
var years = new HashSet<int>();
|
||||
foreach (var schoolClass in roster.Classes)
|
||||
{
|
||||
years.Add(schoolClass.Year);
|
||||
}
|
||||
|
||||
var taught = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var person in roster.People)
|
||||
{
|
||||
if (!person.IsStaff)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var subject in person.Subjects)
|
||||
{
|
||||
taught.Add(subject);
|
||||
}
|
||||
}
|
||||
|
||||
var uncovered = new List<SubjectDef>();
|
||||
foreach (var subject in catalog.Subjects.Values)
|
||||
{
|
||||
if (subject.Abstract || !TouchesYears(subject, years) || taught.Contains(subject.DefName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
uncovered.Add(subject);
|
||||
}
|
||||
|
||||
return uncovered;
|
||||
}
|
||||
|
||||
public static StaffingOutcome Hire(
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
Roster roster,
|
||||
ApplicantPool pool,
|
||||
string personId,
|
||||
string position,
|
||||
float allocated)
|
||||
{
|
||||
var rules = RequireRules(catalog);
|
||||
var payroll = Payroll(rules, roster.People);
|
||||
|
||||
if (roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal) && person.IsStaff))
|
||||
{
|
||||
return Fail(StaffingError.AlreadyHired, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var applicant = pool.Applicants.FirstOrDefault(candidate => candidate.Person.Id.Equals(personId, StringComparison.Ordinal));
|
||||
if (applicant is null)
|
||||
{
|
||||
return Fail(StaffingError.UnknownApplicant, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (!IsConcretePosition(catalog, position))
|
||||
{
|
||||
return Fail(StaffingError.UnknownPosition, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (!TryWorkplace(catalog, map, roster, position, out var workplace))
|
||||
{
|
||||
return Fail(StaffingError.NoOpening, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var pay = MonthlyPay(rules, applicant.HourlyWageAsk, subjectCount: 0);
|
||||
var attempted = Round(payroll + pay);
|
||||
if (attempted > allocated)
|
||||
{
|
||||
return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted);
|
||||
}
|
||||
|
||||
var hired = applicant.Person with
|
||||
{
|
||||
IsStaff = true,
|
||||
Position = position,
|
||||
WorkplaceRoomId = workplace,
|
||||
HourlyWageAsk = applicant.HourlyWageAsk,
|
||||
Subjects = [],
|
||||
};
|
||||
|
||||
var nextRoster = UpsertStaff(roster, hired);
|
||||
var nextPool = pool.Without(personId);
|
||||
return Ok(nextRoster, nextPool, allocated, Payroll(rules, nextRoster.People));
|
||||
}
|
||||
|
||||
public static StaffingOutcome AssignSubject(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
ApplicantPool pool,
|
||||
string personId,
|
||||
string subject,
|
||||
float allocated)
|
||||
{
|
||||
var rules = RequireRules(catalog);
|
||||
var payroll = Payroll(rules, roster.People);
|
||||
var index = IndexOf(roster, personId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var person = roster.People[index];
|
||||
if (!person.IsStaff)
|
||||
{
|
||||
return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (!TeacherPosition.Equals(person.Position, StringComparison.Ordinal))
|
||||
{
|
||||
return Fail(StaffingError.NotTeacher, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (!IsConcreteSubject(catalog, subject))
|
||||
{
|
||||
return Fail(StaffingError.UnknownSubject, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (person.Subjects.Contains(subject, StringComparer.Ordinal))
|
||||
{
|
||||
return Fail(StaffingError.AlreadyAssigned, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var ask = person.HourlyWageAsk ?? ApplicantPool.HourlyAsk(catalog, person);
|
||||
var nextSubjects = person.Subjects.Append(subject).ToArray();
|
||||
var currentPay = person.HourlyWageAsk is float currentAsk
|
||||
? MonthlyPay(rules, currentAsk, person.Subjects.Count)
|
||||
: MonthlyPay(rules, ask, person.Subjects.Count);
|
||||
var nextPay = MonthlyPay(rules, ask, nextSubjects.Length);
|
||||
var attempted = Round(payroll - currentPay + nextPay);
|
||||
if (attempted > allocated)
|
||||
{
|
||||
return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted);
|
||||
}
|
||||
|
||||
var nextRoster = Replace(roster, index, person with { HourlyWageAsk = ask, Subjects = nextSubjects });
|
||||
return Ok(nextRoster, pool, allocated, Payroll(rules, nextRoster.People));
|
||||
}
|
||||
|
||||
public static StaffingOutcome UnassignSubject(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
ApplicantPool pool,
|
||||
string personId,
|
||||
string subject,
|
||||
float allocated)
|
||||
{
|
||||
var rules = RequireRules(catalog);
|
||||
var payroll = Payroll(rules, roster.People);
|
||||
var index = IndexOf(roster, personId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var person = roster.People[index];
|
||||
if (!person.IsStaff)
|
||||
{
|
||||
return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
if (!person.Subjects.Contains(subject, StringComparer.Ordinal))
|
||||
{
|
||||
return Fail(StaffingError.SubjectNotAssigned, roster, pool, allocated, payroll, payroll);
|
||||
}
|
||||
|
||||
var nextSubjects = person.Subjects.Where(name => !name.Equals(subject, StringComparison.Ordinal)).ToArray();
|
||||
var nextRoster = Replace(roster, index, person with { Subjects = nextSubjects });
|
||||
return Ok(nextRoster, pool, allocated, Payroll(rules, nextRoster.People));
|
||||
}
|
||||
|
||||
private static StaffingOutcome Ok(Roster roster, ApplicantPool pool, float allocated, float payroll) =>
|
||||
new(StaffingError.None, roster, pool, allocated, payroll, Remaining(allocated, payroll), payroll);
|
||||
|
||||
private static StaffingOutcome Fail(
|
||||
StaffingError error,
|
||||
Roster roster,
|
||||
ApplicantPool pool,
|
||||
float allocated,
|
||||
float payroll,
|
||||
float attempted) =>
|
||||
new(error, roster, pool, allocated, payroll, Remaining(allocated, payroll), attempted);
|
||||
|
||||
private static float Remaining(float allocated, float payroll) => Round(MathF.Max(0f, allocated - payroll));
|
||||
|
||||
private static float Round(float value) => MathF.Round(value, 2);
|
||||
|
||||
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
||||
catalog.StaffingRules ?? throw new InvalidOperationException("The catalog has no StaffingDef.");
|
||||
|
||||
private static bool IsConcretePosition(DefCatalog catalog, string position) =>
|
||||
catalog.Positions.TryGetValue(position, out var def) && !def.Abstract;
|
||||
|
||||
private static bool IsConcreteSubject(DefCatalog catalog, string subject) =>
|
||||
catalog.Subjects.TryGetValue(subject, out var def) && !def.Abstract;
|
||||
|
||||
private static bool TouchesYears(SubjectDef subject, HashSet<int> years)
|
||||
{
|
||||
for (var year = subject.Grades.Min; year <= subject.Grades.Max; year++)
|
||||
{
|
||||
if (years.Contains(year))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryWorkplace(
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
Roster roster,
|
||||
string position,
|
||||
out string? workplace)
|
||||
{
|
||||
workplace = null;
|
||||
if (TeacherPosition.Equals(position, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (map is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var taken = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var person in roster.People)
|
||||
{
|
||||
if (person.IsStaff
|
||||
&& position.Equals(person.Position, StringComparison.Ordinal)
|
||||
&& person.WorkplaceRoomId is { } roomId)
|
||||
{
|
||||
taken.Add(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var opening in SchoolDemand.From(catalog, map).Staff)
|
||||
{
|
||||
if (opening.Position.Equals(position, StringComparison.Ordinal) && taken.Add(opening.RoomId))
|
||||
{
|
||||
workplace = opening.RoomId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int IndexOf(Roster roster, string personId)
|
||||
{
|
||||
for (var i = 0; i < roster.People.Count; i++)
|
||||
{
|
||||
if (roster.People[i].Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Roster Replace(Roster roster, int index, Person person)
|
||||
{
|
||||
var people = roster.People.ToArray();
|
||||
people[index] = person;
|
||||
return roster with { People = people };
|
||||
}
|
||||
|
||||
private static Roster UpsertStaff(Roster roster, Person hired)
|
||||
{
|
||||
var index = IndexOf(roster, hired.Id);
|
||||
if (index >= 0)
|
||||
{
|
||||
return Replace(roster, index, hired);
|
||||
}
|
||||
|
||||
var people = roster.People.Append(hired).ToArray();
|
||||
if (roster.Families.Any(family => family.Id.Equals(hired.FamilyId, StringComparison.Ordinal)))
|
||||
{
|
||||
return roster with { People = people };
|
||||
}
|
||||
|
||||
var families = roster.Families.Append(new Family(hired.FamilyId, [hired.Id], [])).ToArray();
|
||||
return roster with { People = people, Families = families };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user