feat: add student master data import

This commit is contained in:
2026-08-17 13:12:03 +02:00
parent 9286bfa3b5
commit 37f4fee574
18 changed files with 2359 additions and 13 deletions
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
namespace LehrerApp.Core.Importing;
/// <summary>
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
/// </summary>
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 ?? "";
}
/// <summary>Eine vollständig in den Speicher geladene Importdatei.</summary>
public sealed class ImportFile
{
public string FileName { get; }
public ReadOnlyMemory<byte> Content { get; }
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
public ImportFile(string fileName, ReadOnlyMemory<byte> 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);
/// <summary>
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
/// </summary>
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<T>
{
public IReadOnlyList<T> Items { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
public ImportParseResult(IEnumerable<T> items, IEnumerable<ImportMessage>? messages = null)
{
ArgumentNullException.ThrowIfNull(items);
Items = ReadOnly(items);
Messages = ReadOnly(messages ?? []);
}
private static IReadOnlyList<TItem> ReadOnly<TItem>(IEnumerable<TItem> values) =>
new ReadOnlyCollection<TItem>(values.ToList());
}
/// <summary>
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
/// normalisiertes fachliches Importmodell.
/// </summary>
public interface IImportHandler<T>
{
ImportFormatId FormatId { get; }
string DisplayName { get; }
IReadOnlyCollection<string> SupportedExtensions { get; }
ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default);
ValueTask<ImportParseResult<T>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default);
}
public sealed record DetectedImportHandler<T>(
IImportHandler<T> 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<string> formats)
: ImportFormatException(
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
/// <summary>
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
/// </summary>
public sealed class ImportHandlerCatalog<T>
{
private readonly IReadOnlyList<IImportHandler<T>> _handlers;
public ImportHandlerCatalog(IEnumerable<IImportHandler<T>> 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<IImportHandler<T>>(materialized);
}
public IReadOnlyList<IImportHandler<T>> Handlers => _handlers;
public IReadOnlyList<string> 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<DetectedImportHandler<T>> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
var matches = new List<Candidate>();
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<T>(best[0].Handler, best[0].Detection);
}
private static bool SupportsExtension(IImportHandler<T> 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<T> 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<ImportConflictOption> Options,
string DefaultOptionId,
string? SourceReference = null);
/// <summary>Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.</summary>
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);
@@ -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));
}
/// <summary>Formatunabhängige Schülerdaten, die ein konkreter Handler erzeugt.</summary>
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<string> 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<StudentImportEntry> Entries { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public IReadOnlyList<ImportConflict> Conflicts { get; }
public IReadOnlyList<StudentImportGroupAssignment> 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<StudentImportEntry> entries,
IEnumerable<ImportMessage> messages,
IEnumerable<ImportConflict> conflicts,
IEnumerable<StudentImportGroupAssignment> 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<T> ReadOnly<T>(IEnumerable<T> values) =>
new ReadOnlyCollection<T>(values.ToList());
}
public sealed record StudentImportApplyResult(
int CreatedStudents,
int UpdatedStudents,
int MatchedExistingStudents,
int SkippedStudents,
int CreatedMemberships,
IReadOnlyList<Guid> CreatedStudentIds);
@@ -0,0 +1,298 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
/// <summary>
/// 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.
/// </summary>
public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedStudent>
{
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<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> 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<ImportParseResult<ImportedStudent>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = new List<ImportMessage>();
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<ImportedStudent>([], 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<ImportedStudent>([], messages));
}
if (records.Count == 0)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.empty",
"Die Stammdatenliste ist leer."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], 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<ImportedStudent>([], messages));
var students = new List<ImportedStudent>();
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<ImportedStudent>(students, messages));
}
private static Gender? ParseGender(
string value,
string source,
ICollection<ImportMessage> 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<ImportMessage> 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<ImportMessage> 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<string, int> 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<string> Fields);
private static class DelimitedTextParser
{
public static bool TryParse(
string text,
char delimiter,
out List<DelimitedRecord> records,
out string? error)
{
records = [];
error = null;
var fields = new List<string>();
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;
}
}
}