Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs
T
admin 1291b24478
CI / build-and-test (push) Canceled after 0s
Untis-Fehlzeiten und Dashboard-Hinweise verfeinern
2026-09-25 12:09:32 +02:00

304 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
/// weggefiltert zu werden) - <see cref="AssignedStudent"/> kann manuell per Auswahlliste gesetzt
/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.</summary>
public partial class WebUntisLessonAbsenceRow : ObservableObject
{
public required string UntisStudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Action<WebUntisLessonAbsenceRow>? OnAssignmentChanged { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
// an das KI-Backend personenbezogen leer bleibt.
internal int AbsentMinutes { get; init; }
internal bool HandledOn { get; init; }
internal bool? ExternKeyInParentheses { get; init; }
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
internal static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.SignificantlyLate,
AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
[ObservableProperty] private string _selectedStatusName = "";
public AttendanceStatus SelectedStatus =>
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (<see cref="LearningGroup.WebUntisLessonId"/>).
/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.</summary>
public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly AiPlanningService _ai;
private readonly AiSettingsService _aiSettings;
private IReadOnlyList<Student> _loadedStudents = [];
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
new Dictionary<DateOnly, ParticipationSession>();
public ObservableCollection<WebUntisLessonAbsenceRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _aiSuggestBusy;
[ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation; _ai = ai; _aiSettings = aiSettings;
}
[RelayCommand]
private async Task Load()
{
var lessonId = _group.WebUntisLessonId;
if (lessonId is null)
{
Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
"(Gruppe bearbeiten).";
return;
}
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 courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
_loadedStudents = courseStudents;
_loadedSessions = localSessions;
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents
.Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
var ordered = absences
.Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
localSessions.TryGetValue(row.Date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
row.SessionId = session?.Id;
row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
// WebUntis unterscheidet nicht zwischen "verspätet" und der lokal genaueren
// Einstufung "erheblich verspätet". Eine solche Präzisierung ist daher bereits
// passend und darf nicht automatisch zur Rückstufung markiert werden. Andere
// Statusänderungen (etwa eine nachgereichte Entschuldigung) bleiben markiert.
row.Selected = session is not null &&
!UntisLessonAbsenceHelper.MatchesUntisStatus(entry?.Attendance, row.TargetStatus);
}
foreach (var (absence, date) in ordered)
{
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(UntisNameMatching.NameKey(absence.StudentName));
var targetStatus = MapStatus(absence);
var row = new WebUntisLessonAbsenceRow
{
UntisStudentName = absence.StudentName, Date = date!.Value,
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
UntisStatus = DisplayUntisStatus(absence),
TargetStatus = targetStatus, Reason = absence.Reason,
AbsentMinutes = absence.AbsentMinutes,
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
/// <summary>
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
/// übernehmen" bleibt der einzige schreibende Schritt.
/// </summary>
[RelayCommand]
private async Task SuggestStatusWithAi()
{
var token = _aiSettings.GetToken();
if (token is null)
{
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
return;
}
var candidates = Rows.Where(x => x.CanApply).ToList();
if (candidates.Count == 0)
{
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
return;
}
AiSuggestBusy = true;
try
{
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
{
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
CurrentGuess = row.TargetStatus.ToString(),
}).ToList();
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
var applied = 0;
for (var i = 0; i < candidates.Count; i++)
{
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
applied++;
}
Status = suggestions.Count == 0
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
}
catch (AiBackendException ex) { Status = ex.Message; }
finally { AiSuggestBusy = 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.Attendance = row.SelectedStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
(MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
foreach (var row in selected) row.Selected = false;
}
// "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
// gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
// Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
// und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
// mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
private int FillUnknownAsPresent()
{
var identified = Rows.Where(x => x.AssignedStudent is not null)
.Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
.ToHashSet();
var filled = 0;
foreach (var session in _loadedSessions.Values)
foreach (var student in _loadedStudents)
{
if (identified.Contains((session.Date, student.Id))) continue;
var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
if (entry?.Attendance is not null) continue;
entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
entry.Attendance = AttendanceStatus.Present;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
filled++;
}
return filled;
}
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
// aus dem Tritt geraten könnte.
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
UntisLessonAbsenceHelper.MapStatus(absence);
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
.Where(x => !string.IsNullOrWhiteSpace(x)));
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string TimeLabel(int? start, int? end) =>
start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}";
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}