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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml.cs
new file mode 100644
index 0000000..c7ad030
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/StudentImportDialog.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Students;
+
+namespace LehrerApp.Desktop.Views.Students;
+
+public partial class StudentImportDialog : Window
+{
+ public StudentImportDialog() => InitializeComponent();
+
+ private async void OnApply(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is StudentImportDialogViewModel vm && await vm.TryApplyAsync())
+ Close(true);
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Desktop/Views/Students/StudentListView.axaml b/LehrerApp.Desktop/Views/Students/StudentListView.axaml
index 62b43d9..fa0b838 100644
--- a/LehrerApp.Desktop/Views/Students/StudentListView.axaml
+++ b/LehrerApp.Desktop/Views/Students/StudentListView.axaml
@@ -8,12 +8,14 @@
-
+
-
+
diff --git a/LehrerApp.Desktop/Views/Students/StudentListView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentListView.axaml.cs
index 6bef4c2..8af0460 100644
--- a/LehrerApp.Desktop/Views/Students/StudentListView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Students/StudentListView.axaml.cs
@@ -1,3 +1,71 @@
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using LehrerApp.Core.Importing;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Students;
+using Microsoft.Extensions.DependencyInjection;
+
namespace LehrerApp.Desktop.Views.Students;
-public partial class StudentListView : UserControl { public StudentListView() => InitializeComponent(); }
+
+public partial class StudentListView : UserControl
+{
+ private const int MaximumImportFileSize = 20 * 1024 * 1024;
+
+ public StudentListView() => InitializeComponent();
+
+ private async void OnImportClick(object? sender, RoutedEventArgs e)
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null || DataContext is not StudentListViewModel list) return;
+
+ var service = App.Services.GetRequiredService();
+ var patterns = service.SupportedExtensions.Select(extension => $"*{extension}").ToArray();
+ var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Schüler-Stammdaten importieren",
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType("Unterstützte Schülerlisten")
+ {
+ Patterns = patterns.Length > 0 ? patterns : ["*.csv"],
+ },
+ ],
+ });
+ if (files.Count == 0) return;
+
+ try
+ {
+ await using var source = await files[0].OpenReadAsync();
+ if (source.CanSeek && source.Length > MaximumImportFileSize)
+ throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
+
+ using var buffer = new MemoryStream();
+ await source.CopyToAsync(buffer);
+ if (buffer.Length > MaximumImportFileSize)
+ throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
+
+ var importFile = new ImportFile(files[0].Name, buffer.ToArray());
+ var preview = await Task.Run(async () =>
+ await service.AnalyzeAsync(importFile).ConfigureAwait(false));
+ var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
+ var dialog = new StudentImportDialog { DataContext = dialogVm };
+ if (!await dialog.ShowDialog(owner)) return;
+
+ list.LoadStudents();
+ var result = dialogVm.Result!;
+ App.Services.GetRequiredService().ShowSuccess(
+ $"Import abgeschlossen: {result.CreatedStudents} neu, {result.UpdatedStudents} ergänzt, "
+ + $"{result.CreatedMemberships} Gruppenzuordnungen.");
+ }
+ catch (Exception ex) when (ex is ImportFormatException
+ or InvalidDataException
+ or IOException
+ or UnauthorizedAccessException)
+ {
+ App.Services.GetRequiredService().ShowError(ex.Message);
+ }
+ }
+}
diff --git a/LehrerApp.Tests/StudentImportServiceTests.cs b/LehrerApp.Tests/StudentImportServiceTests.cs
new file mode 100644
index 0000000..24048bd
--- /dev/null
+++ b/LehrerApp.Tests/StudentImportServiceTests.cs
@@ -0,0 +1,400 @@
+using LehrerApp.Core.Importing;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class StudentImportServiceTests
+{
+ private static readonly ImportFormatId TestFormat = new("test.students");
+
+ [Fact]
+ public void ImportFormatId_NormalisiertDenWert()
+ {
+ var id = new ImportFormatId(" TEST.Students ");
+
+ Assert.Equal(TestFormat, id);
+ Assert.Equal("test.students", id.ToString());
+ }
+
+ [Fact]
+ public async Task Catalog_WaehltBeiGleicherKonfidenzDiePassendeDateiendung()
+ {
+ var csv = new FakeHandler(new ImportFormatId("test.csv"), "CSV", [".csv"], 80, []);
+ var xml = new FakeHandler(new ImportFormatId("test.xml"), "XML", [".xml"], 80, []);
+ var catalog = new ImportHandlerCatalog([xml, csv]);
+
+ var detected = await catalog.DetectAsync(new ImportFile("klasse.csv", "data"u8.ToArray()));
+
+ Assert.Same(csv, detected.Handler);
+ }
+
+ [Fact]
+ public async Task Analyze_EindeutigerTrefferWirdAutomatischVorhandenemSchuelerZugeordnet()
+ {
+ var existing = ExistingStudent();
+ var repository = new InMemoryStudentRepository([existing]);
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = " Erika ", LastName = " Mustermann ",
+ DateOfBirth = new DateOnly(2012, 4, 5),
+ },
+ ]);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ Assert.True(preview.CanApply);
+ Assert.Empty(preview.Conflicts);
+ var resolution = Assert.Single(preview.Entries).AutomaticResolution;
+ Assert.Equal(StudentImportResolutionKind.UseExisting, resolution?.Kind);
+ Assert.Equal(existing.Id, resolution?.ExistingStudentId);
+ }
+
+ [Fact]
+ public async Task Analyze_GleicherNameOhneEindeutigesGeburtsdatumErzeugtUINeutralenKonflikt()
+ {
+ var repository = new InMemoryStudentRepository([ExistingStudent()]);
+ var service = Service(repository,
+ [
+ new ImportedStudent { FirstName = "Erika", LastName = "Mustermann" },
+ ]);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ var conflict = Assert.Single(preview.Conflicts);
+ Assert.Equal(conflict.Id, Assert.Single(preview.Entries).ConflictId);
+ Assert.Contains(conflict.Options, option => option.Id == "create");
+ Assert.Contains(conflict.Options, option => option.Id == "skip");
+ Assert.Contains(conflict.Options, option => option.Id.StartsWith("existing:"));
+ }
+
+ [Fact]
+ public async Task Apply_VerlangtFuerJedenKonfliktEineEntscheidung()
+ {
+ var repository = new InMemoryStudentRepository([ExistingStudent()]);
+ var service = Service(repository,
+ [
+ new ImportedStudent { FirstName = "Erika", LastName = "Mustermann" },
+ ]);
+ var preview = await service.AnalyzeAsync(File());
+
+ var exception = await Assert.ThrowsAsync(
+ () => service.ApplyAsync(preview));
+
+ Assert.Contains("alle Konflikte", exception.Message);
+ Assert.Single(repository.Students);
+ }
+
+ [Fact]
+ public async Task Apply_LegtNeueSchuelerErstNachDerVorschauAn()
+ {
+ var repository = new InMemoryStudentRepository();
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = "Max", LastName = "Beispiel",
+ DateOfBirth = new DateOnly(2011, 9, 10), Gender = Gender.M,
+ },
+ ]);
+
+ var preview = await service.AnalyzeAsync(File());
+ Assert.Empty(repository.Students);
+
+ var result = await service.ApplyAsync(preview);
+
+ Assert.Equal(1, result.CreatedStudents);
+ var created = Assert.Single(repository.Students);
+ Assert.Equal("Max", created.FirstName);
+ Assert.Equal(Gender.M, created.Gender);
+ }
+
+ [Fact]
+ public async Task Apply_NachAenderungDerBestandsdatenVerlangtNeueVorschau()
+ {
+ var existing = ExistingStudent();
+ var repository = new InMemoryStudentRepository([existing]);
+ var service = Service(repository,
+ [
+ new ImportedStudent { FirstName = "Max", LastName = "Beispiel" },
+ ]);
+ var preview = await service.AnalyzeAsync(File());
+ existing.UpdatedAt = existing.UpdatedAt.AddSeconds(1);
+
+ var exception = await Assert.ThrowsAsync(
+ () => service.ApplyAsync(preview));
+
+ Assert.Contains("Vorschau", exception.Message);
+ Assert.Single(repository.Students);
+ }
+
+ [Fact]
+ public async Task Analyze_FehlenderNachnameVerhindertApply()
+ {
+ var repository = new InMemoryStudentRepository();
+ var service = Service(repository,
+ [
+ new ImportedStudent { FirstName = "Max", LastName = " " },
+ ]);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ Assert.False(preview.CanApply);
+ Assert.Contains(preview.Messages, message => message.Code == "student.last-name.required");
+ await Assert.ThrowsAsync(() => service.ApplyAsync(preview));
+ Assert.Empty(repository.Students);
+ }
+
+ [Fact]
+ public async Task Analyze_EindeutigDoppelteImportzeilenWerdenNichtGespeichert()
+ {
+ var repository = new InMemoryStudentRepository();
+ var student = new ImportedStudent
+ {
+ FirstName = "Max", LastName = "Beispiel",
+ DateOfBirth = new DateOnly(2011, 9, 10),
+ };
+ var service = Service(repository, [student, student]);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ Assert.False(preview.CanApply);
+ Assert.Contains(preview.Messages, message => message.Code == "student.duplicate");
+ await Assert.ThrowsAsync(() => service.ApplyAsync(preview));
+ Assert.Empty(repository.Students);
+ }
+
+ [Fact]
+ public async Task Apply_ErgaenztEinenAusgewaehltenVorhandenenSchuelerOhneBestehendeWerteZuUeberschreiben()
+ {
+ var existing = new Student
+ {
+ FirstName = "Max",
+ LastName = "Beispiel",
+ Gender = Gender.M,
+ };
+ var repository = new InMemoryStudentRepository([existing]);
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = "Max",
+ LastName = "Beispiel",
+ Gender = Gender.W,
+ DateOfBirth = new DateOnly(2011, 9, 10),
+ ExternalId = "4711",
+ Contact = new ImportedStudentContact
+ {
+ Email = "max@example.invalid",
+ MobilePhone = "01234",
+ Street = "Musterweg 1",
+ },
+ },
+ ]);
+ var preview = await service.AnalyzeAsync(File());
+ var conflict = Assert.Single(preview.Conflicts);
+ var useExisting = conflict.Options.Single(option => option.Id.StartsWith("existing:"));
+
+ var result = await service.ApplyAsync(preview,
+ [new ImportDecision(conflict.Id, useExisting.Id)]);
+
+ Assert.Equal(1, result.UpdatedStudents);
+ Assert.Equal(new DateOnly(2011, 9, 10), existing.DateOfBirth);
+ Assert.Equal(Gender.M, existing.Gender);
+ Assert.Equal("4711", existing.ExternalIds[TestFormat.Value]);
+ var contact = Assert.Single(existing.Contacts);
+ Assert.Equal("Stammdaten", contact.Relation);
+ Assert.Equal("max@example.invalid", contact.Email);
+ Assert.Equal("01234", contact.MobilePhone);
+ }
+
+ [Fact]
+ public async Task Apply_OrdnetBeiEindeutigerAktiverGruppeImAktuellenSchuljahrAutomatischZu()
+ {
+ var repository = new InMemoryStudentRepository();
+ var schoolYears = new SchoolYearService();
+ var group = new LearningGroup
+ {
+ Name = "10c",
+ SchoolYear = schoolYears.CurrentSchoolYear(),
+ GradeLevel = 10,
+ IsActive = true,
+ };
+ var groups = new InMemoryGroupRepository([group]);
+ var memberships = new InMemoryGroupMembershipRepository();
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = "Max", LastName = "Beispiel", GroupName = "10C",
+ },
+ ], groups, memberships, schoolYears);
+
+ var preview = await service.AnalyzeAsync(File());
+ Assert.Empty(preview.Conflicts);
+ Assert.Equal(group.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
+
+ var result = await service.ApplyAsync(preview);
+
+ Assert.Equal(1, result.CreatedMemberships);
+ var membership = Assert.Single(memberships.Memberships);
+ Assert.Equal(group.Id, membership.GroupId);
+ Assert.Equal(Assert.Single(repository.Students).Id, membership.StudentId);
+ }
+
+ [Fact]
+ public async Task Analyze_GruppeAusAnderemSchuljahrWirdNichtAutomatischVerwendet()
+ {
+ var repository = new InMemoryStudentRepository();
+ var schoolYears = new SchoolYearService();
+ var oldGroup = new LearningGroup
+ {
+ Name = "10c",
+ SchoolYear = schoolYears.RecentSchoolYears(2)[1],
+ GradeLevel = 10,
+ IsActive = true,
+ };
+ var groups = new InMemoryGroupRepository([oldGroup]);
+ var memberships = new InMemoryGroupMembershipRepository();
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = "Max", LastName = "Beispiel", GroupName = "10c",
+ },
+ ], groups, memberships, schoolYears);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ Assert.Null(Assert.Single(preview.GroupAssignments).AutomaticGroupId);
+ Assert.Contains(preview.Messages, message => message.Code == "student.group.not-found");
+ }
+
+ [Fact]
+ public async Task Analyze_BevorzugtBeiMehrerenNamensgleichenGruppenDieEigeneKlasse()
+ {
+ var repository = new InMemoryStudentRepository();
+ var schoolYears = new SchoolYearService();
+ var schoolYear = schoolYears.CurrentSchoolYear();
+ var subjectGroup = new LearningGroup
+ {
+ Name = "10c", SchoolYear = schoolYear, GradeLevel = 10,
+ Type = GroupType.Class, IsOwnClass = false,
+ };
+ var ownClass = new LearningGroup
+ {
+ Name = "10c", SchoolYear = schoolYear, GradeLevel = 10,
+ Type = GroupType.Class, IsOwnClass = true,
+ };
+ var service = Service(repository,
+ [
+ new ImportedStudent
+ {
+ FirstName = "Max", LastName = "Beispiel", GroupName = "10c",
+ },
+ ], new InMemoryGroupRepository([subjectGroup, ownClass]),
+ new InMemoryGroupMembershipRepository(), schoolYears);
+
+ var preview = await service.AnalyzeAsync(File());
+
+ Assert.Empty(preview.Conflicts);
+ Assert.Equal(ownClass.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
+ }
+
+ private static StudentImportService Service(
+ InMemoryStudentRepository repository,
+ IReadOnlyList imported,
+ InMemoryGroupRepository? groups = null,
+ InMemoryGroupMembershipRepository? memberships = null,
+ SchoolYearService? schoolYears = null) =>
+ new(
+ [new FakeHandler(TestFormat, "Testformat", [".test"], 100, imported)],
+ repository,
+ groups ?? new InMemoryGroupRepository(),
+ memberships ?? new InMemoryGroupMembershipRepository(),
+ schoolYears ?? new SchoolYearService());
+
+ private static ImportFile File() => new("schueler.test", "test"u8.ToArray());
+
+ private static Student ExistingStudent() => new()
+ {
+ FirstName = "Erika",
+ LastName = "Mustermann",
+ DateOfBirth = new DateOnly(2012, 4, 5),
+ };
+
+ private sealed class FakeHandler(
+ ImportFormatId formatId,
+ string displayName,
+ IReadOnlyCollection extensions,
+ int confidence,
+ IReadOnlyList imported) : IImportHandler
+ {
+ public ImportFormatId FormatId => formatId;
+ public string DisplayName => displayName;
+ public IReadOnlyCollection SupportedExtensions => extensions;
+
+ public ValueTask DetectAsync(
+ ImportFile file,
+ CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(ImportDetection.Match(confidence));
+
+ public ValueTask> ParseAsync(
+ ImportFile file,
+ CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(new ImportParseResult(imported));
+ }
+
+ private sealed class InMemoryStudentRepository(IEnumerable? initial = null)
+ : IStudentRepository
+ {
+ public List Students { get; } = initial?.ToList() ?? [];
+
+ public Student? GetById(Guid id) => Students.FirstOrDefault(student => student.Id == id);
+ public List GetAll(bool includeInactive = false) => Students
+ .Where(student => includeInactive || student.IsActive)
+ .ToList();
+ public List GetByGroup(Guid groupId) => [];
+ public StudentReferenceSummary GetReferenceSummary(Guid studentId) => new(0, 0, 0, 0, 0, 0);
+ public void Save(Student student)
+ {
+ var index = Students.FindIndex(existing => existing.Id == student.Id);
+ if (index >= 0) Students[index] = student;
+ else Students.Add(student);
+ }
+ public void Delete(Guid id) => Students.RemoveAll(student => student.Id == id);
+ }
+
+ private sealed class InMemoryGroupRepository(IEnumerable? initial = null)
+ : IGroupRepository
+ {
+ public List Groups { get; } = initial?.ToList() ?? [];
+ public LearningGroup? GetById(Guid id) => Groups.FirstOrDefault(group => group.Id == id);
+ public List GetAll(bool includeInactive = false) => Groups
+ .Where(group => includeInactive || group.IsActive)
+ .ToList();
+ public List GetBySchoolYear(string schoolYear, bool includeInactive = false) => Groups
+ .Where(group => group.SchoolYear == schoolYear && (includeInactive || group.IsActive))
+ .ToList();
+ public void Save(LearningGroup group) => Groups.Add(group);
+ public void Delete(Guid id) => Groups.RemoveAll(group => group.Id == id);
+ }
+
+ private sealed class InMemoryGroupMembershipRepository : IGroupMembershipRepository
+ {
+ public List Memberships { get; } = [];
+ public List GetByStudent(Guid studentId) =>
+ Memberships.Where(membership => membership.StudentId == studentId).ToList();
+ public List GetByGroup(Guid groupId) =>
+ Memberships.Where(membership => membership.GroupId == groupId).ToList();
+ public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
+ Memberships.FirstOrDefault(membership =>
+ membership.StudentId == studentId && membership.GroupId == groupId);
+ public void Save(GroupMembership membership) => Memberships.Add(membership);
+ public void Delete(Guid id) => Memberships.RemoveAll(membership => membership.Id == id);
+ }
+}
diff --git a/LehrerApp.Tests/StudentMasterDataCsvImportHandlerTests.cs b/LehrerApp.Tests/StudentMasterDataCsvImportHandlerTests.cs
new file mode 100644
index 0000000..eff8d6a
--- /dev/null
+++ b/LehrerApp.Tests/StudentMasterDataCsvImportHandlerTests.cs
@@ -0,0 +1,103 @@
+using System.Text;
+using LehrerApp.Core.Importing;
+using LehrerApp.Core.Models;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class StudentMasterDataCsvImportHandlerTests
+{
+ private readonly StudentMasterDataCsvImportHandler _handler = new();
+
+ [Fact]
+ public async Task Detect_ErkenntDieCharakteristischenSpaltenUnabhaengigVomDateinamen()
+ {
+ var detection = await _handler.DetectAsync(File(ValidFile(), "export.data"));
+
+ Assert.True(detection.IsMatch);
+ Assert.Equal(95, detection.Confidence);
+ }
+
+ [Fact]
+ public async Task Detect_LehntEineBeliebigeCsvDateiAb()
+ {
+ var detection = await _handler.DetectAsync(File("Vorname;Nachname\nMax;Beispiel"));
+
+ Assert.False(detection.IsMatch);
+ }
+
+ [Fact]
+ public async Task Parse_UebernimmtDieUnterstuetztenStammdaten()
+ {
+ var result = await _handler.ParseAsync(File(ValidFile()));
+
+ Assert.True(result.IsValid);
+ Assert.Equal(2, result.Items.Count);
+
+ var first = result.Items[0];
+ Assert.Equal("Max", first.FirstName);
+ Assert.Equal("Beispiel", first.LastName);
+ Assert.Equal(Gender.M, first.Gender);
+ Assert.Equal(new DateOnly(2010, 1, 2), first.DateOfBirth);
+ Assert.Equal("4711", first.ExternalId);
+ Assert.Equal("10c", first.GroupName);
+ Assert.Equal("Zeile 2", first.SourceReference);
+ Assert.Equal("max@example.invalid", first.Contact?.Email);
+ Assert.Equal("01234", first.Contact?.Phone);
+ Assert.Equal("Musterweg 1", first.Contact?.Street);
+
+ Assert.Equal(Gender.W, result.Items[1].Gender);
+ Assert.Empty(result.Messages);
+ }
+
+ [Fact]
+ public async Task Parse_UngueltigesGeburtsdatumErzeugtEinenFehlerMitZeilenangabe()
+ {
+ var text = ValidFile().Replace("02.01.2010", "2010-01-02");
+
+ var result = await _handler.ParseAsync(File(text));
+
+ Assert.False(result.IsValid);
+ var error = Assert.Single(result.Messages,
+ message => message.Code == "master-data.birth-date.invalid");
+ Assert.Equal("Zeile 2", error.SourceReference);
+ }
+
+ [Fact]
+ public async Task Parse_BehandeltTabulatorenUndAnfuehrungszeichenInTextfeldern()
+ {
+ var text = ValidFile().Replace("Musterweg 1", "\"Musterweg\t\"\"Nord\"\" 1\"");
+
+ var result = await _handler.ParseAsync(File(text));
+
+ Assert.True(result.IsValid);
+ Assert.Equal(2, result.Items.Count);
+ }
+
+ private static ImportFile File(string text, string name = "students.csv") =>
+ new(name, Encoding.UTF8.GetBytes(text));
+
+ private static string ValidFile()
+ {
+ var header = string.Join('\t',
+ [
+ "name", "longName", "foreName", "gender", "birthDate", "klasse.name",
+ "entryDate", "exitDate", "text", "id", "externKey", "medicalReportDuty",
+ "schulpflicht", "majority", "address.email", "address.mobile", "address.phone",
+ "address.city", "address.postCode", "address.street", "attribute.iL",
+ ]);
+ var first = string.Join('\t',
+ [
+ "max.b", "Beispiel", "Max", "m", "02.01.2010", "10c",
+ "01.08.2021", "", "", "100", "4711", "false", "false", "false",
+ "max@example.invalid", "", "01234", "Beispielstadt", "12345", "Musterweg 1", "max.b",
+ ]);
+ var second = string.Join('\t',
+ [
+ "erika.m", "Mustermann", "Erika", "w", "03.04.2010", "10c",
+ "01.08.2021", "", "", "101", "4712", "false", "false", "false",
+ "", "", "", "", "", "", "erika.m",
+ ]);
+ return string.Join('\n', header, first, second) + "\n";
+ }
+}