- 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>
229 lines
14 KiB
C#
229 lines
14 KiB
C#
using LehrerApp.Core.Models;
|
|
|
|
namespace LehrerApp.Core.Services;
|
|
|
|
public sealed class UntisDiffResult
|
|
{
|
|
/// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über
|
|
/// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert
|
|
/// statt dupliziert werden muss; siehe UntisSyncService).
|
|
public List<SubstitutionEntry> SubstitutionsToSave { get; init; } = [];
|
|
public List<UntisSnapshotEntry> SnapshotToSave { get; init; } = [];
|
|
/// 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>
|
|
/// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten
|
|
/// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden").
|
|
/// Framework-frei wie <see cref="UntisMatchingService"/> — nimmt nur einfache Objekte/Listen
|
|
/// entgegen, kein Datenbankzugriff.
|
|
/// </summary>
|
|
public class UntisDiffService
|
|
{
|
|
public const int DefaultLookaheadDays = 14;
|
|
|
|
public UntisDiffResult Diff(List<UntisIcsEvent> newEvents, List<UntisSnapshotEntry> previousSnapshot,
|
|
List<UntisSlotMapping> confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays,
|
|
List<SupervisionDuty>? existingSupervisionDuties = null, HashSet<DateOnly>? freeDates = null)
|
|
{
|
|
var previousByUid = previousSnapshot.ToDictionary(s => s.Uid);
|
|
// GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot
|
|
// (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer
|
|
// ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen.
|
|
var mappingByWeekdayStart = confirmedMappings
|
|
.Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null))
|
|
.GroupBy(m => (m.Weekday, m.StartTime))
|
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
|
|
// Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe
|
|
// SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die
|
|
// KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll
|
|
// deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen.
|
|
var regularDutyKeys = (existingSupervisionDuties ?? [])
|
|
.Select(d => (d.Weekday, d.AfterPeriod))
|
|
.ToHashSet();
|
|
|
|
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)
|
|
{
|
|
snapshotToSave.Add(new UntisSnapshotEntry
|
|
{
|
|
Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(),
|
|
Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime,
|
|
Summary = evt.Summary, Location = evt.Location, Description = evt.Description,
|
|
Status = evt.Status, LastSeenAt = DateTime.UtcNow,
|
|
});
|
|
|
|
if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue;
|
|
|
|
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
|
|
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
|
|
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
|
|
continue;
|
|
}
|
|
|
|
if (mapping.Kind == SubstitutionKind.Lesson)
|
|
{
|
|
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)))
|
|
{
|
|
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht
|
|
// doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses
|
|
// Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung
|
|
// 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
|
|
// Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn
|
|
// die Aufsicht vorher tatsächlich einmal gesehen wurde).
|
|
var snapshotIdsToDelete = new List<Guid>();
|
|
foreach (var previous in previousSnapshot)
|
|
{
|
|
if (newEventsByUid.ContainsKey(previous.Uid)) continue;
|
|
if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue;
|
|
if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue;
|
|
if (mapping.Kind != SubstitutionKind.Supervision) continue;
|
|
|
|
var vanishedId = BuildVanishedExternalId(previous.Date, mapping);
|
|
substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen."));
|
|
snapshotIdsToDelete.Add(previous.Id);
|
|
}
|
|
|
|
// Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte
|
|
// künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives
|
|
// Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen
|
|
// VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten
|
|
// (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht
|
|
// nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht
|
|
// stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es
|
|
// gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf
|
|
// das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht
|
|
// Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und
|
|
// um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt.
|
|
if (newEvents.Count > 0)
|
|
{
|
|
var maxKnownDate = newEvents.Max(e => e.Date);
|
|
var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays);
|
|
var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet();
|
|
var freeDateSet = freeDates ?? [];
|
|
|
|
foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson))
|
|
{
|
|
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
|
|
{
|
|
if (date.DayOfWeek != mapping.Weekday) continue;
|
|
if (freeDateSet.Contains(date)) 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)));
|
|
}
|
|
}
|
|
}
|
|
|
|
return new UntisDiffResult
|
|
{
|
|
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
|
|
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
|
|
};
|
|
}
|
|
|
|
// Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese
|
|
// 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 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());
|
|
|
|
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
|
|
{
|
|
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
|
|
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
|
|
};
|
|
|
|
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 ?? "?"}) — {reason}",
|
|
ExternalId = evt.Uid,
|
|
};
|
|
|
|
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
|
|
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
|
|
private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping,
|
|
string externalId, string reason) => new()
|
|
{
|
|
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod,
|
|
Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId,
|
|
};
|
|
|
|
// Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum
|
|
// abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls.
|
|
private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) =>
|
|
$"vanished-{mapping.Id}-{date:yyyyMMdd}";
|
|
|
|
// Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus
|
|
// Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update
|
|
// statt Duplikat), unabhängig vom Snapshot-Stand.
|
|
private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) =>
|
|
$"missing-{mapping.Id}-{date:yyyyMMdd}";
|
|
}
|