265 lines
14 KiB
C#
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;
|
|
}
|