fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen

- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:43:36 +02:00
co-authored by Claude Sonnet 5
parent 4eb4d0a946
commit 4b9bcfe85d
7 changed files with 240 additions and 14 deletions
+53 -12
View File
@@ -12,6 +12,15 @@ public sealed class UntisDiffResult
/// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt,
/// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird.
public List<Guid> SnapshotIdsToDelete { get; init; } = [];
/// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht
/// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine
/// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend
/// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so"
/// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte
/// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim
/// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen.
/// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService).
public List<string> SubstitutionExternalIdsToDelete { get; init; } = [];
}
/// <summary>
@@ -46,6 +55,7 @@ public class UntisDiffService
var substitutions = new List<SubstitutionEntry>();
var snapshotToSave = new List<UntisSnapshotEntry>();
var externalIdsToDelete = new List<string>();
var newEventsByUid = newEvents.ToDictionary(e => e.Uid);
foreach (var evt in newEvents)
@@ -70,8 +80,10 @@ public class UntisDiffService
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (HasDeviated(evt, mapping))
substitutions.Add(BuildChangedLesson(evt, mapping));
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
@@ -81,6 +93,10 @@ public class UntisDiffService
// davon.
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis."));
}
else
{
externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig
}
}
// Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher
@@ -123,7 +139,13 @@ public class UntisDiffService
{
if (date.DayOfWeek != mapping.Weekday) continue;
if (freeDateSet.Contains(date)) continue;
if (eventDates.Contains((date, mapping.StartTime))) continue;
if (eventDates.Contains((date, mapping.StartTime)))
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
}
@@ -133,7 +155,7 @@ public class UntisDiffService
return new UntisDiffResult
{
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
SnapshotIdsToDelete = snapshotIdsToDelete,
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
};
}
@@ -141,12 +163,31 @@ public class UntisDiffService
// manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim
// Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die
// Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c;
// Gastro HED" — ein reiner Teilstring-Vergleich schlug für JEDE kombinierte/differenzierte
// Gruppe (mehr als ein Klassen-Token) strukturell fehl, unabhängig von der tatsächlich
// gewählten Gruppe. Fix: beide Seiten vor dem Vergleich von Leerzeichen befreien (gleiches
// Prinzip wie UntisMatchingService.Normalize für den Gruppennamen-Abgleich).
private static bool HasDeviated(UntisIcsEvent evt, UntisSlotMapping mapping) =>
evt.Summary != mapping.Summary || !RemoveWhitespace(evt.Description).Contains(mapping.ClassToken);
// Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb
// aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält
// (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit
// Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog).
// Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie
// UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens
// akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen.
//
// Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster
// ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der
// genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht
// mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende
// Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den
// gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die
// Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen.
private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping)
{
if (evt.Summary != mapping.Summary)
return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“.";
if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken)))
return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“.";
return null;
}
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
@@ -157,11 +198,11 @@ public class UntisDiffService
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping) => new()
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"})",
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
@@ -148,13 +148,19 @@ public class UntisMatchingService
}
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
// Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER
// Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht
// sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich
// auf eine Annahme festzulegen.
private static readonly char[] ClassTokenSeparators = [';', ','];
private static List<string> ExtractClassTokens(string description, string? teacherToken)
{
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
? description[..^teacherToken.Length].TrimEnd()
: description;
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
return withoutTeacher.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)