From 37f4fee5745fdb268ac272ed204e425551316123 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 13:12:03 +0200 Subject: [PATCH] feat: add student master data import --- .../Importing/ImportAbstractions.cs | 210 +++++ .../Importing/StudentImportModels.cs | 99 +++ .../StudentMasterDataCsvImportHandler.cs | 298 +++++++ LehrerApp.Core/Models/Student.cs | 3 + .../Services/StudentImportService.cs | 787 ++++++++++++++++++ .../Repositories/AllRepositories.cs | 2 + .../StudentImportDialogViewModelTests.cs | 87 ++ LehrerApp.Desktop/AppBootstrapper.cs | 3 + .../Students/StudentImportDialogViewModel.cs | 149 ++++ .../ViewModels/Students/StudentViewModels.cs | 14 +- .../Views/Students/AddStudentDialog.axaml | 9 +- .../Views/Students/ContactEditDialog.axaml | 11 +- .../Views/Students/StudentImportDialog.axaml | 103 +++ .../Students/StudentImportDialog.axaml.cs | 18 + .../Views/Students/StudentListView.axaml | 6 +- .../Views/Students/StudentListView.axaml.cs | 70 +- LehrerApp.Tests/StudentImportServiceTests.cs | 400 +++++++++ .../StudentMasterDataCsvImportHandlerTests.cs | 103 +++ 18 files changed, 2359 insertions(+), 13 deletions(-) create mode 100644 LehrerApp.Core/Importing/ImportAbstractions.cs create mode 100644 LehrerApp.Core/Importing/StudentImportModels.cs create mode 100644 LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs create mode 100644 LehrerApp.Core/Services/StudentImportService.cs create mode 100644 LehrerApp.Desktop.Tests/StudentImportDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/StudentImportDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml.cs create mode 100644 LehrerApp.Tests/StudentImportServiceTests.cs create mode 100644 LehrerApp.Tests/StudentMasterDataCsvImportHandlerTests.cs diff --git a/LehrerApp.Core/Importing/ImportAbstractions.cs b/LehrerApp.Core/Importing/ImportAbstractions.cs new file mode 100644 index 0000000..abd893b --- /dev/null +++ b/LehrerApp.Core/Importing/ImportAbstractions.cs @@ -0,0 +1,210 @@ +using System.Collections.ObjectModel; + +namespace LehrerApp.Core.Importing; + +/// +/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit +/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen. +/// +public readonly record struct ImportFormatId +{ + public string Value { get; } + + public ImportFormatId(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + Value = value.Trim().ToLowerInvariant(); + } + + public override string ToString() => Value ?? ""; +} + +/// Eine vollständig in den Speicher geladene Importdatei. +public sealed class ImportFile +{ + public string FileName { get; } + public ReadOnlyMemory Content { get; } + public string Extension => Path.GetExtension(FileName).ToLowerInvariant(); + + public ImportFile(string fileName, ReadOnlyMemory content) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + FileName = Path.GetFileName(fileName); + Content = content; + } +} + +public enum ImportMessageSeverity { Information, Warning, Error } + +public sealed record ImportMessage( + ImportMessageSeverity Severity, + string Code, + string Message, + string? SourceReference = null); + +/// +/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen +/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen. +/// +public sealed record ImportDetection +{ + public int Confidence { get; } + public string? Reason { get; } + public bool IsMatch => Confidence > 0; + + public ImportDetection(int confidence, string? reason = null) + { + if (confidence is < 0 or > 100) + throw new ArgumentOutOfRangeException(nameof(confidence)); + Confidence = confidence; + Reason = reason; + } + + public static ImportDetection NoMatch(string? reason = null) => new(0, reason); + public static ImportDetection Match(int confidence, string? reason = null) => + new(confidence, reason); +} + +public sealed class ImportParseResult +{ + public IReadOnlyList Items { get; } + public IReadOnlyList Messages { get; } + public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error); + + public ImportParseResult(IEnumerable items, IEnumerable? messages = null) + { + ArgumentNullException.ThrowIfNull(items); + Items = ReadOnly(items); + Messages = ReadOnly(messages ?? []); + } + + private static IReadOnlyList ReadOnly(IEnumerable values) => + new ReadOnlyCollection(values.ToList()); +} + +/// +/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein +/// normalisiertes fachliches Importmodell. +/// +public interface IImportHandler +{ + ImportFormatId FormatId { get; } + string DisplayName { get; } + IReadOnlyCollection SupportedExtensions { get; } + + ValueTask DetectAsync( + ImportFile file, + CancellationToken cancellationToken = default); + + ValueTask> ParseAsync( + ImportFile file, + CancellationToken cancellationToken = default); +} + +public sealed record DetectedImportHandler( + IImportHandler Handler, + ImportDetection Detection); + +public abstract class ImportFormatException(string message) : Exception(message); + +public sealed class UnsupportedImportFormatException(string fileName) + : ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden."); + +public sealed class AmbiguousImportFormatException(string fileName, IEnumerable formats) + : ImportFormatException( + $"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}."); + +/// +/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts +/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis. +/// +public sealed class ImportHandlerCatalog +{ + private readonly IReadOnlyList> _handlers; + + public ImportHandlerCatalog(IEnumerable> handlers) + { + ArgumentNullException.ThrowIfNull(handlers); + var materialized = handlers.ToList(); + var duplicate = materialized + .GroupBy(handler => handler.FormatId) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + throw new ArgumentException( + $"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers)); + _handlers = new ReadOnlyCollection>(materialized); + } + + public IReadOnlyList> Handlers => _handlers; + + public IReadOnlyList SupportedExtensions => _handlers + .SelectMany(handler => handler.SupportedExtensions) + .Select(NormalizeExtension) + .Where(extension => extension.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase) + .ToList() + .AsReadOnly(); + + public async ValueTask> DetectAsync( + ImportFile file, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(file); + var matches = new List(); + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false); + if (detection.IsMatch) + matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension))); + } + + if (matches.Count == 0) + throw new UnsupportedImportFormatException(file.FileName); + + var highestConfidence = matches.Max(match => match.Detection.Confidence); + var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList(); + if (best.Count > 1 && best.Any(match => match.ExtensionMatches)) + best = best.Where(match => match.ExtensionMatches).ToList(); + + if (best.Count != 1) + throw new AmbiguousImportFormatException( + file.FileName, + best.Select(match => match.Handler.DisplayName)); + + return new DetectedImportHandler(best[0].Handler, best[0].Detection); + } + + private static bool SupportsExtension(IImportHandler handler, string extension) => + handler.SupportedExtensions + .Select(NormalizeExtension) + .Contains(extension, StringComparer.OrdinalIgnoreCase); + + private static string NormalizeExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) return ""; + var trimmed = extension.Trim().ToLowerInvariant(); + return trimmed.StartsWith('.') ? trimmed : $".{trimmed}"; + } + + private sealed record Candidate( + IImportHandler Handler, + ImportDetection Detection, + bool ExtensionMatches); +} + +public sealed record ImportConflictOption(string Id, string Label, string? Description = null); + +public sealed record ImportConflict( + string Id, + string Title, + string Description, + string ImportedValue, + string? ExistingValue, + IReadOnlyList Options, + string DefaultOptionId, + string? SourceReference = null); + +/// Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung. +public sealed record ImportDecision(string ConflictId, string SelectedOptionId); diff --git a/LehrerApp.Core/Importing/StudentImportModels.cs b/LehrerApp.Core/Importing/StudentImportModels.cs new file mode 100644 index 0000000..453ee40 --- /dev/null +++ b/LehrerApp.Core/Importing/StudentImportModels.cs @@ -0,0 +1,99 @@ +using System.Collections.ObjectModel; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Importing; + +public sealed record ImportedStudentContact +{ + public string? Email { get; init; } + public string? MobilePhone { get; init; } + public string? Phone { get; init; } + public string? Street { get; init; } + public string? PostalCode { get; init; } + public string? City { get; init; } + + public bool HasData => new[] { Email, MobilePhone, Phone, Street, PostalCode, City } + .Any(value => !string.IsNullOrWhiteSpace(value)); +} + +/// Formatunabhängige Schülerdaten, die ein konkreter Handler erzeugt. +public sealed record ImportedStudent +{ + public required string FirstName { get; init; } + public required string LastName { get; init; } + public DateOnly? DateOfBirth { get; init; } + public Gender? Gender { get; init; } + public string? ExternalId { get; init; } + public string? GroupName { get; init; } + public ImportedStudentContact? Contact { get; init; } + public string? SourceReference { get; init; } +} + +public static class StudentImportFormats +{ + public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv"); +} + +public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip } + +public sealed record StudentImportResolution( + StudentImportResolutionKind Kind, + Guid? ExistingStudentId = null); + +public sealed record StudentImportEntry( + string Id, + ImportedStudent Student, + StudentImportResolution? AutomaticResolution, + string? ConflictId, + IReadOnlyList FieldsToSupplement); + +public sealed record StudentImportGroupAssignment( + string SourceGroupName, + Guid? AutomaticGroupId, + string? ConflictId); + +public sealed class StudentImportPreview +{ + public ImportFormatId FormatId { get; } + public string FormatName { get; } + public IReadOnlyList Entries { get; } + public IReadOnlyList Messages { get; } + public IReadOnlyList Conflicts { get; } + public IReadOnlyList GroupAssignments { get; } + internal string ExistingStudentsFingerprint { get; } + internal string GroupStateFingerprint { get; } + + public bool CanApply => + Entries.Count > 0 && Messages.All(message => message.Severity != ImportMessageSeverity.Error); + + internal StudentImportPreview( + ImportFormatId formatId, + string formatName, + IEnumerable entries, + IEnumerable messages, + IEnumerable conflicts, + IEnumerable groupAssignments, + string existingStudentsFingerprint, + string groupStateFingerprint) + { + FormatId = formatId; + FormatName = formatName; + Entries = ReadOnly(entries); + Messages = ReadOnly(messages); + Conflicts = ReadOnly(conflicts); + GroupAssignments = ReadOnly(groupAssignments); + ExistingStudentsFingerprint = existingStudentsFingerprint; + GroupStateFingerprint = groupStateFingerprint; + } + + private static IReadOnlyList ReadOnly(IEnumerable values) => + new ReadOnlyCollection(values.ToList()); +} + +public sealed record StudentImportApplyResult( + int CreatedStudents, + int UpdatedStudents, + int MatchedExistingStudents, + int SkippedStudents, + int CreatedMemberships, + IReadOnlyList CreatedStudentIds); diff --git a/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs b/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs new file mode 100644 index 0000000..f5c2928 --- /dev/null +++ b/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using System.Text; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Importing; + +/// +/// Liest den tabulatorgetrennten Stammdatenexport mit Spalten wie „longName“, +/// „foreName“, „birthDate“ und „klasse.name“. Die Datei trägt historisch die +/// Endung .csv, obwohl als Trennzeichen ein Tabulator verwendet wird. +/// +public sealed class StudentMasterDataCsvImportHandler : IImportHandler +{ + private static readonly string[] SignatureHeaders = + ["longName", "foreName", "gender", "birthDate", "klasse.name", "externKey"]; + + private static readonly UTF8Encoding StrictUtf8 = new( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + + public ImportFormatId FormatId => StudentImportFormats.MasterDataCsv; + public string DisplayName => "Schüler-Stammdatenliste"; + public IReadOnlyCollection SupportedExtensions { get; } = [".csv"]; + + public ValueTask DetectAsync( + ImportFile file, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + string text; + try + { + text = StrictUtf8.GetString(file.Content.Span); + } + catch (DecoderFallbackException) + { + return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8.")); + } + + var firstLineEnd = text.IndexOfAny(['\r', '\n']); + var firstLine = (firstLineEnd >= 0 ? text[..firstLineEnd] : text).TrimStart('\uFEFF'); + var headers = firstLine.Split('\t') + .Select(header => header.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!SignatureHeaders.All(headers.Contains)) + return ValueTask.FromResult(ImportDetection.NoMatch("Die erwarteten Stammdatenspalten fehlen.")); + + return ValueTask.FromResult(ImportDetection.Match( + file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95, + "Die charakteristischen Stammdatenspalten wurden gefunden.")); + } + + public ValueTask> ParseAsync( + ImportFile file, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var messages = new List(); + string text; + try + { + text = StrictUtf8.GetString(file.Content.Span); + } + catch (DecoderFallbackException) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.encoding", + "Die Stammdatenliste ist nicht als gültiges UTF-8 gespeichert.")); + return ValueTask.FromResult(new ImportParseResult([], messages)); + } + + if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError)) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.structure", + parseError ?? "Die Stammdatenliste ist syntaktisch ungültig.")); + return ValueTask.FromResult(new ImportParseResult([], messages)); + } + + if (records.Count == 0) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.empty", + "Die Stammdatenliste ist leer.")); + return ValueTask.FromResult(new ImportParseResult([], messages)); + } + + var headers = records[0].Fields + .Select((header, index) => (Name: header.Trim().TrimStart('\uFEFF'), Index: index)) + .ToList(); + var duplicateHeader = headers + .GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateHeader is not null) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.header.duplicate", + $"Die Spalte „{duplicateHeader.Key}“ kommt mehrfach vor.", + "Kopfzeile")); + + var columns = headers + .GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase); + foreach (var required in SignatureHeaders.Where(required => !columns.ContainsKey(required))) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.header.missing", + $"Die erforderliche Spalte „{required}“ fehlt.", + "Kopfzeile")); + if (messages.Any(message => message.Severity == ImportMessageSeverity.Error)) + return ValueTask.FromResult(new ImportParseResult([], messages)); + + var students = new List(); + foreach (var record in records.Skip(1)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record.Fields.All(string.IsNullOrWhiteSpace)) continue; + var source = $"Zeile {record.LineNumber}"; + if (record.Fields.Count != headers.Count) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.columns", + $"Die Zeile enthält {record.Fields.Count} statt {headers.Count} Spalten.", + source)); + continue; + } + + var gender = ParseGender(Value(record, columns, "gender"), source, messages); + var dateOfBirth = ParseDate(Value(record, columns, "birthDate"), source, messages); + var externalId = FirstNotEmpty( + Value(record, columns, "externKey"), + Value(record, columns, "id")); + students.Add(new ImportedStudent + { + FirstName = Value(record, columns, "foreName"), + LastName = Value(record, columns, "longName"), + Gender = gender, + DateOfBirth = dateOfBirth, + ExternalId = externalId, + GroupName = NullIfWhiteSpace(Value(record, columns, "klasse.name")), + Contact = new ImportedStudentContact + { + Email = NullIfWhiteSpace(Value(record, columns, "address.email")), + MobilePhone = NullIfWhiteSpace(Value(record, columns, "address.mobile")), + Phone = NullIfWhiteSpace(Value(record, columns, "address.phone")), + City = NullIfWhiteSpace(Value(record, columns, "address.city")), + PostalCode = NullIfWhiteSpace(Value(record, columns, "address.postCode")), + Street = NullIfWhiteSpace(Value(record, columns, "address.street")), + }, + SourceReference = source, + }); + } + + return ValueTask.FromResult(new ImportParseResult(students, messages)); + } + + private static Gender? ParseGender( + string value, + string source, + ICollection messages) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return value.Trim().ToLowerInvariant() switch + { + "m" or "männlich" or "maennlich" => Gender.M, + "w" or "weiblich" => Gender.W, + "d" or "divers" => Gender.D, + _ => UnknownGender(value, source, messages), + }; + } + + private static Gender? UnknownGender( + string value, + string source, + ICollection messages) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Warning, + "master-data.gender.unknown", + $"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", + source)); + return null; + } + + private static DateOnly? ParseDate( + string value, + string source, + ICollection messages) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (DateOnly.TryParseExact( + value.Trim(), + "dd.MM.yyyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var date)) + return date; + + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "master-data.birth-date.invalid", + $"Das Geburtsdatum „{value.Trim()}“ besitzt nicht das erwartete Format TT.MM.JJJJ.", + source)); + return null; + } + + private static string Value( + DelimitedRecord record, + IReadOnlyDictionary columns, + string column) => + columns.TryGetValue(column, out var index) && index < record.Fields.Count + ? record.Fields[index].Trim() + : ""; + + private static string? FirstNotEmpty(params string[] values) => + values.Select(NullIfWhiteSpace).FirstOrDefault(value => value is not null); + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record DelimitedRecord(int LineNumber, IReadOnlyList Fields); + + private static class DelimitedTextParser + { + public static bool TryParse( + string text, + char delimiter, + out List records, + out string? error) + { + records = []; + error = null; + var fields = new List(); + var field = new StringBuilder(); + var inQuotes = false; + var line = 1; + var recordStartLine = 1; + + for (var index = 0; index < text.Length; index++) + { + var current = text[index]; + if (current == '"') + { + if (inQuotes && index + 1 < text.Length && text[index + 1] == '"') + { + field.Append('"'); + index++; + } + else + { + inQuotes = !inQuotes; + } + continue; + } + + if (current == delimiter && !inQuotes) + { + fields.Add(field.ToString()); + field.Clear(); + continue; + } + + if ((current == '\r' || current == '\n') && !inQuotes) + { + if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n') + index++; + fields.Add(field.ToString()); + field.Clear(); + records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); + fields.Clear(); + line++; + recordStartLine = line; + continue; + } + + if (current == '\n') line++; + field.Append(current); + } + + if (inQuotes) + { + error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen."; + return false; + } + + if (field.Length > 0 || fields.Count > 0) + { + fields.Add(field.ToString()); + records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); + } + return true; + } + } +} diff --git a/LehrerApp.Core/Models/Student.cs b/LehrerApp.Core/Models/Student.cs index 5831a5b..0c25872 100644 --- a/LehrerApp.Core/Models/Student.cs +++ b/LehrerApp.Core/Models/Student.cs @@ -8,6 +8,8 @@ public class Student public string FullName => $"{LastName}, {FirstName}"; public DateOnly? DateOfBirth { get; set; } public Gender? Gender { get; set; } + /// Quellsystembezogene Kennungen, z.B. für wiederholbare Stammdatenimporte. + public Dictionary ExternalIds { get; set; } = []; public List Contacts { get; set; } = []; public string? Notes { get; set; } public bool IsActive { get; set; } = true; @@ -22,6 +24,7 @@ public class Contact /// Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“. public string? LetterSalutation { get; set; } public string? Phone { get; set; } + public string? MobilePhone { get; set; } public string? Email { get; set; } public string? Street { get; set; } public string? PostalCode { get; set; } diff --git a/LehrerApp.Core/Services/StudentImportService.cs b/LehrerApp.Core/Services/StudentImportService.cs new file mode 100644 index 0000000..936b8ca --- /dev/null +++ b/LehrerApp.Core/Services/StudentImportService.cs @@ -0,0 +1,787 @@ +using System.Globalization; +using System.Text; +using LehrerApp.Core.Importing; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// +/// Orchestriert den Schülerimport ohne UI-Abhängigkeit: Format erkennen, vollständig +/// analysieren, Konflikte beschreiben und erst nach Bestätigung speichern. +/// +public sealed class StudentImportService +{ + private const string CreateOption = "create"; + private const string SkipOption = "skip"; + private const string ExistingOptionPrefix = "existing:"; + private const string GroupOptionPrefix = "group:"; + private const string ImportedContactRelation = "Stammdaten"; + + private readonly ImportHandlerCatalog _handlers; + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IGroupMembershipRepository _memberships; + private readonly SchoolYearService _schoolYears; + + public StudentImportService( + IEnumerable> handlers, + IStudentRepository students, + IGroupRepository groups, + IGroupMembershipRepository memberships, + SchoolYearService schoolYears) + { + _handlers = new ImportHandlerCatalog(handlers); + _students = students; + _groups = groups; + _memberships = memberships; + _schoolYears = schoolYears; + } + + public IReadOnlyList> Handlers => _handlers.Handlers; + public IReadOnlyList SupportedExtensions => _handlers.SupportedExtensions; + + public async Task AnalyzeAsync( + ImportFile file, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(file); + var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false); + var parsed = await detected.Handler.ParseAsync(file, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var messages = parsed.Messages.ToList(); + var imported = parsed.Items.Select(Normalize).ToList(); + Validate(imported, messages); + if (imported.Count == 0 && messages.All(message => message.Severity != ImportMessageSeverity.Error)) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "students.empty", + "Die Importdatei enthält keine Schülerdaten.")); + + var existing = _students.GetAll(includeInactive: true); + var groups = _groups.GetAll(includeInactive: true); + var currentSchoolYear = _schoolYears.CurrentSchoolYear(); + var currentGroups = groups + .Where(group => group.IsActive && group.SchoolYear == currentSchoolYear) + .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var entries = new List(); + var conflicts = new List(); + + for (var index = 0; index < imported.Count; index++) + { + var candidate = imported[index]; + var entryId = $"student:{index + 1}"; + var externalMatches = FindByExternalId(existing, detected.Handler.FormatId, candidate.ExternalId); + if (externalMatches.Count == 1) + { + var matched = externalMatches[0]; + AddDifferenceWarning(candidate, matched, messages); + entries.Add(new StudentImportEntry( + entryId, + candidate, + new StudentImportResolution(StudentImportResolutionKind.UseExisting, matched.Id), + null, + FieldsToSupplement(matched, candidate, detected.Handler.FormatId))); + continue; + } + if (externalMatches.Count > 1) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.external-id.ambiguous", + "Die externe Schüler-ID ist mehreren vorhandenen Schülern zugeordnet.", + candidate.SourceReference)); + entries.Add(new StudentImportEntry( + entryId, + candidate, + new StudentImportResolution(StudentImportResolutionKind.Skip), + null, + [])); + continue; + } + + var sameName = existing.Where(student => NamesEqual(student, candidate)).ToList(); + var exact = candidate.DateOfBirth is not null + ? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList() + : []; + + if (exact.Count == 1) + { + AddDifferenceWarning(candidate, exact[0], messages); + entries.Add(new StudentImportEntry( + entryId, + candidate, + new StudentImportResolution(StudentImportResolutionKind.UseExisting, exact[0].Id), + null, + FieldsToSupplement(exact[0], candidate, detected.Handler.FormatId))); + continue; + } + + if (sameName.Count == 0) + { + entries.Add(new StudentImportEntry( + entryId, + candidate, + new StudentImportResolution(StudentImportResolutionKind.CreateNew), + null, + [])); + continue; + } + + var conflictId = $"student-match:{index + 1}"; + conflicts.Add(CreateMatchConflict( + conflictId, + candidate, + sameName, + detected.Handler.FormatId)); + entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, [])); + } + + var groupAssignments = BuildGroupAssignments(imported, currentGroups, conflicts, messages); + var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList(); + + return new StudentImportPreview( + detected.Handler.FormatId, + detected.Handler.DisplayName, + entries, + messages, + conflicts, + groupAssignments, + Fingerprint(existing), + GroupStateFingerprint(groups, groupMemberships, currentSchoolYear)); + } + + public Task ApplyAsync( + StudentImportPreview preview, + IEnumerable? decisions = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(preview); + cancellationToken.ThrowIfCancellationRequested(); + if (!preview.CanApply) + throw new InvalidOperationException( + "Der Import enthält Fehler oder keine Schüler und kann nicht angewendet werden."); + + var existing = _students.GetAll(includeInactive: true); + if (!string.Equals( + preview.ExistingStudentsFingerprint, + Fingerprint(existing), + StringComparison.Ordinal)) + throw new InvalidOperationException( + "Die Schülerdaten wurden seit der Vorschau verändert. Bitte analysiere die Datei erneut."); + + var groups = _groups.GetAll(includeInactive: true); + var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList(); + if (!string.Equals( + preview.GroupStateFingerprint, + GroupStateFingerprint(groups, groupMemberships, _schoolYears.CurrentSchoolYear()), + StringComparison.Ordinal)) + throw new InvalidOperationException( + "Die Lerngruppen oder Zuordnungen wurden seit der Vorschau verändert. " + + "Bitte analysiere die Datei erneut."); + + var decisionMap = BuildDecisionMap(preview, decisions ?? []); + var resolutions = preview.Entries + .Select(entry => Resolve(entry, decisionMap)) + .ToList(); + + // Alle Entscheidungen werden vor dem ersten Schreibzugriff geprüft. + ValidateResolutions(resolutions, existing); + var groupResolutions = preview.GroupAssignments.ToDictionary( + assignment => assignment.SourceGroupName, + assignment => ResolveGroup(assignment, decisionMap), + StringComparer.OrdinalIgnoreCase); + ValidateGroupResolutions(groupResolutions.Values, groups); + + var createdIds = new List(); + var studentIdsByEntry = new Dictionary(StringComparer.Ordinal); + var existingById = existing.ToDictionary(student => student.Id); + var updated = 0; + var matched = 0; + var skipped = 0; + foreach (var (entry, resolution) in preview.Entries.Zip(resolutions)) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (resolution.Kind) + { + case StudentImportResolutionKind.CreateNew: + var created = CreateStudent(entry.Student, preview.FormatId); + _students.Save(created); + createdIds.Add(created.Id); + studentIdsByEntry[entry.Id] = created.Id; + break; + case StudentImportResolutionKind.UseExisting: + var existingStudent = existingById[resolution.ExistingStudentId!.Value]; + if (Supplement(existingStudent, entry.Student, preview.FormatId)) + { + _students.Save(existingStudent); + updated++; + } + matched++; + studentIdsByEntry[entry.Id] = existingStudent.Id; + break; + case StudentImportResolutionKind.Skip: + skipped++; + studentIdsByEntry[entry.Id] = null; + break; + default: + throw new InvalidOperationException("Unbekannte Importentscheidung."); + } + } + + var createdMemberships = 0; + foreach (var entry in preview.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entry.Student.GroupName is not { } sourceGroup + || !studentIdsByEntry.TryGetValue(entry.Id, out var studentId) + || studentId is null + || !groupResolutions.TryGetValue(sourceGroup, out var groupId) + || groupId is null + || _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null) + continue; + + _memberships.Save(new GroupMembership + { + StudentId = studentId.Value, + GroupId = groupId.Value, + AddedOn = DateOnly.FromDateTime(DateTime.Today), + Period = MembershipPeriod.FullYear, + }); + createdMemberships++; + } + + return Task.FromResult(new StudentImportApplyResult( + createdIds.Count, + updated, + matched, + skipped, + createdMemberships, + createdIds.AsReadOnly())); + } + + private static ImportedStudent Normalize(ImportedStudent student) => student with + { + FirstName = student.FirstName?.Trim() ?? "", + LastName = student.LastName?.Trim() ?? "", + ExternalId = NullIfWhiteSpace(student.ExternalId), + GroupName = NullIfWhiteSpace(student.GroupName), + Contact = Normalize(student.Contact), + SourceReference = NullIfWhiteSpace(student.SourceReference), + }; + + private static ImportedStudentContact? Normalize(ImportedStudentContact? contact) + { + if (contact is null) return null; + var normalized = contact with + { + Email = NullIfWhiteSpace(contact.Email), + MobilePhone = NullIfWhiteSpace(contact.MobilePhone), + Phone = NullIfWhiteSpace(contact.Phone), + Street = NullIfWhiteSpace(contact.Street), + PostalCode = NullIfWhiteSpace(contact.PostalCode), + City = NullIfWhiteSpace(contact.City), + }; + return normalized.HasData ? normalized : null; + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static void Validate( + IReadOnlyList imported, + ICollection messages) + { + for (var index = 0; index < imported.Count; index++) + { + var student = imported[index]; + var source = student.SourceReference ?? $"Eintrag {index + 1}"; + if (student.FirstName.Length == 0) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.first-name.required", + "Der Vorname fehlt.", + source)); + if (student.LastName.Length == 0) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.last-name.required", + "Der Nachname fehlt.", + source)); + } + + var duplicateExternalIds = imported + .Where(student => student.ExternalId is not null) + .GroupBy(student => student.ExternalId!, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1); + foreach (var duplicate in duplicateExternalIds) + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.external-id.duplicate", + $"Die externe Schüler-ID „{duplicate.Key}“ kommt mehrfach vor.")); + + var duplicateIdentities = imported + .Where(student => student.FirstName.Length > 0 + && student.LastName.Length > 0 + && student.DateOfBirth is not null) + .GroupBy(student => + $"{student.FirstName.ToUpperInvariant()}\u001f{student.LastName.ToUpperInvariant()}\u001f{student.DateOfBirth:yyyyMMdd}") + .Where(group => group.Count() > 1); + foreach (var duplicate in duplicateIdentities) + { + var student = duplicate.First(); + messages.Add(new ImportMessage( + ImportMessageSeverity.Error, + "student.duplicate", + $"„{student.LastName}, {student.FirstName}“ mit Geburtsdatum " + + $"{DateDisplay(student.DateOfBirth)} kommt mehrfach im Import vor.")); + } + } + + private static ImportConflict CreateMatchConflict( + string conflictId, + ImportedStudent imported, + IReadOnlyList candidates, + ImportFormatId formatId) + { + var options = candidates.Select(candidate => new ImportConflictOption( + ExistingOption(candidate.Id), + $"Vorhandenen Schüler „{candidate.FullName}“ verwenden", + SupplementDescription(candidate, imported, formatId))) + .Append(new ImportConflictOption( + CreateOption, + "Als neuen Schüler anlegen", + "Der vorhandene Datensatz bleibt unverändert.")) + .Append(new ImportConflictOption( + SkipOption, + "Eintrag überspringen")) + .ToList() + .AsReadOnly(); + + return new ImportConflict( + conflictId, + "Möglicher vorhandener Schüler", + "Vor- und Nachname stimmen überein, der Datensatz konnte aber nicht eindeutig zugeordnet werden.", + ImportedDetails(imported), + string.Join(Environment.NewLine, candidates.Select(StudentDetails)), + options, + SkipOption, + imported.SourceReference); + } + + private static IReadOnlyList FindByExternalId( + IEnumerable existing, + ImportFormatId formatId, + string? externalId) + { + if (externalId is null) return []; + return existing.Where(student => + student.ExternalIds is not null + && student.ExternalIds.TryGetValue(formatId.Value, out var stored) + && string.Equals(stored, externalId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static void AddDifferenceWarning( + ImportedStudent imported, + Student existing, + ICollection messages) + { + var differences = new List(); + if (existing.DateOfBirth is not null && imported.DateOfBirth is not null + && existing.DateOfBirth != imported.DateOfBirth) + differences.Add("Geburtsdatum"); + if (existing.Gender is not null && imported.Gender is not null + && existing.Gender != imported.Gender) + differences.Add("Geschlecht"); + if (differences.Count == 0) return; + + messages.Add(new ImportMessage( + ImportMessageSeverity.Warning, + "student.existing-values-kept", + $"Vorhandene abweichende Werte ({string.Join(", ", differences)}) werden nicht überschrieben.", + imported.SourceReference)); + } + + private static IReadOnlyList FieldsToSupplement( + Student existing, + ImportedStudent imported, + ImportFormatId formatId) + { + var fields = new List(); + if (existing.DateOfBirth is null && imported.DateOfBirth is not null) + fields.Add("Geburtsdatum"); + if (existing.Gender is null && imported.Gender is not null) + fields.Add("Geschlecht"); + if (imported.ExternalId is not null + && (existing.ExternalIds is null || !existing.ExternalIds.ContainsKey(formatId.Value))) + fields.Add("externe Kennung"); + + if (imported.Contact is { HasData: true } contact) + { + var target = existing.Contacts?.FirstOrDefault(IsImportedContact); + AddMissingField(fields, "E-Mail", target?.Email, contact.Email); + AddMissingField(fields, "Mobiltelefon", target?.MobilePhone, contact.MobilePhone); + AddMissingField(fields, "Telefon", target?.Phone, contact.Phone); + AddMissingField(fields, "Straße", target?.Street, contact.Street); + AddMissingField(fields, "Postleitzahl", target?.PostalCode, contact.PostalCode); + AddMissingField(fields, "Ort", target?.City, contact.City); + } + return fields.AsReadOnly(); + } + + private static void AddMissingField( + ICollection fields, + string label, + string? existing, + string? imported) + { + if (string.IsNullOrWhiteSpace(existing) && !string.IsNullOrWhiteSpace(imported)) + fields.Add(label); + } + + private static string SupplementDescription( + Student existing, + ImportedStudent imported, + ImportFormatId formatId) + { + var supplements = FieldsToSupplement(existing, imported, formatId); + return supplements.Count == 0 + ? $"{StudentDetails(existing)} · keine Ergänzungen erforderlich" + : $"{StudentDetails(existing)} · ergänzt: {string.Join(", ", supplements)}"; + } + + private static List BuildGroupAssignments( + IEnumerable imported, + IReadOnlyList currentGroups, + ICollection conflicts, + ICollection messages) + { + var result = new List(); + var sourceGroups = imported + .Select(student => student.GroupName) + .Where(group => group is not null) + .Select(group => group!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group, StringComparer.OrdinalIgnoreCase); + + foreach (var sourceGroup in sourceGroups) + { + var sourceGradeLevel = LeadingGradeLevel(sourceGroup); + var exact = currentGroups + .Where(group => string.Equals( + group.Name.Trim(), sourceGroup, StringComparison.OrdinalIgnoreCase) + && group.Type == GroupType.Class + && (sourceGradeLevel is null || group.GradeLevel == sourceGradeLevel)) + .ToList(); + var ownClassMatches = exact.Where(group => group.IsOwnClass).ToList(); + if (ownClassMatches.Count == 1) + exact = ownClassMatches; + if (exact.Count == 1) + { + result.Add(new StudentImportGroupAssignment(sourceGroup, exact[0].Id, null)); + continue; + } + + var candidates = exact.Count > 1 ? exact : currentGroups; + if (candidates.Count == 0) + { + messages.Add(new ImportMessage( + ImportMessageSeverity.Warning, + "student.group.not-found", + $"Für die Klasse „{sourceGroup}“ gibt es im aktuellen Schuljahr keine aktive Lerngruppe; " + + "die Schüler werden ohne Gruppenzuordnung importiert.")); + result.Add(new StudentImportGroupAssignment(sourceGroup, null, null)); + continue; + } + + var conflictId = $"student-group:{sourceGroup.ToLowerInvariant()}"; + var options = candidates.Select(group => new ImportConflictOption( + GroupOption(group.Id), + $"Lerngruppe „{group.Name}“ verwenden", + $"{group.SchoolYear} · Klassenstufe {group.GradeLevel}")) + .Append(new ImportConflictOption( + SkipOption, + "Keine Lerngruppe zuordnen")) + .ToList() + .AsReadOnly(); + conflicts.Add(new ImportConflict( + conflictId, + "Lerngruppe zuordnen", + exact.Count > 1 + ? $"Für „{sourceGroup}“ wurden mehrere namensgleiche Lerngruppen gefunden." + : $"Für „{sourceGroup}“ wurde keine eindeutig namensgleiche Lerngruppe gefunden.", + sourceGroup, + null, + options, + SkipOption)); + result.Add(new StudentImportGroupAssignment(sourceGroup, null, conflictId)); + } + return result; + } + + private static int? LeadingGradeLevel(string groupName) + { + var digits = new string(groupName.Trim().TakeWhile(char.IsDigit).ToArray()); + return int.TryParse(digits, CultureInfo.InvariantCulture, out var gradeLevel) + ? gradeLevel + : null; + } + + private static Dictionary BuildDecisionMap( + StudentImportPreview preview, + IEnumerable decisions) + { + var conflicts = preview.Conflicts.ToDictionary(conflict => conflict.Id, StringComparer.Ordinal); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var decision in decisions) + { + if (!conflicts.TryGetValue(decision.ConflictId, out var conflict)) + throw new InvalidOperationException( + $"Die Entscheidung verweist auf den unbekannten Konflikt „{decision.ConflictId}“."); + if (!conflict.Options.Any(option => option.Id == decision.SelectedOptionId)) + throw new InvalidOperationException( + $"Die Option „{decision.SelectedOptionId}“ gehört nicht zum Konflikt „{decision.ConflictId}“."); + if (!map.TryAdd(decision.ConflictId, decision)) + throw new InvalidOperationException( + $"Für den Konflikt „{decision.ConflictId}“ wurden mehrere Entscheidungen übergeben."); + } + + var missing = conflicts.Keys.Where(id => !map.ContainsKey(id)).ToList(); + if (missing.Count > 0) + throw new InvalidOperationException( + "Vor dem Import müssen alle Konflikte entschieden werden."); + return map; + } + + private static StudentImportResolution Resolve( + StudentImportEntry entry, + IReadOnlyDictionary decisions) + { + if (entry.AutomaticResolution is not null) + return entry.AutomaticResolution; + if (entry.ConflictId is null || !decisions.TryGetValue(entry.ConflictId, out var decision)) + throw new InvalidOperationException("Für einen Importkonflikt fehlt die Entscheidung."); + + if (decision.SelectedOptionId == CreateOption) + return new StudentImportResolution(StudentImportResolutionKind.CreateNew); + if (decision.SelectedOptionId == SkipOption) + return new StudentImportResolution(StudentImportResolutionKind.Skip); + if (TryReadExistingOption(decision.SelectedOptionId, out var existingId)) + return new StudentImportResolution(StudentImportResolutionKind.UseExisting, existingId); + + throw new InvalidOperationException( + $"Die Konfliktoption „{decision.SelectedOptionId}“ ist unbekannt."); + } + + private static Guid? ResolveGroup( + StudentImportGroupAssignment assignment, + IReadOnlyDictionary decisions) + { + if (assignment.ConflictId is null) + return assignment.AutomaticGroupId; + if (!decisions.TryGetValue(assignment.ConflictId, out var decision)) + throw new InvalidOperationException("Für die Lerngruppenzuordnung fehlt die Entscheidung."); + if (decision.SelectedOptionId == SkipOption) + return null; + if (TryReadGroupOption(decision.SelectedOptionId, out var groupId)) + return groupId; + throw new InvalidOperationException( + $"Die Gruppenoption „{decision.SelectedOptionId}“ ist unbekannt."); + } + + private static void ValidateResolutions( + IReadOnlyList resolutions, + IReadOnlyCollection existing) + { + var existingIds = existing.Select(student => student.Id).ToHashSet(); + foreach (var resolution in resolutions.Where(resolution => + resolution.Kind == StudentImportResolutionKind.UseExisting)) + { + if (resolution.ExistingStudentId is not Guid id || !existingIds.Contains(id)) + throw new InvalidOperationException( + "Ein ausgewählter vorhandener Schüler existiert nicht mehr."); + } + } + + private static void ValidateGroupResolutions( + IEnumerable resolutions, + IReadOnlyCollection groups) + { + var writableGroupIds = groups + .Where(group => group.IsActive) + .Select(group => group.Id) + .ToHashSet(); + if (resolutions.Any(id => id is not null && !writableGroupIds.Contains(id.Value))) + throw new InvalidOperationException( + "Eine ausgewählte Lerngruppe existiert nicht mehr oder ist archiviert."); + } + + private static Student CreateStudent(ImportedStudent imported, ImportFormatId formatId) + { + var student = new Student + { + FirstName = imported.FirstName, + LastName = imported.LastName, + DateOfBirth = imported.DateOfBirth, + Gender = imported.Gender, + }; + if (imported.ExternalId is not null) + student.ExternalIds[formatId.Value] = imported.ExternalId; + var contact = CreateImportedContact(student, imported.Contact); + if (contact is not null) student.Contacts.Add(contact); + return student; + } + + private static bool Supplement( + Student target, + ImportedStudent imported, + ImportFormatId formatId) + { + var changed = false; + if (target.DateOfBirth is null && imported.DateOfBirth is not null) + { + target.DateOfBirth = imported.DateOfBirth; + changed = true; + } + if (target.Gender is null && imported.Gender is not null) + { + target.Gender = imported.Gender; + changed = true; + } + + target.ExternalIds ??= []; + if (imported.ExternalId is not null && !target.ExternalIds.ContainsKey(formatId.Value)) + { + target.ExternalIds[formatId.Value] = imported.ExternalId; + changed = true; + } + + if (imported.Contact is not { HasData: true } importedContact) + return changed; + target.Contacts ??= []; + var contact = target.Contacts.FirstOrDefault(IsImportedContact); + if (contact is null) + { + contact = CreateImportedContact(target, importedContact)!; + target.Contacts.Add(contact); + return true; + } + + changed |= FillIfEmpty(contact.Email, importedContact.Email, value => contact.Email = value); + changed |= FillIfEmpty(contact.MobilePhone, importedContact.MobilePhone, value => contact.MobilePhone = value); + changed |= FillIfEmpty(contact.Phone, importedContact.Phone, value => contact.Phone = value); + changed |= FillIfEmpty(contact.Street, importedContact.Street, value => contact.Street = value); + changed |= FillIfEmpty(contact.PostalCode, importedContact.PostalCode, value => contact.PostalCode = value); + changed |= FillIfEmpty(contact.City, importedContact.City, value => contact.City = value); + return changed; + } + + private static Contact? CreateImportedContact( + Student student, + ImportedStudentContact? imported) + { + if (imported is not { HasData: true }) return null; + return new Contact + { + Name = $"{student.FirstName} {student.LastName}".Trim(), + Relation = ImportedContactRelation, + Email = imported.Email, + MobilePhone = imported.MobilePhone, + Phone = imported.Phone, + Street = imported.Street, + PostalCode = imported.PostalCode, + City = imported.City, + }; + } + + private static bool IsImportedContact(Contact contact) => + string.Equals(contact.Relation, ImportedContactRelation, StringComparison.OrdinalIgnoreCase); + + private static bool FillIfEmpty(string? existing, string? imported, Action assign) + { + if (!string.IsNullOrWhiteSpace(existing) || string.IsNullOrWhiteSpace(imported)) + return false; + assign(imported); + return true; + } + + private static bool NamesEqual(Student existing, ImportedStudent imported) => + string.Equals(existing.FirstName.Trim(), imported.FirstName, StringComparison.OrdinalIgnoreCase) + && string.Equals(existing.LastName.Trim(), imported.LastName, StringComparison.OrdinalIgnoreCase); + + private static string ExistingOption(Guid id) => $"{ExistingOptionPrefix}{id:N}"; + private static string GroupOption(Guid id) => $"{GroupOptionPrefix}{id:N}"; + + private static bool TryReadExistingOption(string option, out Guid id) + { + id = Guid.Empty; + return option.StartsWith(ExistingOptionPrefix, StringComparison.Ordinal) + && Guid.TryParseExact(option[ExistingOptionPrefix.Length..], "N", out id); + } + + private static bool TryReadGroupOption(string option, out Guid id) + { + id = Guid.Empty; + return option.StartsWith(GroupOptionPrefix, StringComparison.Ordinal) + && Guid.TryParseExact(option[GroupOptionPrefix.Length..], "N", out id); + } + + private static string ImportedDetails(ImportedStudent student) => + $"{student.LastName}, {student.FirstName} · {DateDisplay(student.DateOfBirth)}"; + + private static string StudentDetails(Student student) => + $"{student.FullName} · {DateDisplay(student.DateOfBirth)}"; + + private static string DateDisplay(DateOnly? date) => + date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "Geburtsdatum unbekannt"; + + private static string Fingerprint(IEnumerable students) + { + var builder = new StringBuilder(); + foreach (var student in students.OrderBy(student => student.Id)) + { + builder.Append(student.Id.ToString("N")) + .Append(':').Append(student.UpdatedAt.Ticks) + .Append(':').Append(student.FirstName) + .Append(':').Append(student.LastName) + .Append(':').Append(student.DateOfBirth?.ToString("yyyyMMdd", CultureInfo.InvariantCulture)) + .Append(':').Append(student.Gender) + .Append(':').Append(student.IsActive) + .Append(':').Append(string.Join(",", (student.ExternalIds ?? []) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}={pair.Value}"))) + .Append(':').Append(string.Join(",", (student.Contacts ?? []) + .OrderBy(contact => contact.Id) + .Select(contact => + $"{contact.Id:N}={contact.Name}/{contact.Relation}/{contact.Email}/" + + $"{contact.MobilePhone}/{contact.Phone}/{contact.Street}/" + + $"{contact.PostalCode}/{contact.City}"))) + .Append('|'); + } + return builder.ToString(); + } + + private static string GroupStateFingerprint( + IEnumerable groups, + IEnumerable memberships, + string currentSchoolYear) + { + var builder = new StringBuilder(currentSchoolYear).Append('|'); + foreach (var group in groups.OrderBy(group => group.Id)) + builder.Append(group.Id.ToString("N")) + .Append(':').Append(group.UpdatedAt.Ticks) + .Append(':').Append(group.Name) + .Append(':').Append(group.SchoolYear) + .Append(':').Append(group.IsActive) + .Append('|'); + foreach (var membership in memberships.OrderBy(membership => membership.Id)) + builder.Append(membership.Id.ToString("N")) + .Append(':').Append(membership.StudentId.ToString("N")) + .Append(':').Append(membership.GroupId.ToString("N")) + .Append('|'); + return builder.ToString(); + } +} diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index d59e0ed..65553d9 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -37,6 +37,8 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository db.Documentation.Count(d => d.StudentId == studentId)); public void Save(Student s) { + s.ExternalIds ??= []; + s.Contacts ??= []; s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s); diff --git a/LehrerApp.Desktop.Tests/StudentImportDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/StudentImportDialogViewModelTests.cs new file mode 100644 index 0000000..d3e01f5 --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentImportDialogViewModelTests.cs @@ -0,0 +1,87 @@ +using LehrerApp.Core.Importing; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class StudentImportDialogViewModelTests +{ + [Fact] + public async Task Vorschau_ZeigtZusammenfassungUndImportiertNeueSchueler() + { + var stored = new List(); + var service = Service(stored, + [ + new ImportedStudent + { + FirstName = "Max", + LastName = "Beispiel", + Gender = Gender.M, + }, + ]); + var preview = await service.AnalyzeAsync(File()); + var vm = new StudentImportDialogViewModel(service, preview, "students.csv"); + + Assert.Contains("1 erkannt", vm.Summary); + Assert.Equal(1, vm.NewStudents); + Assert.True(vm.IsReadyWithoutConflicts); + + Assert.True(await vm.TryApplyAsync()); + Assert.Equal(1, vm.Result?.CreatedStudents); + Assert.Single(stored); + } + + [Fact] + public async Task Konflikt_VerwendetDieSichereStandardoptionUeberspringen() + { + var stored = new List + { + new() { FirstName = "Max", LastName = "Beispiel" }, + }; + var service = Service(stored, + [ + new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }, + ]); + var preview = await service.AnalyzeAsync(File()); + var vm = new StudentImportDialogViewModel(service, preview, "students.csv"); + + var conflict = Assert.Single(vm.Conflicts); + Assert.Equal("skip", conflict.SelectedOption.Id); + Assert.Equal("skip", Assert.Single(vm.BuildDecisions()).SelectedOptionId); + + Assert.True(await vm.TryApplyAsync()); + Assert.Equal(1, vm.Result?.SkippedStudents); + Assert.Single(stored); + } + + private static StudentImportService Service( + List stored, + IReadOnlyList imported) => new( + [new FakeImportHandler(imported)], + new FakeStudents(stored), + new FakeGroups([]), + new FakeMemberships([]), + new SchoolYearService()); + + private static ImportFile File() => new("students.csv", "test"u8.ToArray()); + + private sealed class FakeImportHandler(IReadOnlyList imported) + : IImportHandler + { + public ImportFormatId FormatId { get; } = new("test.students"); + public string DisplayName => "Testformat"; + public IReadOnlyCollection SupportedExtensions { get; } = [".csv"]; + + public ValueTask DetectAsync( + ImportFile file, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(ImportDetection.Match(100)); + + public ValueTask> ParseAsync( + ImportFile file, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new ImportParseResult(imported)); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 09640b0..d190302 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Importing; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Data.Repositories; @@ -111,6 +112,8 @@ public static class AppBootstrapper services.AddSingleton(_ => new PrivacySettingsService(appData)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton, StudentMasterDataCsvImportHandler>(); + services.AddSingleton(); // ── Datenbank ───────────────────────────────────────────────────────── services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword)); diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentImportDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/StudentImportDialogViewModel.cs new file mode 100644 index 0000000..44eb6ca --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/StudentImportDialogViewModel.cs @@ -0,0 +1,149 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Importing; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Students; + +public partial class StudentImportDialogViewModel : ObservableObject +{ + private readonly StudentImportService _service; + private readonly StudentImportPreview _preview; + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _error = ""; + + public string FileName { get; } + public string FormatName => _preview.FormatName; + public int TotalStudents => _preview.Entries.Count; + public int NewStudents => _preview.Entries.Count(entry => + entry.AutomaticResolution?.Kind == StudentImportResolutionKind.CreateNew); + public int ExistingStudents => _preview.Entries.Count(entry => + entry.AutomaticResolution?.Kind == StudentImportResolutionKind.UseExisting); + public int StudentsToSupplement => _preview.Entries.Count(entry => entry.FieldsToSupplement.Count > 0); + public string Summary => + $"{TotalStudents} erkannt · {NewStudents} neu · {ExistingStudents} eindeutig vorhanden · " + + $"{StudentsToSupplement} mit Ergänzungen"; + + public string GroupSummary { get; } + public ObservableCollection Messages { get; } = []; + public ObservableCollection Conflicts { get; } = []; + public bool HasMessages => Messages.Count > 0; + public bool HasConflicts => Conflicts.Count > 0; + public bool IsReadyWithoutConflicts => !HasConflicts && _preview.CanApply; + public bool HasBlockingErrors => !_preview.CanApply; + public bool CanApply => _preview.CanApply && !IsBusy; + public StudentImportApplyResult? Result { get; private set; } + + public StudentImportDialogViewModel( + StudentImportService service, + StudentImportPreview preview, + string fileName) + { + _service = service; + _preview = preview; + FileName = fileName; + + foreach (var message in preview.Messages) + Messages.Add(new StudentImportMessageItem(message)); + foreach (var conflict in preview.Conflicts) + Conflicts.Add(new StudentImportConflictItem(conflict)); + + var automaticGroups = preview.GroupAssignments.Count(group => group.AutomaticGroupId is not null); + var unresolvedGroups = preview.GroupAssignments.Count(group => group.ConflictId is not null); + var unassignedGroups = preview.GroupAssignments.Count - automaticGroups - unresolvedGroups; + GroupSummary = preview.GroupAssignments.Count == 0 + ? "Die Datei enthält keine Klassenangabe." + : $"Lerngruppen: {automaticGroups} automatisch · {unresolvedGroups} zu entscheiden" + + (unassignedGroups > 0 ? $" · {unassignedGroups} nicht zugeordnet" : ""); + } + + partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanApply)); + + public IReadOnlyList BuildDecisions() => Conflicts + .Select(conflict => new ImportDecision(conflict.Id, conflict.SelectedOption.Id)) + .ToList() + .AsReadOnly(); + + public async Task TryApplyAsync() + { + Error = ""; + if (!_preview.CanApply) + { + Error = "Der Import enthält Fehler und kann nicht angewendet werden."; + return false; + } + + IsBusy = true; + try + { + var decisions = BuildDecisions(); + Result = await Task.Run(async () => + await _service.ApplyAsync(_preview, decisions).ConfigureAwait(false)); + return true; + } + catch (InvalidOperationException ex) + { + Error = ex.Message; + return false; + } + finally + { + IsBusy = false; + } + } +} + +public sealed class StudentImportMessageItem +{ + public string Message { get; } + public string SourceReference { get; } + public string Display => SourceReference.Length == 0 ? Message : $"{SourceReference}: {Message}"; + public string Foreground { get; } + + public StudentImportMessageItem(ImportMessage message) + { + Message = message.Message; + SourceReference = message.SourceReference ?? ""; + Foreground = message.Severity switch + { + ImportMessageSeverity.Error => "#C62828", + ImportMessageSeverity.Warning => "#B06A00", + _ => "#2563EB", + }; + } +} + +public partial class StudentImportConflictItem : ObservableObject +{ + [ObservableProperty] private StudentImportConflictOptionItem _selectedOption; + + public string Id { get; } + public string Title { get; } + public string Description { get; } + public string ImportedValue { get; } + public string ExistingValue { get; } + public string SourceReference { get; } + public IReadOnlyList Options { get; } + + public StudentImportConflictItem(ImportConflict conflict) + { + Id = conflict.Id; + Title = conflict.Title; + Description = conflict.Description; + ImportedValue = conflict.ImportedValue; + ExistingValue = conflict.ExistingValue ?? ""; + SourceReference = conflict.SourceReference ?? ""; + Options = conflict.Options + .Select(option => new StudentImportConflictOptionItem( + option.Id, option.Label, option.Description ?? "")) + .ToList() + .AsReadOnly(); + _selectedOption = Options.First(option => option.Id == conflict.DefaultOptionId); + } +} + +public sealed record StudentImportConflictOptionItem( + string Id, + string Label, + string Description); diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 6d4b958..7b6df1d 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -572,14 +572,16 @@ public class ContactItem public string Relation { get; } public string LetterSalutation { get; } public string? Phone { get; } + public string? MobilePhone { get; } public string? Email { get; } public string Address { get; } - public bool HasPhone => !string.IsNullOrEmpty(Phone); + public bool HasPhone => !string.IsNullOrEmpty(Phone) || !string.IsNullOrEmpty(MobilePhone); public bool HasEmail => !string.IsNullOrEmpty(Email); public bool HasAddress => !string.IsNullOrEmpty(Address); public bool IsInvalid => Model.InvalidSince.HasValue; public bool IsValid => !IsInvalid; - public string PhoneDisplay => Phone ?? ""; + public string PhoneDisplay => string.Join(" · ", new[] { MobilePhone, Phone } + .Where(value => !string.IsNullOrWhiteSpace(value))); public string EmailDisplay => Email ?? ""; public string StatusText => IsInvalid ? $"Ungültig seit {Model.InvalidSince:dd.MM.yyyy} · {InvalidReasonText(Model.InvalidReason)}" @@ -595,9 +597,10 @@ public class ContactItem Relation = c.Relation; LetterSalutation = c.LetterSalutation ?? ""; Phone = c.Phone; + MobilePhone = c.MobilePhone; Email = c.Email; Address = FormatAddress(c); - CallCommand = new RelayCommand(() => OpenUri($"tel:{Phone}"), () => HasPhone); + CallCommand = new RelayCommand(() => OpenUri($"tel:{MobilePhone ?? Phone}"), () => HasPhone); MailCommand = new RelayCommand(() => OpenUri($"mailto:{Email}"), () => HasEmail); } @@ -696,6 +699,7 @@ public partial class ContactEntryViewModel : ObservableObject [ObservableProperty] private string _relation = ""; [ObservableProperty] private string _letterSalutation = ""; [ObservableProperty] private string _phone = ""; + [ObservableProperty] private string _mobilePhone = ""; [ObservableProperty] private string _email = ""; [ObservableProperty] private string _street = ""; [ObservableProperty] private string _postalCode = ""; @@ -711,6 +715,7 @@ public partial class ContactEntryViewModel : ObservableObject Relation = Relation.Trim(), LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(), Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(), + MobilePhone = string.IsNullOrWhiteSpace(MobilePhone) ? null : MobilePhone.Trim(), Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(), Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(), PostalCode = string.IsNullOrWhiteSpace(PostalCode) ? null : PostalCode.Trim(), @@ -726,6 +731,7 @@ public partial class ContactEditDialogViewModel : ObservableObject [ObservableProperty] private string _relation = "Elternteil"; [ObservableProperty] private string _letterSalutation = ""; [ObservableProperty] private string _phone = ""; + [ObservableProperty] private string _mobilePhone = ""; [ObservableProperty] private string _email = ""; [ObservableProperty] private string _street = ""; [ObservableProperty] private string _postalCode = ""; @@ -753,6 +759,7 @@ public partial class ContactEditDialogViewModel : ObservableObject Relation = source.Relation; LetterSalutation = source.LetterSalutation ?? ""; Phone = source.Phone ?? ""; + MobilePhone = source.MobilePhone ?? ""; Email = source.Email ?? ""; Street = source.Street ?? ""; PostalCode = source.PostalCode ?? ""; @@ -804,6 +811,7 @@ public partial class ContactEditDialogViewModel : ObservableObject Relation = Relation.Trim(), LetterSalutation = NullIfEmpty(LetterSalutation), Phone = NullIfEmpty(Phone), + MobilePhone = NullIfEmpty(MobilePhone), Email = NullIfEmpty(Email), Street = NullIfEmpty(Street), PostalCode = NullIfEmpty(PostalCode), diff --git a/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml b/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml index 1c5d518..84afcfc 100644 --- a/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/AddStudentDialog.axaml @@ -75,13 +75,14 @@ - + - + PlaceholderText="Festnetz" FontSize="12"/> + + diff --git a/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml b/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml index b6aaaec..ed460da 100644 --- a/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/ContactEditDialog.axaml @@ -39,14 +39,19 @@ - + - - + + + + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml b/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml new file mode 100644 index 0000000..467acf3 --- /dev/null +++ b/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +