CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist DateTimeOffset?, die per DatePicker- zu-CalendarDatePicker-Umstellung betroffenen ViewModel-Eigenschaften waren aber nicht-nullbares DateTimeOffset. Der alte DatePicker tolerierte das, CalendarDatePickers Bindung nicht - Feld blieb leer und ein Konvertierungsfehler wurde beim Auswählen geworfen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
13 KiB
C#
239 lines
13 KiB
C#
using System.Collections.ObjectModel;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Importing;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Desktop.Services;
|
||
|
||
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;
|
||
|
||
[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);
|
||
}
|
||
|
||
/// <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 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 _markUnknownAsPresent;
|
||
|
||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||
IStudentRepository students, IParticipationSessionRepository sessions,
|
||
IParticipationRepository participation)
|
||
{
|
||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||
_participation = participation;
|
||
}
|
||
|
||
[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: StudentKey(student)))
|
||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||
var byName = courseStudents
|
||
.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);
|
||
|
||
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);
|
||
row.Selected = session is not null && 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(NameKey(absence.StudentName));
|
||
var row = new WebUntisLessonAbsenceRow
|
||
{
|
||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||
UntisStatus = DisplayUntisStatus(absence),
|
||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||
};
|
||
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; }
|
||
}
|
||
|
||
[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.TargetStatus;
|
||
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;
|
||
}
|
||
|
||
private static int? StudentKey(Student student)
|
||
{
|
||
student.ExternalIds ??= [];
|
||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||
&& int.TryParse(value, out var key) ? key : null;
|
||
}
|
||
|
||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||
|
||
private const int FullLessonMinutes = 45;
|
||
|
||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||
{
|
||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||
}
|
||
|
||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||
|
||
// 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}";
|
||
}
|