using System.Collections.ObjectModel; using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.Groups; /// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - /// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie /// und /// . public partial class WebUntisHomeworkRow : ObservableObject { public required string ClassName { get; init; } public required DateOnly Date { get; init; } public required string UntisStudentName { get; init; } public string? SubjectLabel { get; init; } public string? Text { get; init; } public required IReadOnlyList Candidates { get; init; } internal Guid? MatchedSubjectId { get; init; } internal Action? OnAssignmentChanged { get; init; } public string DateLabel => Date.ToString("dd.MM.yyyy"); public bool CanApply => SessionId is not null; [ObservableProperty] private Student? _assignedStudent; [ObservableProperty] private string _localStatus = "ohne Zuordnung"; [ObservableProperty] private Guid? _sessionId; [ObservableProperty] private bool _selected; partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); } /// Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe /// ) gegen /// - Dashboard-weit statt pro Lerngruppe, aus demselben /// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend, /// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe /// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält /// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive /// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten. /// Ein Eintrag setzt lokal ausschließlich vor, und auch nur, /// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise /// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich /// danebengestellt (Nutzerwunsch). public partial class WebUntisHomeworkComparisonViewModel : ObservableObject { private readonly WebUntisIntegrationService _untis; private readonly IStudentRepository _students; private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private readonly IGroupMembershipRepository _memberships; private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _participation; private readonly SchoolYearService _schoolYears; public ObservableCollection Rows { get; } = []; [ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7); [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden."; [ObservableProperty] private bool _busy; public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students, IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships, IParticipationSessionRepository sessions, IParticipationRepository participation, SchoolYearService schoolYears) { _untis = untis; _students = students; _groups = groups; _subjects = subjects; _memberships = memberships; _sessions = sessions; _participation = participation; _schoolYears = schoolYears; } [RelayCommand] private async Task Load() { var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime); if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } Busy = true; Rows.Clear(); try { var ownStudents = _students.GetAll(); var globalIndex = BuildNameIndex(ownStudents); // Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in // verschiedenen Klassen unterscheiden zu können. var classIndexes = _groups.GetAll() .Where(g => g.Type == GroupType.Class) .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), StringComparer.OrdinalIgnoreCase); var shortNameIndex = _subjects.GetAll() .Where(s => !string.IsNullOrWhiteSpace(s.ShortName)) .GroupBy(s => s.ShortName.Trim().ToUpperInvariant()) .Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten .ToDictionary(g => g.Key, g => g.First()); var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end); var ordered = entries .Where(IsMissingHomeworkEntry) .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) .Where(x => x.Date is not null) .OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName); void ResolveLocalMatch(WebUntisHomeworkRow row) { if (row.AssignedStudent is not { } student) { row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false; return; } var group = ResolveGroup(student, row.MatchedSubjectId, row.Date); var session = group is null ? null : _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date); var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id); var current = entry is null ? null : HomeworkDisplay.Effective(entry); row.SessionId = session?.Id; row.LocalStatus = session is null ? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt" : group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde" : HomeworkDisplay.Label(current); // Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene // Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar. row.Selected = session is not null && current is null; } foreach (var (entry, date) in ordered) { var nameKey = NameKey(entry.StudentName); var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex) ? classIndex.GetValueOrDefault(nameKey) : null) ?? globalIndex.GetValueOrDefault(nameKey); var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex); var row = new WebUntisHomeworkRow { ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName, SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents, MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch, }; Rows.Add(row); row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected } var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null); var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null); Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" + (unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") + (unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") + $". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar."; } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; } } [RelayCommand] private void Apply() { var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList(); foreach (var row in selected) { var studentId = row.AssignedStudent!.Id; var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; entry.Homework = HomeworkStatus.MissingOpen; entry.HomeworkMissing = true; entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen); row.Selected = false; } Status = $"{selected.Count} Hausaufgaben-Status übernommen."; } /// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv /// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst /// unaufgelöst statt irgendeinen davon zu wählen. private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date) { if (subjectId is null) return null; var schoolYear = _schoolYears.CurrentSchoolYear(date); var candidates = _memberships.GetByStudent(student.Id) .Where(m => GroupMembershipService.IsActiveOn(m, date)) .Select(m => _groups.GetById(m.GroupId)) .Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear) .Cast() .ToList(); return candidates.Count == 1 ? candidates[0] : null; } // Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative // Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal). private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) => string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) && (entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true); private static Subject? MatchSubject(string? text, IReadOnlyDictionary shortNameIndex) { if (string.IsNullOrWhiteSpace(text)) return null; foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+")) if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject)) return subject; return null; } private static string NameKey(string value) => value.Trim().ToLowerInvariant(); // Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls // innerhalb der Kandidaten eindeutig. private static Dictionary BuildNameIndex(IReadOnlyList candidates) => candidates .SelectMany(student => new[] { NameKey($"{student.LastName} {student.FirstName}"), NameKey($"{student.FirstName} {student.LastName}"), }.Select(key => (Key: key, Student: student))) .GroupBy(x => x.Key) .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) .ToDictionary(group => group.Key, group => group.First().Student); private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); }