226 lines
9.1 KiB
C#
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();
|
|
|
|
}
|