diff --git a/README.md b/README.md index d8cbd86..a422d9e 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Simulation tunables live under the `Simulation` section of | `ModsDirectory` | `mods` | pack folders; `core` is required | | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | | `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed | +| `MonthlyPayrollCap` | 10000 | monthly payroll the player may commit; hire and extra subjects that would exceed it are rejected | ## What is deliberately missing diff --git a/docs/phases/12-hiring-budget.md b/docs/phases/12-hiring-budget.md index ac45ae6..10e1155 100644 --- a/docs/phases/12-hiring-budget.md +++ b/docs/phases/12-hiring-budget.md @@ -11,20 +11,20 @@ ## Задачи -- [ ] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием -- [ ] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого -- [ ] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель +- [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием +- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого +- [x] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель остаётся родителем — новой сущности не заводится -- [ ] Назначение предмета нанятому и снятие предмета -- [ ] Предел проверяется **в момент действия**: наём или назначение, выводящее фонд за +- [x] Назначение предмета нанятому и снятие предмета +- [x] Предел проверяется **в момент действия**: наём или назначение, выводящее фонд за выделенную сумму, отклоняется с внятным кодом ошибки, а не откладывается до конца месяца -- [ ] Покрытие предметов: какие предметы преподаются в существующих параллелях и не имеют ни +- [x] Покрытие предметов: какие предметы преподаются в существующих параллелях и не имеют ни одного учителя -- [ ] `GET /api/schools/{id}/staffing` — деньги, покрытие, пул и штат; читает опубликованный +- [x] `GET /api/schools/{id}/staffing` — деньги, покрытие, пул и штат; читает опубликованный снимок, воркер не трогает -- [ ] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete -- [ ] Состав пишется на диск при изменении, снимок публикуется заново -- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики +- [x] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete +- [x] Состав пишется на диск при изменении, снимок публикуется заново +- [x] `docs/protocol.md` пополняется в том же коммите, что и обработчики ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index a9d3cca..154321c 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -51,7 +51,7 @@ | --- | --- | --- | | [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест | | [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате | -| [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты | +| [12. Наём и бюджет](12-hiring-budget.md) | ✅ | Наём, назначение предметов, предел фонда оплаты | | [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране | ## Срез 4. Расписание diff --git a/docs/protocol.md b/docs/protocol.md index ffcdcbb..c963e35 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -212,6 +212,98 @@ cards. } ``` +### `GET /api/schools/{id}/staffing` + +Money, uncovered subjects, the applicant pool and current staff. Reads the **published** +roster, applicant snapshot and catalog — it does not post to the worker. Unknown `{id}` is +`404` `unknown-school`. `?lang=ru|en` labels subjects and positions. + +`allocated` is `Simulation:MonthlyPayrollCap`. `payroll` is the sum of each staff member's +monthly base (`hourlyWageAsk × baseWeeklyHours × weeksPerMonth`) plus +`extraSubjectSurcharge` of that base for every subject after the first. The cap is checked +when hiring or assigning, not at month end; money itself does not move. + +```json +{ + "allocated": 10000, + "payroll": 5000, + "remaining": 5000, + "uncovered": [ + { + "defName": "Mathematics", + "label": "Математика", + "gradeMin": 5, + "gradeMax": 11, + "hoursPerWeek": 5 + } + ], + "applicants": [ + { + "id": "a0.p0", + "fullName": "Соколов Иван Петрович", + "female": false, + "age": 34, + "isParent": false, + "hourlyWageAsk": 50, + "monthlyBase": 4000 + } + ], + "staff": [ + { + "id": "f3.p1", + "fullName": "Иванова Ольга Михайловна", + "female": true, + "age": 41, + "isParent": true, + "position": "Teacher", + "positionLabel": "Учитель", + "hourlyWageAsk": 50, + "monthlyPay": 5000, + "subjects": [{ "defName": "Mathematics", "label": "Математика" }] + } + ] +} +``` + +Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same +id on the roster; hiring them sets `isStaff` on that person and does not create a second +entity. Generated candidates (`aN.p0`) join the roster only when hired. + +### `POST /api/schools/{id}/staff/hire` + +Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox. +On success returns the same payload as `GET .../staffing`. `Teacher` needs no room opening; +other positions fill the first free `RoomDef.positions` slot of that kind. + +| Status | `code` | When | +| --- | --- | --- | +| `404` | `unknown-school` | No school with that id. | +| `404` | `unknown-applicant` | `personId` is not in the pool. | +| `409` | `already-hired` | That person is already staff. | +| `400` | `unknown-position` | Not a concrete `PositionDef`. | +| `409` | `no-opening` | Every opening of that position is filled. | +| `409` | `payroll-exceeded` | Hire would take `payroll` past `allocated`. | + +`payroll-exceeded` includes `allocated`, `payroll` (current), `remaining` and `attempted` +(what payroll would become). Same RFC 7807 `code` field as the other errors. + +### `POST /api/schools/{id}/staff/{personId}/subjects` + +Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET staffing. + +| Status | `code` | When | +| --- | --- | --- | +| `400` | `not-staff` | Person is not staff. | +| `400` | `not-teacher` | Position is not `Teacher`. | +| `400` | `unknown-subject` | Not a concrete `SubjectDef`. | +| `409` | `already-assigned` | Already on this person. | +| `409` | `payroll-exceeded` | Extra subject would exceed the cap. | + +### `DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}` + +Removes one assignment. Payroll drops when the subject was not the only one. Unknown +assignment is `404` `unknown-assignment`. + ## WebSocket message ids Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 742de73..27348f5 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -296,6 +296,16 @@ internal static class PeopleDefValidator { throw new ContentLoadException($"StaffingDef '{staffing.DefName}' wage scale cannot be negative."); } + + if (staffing.BaseWeeklyHours <= 0f || staffing.WeeksPerMonth <= 0f) + { + throw new ContentLoadException($"StaffingDef '{staffing.DefName}' monthly hours must be positive."); + } + + if (staffing.ExtraSubjectSurcharge < 0f) + { + throw new ContentLoadException($"StaffingDef '{staffing.DefName}' extraSubjectSurcharge cannot be negative."); + } } private static void ValidateNameSet(NameSetDef names) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 1d759d4..15d254f 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -175,6 +175,17 @@ public sealed class StaffingDef : Def public float HourlyWageBase { get; init; } public float HourlyWagePerSkill { get; init; } + + /// Hours assumed in the monthly base rate. Extra subjects add a surcharge on top. + public float BaseWeeklyHours { get; init; } + + public float WeeksPerMonth { get; init; } + + /// + /// Added to the monthly base for each subject after the first. 0.25 means a second + /// subject costs 25% of the base rate. + /// + public float ExtraSubjectSurcharge { get; init; } } public enum BodyAttributeKind diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs index d039f13..57caa57 100644 --- a/src/HSchool.People/ApplicantPool.cs +++ b/src/HSchool.People/ApplicantPool.cs @@ -48,6 +48,14 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList + 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 diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 738d78a..20b0728 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -40,6 +40,12 @@ public sealed record Person public required IReadOnlyDictionary Needs { get; init; } + /// SubjectDef names this staff member is assigned. Empty for everyone else. + public IReadOnlyList Subjects { get; init; } = []; + + /// Hourly rate frozen at hire. Null until the person is staff. + public float? HourlyWageAsk { get; init; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/Staffing.cs b/src/HSchool.People/Staffing.cs new file mode 100644 index 0000000..bf50351 --- /dev/null +++ b/src/HSchool.People/Staffing.cs @@ -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); + +/// +/// Hire, subject assignment and the monthly payroll cap. Scale lives on ; +/// the cap is supplied by the caller because it is a simulation option, not catalog data. +/// +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 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 Uncovered(DefCatalog catalog, Roster roster) + { + var years = new HashSet(); + foreach (var schoolClass in roster.Classes) + { + years.Add(schoolClass.Year); + } + + var taught = new HashSet(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(); + 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 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(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 }; + } +} diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 56b9050..6124a7c 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -169,3 +169,123 @@ internal static class PeopleListMapper return new PeopleFilterOptionsResponse(years, letters, positions); } } + +internal sealed record HireStaffRequest(string? PersonId, string? Position); + +internal sealed record AssignSubjectRequest(string? Subject); + +internal sealed record StaffingResponse( + float Allocated, + float Payroll, + float Remaining, + IReadOnlyList Uncovered, + IReadOnlyList Applicants, + IReadOnlyList Staff); + +internal sealed record UncoveredSubjectResponse( + string DefName, + string Label, + int GradeMin, + int GradeMax, + int HoursPerWeek); + +internal sealed record ApplicantResponse( + string Id, + string FullName, + bool Female, + int Age, + bool IsParent, + float HourlyWageAsk, + float MonthlyBase); + +internal sealed record StaffMemberResponse( + string Id, + string FullName, + bool Female, + int Age, + bool IsParent, + string Position, + string PositionLabel, + float HourlyWageAsk, + float MonthlyPay, + IReadOnlyList Subjects); + +internal static class StaffingMapper +{ + public static StaffingResponse From( + Roster? roster, + ApplicantPool? pool, + DefCatalog? catalog, + DateTime asOf, + float allocated, + string locale) + { + roster ??= new Roster([], [], []); + pool ??= ApplicantPool.Empty; + var rules = catalog?.StaffingRules; + var payroll = rules is null ? 0f : Staffing.Payroll(rules, roster.People); + var remaining = MathF.Round(MathF.Max(0f, allocated - payroll), 2); + + var uncovered = catalog is null + ? Array.Empty() + : Staffing.Uncovered(catalog, roster) + .Select(subject => new UncoveredSubjectResponse( + subject.DefName, + catalog.Label(locale, subject), + subject.Grades.Min, + subject.Grades.Max, + subject.HoursPerWeek)) + .ToArray(); + + var applicants = pool.Applicants + .Select(applicant => new ApplicantResponse( + applicant.Person.Id, + applicant.Person.Name.Full, + applicant.Person.Female, + applicant.Person.AgeOn(asOf), + applicant.Person.IsParent, + applicant.HourlyWageAsk, + rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk))) + .ToArray(); + + var staff = roster.People + .Where(person => person.IsStaff) + .OrderBy(person => person.Name.Surname, StringComparer.Ordinal) + .ThenBy(person => person.Id, StringComparer.Ordinal) + .Select(person => Member(person, catalog, rules, locale, asOf)) + .ToArray(); + + return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff); + } + + private static StaffMemberResponse Member( + Person person, + DefCatalog? catalog, + StaffingDef? rules, + string locale, + DateTime asOf) + { + var ask = person.HourlyWageAsk ?? 0f; + var pay = rules is null ? 0f : Staffing.MonthlyPay(rules, ask, person.Subjects.Count); + var subjects = person.Subjects + .Select(name => new DefLabelResponse( + name, + catalog is not null && catalog.Subjects.TryGetValue(name, out var def) + ? catalog.Label(locale, def) + : name)) + .ToArray(); + + return new StaffMemberResponse( + person.Id, + person.Name.Full, + person.Female, + person.AgeOn(asOf), + person.IsParent, + person.Position ?? string.Empty, + PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty, + ask, + pay, + subjects); + } +} + diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index bfd6aa4..db66c6d 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -6,8 +6,8 @@ using HSchool.Simulation; namespace HSchool.Server.Api; /// -/// The main menu talks to these: list, create, delete. The people list reads a published roster -/// snapshot; the person card goes through the school's mailbox because needs are live. +/// The main menu talks to these: list, create, delete. People list and staffing read published +/// snapshots; the person card, hire and subject changes go through the school's mailbox. /// internal static class SchoolEndpoints { @@ -156,6 +156,86 @@ internal static class SchoolEndpoints }; }) .WithName("GetSchoolPerson"); + + schools.MapGet("/{id:int}/staffing", ( + int id, + string? lang, + GameLoopService loop) => + { + var published = loop.FindPeople(id); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, ParseLocale(lang))); + }) + .WithName("GetSchoolStaffing"); + + schools.MapPost("/{id:int}/staff/hire", async ( + int id, + HireStaffRequest request, + string? lang, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!TryPersonId(request.PersonId, out var personId, out var error) + || !TryDefName(request.Position, "position", out var position, out error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.HireStaff(id, personId, position, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return StaffingResult(id, outcome, loop, ParseLocale(lang)); + }) + .WithName("HireSchoolStaff"); + + schools.MapPost("/{id:int}/staff/{personId}/subjects", async ( + int id, + string personId, + AssignSubjectRequest request, + string? lang, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!TryPersonId(personId, out var idValue, out var error) + || !TryDefName(request.Subject, "subject", out var subject, out error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.AssignSubject(id, idValue, subject, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return StaffingResult(id, outcome, loop, ParseLocale(lang)); + }) + .WithName("AssignSchoolSubject"); + + schools.MapDelete("/{id:int}/staff/{personId}/subjects/{subject}", async ( + int id, + string personId, + string subject, + string? lang, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!TryPersonId(personId, out var idValue, out var error) + || !TryDefName(subject, "subject", out var subjectName, out error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.UnassignSubject(id, idValue, subjectName, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return StaffingResult(id, outcome, loop, ParseLocale(lang)); + }) + .WithName("UnassignSchoolSubject"); } /// The supervisor must never be blocked by a continuation of a waiting request. @@ -279,11 +359,100 @@ internal static class SchoolEndpoints return true; } - private static IResult Problem(int statusCode, string code, string detail) => - Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary + private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) => + StaffingMapper.From( + published.Roster, + published.Applicants, + published.Catalog, + published.School.GameTime, + allocated, + locale); + + private static IResult StaffingResult(int schoolId, StaffingOutcome outcome, GameLoopService loop, string locale) + { + if (outcome.Error != StaffingError.None) { - ["code"] = code, - }); + return StaffingProblem(outcome); + } + + var published = loop.FindPeople(schoolId); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale)); + } + + private static IResult StaffingProblem(StaffingOutcome outcome) => + outcome.Error switch + { + StaffingError.UnknownApplicant => + Problem(StatusCodes.Status404NotFound, "unknown-applicant", "That person is not in the applicant pool."), + StaffingError.AlreadyHired => + Problem(StatusCodes.Status409Conflict, "already-hired", "That person is already on staff."), + StaffingError.UnknownPosition => + Problem(StatusCodes.Status400BadRequest, "unknown-position", "That position is not in the catalog."), + StaffingError.UnknownSubject => + Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."), + StaffingError.NotStaff => + Problem(StatusCodes.Status400BadRequest, "not-staff", "That person is not on staff."), + StaffingError.NotTeacher => + Problem(StatusCodes.Status400BadRequest, "not-teacher", "Only a teacher can be assigned a subject."), + StaffingError.AlreadyAssigned => + Problem(StatusCodes.Status409Conflict, "already-assigned", "That subject is already assigned to this person."), + StaffingError.SubjectNotAssigned => + Problem(StatusCodes.Status404NotFound, "unknown-assignment", "That subject is not assigned to this person."), + StaffingError.PayrollExceeded => + Problem( + StatusCodes.Status409Conflict, + "payroll-exceeded", + $"That change would take payroll from {outcome.Payroll} to {outcome.Attempted} against an allocation of {outcome.Allocated}.", + outcome), + StaffingError.NoOpening => + Problem(StatusCodes.Status409Conflict, "no-opening", "There is no free opening for that position."), + _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), + }; + + private static bool TryPersonId(string? value, out string personId, out string error) + { + personId = value?.Trim() ?? string.Empty; + if (personId.Length is < 1 or > 64) + { + error = "The person id is not valid."; + return false; + } + + error = string.Empty; + return true; + } + + private static bool TryDefName(string? value, string field, out string name, out string error) + { + name = value?.Trim() ?? string.Empty; + if (name.Length is < 1 or > 64) + { + error = $"{field} is not valid."; + return false; + } + + error = string.Empty; + return true; + } + + private static IResult Problem(int statusCode, string code, string detail, StaffingOutcome? staffing = null) + { + var extensions = new Dictionary { ["code"] = code }; + if (staffing is not null) + { + extensions["allocated"] = staffing.Allocated; + extensions["payroll"] = staffing.Payroll; + extensions["remaining"] = staffing.Remaining; + extensions["attempted"] = staffing.Attempted; + } + + return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions); + } } /// Body of POST /api/schools. The start date is a game calendar date, not a real one. diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 63b24d8..2c9b0d5 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -1,4 +1,5 @@ using HSchool.Content; +using HSchool.People; using HSchool.Server.Api; using HSchool.Simulation; @@ -50,4 +51,22 @@ internal abstract record GameCommand string PersonId, string Locale, TaskCompletionSource Result) : GameCommand; + + internal sealed record HireStaff( + int SchoolId, + string PersonId, + string Position, + TaskCompletionSource Result) : GameCommand; + + internal sealed record AssignSubject( + int SchoolId, + string PersonId, + string Subject, + TaskCompletionSource Result) : GameCommand; + + internal sealed record UnassignSubject( + int SchoolId, + string PersonId, + string Subject, + TaskCompletionSource Result) : GameCommand; } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 9d16dae..a643662 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -171,6 +171,27 @@ internal sealed class GameLoopService( case GameCommand.GetPerson getPerson: HandleGetPerson(getPerson); break; + + case GameCommand.HireStaff hire: + HandleStaffing( + hire.SchoolId, + new WorkerCommand.HireStaff(hire.PersonId, hire.Position, hire.Result), + hire.Result); + break; + + case GameCommand.AssignSubject assign: + HandleStaffing( + assign.SchoolId, + new WorkerCommand.AssignSubject(assign.PersonId, assign.Subject, assign.Result), + assign.Result); + break; + + case GameCommand.UnassignSubject unassign: + HandleStaffing( + unassign.SchoolId, + new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result), + unassign.Result); + break; } } @@ -183,6 +204,14 @@ internal sealed class GameLoopService( } } + private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result) + { + if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) + { + result.TrySetResult(Staffing.UnknownSchool()); + } + } + /// /// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock /// never moves again, and tell anybody watching it to go back to the menu. The save file stays diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index b69bcf4..feb6115 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -342,10 +342,7 @@ internal sealed class SchoolWorker { while (_mailbox.Reader.TryRead(out var orphan)) { - if (orphan is WorkerCommand.GetPerson getPerson) - { - getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); - } + CompleteOrphan(orphan); } return; @@ -393,14 +390,23 @@ internal sealed class SchoolWorker ? new PersonCardResult(null, PersonLookupError.UnknownPerson) : new PersonCardResult(card, PersonLookupError.None)); break; + + case WorkerCommand.HireStaff hire: + hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position)); + break; + + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject)); + break; + + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); + break; } } catch (Exception ex) { - if (command is WorkerCommand.GetPerson failed) - { - failed.Result.TrySetException(ex); - } + FailCommand(command, ex); _logger.LogError( ex, @@ -421,6 +427,76 @@ internal sealed class SchoolWorker } } + private static void CompleteOrphan(WorkerCommand command) + { + switch (command) + { + case WorkerCommand.GetPerson getPerson: + getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); + break; + case WorkerCommand.HireStaff hire: + hire.Result.TrySetResult(Staffing.UnknownSchool()); + break; + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetResult(Staffing.UnknownSchool()); + break; + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetResult(Staffing.UnknownSchool()); + break; + } + } + + private static void FailCommand(WorkerCommand command, Exception exception) + { + switch (command) + { + case WorkerCommand.GetPerson getPerson: + getPerson.Result.TrySetException(exception); + break; + case WorkerCommand.HireStaff hire: + hire.Result.TrySetException(exception); + break; + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetException(exception); + break; + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetException(exception); + break; + } + } + + private StaffingOutcome ApplyHire(School school, string personId, string position) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyAssign(School school, string personId, string subject) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyUnassign(School school, string personId, string subject) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyStaffingChange( + School school, + Func apply) + { + if (school.Roster is null || school.Applicants is null || school.Catalog is null) + { + return Staffing.UnknownSchool(); + } + + var outcome = apply(school.Catalog, school.Roster, school.Applicants); + if (outcome.Error == StaffingError.None) + { + school.ApplyStaffing(outcome.Roster, outcome.Pool); + PersistPeople(); + PublishSnapshot(); + } + + return outcome; + } + /// /// Writes pause/speed changes, at most once per . /// A single click still lands within that window; a burst collapses into one write. @@ -459,7 +535,7 @@ internal sealed class SchoolWorker /// /// Writes the composition file. Not called from the 30-second clock save — the roster and - /// applicant pool change on create, weekly refresh and yearly intake, not every tick. + /// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick. /// private void PersistPeople() { diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index f3a3fb7..e93f5c9 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -1,3 +1,4 @@ +using HSchool.People; using HSchool.Server.Api; using HSchool.Server.Net; @@ -21,4 +22,19 @@ internal abstract record WorkerCommand string PersonId, string Locale, TaskCompletionSource Result) : WorkerCommand; + + internal sealed record HireStaff( + string PersonId, + string Position, + TaskCompletionSource Result) : WorkerCommand; + + internal sealed record AssignSubject( + string PersonId, + string Subject, + TaskCompletionSource Result) : WorkerCommand; + + internal sealed record UnassignSubject( + string PersonId, + string Subject, + TaskCompletionSource Result) : WorkerCommand; } diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index a3da41f..a2c9955 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -21,6 +21,7 @@ builder.Services .Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.") .Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.") .Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.") + .Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.") .ValidateOnStart(); builder.Services.AddSingleton(); diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index 06685bb..f362471 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -13,6 +13,7 @@ "DefaultStartDate": "2012-03-31T06:00:00", "SavesDirectory": "saves", "ModsDirectory": "mods", - "SaveIntervalSeconds": 30 + "SaveIntervalSeconds": 30, + "MonthlyPayrollCap": 10000 } } diff --git a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc index 2aad1ac..6dbaf56 100644 --- a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc @@ -5,4 +5,7 @@ "parentChance": 0.35, "hourlyWageBase": 30, "hourlyWagePerSkill": 0.6, + "baseWeeklyHours": 20, + "weeksPerMonth": 4, + "extraSubjectSurcharge": 0.25, } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 63948b4..081348b 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -91,6 +91,21 @@ public sealed class School : IDisposable RosterSpawner.Spawn(World, roster); } + /// + /// Replaces the roster and applicant pool after hire or a subject change. The World is rebuilt + /// so the people list and card match; needs reset to the roster snapshot, same as yearly intake. + /// + public void ApplyStaffing(Roster roster, ApplicantPool applicants) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(roster); + ArgumentNullException.ThrowIfNull(applicants); + + Roster = roster; + Applicants = applicants; + RosterSpawner.Replace(World, roster); + } + /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay. /// when the roster or the applicant pool changed this step. public bool Tick(double deltaTime, double gameMinutesPerRealSecond) diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index b36bb54..f661d57 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -49,6 +49,12 @@ public sealed class SimulationOptions /// public int MinSaveIntervalMilliseconds { get; set; } = 1000; + /// + /// Monthly payroll the player may commit. Hire and subject assignment that would exceed it + /// are rejected immediately; money itself does not move. + /// + public float MonthlyPayrollCap { get; set; } = 10_000f; + /// Length of one fixed step. public double FixedDeltaTime => 1d / TickRate; diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs new file mode 100644 index 0000000..50861c9 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -0,0 +1,260 @@ +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class StaffingApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetStaffing_UnknownSchool_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + + using var response = await client.GetAsync("/api/schools/999999/staffing", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-school", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task GetStaffing_EmptySchool_HasPoolAndNoPayroll() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Штат пустой", Start); + + var staffing = await GetStaffingAsync(client, school.Id); + + Assert.Equal(10_000f, staffing.Allocated); + Assert.Equal(0f, staffing.Payroll); + Assert.Equal(10_000f, staffing.Remaining); + Assert.Equal(12, staffing.Applicants.Count); + Assert.Empty(staffing.Staff); + Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics"); + Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool"); + } + + [Fact] + public async Task Hire_AddsStaff_RemovesApplicant_AndRejectsASecondHireOfTheSamePerson() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Штат наём", Start); + var before = await GetStaffingAsync(client, school.Id); + var applicant = before.Applicants[0]; + + var after = await HireAsync(client, school.Id, applicant.Id, "Teacher"); + + Assert.Contains(after.Staff, member => member.Id == applicant.Id && member.Position == "Teacher"); + Assert.DoesNotContain(after.Applicants, candidate => candidate.Id == applicant.Id); + Assert.True(after.Payroll > 0f); + Assert.True(after.Payroll <= after.Allocated); + + using var again = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/hire", + new { personId = applicant.Id, position = "Teacher" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + Assert.Equal("already-hired", await ProblemCodeAsync(again)); + + var people = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people?role=staff&pageSize=10", + TestContext.Current.CancellationToken); + Assert.NotNull(people); + Assert.Contains(people.People, person => person.Id == applicant.Id); + } + + [Fact] + public async Task Hire_UnknownApplicant_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Штат неизвестный", Start); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/hire", + new { personId = "nobody", position = "Teacher" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-applicant", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task Hire_PastTheCap_IsRejectedWithTheNumbers() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Штат предел", Start); + + var staffing = await GetStaffingAsync(client, school.Id); + ProblemResponse? problem = null; + while (staffing.Applicants.Count > 0) + { + var applicant = staffing.Applicants[0]; + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/hire", + new { personId = applicant.Id, position = "Teacher" }, + TestContext.Current.CancellationToken); + if (response.StatusCode == HttpStatusCode.Conflict) + { + problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + break; + } + + response.EnsureSuccessStatusCode(); + staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + } + + 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.True(staffing.Staff.Count >= 1); + } + + [Fact] + public async Task AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Штат предметы", Start); + var before = await GetStaffingAsync(client, school.Id); + var hired = await HireAsync(client, school.Id, before.Applicants[0].Id, "Teacher"); + var teacher = hired.Staff.Single(); + + var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics"); + Assert.Equal(hired.Payroll, assigned.Payroll); + + assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage"); + Assert.True(assigned.Payroll > hired.Payroll); + Assert.Equal(2, assigned.Staff.Single().Subjects.Count); + + using var over = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects", + new { subject = "Literature" }, + TestContext.Current.CancellationToken); + // Cheap asks may still fit; only the numbers matter when the cap actually bites. + if (over.StatusCode == HttpStatusCode.Conflict) + { + var problem = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("payroll-exceeded", problem?.Code); + Assert.True(problem?.Attempted > problem?.Allocated); + Assert.Equal(assigned.Payroll, problem?.Payroll); + } + else + { + over.EnsureSuccessStatusCode(); + assigned = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(assigned); + } + + var payrollWithSubjects = assigned.Payroll; + using var unassign = await client.DeleteAsync( + $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage", + TestContext.Current.CancellationToken); + unassign.EnsureSuccessStatusCode(); + var afterDrop = await unassign.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(afterDrop); + Assert.True(afterDrop.Payroll < payrollWithSubjects); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var restored = await GetStaffingAsync(client, school.Id); + Assert.Equal(afterDrop.Payroll, restored.Payroll); + Assert.Equal(teacher.Id, restored.Staff.Single().Id); + Assert.Equal( + afterDrop.Staff.Single().Subjects.Select(subject => subject.DefName), + restored.Staff.Single().Subjects.Select(subject => subject.DefName)); + } + + private static async Task GetStaffingAsync(HttpClient client, int schoolId) + { + var staffing = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/staffing?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/staff/hire", + new { personId, position }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task AssignAsync(HttpClient client, int schoolId, string personId, string subject) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects", + new { subject }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code, float? Allocated, float? Payroll, float? Remaining, float? Attempted); + + private sealed record StaffingResponse( + float Allocated, + float Payroll, + float Remaining, + IReadOnlyList Uncovered, + IReadOnlyList Applicants, + IReadOnlyList Staff); + + private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek); + + private sealed record ApplicantResponse(string Id, string FullName, bool Female, int Age, bool IsParent, float HourlyWageAsk, float MonthlyBase); + + private sealed record StaffMemberResponse( + string Id, + string FullName, + bool Female, + int Age, + bool IsParent, + string Position, + string PositionLabel, + float HourlyWageAsk, + float MonthlyPay, + IReadOnlyList Subjects); + + private sealed record DefLabelResponse(string DefName, string Label); + + private sealed record PeopleListResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList People, + object Filters); + + private sealed record PersonListItemResponse( + string Id, + string FullName, + string Surname, + string Given, + string Patronymic, + bool Female, + int Age, + IReadOnlyList Roles, + int? ClassYear, + string? ClassLetter, + string? Position, + string? PositionLabel); +} diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 7f26a24..a990f11 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -26,6 +26,9 @@ public class PeopleDefTests Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.NotNull(catalog.StaffingRules); 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("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"])); Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"])); } diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 6638acf..929385f 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -43,6 +43,8 @@ public class VanillaCoreTests Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation")); 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(2, map.Buildings.Count); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); Assert.Equal(11, homerooms.Count); diff --git a/tests/HSchool.People.Tests/StaffingTests.cs b/tests/HSchool.People.Tests/StaffingTests.cs new file mode 100644 index 0000000..f612ec3 --- /dev/null +++ b/tests/HSchool.People.Tests/StaffingTests.cs @@ -0,0 +1,212 @@ +namespace HSchool.People.Tests; + +public class StaffingTests +{ + private const float Cap = 10_000f; + + [Fact] + public void MonthlyPay_AddsSurchargeAfterTheFirstSubject() + { + 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)); + } + + [Fact] + public void Hire_TwoPeopleAtThreeAndSevenThousand_RejectsTheThird() + { + var (catalog, map, roster, pool) = Fresh(); + var cheap = WithAsk(pool.Applicants[0], 37.5f); + var expensive = WithAsk(pool.Applicants[1], 87.5f); + var extra = WithAsk(pool.Applicants[2], 40f); + pool = pool with { Applicants = [cheap, expensive, extra] }; + + var first = Staffing.Hire(catalog, map, roster, pool, cheap.Person.Id, Staffing.TeacherPosition, Cap); + Assert.Equal(StaffingError.None, first.Error); + Assert.Equal(3_000f, first.Payroll); + + var second = Staffing.Hire(catalog, map, first.Roster, first.Pool, expensive.Person.Id, Staffing.TeacherPosition, Cap); + Assert.Equal(StaffingError.None, second.Error); + Assert.Equal(10_000f, second.Payroll); + Assert.Equal(0f, second.Remaining); + + var third = Staffing.Hire(catalog, map, second.Roster, second.Pool, extra.Person.Id, Staffing.TeacherPosition, Cap); + Assert.Equal(StaffingError.PayrollExceeded, third.Error); + Assert.Equal(10_000f, third.Payroll); + Assert.True(third.Attempted > Cap); + Assert.Equal(2, second.Roster.People.Count(person => person.IsStaff)); + Assert.DoesNotContain(third.Roster.People, person => person.Id == extra.Person.Id && person.IsStaff); + } + + [Fact] + public void UnassignSubject_LowersPayrollSoAnotherHireFits() + { + var (catalog, map, roster, pool) = Fresh(); + var first = WithAsk(pool.Applicants[0], 37.5f); + var second = WithAsk(pool.Applicants[1], 50f); + pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] }; + + var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap); + Assert.Equal(StaffingError.None, hired.Error); + + var loaded = hired; + foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "ForeignLanguage" }) + { + 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); + + 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); + Assert.Equal(StaffingError.None, dropped.Error); + Assert.Equal(6_000f, 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); + } + + [Fact] + public void AssignSubject_ThatWouldExceedTheCap_IsRejected() + { + var (catalog, map, roster, pool) = Fresh(); + var applicant = WithAsk(pool.Applicants[0], 50f); + 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(10_000f, loaded.Payroll); + + var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "Geography", 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.DoesNotContain( + rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects, + name => name == "Geography"); + } + + [Fact] + public void Hire_ParentKeepsTheSameId_GeneratedJoinsTheRoster() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + Roster? roster = null; + Applicant? parent = null; + Applicant? generated = null; + ApplicantPool? pool = null; + for (var seed = 1; seed <= 30 && pool is null; seed++) + { + var candidate = Fixtures.Generate(map, seed); + var created = ApplicantPool.Create(catalog, candidate, seed, "Slavic", Fixtures.AsOf); + var rosterIds = candidate.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); + var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id)); + var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a')); + if (parentHere is not null && generatedHere is not null) + { + roster = candidate; + pool = created; + parent = parentHere; + generated = generatedHere; + } + } + + Assert.NotNull(roster); + Assert.NotNull(pool); + Assert.NotNull(parent); + Assert.NotNull(generated); + + var afterParent = Staffing.Hire(catalog, map, roster, pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f); + Assert.Equal(StaffingError.None, afterParent.Error); + var staffParent = afterParent.Roster.People.Single(person => person.Id == parent.Person.Id); + Assert.True(staffParent.IsParent); + Assert.True(staffParent.IsStaff); + Assert.Equal(1, afterParent.Roster.People.Count(person => person.Id == parent.Person.Id)); + Assert.DoesNotContain(afterParent.Pool.Applicants, applicant => applicant.Person.Id == parent.Person.Id); + + var afterGenerated = Staffing.Hire(catalog, map, afterParent.Roster, afterParent.Pool, generated.Person.Id, Staffing.TeacherPosition, 50_000f); + Assert.Equal(StaffingError.None, afterGenerated.Error); + Assert.Contains(afterGenerated.Roster.People, person => person.Id == generated.Person.Id && person.IsStaff && !person.IsParent); + Assert.Contains(afterGenerated.Roster.Families, family => family.Id == generated.Person.FamilyId); + + var again = Staffing.Hire(catalog, map, afterGenerated.Roster, afterGenerated.Pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f); + Assert.Equal(StaffingError.AlreadyHired, again.Error); + } + + [Fact] + public void Uncovered_ListsSubjectsTaughtInExistingYearsWithoutATeacher() + { + var (catalog, map, roster, pool) = Fresh(); + var uncovered = Staffing.Uncovered(catalog, roster); + Assert.Contains(uncovered, subject => subject.DefName == "PrimarySchool"); + Assert.Contains(uncovered, subject => subject.DefName == "Mathematics"); + Assert.Contains(uncovered, subject => subject.DefName == "PhysicalEducation"); + Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count); + + var applicant = pool.Applicants[0]; + var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); + var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap); + + Assert.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics"); + Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool"); + } + + [Fact] + public void PeopleJson_RoundTripsHiredStaffAndSubjects() + { + var (catalog, map, roster, pool) = Fresh(); + var applicant = pool.Applicants[0]; + var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); + var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap); + + var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool)); + var loaded = RosterJson.Parse(json); + var person = loaded.People.Single(member => member.Id == applicant.Person.Id); + + Assert.True(person.IsStaff); + Assert.Equal(Staffing.TeacherPosition, person.Position); + Assert.Equal(applicant.HourlyWageAsk, person.HourlyWageAsk); + Assert.Equal(["Mathematics"], person.Subjects); + Assert.DoesNotContain(loaded.Applicants!.Applicants, candidate => candidate.Person.Id == applicant.Person.Id); + } + + [Fact] + public void Hire_UnknownApplicantAndUnknownPosition() + { + var (catalog, map, roster, pool) = Fresh(); + + Assert.Equal( + StaffingError.UnknownApplicant, + Staffing.Hire(catalog, map, roster, pool, "nobody", Staffing.TeacherPosition, Cap).Error); + Assert.Equal( + StaffingError.UnknownPosition, + Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, "Janitor", Cap).Error); + } + + private static (DefCatalog Catalog, MapLayout Map, Roster Roster, ApplicantPool Pool) Fresh() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + var roster = Fixtures.Generate(map); + var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf); + return (catalog, map, roster, pool); + } + + private static Applicant WithAsk(Applicant applicant, float hourlyAsk) => + applicant with { HourlyWageAsk = hourlyAsk }; +}