- 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>
242 lines
12 KiB
C#
242 lines
12 KiB
C#
using LehrerApp.Core.Models;
|
||
|
||
namespace LehrerApp.Core.Services;
|
||
|
||
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
|
||
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
|
||
/// Stundenplan in der App zuordnet").</summary>
|
||
public sealed class UntisWeeklyPattern
|
||
{
|
||
public DayOfWeek Weekday { get; init; }
|
||
public TimeOnly StartTime { get; init; }
|
||
public TimeOnly EndTime { get; init; }
|
||
public string? Summary { get; init; }
|
||
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
|
||
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
|
||
public List<string> ClassTokens { get; init; } = [];
|
||
public int OccurrenceCount { get; init; }
|
||
}
|
||
|
||
public sealed class UntisSlotMatch
|
||
{
|
||
public required UntisWeeklyPattern Pattern { get; init; }
|
||
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
|
||
/// entspricht CoveredPeriods[0].
|
||
public int? PeriodNumber { get; init; }
|
||
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
|
||
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:50–09:20 für Stunde 1+2), während der
|
||
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
|
||
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
|
||
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
|
||
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
|
||
public List<int> CoveredPeriods { get; init; } = [];
|
||
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
|
||
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
|
||
public int? AfterPeriod { get; init; }
|
||
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
|
||
public Guid? SuggestedGroupId { get; init; }
|
||
public bool IsConfident { get; init; }
|
||
}
|
||
|
||
public sealed class UntisMatchResult
|
||
{
|
||
public List<UntisSlotMatch> Matches { get; init; } = [];
|
||
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
|
||
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
|
||
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
|
||
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
|
||
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
|
||
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
|
||
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
|
||
/// </summary>
|
||
public class UntisMatchingService
|
||
{
|
||
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
|
||
// und den tatsächlichen WebUntis-Zeiten.
|
||
private const int StartTimeToleranceMinutes = 3;
|
||
|
||
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
|
||
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
|
||
{
|
||
var teacherToken = DetectTeacherToken(events);
|
||
var patterns = BuildWeeklyPatterns(events, teacherToken);
|
||
|
||
var matches = new List<UntisSlotMatch>();
|
||
foreach (var pattern in patterns)
|
||
{
|
||
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
|
||
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
|
||
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
|
||
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
|
||
if (pattern.ClassTokens.Count == 0)
|
||
{
|
||
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
|
||
continue;
|
||
}
|
||
|
||
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
|
||
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
|
||
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
|
||
matches.Add(new UntisSlotMatch
|
||
{
|
||
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
|
||
SuggestedGroupId = groupId, IsConfident = confident,
|
||
});
|
||
}
|
||
|
||
var matchedSlotKeys = matches
|
||
.Where(m => m.SuggestedGroupId is not null)
|
||
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
|
||
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
|
||
.ToHashSet();
|
||
var unmatchedSlots = timetableSlots
|
||
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
|
||
.ToList();
|
||
|
||
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
|
||
}
|
||
|
||
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
|
||
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
|
||
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
|
||
events
|
||
.Select(e => LastToken(e.Description))
|
||
.Where(t => t is not null)
|
||
.GroupBy(t => t)
|
||
.OrderByDescending(g => g.Count())
|
||
.Select(g => g.Key)
|
||
.FirstOrDefault();
|
||
|
||
private static string? LastToken(string description)
|
||
{
|
||
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return parts.Length == 0 ? null : parts[^1];
|
||
}
|
||
|
||
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
|
||
{
|
||
var withTokens = events.Select(e => new
|
||
{
|
||
Event = e,
|
||
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
|
||
});
|
||
|
||
var patterns = new List<UntisWeeklyPattern>();
|
||
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
|
||
{
|
||
var modal = group
|
||
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
|
||
.OrderByDescending(g => g.Count())
|
||
.First();
|
||
|
||
patterns.Add(new UntisWeeklyPattern
|
||
{
|
||
Weekday = group.Key.Weekday,
|
||
StartTime = group.Key.StartTime,
|
||
EndTime = group.Key.EndTime,
|
||
Summary = modal.Key.Summary,
|
||
ClassTokens = modal.Key.ClassTokens.Length == 0
|
||
? [] : modal.Key.ClassTokens.Split(';').ToList(),
|
||
OccurrenceCount = modal.Count(),
|
||
});
|
||
}
|
||
return patterns;
|
||
}
|
||
|
||
// "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(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||
}
|
||
|
||
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
|
||
{
|
||
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
|
||
if (exact is not null) return exact.PeriodNumber;
|
||
|
||
var closest = periodSchedule.Periods
|
||
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
|
||
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
|
||
.OrderBy(t => t.DiffMinutes)
|
||
.FirstOrDefault();
|
||
return closest.Period?.PeriodNumber;
|
||
}
|
||
|
||
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
|
||
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
|
||
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
|
||
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
|
||
{
|
||
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
|
||
return periodSchedule.Periods
|
||
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
|
||
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
|
||
.OrderBy(p => p.PeriodNumber)
|
||
.Select(p => p.PeriodNumber)
|
||
.ToList();
|
||
}
|
||
|
||
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
|
||
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
|
||
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
|
||
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
|
||
periodSchedule.Periods
|
||
.Where(p => p.End <= startTime)
|
||
.OrderByDescending(p => p.End)
|
||
.Select(p => (int?)p.PeriodNumber)
|
||
.FirstOrDefault() ?? 0;
|
||
|
||
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
|
||
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
|
||
{
|
||
if (classTokens.Count == 0) return (null, false);
|
||
|
||
var candidates = classTokens
|
||
.Select(token => ResolveSingleGroup(token, groups))
|
||
.Where(g => g is not null)
|
||
.Select(g => g!)
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
if (candidates.Count == 1) return (candidates[0].Id, true);
|
||
if (candidates.Count == 0) return (null, false);
|
||
|
||
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
|
||
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
|
||
if (periodNumber is { } period)
|
||
{
|
||
var withExistingSlot = candidates
|
||
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
|
||
.ToList();
|
||
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
|
||
}
|
||
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
|
||
}
|
||
|
||
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
|
||
{
|
||
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
|
||
if (exact is not null) return exact;
|
||
|
||
var normalizedToken = Normalize(token);
|
||
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
|
||
}
|
||
|
||
private static string Normalize(string value) =>
|
||
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
|
||
}
|