Enhance staffing management with uncovered teacher tracking
- Updated `protocol.md` to clarify the concept of uncovered subjects and the number of additional teachers required. - Introduced `teachersShort` property in the `StaffingSubject` interface to indicate how many more teachers are needed for a subject. - Enhanced localization strings to reflect the new uncovered teacher information in both English and Russian. - Updated the management panel UI to display the number of teachers short for each uncovered subject. - Revised the `Uncovered` method in the staffing logic to return detailed information about uncovered subjects, including the shortfall of teachers. - Added tests to validate the new uncovered teacher tracking functionality and ensure accurate reporting in the staffing API. - Marked related tasks as complete in the documentation for the golden fixtures phase.
This commit is contained in:
@@ -24,6 +24,8 @@ describe('t', () => {
|
||||
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
|
||||
expect(t('staffErrorPayroll', { allocated: '10 000', payroll: '8 000', remaining: '2 000', attempted: '12 000' }))
|
||||
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||
expect(t('staffUncoveredShort', { label: 'Начальные классы', min: 1, max: 4, n: 3 }))
|
||||
.toBe('Начальные классы (1–4) — 3 short');
|
||||
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
||||
.toBe('Кабинет 204 (Математика · 5Б)');
|
||||
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
|
||||
|
||||
@@ -159,6 +159,7 @@ const ru = {
|
||||
staffColPay: 'В месяц',
|
||||
staffAsk: '{hourly}/ч · {monthly}/мес',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffUncoveredShort: '{label} ({min}–{max}) — не хватает {n}',
|
||||
staffParent: 'родитель',
|
||||
staffPickHint: 'Выберите сотрудника в списке или откройте соискателей.',
|
||||
staffHireTitle: 'Наём',
|
||||
@@ -369,6 +370,7 @@ const en: Messages = {
|
||||
staffColPay: 'Monthly',
|
||||
staffAsk: '{hourly}/h · {monthly}/mo',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffUncoveredShort: '{label} ({min}–{max}) — {n} short',
|
||||
staffParent: 'parent',
|
||||
staffPickHint: 'Select a staff member in the list, or open the applicants.',
|
||||
staffHireTitle: 'Hire',
|
||||
|
||||
@@ -321,6 +321,7 @@ export interface StaffingSubject {
|
||||
readonly gradeMin: number;
|
||||
readonly gradeMax: number;
|
||||
readonly hoursPerWeek: number;
|
||||
readonly teachersShort: number;
|
||||
}
|
||||
|
||||
export interface StaffingApplicant {
|
||||
|
||||
@@ -257,10 +257,11 @@ export class ManagementPanel {
|
||||
this.uncovered.append(
|
||||
el('span', {
|
||||
class: 'people__tag',
|
||||
text: t('staffSubjectRange', {
|
||||
text: t('staffUncoveredShort', {
|
||||
label: subject.label,
|
||||
min: subject.gradeMin,
|
||||
max: subject.gradeMax,
|
||||
n: subject.teachersShort,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,14 @@ public enum StaffingError
|
||||
NoOpening,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subject the school cannot actually teach, and how many more teachers would cover it.
|
||||
/// </summary>
|
||||
public sealed record UncoveredSubject(SubjectDef Subject, int Assigned, int TeachersShort)
|
||||
{
|
||||
public string DefName => Subject.DefName;
|
||||
}
|
||||
|
||||
public sealed record StaffingOutcome(
|
||||
StaffingError Error,
|
||||
Roster Roster,
|
||||
@@ -130,12 +138,32 @@ public static class Staffing
|
||||
return Round(total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many people a subject needs at the weekly cap. Primary school on a vanilla map is
|
||||
/// eighty hours: one person cannot carry it, and the uncovered row has to say so.
|
||||
/// </summary>
|
||||
public static int TeachersToCover(float hours, float maxWeeklyHours)
|
||||
{
|
||||
if (hours <= 0f)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (maxWeeklyHours <= 0f)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int)Math.Ceiling(hours / maxWeeklyHours);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subjects the school cannot actually teach: nobody is assigned them, or the people who are
|
||||
/// cannot between them carry the curriculum hours. Both read the same way to a player — the
|
||||
/// lessons will not happen — so both belong in one list.
|
||||
/// lessons will not happen — so both belong in one list. <see cref="UncoveredSubject.TeachersShort"/>
|
||||
/// is how many more people it still needs.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SubjectDef> Uncovered(DefCatalog catalog, Roster roster)
|
||||
public static IReadOnlyList<UncoveredSubject> Uncovered(DefCatalog catalog, Roster roster)
|
||||
{
|
||||
var years = new HashSet<int>();
|
||||
foreach (var schoolClass in roster.Classes)
|
||||
@@ -158,7 +186,7 @@ public static class Staffing
|
||||
}
|
||||
|
||||
var cap = catalog.StaffingRules?.MaxWeeklyHours ?? 0f;
|
||||
var uncovered = new List<SubjectDef>();
|
||||
var uncovered = new List<UncoveredSubject>();
|
||||
foreach (var subject in catalog.Subjects.Values)
|
||||
{
|
||||
if (subject.Abstract || !TouchesYears(subject, years))
|
||||
@@ -167,9 +195,12 @@ public static class Staffing
|
||||
}
|
||||
|
||||
var assigned = teachers.GetValueOrDefault(subject.DefName);
|
||||
if (assigned == 0 || (cap > 0f && SubjectHours(catalog, roster, subject.DefName) > assigned * cap))
|
||||
var hours = SubjectHours(catalog, roster, subject.DefName);
|
||||
var needed = TeachersToCover(hours, cap);
|
||||
var shortfall = Math.Max(0, needed - assigned);
|
||||
if (shortfall > 0)
|
||||
{
|
||||
uncovered.Add(subject);
|
||||
uncovered.Add(new UncoveredSubject(subject, assigned, shortfall));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,8 @@ internal sealed record UncoveredSubjectResponse(
|
||||
string Label,
|
||||
int GradeMin,
|
||||
int GradeMax,
|
||||
int HoursPerWeek);
|
||||
int HoursPerWeek,
|
||||
int TeachersShort);
|
||||
|
||||
internal sealed record ApplicantResponse(
|
||||
string Id,
|
||||
@@ -241,12 +242,13 @@ internal static class StaffingMapper
|
||||
var uncovered = catalog is null
|
||||
? Array.Empty<UncoveredSubjectResponse>()
|
||||
: Staffing.Uncovered(catalog, roster)
|
||||
.Select(subject => new UncoveredSubjectResponse(
|
||||
subject.DefName,
|
||||
catalog.Label(locale, subject),
|
||||
subject.Grades.Min,
|
||||
subject.Grades.Max,
|
||||
subject.HoursPerWeek))
|
||||
.Select(row => new UncoveredSubjectResponse(
|
||||
row.Subject.DefName,
|
||||
catalog.Label(locale, row.Subject),
|
||||
row.Subject.Grades.Min,
|
||||
row.Subject.Grades.Max,
|
||||
row.Subject.HoursPerWeek,
|
||||
row.TeachersShort))
|
||||
.ToArray();
|
||||
|
||||
var applicants = pool.Applicants
|
||||
@@ -286,7 +288,8 @@ internal static class StaffingMapper
|
||||
catalog.Label(locale, def),
|
||||
def.Grades.Min,
|
||||
def.Grades.Max,
|
||||
def.HoursPerWeek))
|
||||
def.HoursPerWeek,
|
||||
0))
|
||||
.ToArray();
|
||||
|
||||
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
|
||||
|
||||
@@ -70,6 +70,9 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("ReloadSchoolsFromDisk");
|
||||
|
||||
app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath }))
|
||||
.WithName("GetSavesDirectory");
|
||||
}
|
||||
|
||||
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
||||
|
||||
Reference in New Issue
Block a user