Files
LehrerApp/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs
T
2026-09-20 22:17:29 +02:00

265 lines
14 KiB
C#

using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record WebUntisRestSyncResult(int EntryCount, int SubstitutionChanges, int ExamChanges);
/// <summary>Verarbeitet den expliziten Ist-Stundenplan der neuen WebUntis-REST-Oberfläche.
/// Zeitspannen werden auf jede überdeckte lokale Stunde aufgefächert; damit gehen bei einem
/// Doppelblock weder die zweite Vertretungs- noch die zweite Ausfallstunde verloren.</summary>
public sealed class WebUntisRestSyncProcessor(
IUntisSlotMappingRepository mappings,
ISubstitutionEntryRepository substitutions,
IExamRepository exams,
IGroupRepository groups,
ISupervisionDutyRepository supervisionDuties,
PeriodScheduleService periodSchedule)
{
public const string SubstitutionPrefix = "webuntis-rest-sub:";
public const string ExamPrefix = "webuntis-rest-exam:";
public WebUntisRestSyncResult Process(IReadOnlyList<UntisTimetableGridEntryDto> entries,
DateOnly today, DateOnly operationalEnd, DateOnly examEnd)
{
var confirmedMappings = mappings.GetAll().Where(mapping => mapping.Confirmed).ToList();
var desiredSubstitutions = entries
.Where(entry => Date(entry) >= today && Date(entry) <= operationalEnd)
.SelectMany(entry => BuildSubstitutions(entry, confirmedMappings))
.GroupBy(entry => entry.ExternalId)
.Select(group => group.First())
.ToList();
var substitutionChanges = ReconcileSubstitutions(desiredSubstitutions, today, operationalEnd);
var examChanges = ReconcileExams(entries.Where(entry => entry.Type == "EXAM" &&
Date(entry) >= today && Date(entry) <= examEnd), confirmedMappings, today, examEnd);
return new WebUntisRestSyncResult(entries.Count, substitutionChanges, examChanges);
}
private IEnumerable<SubstitutionEntry> BuildSubstitutions(UntisTimetableGridEntryDto entry,
List<UntisSlotMapping> confirmedMappings)
{
var date = Date(entry);
var start = TimeOnly.FromDateTime(entry.Start);
var periods = ResolveCoveredPeriods(entry.Start, entry.End);
var mapping = FindMapping(confirmedMappings, date.DayOfWeek, start, periods, entry);
var cancelled = IsCancelled(entry);
if (entry.Type == "EXAM") yield break;
if (entry.Type == "BREAK_SUPERVISION")
{
var afterPeriod = ResolveAfterPeriod(start);
var regular = supervisionDuties.GetAll().Any(duty =>
duty.Weekday == date.DayOfWeek && duty.AfterPeriod == afterPeriod);
var supervisionChanged = !string.Equals(entry.Status, "REGULAR", StringComparison.OrdinalIgnoreCase) ||
entry.Resources.Any(resource => resource.Removed is not null);
if (!cancelled && !supervisionChanged && regular) yield break;
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Supervision,
AfterPeriod = afterPeriod,
Description = cancelled || supervisionChanged && regular
? "WebUntis: Aufsicht entfällt oder wurde übernommen."
: $"Zusätzliche Aufsicht laut WebUntis{LocationSuffix(entry)}.",
ExternalId = ExternalId(entry, $"supervision-{afterPeriod}"),
};
yield break;
}
var changed = entry.Type == "ADDITIONAL_PERIOD" || cancelled ||
!string.Equals(entry.Status, "REGULAR", StringComparison.OrdinalIgnoreCase) ||
entry.Resources.Any(resource => resource.Removed is not null);
if (!changed || periods.Count == 0) yield break;
foreach (var period in periods)
{
if (cancelled)
{
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Cancelled,
PeriodNumber = period,
Description = "Automatisch über den WebUntis-REST-Stundenplan erkannt.",
ExternalId = ExternalId(entry, $"cancelled-{period}"),
};
continue;
}
var classNames = CurrentNames(entry, "CLASS");
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
var group = ResolveGroup(mapping, classNames, subject, period, date.DayOfWeek, confirmedMappings);
yield return new SubstitutionEntry
{
Date = date,
Kind = SubstitutionKind.Lesson,
PeriodNumber = period,
GroupId = group?.Id ?? mapping?.GroupId,
GroupLabel = classNames.Count > 0 ? string.Join("; ", classNames) : mapping?.ClassToken ?? "",
Description = BuildDescription(entry, subject),
ExternalId = ExternalId(entry, $"lesson-{period}"),
};
}
}
private int ReconcileSubstitutions(List<SubstitutionEntry> desired, DateOnly from, DateOnly to)
{
var changes = 0;
var desiredIds = desired.Select(entry => entry.ExternalId!).ToHashSet();
var existingAutomatic = substitutions.GetAll()
.Where(entry => entry.Date >= from && entry.Date <= to && entry.ExternalId is not null)
.ToList();
foreach (var stale in existingAutomatic.Where(entry => !desiredIds.Contains(entry.ExternalId!)))
{
substitutions.Delete(stale.Id);
changes++;
}
foreach (var candidate in desired)
{
var existing = substitutions.GetByExternalId(candidate.ExternalId!);
if (existing is not null)
{
candidate.Id = existing.Id;
if (SameSubstitution(existing, candidate)) continue;
}
substitutions.Save(candidate);
changes++;
}
return changes;
}
private int ReconcileExams(IEnumerable<UntisTimetableGridEntryDto> examEntries,
List<UntisSlotMapping> confirmedMappings, DateOnly from, DateOnly to)
{
var changes = 0;
var desiredIds = new HashSet<string>();
var allExams = exams.GetAll();
foreach (var entry in examEntries)
{
var periods = ResolveCoveredPeriods(entry.Start, entry.End);
var mapping = FindMapping(confirmedMappings, Date(entry).DayOfWeek, TimeOnly.FromDateTime(entry.Start),
periods, entry);
var classNames = CurrentNames(entry, "CLASS");
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
var group = ResolveGroup(mapping, classNames, subject, periods.FirstOrDefault(),
Date(entry).DayOfWeek, confirmedMappings);
var groupId = group?.Id ?? mapping?.GroupId;
if (groupId is null) continue; // keine riskante Klausurzuordnung ohne bestätigte/eindeutige Gruppe
var externalId = ExamPrefix + StableEntryKey(entry);
desiredIds.Add(externalId);
var title = entry.LessonInfo ?? entry.Name ??
(subject is null ? "Arbeit laut WebUntis" : $"Arbeit {subject}");
var existing = allExams.FirstOrDefault(exam => exam.ExternalId == externalId);
if (existing is null)
{
// Eine bereits manuell gepflegte Klausur am selben Tag/in derselben Gruppe hat
// Vorrang und wird weder dupliziert noch nachträglich zum Auto-Datensatz gemacht.
if (allExams.Any(exam => exam.ExternalId is null && exam.GroupId == groupId && exam.Date == Date(entry)))
continue;
existing = new Exam { GroupId = groupId.Value, Date = Date(entry), Title = title, ExternalId = externalId };
exams.Save(existing);
allExams.Add(existing);
changes++;
continue;
}
if (existing.GroupId == groupId && existing.Date == Date(entry) && existing.Title == title) continue;
existing.GroupId = groupId.Value;
existing.Date = Date(entry);
existing.Title = title;
exams.Save(existing);
changes++;
}
foreach (var stale in allExams.Where(exam => exam.ExternalId?.StartsWith(ExamPrefix) == true &&
exam.Date >= from && exam.Date <= to && exam.Status == ExamStatus.Planned &&
!desiredIds.Contains(exam.ExternalId)).ToList())
{
exams.Delete(stale.Id);
changes++;
}
return changes;
}
private UntisSlotMapping? FindMapping(List<UntisSlotMapping> candidates, DayOfWeek weekday, TimeOnly start,
List<int> periods, UntisTimetableGridEntryDto entry)
{
var exact = candidates.Where(mapping => mapping.Kind == SubstitutionKind.Lesson &&
mapping.Weekday == weekday && mapping.StartTime == start)
.OrderByDescending(mapping => mapping.CreatedAt).FirstOrDefault();
if (exact is not null) return exact;
var subject = CurrentNames(entry, "SUBJECT").FirstOrDefault();
return candidates.Where(mapping => mapping.Kind == SubstitutionKind.Lesson &&
mapping.Weekday == weekday &&
(mapping.CoveredPeriods.Any(periods.Contains) ||
mapping.PeriodNumber is { } number && periods.Contains(number)) &&
(subject is null || string.Equals(mapping.Summary, subject,
StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(mapping => mapping.CreatedAt).FirstOrDefault();
}
private LearningGroup? ResolveGroup(UntisSlotMapping? mapping, List<string> classNames, string? subject,
int period, DayOfWeek weekday, List<UntisSlotMapping> confirmedMappings)
{
if (mapping?.GroupId is { } mappedId) return groups.GetById(mappedId);
var bySubjectAndClass = confirmedMappings.Where(item => item.GroupId is not null &&
(subject is null || string.Equals(item.Summary, subject, StringComparison.OrdinalIgnoreCase)) &&
classNames.Any(name => ContainsClassToken(item.ClassToken, name)))
.Select(item => groups.GetById(item.GroupId!.Value)).Where(group => group is not null).Distinct().ToList();
if (bySubjectAndClass.Count == 1) return bySubjectAndClass[0];
var direct = groups.GetAll().Where(group => classNames.Any(name =>
string.Equals(group.Name, name, StringComparison.OrdinalIgnoreCase))).ToList();
return direct.Count == 1 ? direct[0] : null;
}
private List<int> ResolveCoveredPeriods(DateTime start, DateTime end)
{
var startTime = TimeOnly.FromDateTime(start).ToTimeSpan();
var endTime = TimeOnly.FromDateTime(end).ToTimeSpan();
var tolerance = TimeSpan.FromMinutes(3);
return periodSchedule.Periods.Where(period => period.Start.ToTimeSpan() >= startTime - tolerance &&
period.End.ToTimeSpan() <= endTime + tolerance)
.OrderBy(period => period.PeriodNumber).Select(period => period.PeriodNumber).ToList();
}
private int ResolveAfterPeriod(TimeOnly start) => periodSchedule.Periods
.Where(period => period.End <= start).OrderByDescending(period => period.End)
.Select(period => (int?)period.PeriodNumber).FirstOrDefault() ?? 0;
private static bool IsCancelled(UntisTimetableGridEntryDto entry) =>
string.Equals(entry.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry.Status, "CANCELED", StringComparison.OrdinalIgnoreCase) ||
entry.StatusDetail?.Contains("CANCEL", StringComparison.OrdinalIgnoreCase) == true;
private static DateOnly Date(UntisTimetableGridEntryDto entry) => DateOnly.FromDateTime(entry.Start);
private static List<string> CurrentNames(UntisTimetableGridEntryDto entry, string type) => entry.Resources
.Select(change => change.Current).Where(resource => resource is not null && resource.Type == type)
.Select(resource => resource!.ShortName ?? resource.DisplayName).Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
private static string ExternalId(UntisTimetableGridEntryDto entry, string suffix) =>
SubstitutionPrefix + StableEntryKey(entry) + ":" + suffix;
private static string StableEntryKey(UntisTimetableGridEntryDto entry) => entry.Ids.Count > 0
? string.Join("-", entry.Ids.Order())
: $"{entry.Start:yyyyMMddHHmm}-{entry.End:HHmm}-{entry.Type}";
private static bool ContainsClassToken(string tokenList, string value) => tokenList
.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(token => string.Equals(token, value, StringComparison.OrdinalIgnoreCase));
private static string LocationSuffix(UntisTimetableGridEntryDto entry) =>
CurrentNames(entry, "ROOM").FirstOrDefault() is { } room ? $" in {room}" : "";
private static string BuildDescription(UntisTimetableGridEntryDto entry, string? subject)
{
var label = entry.LessonInfo ?? entry.SubstitutionText ?? entry.LessonText;
var head = subject is null ? "Vertretung laut WebUntis" : $"WebUntis: {subject}";
return string.IsNullOrWhiteSpace(label) ? head + LocationSuffix(entry)
: $"{head} · {label}{LocationSuffix(entry)}";
}
private static bool SameSubstitution(SubstitutionEntry left, SubstitutionEntry right) =>
left.Date == right.Date && left.Kind == right.Kind && left.PeriodNumber == right.PeriodNumber &&
left.AfterPeriod == right.AfterPeriod && left.GroupId == right.GroupId && left.GroupLabel == right.GroupLabel &&
left.Description == right.Description && left.ExternalId == right.ExternalId;
}