Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten Ferienkalender für den Rest des Jahres. Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) - sonst wäre der falsche Wert wieder unbemerkt geblieben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,19 +71,29 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
};
|
||||
|
||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||
public int YearAbsenceDayCount { get; init; }
|
||||
public int YearUnexcusedDayCount { get; init; }
|
||||
public int SchoolDaysElapsed { get; init; }
|
||||
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||
public int HolidayWeekdaysExcluded { get; init; }
|
||||
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||
public DateOnly TermStart { get; init; }
|
||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||
public int YearAbsenceRatePercent =>
|
||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||
public string YearSummaryLabel => HasYearSummary
|
||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -94,7 +104,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||
int schoolDaysElapsed = 0)
|
||||
int schoolDaysElapsed = 0,
|
||||
int holidayWeekdaysExcluded = 0,
|
||||
DateOnly termStart = default)
|
||||
{
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
@@ -126,6 +138,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
YearAbsenceDayCount = yearEntries.Count,
|
||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||
TermStart = termStart,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
@@ -202,6 +216,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly AppLogger? _logger;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
@@ -275,7 +290,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||
AppLogger? logger = null)
|
||||
{
|
||||
_settings = settings;
|
||||
_untis = untis;
|
||||
@@ -285,6 +301,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
_students = students;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
_logger = logger;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
@@ -335,9 +352,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(yearStart, today, holidaysTask.Result);
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
@@ -456,28 +477,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return days;
|
||||
}
|
||||
|
||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
var count = 0;
|
||||
for (var d = start; d <= end; d = d.AddDays(1))
|
||||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||||
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||||
/// kennt den echten Ferienkalender bereits (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||
/// Werktagen ausgeschlossen statt wie bisher grob geschätzt) — behebt zusätzlich zum
|
||||
/// Schuljahresbeginn auch die kleineren Verzerrungen durch Herbst-/Weihnachts-/Osterferien
|
||||
/// im weiteren Jahresverlauf (bislang bewusst hingenommene Vereinfachung, siehe TODO.md
|
||||
/// "Klassenlehrer"-Feature — Trend & Fehlquote).
|
||||
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||
/// innerhalb des Zeitraums abzuziehen.
|
||||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||
{
|
||||
if (end < start) return 0;
|
||||
@@ -489,6 +503,16 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||
/// ohnehin ausgeblendet).
|
||||
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||
|
||||
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||
@@ -511,10 +535,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||
.ToList();
|
||||
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||
return fresh;
|
||||
}
|
||||
catch (WebUntisIntegrationException)
|
||||
catch (WebUntisIntegrationException ex)
|
||||
{
|
||||
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||
return cached ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user