Files
LehrerApp/LehrerApp.Core/Importing/StudentMasterDataCsvImportHandler.cs
T

226 lines
9.1 KiB
C#

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();
}