feat: add student master data import
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ public class Student
|
|||||||
public string FullName => $"{LastName}, {FirstName}";
|
public string FullName => $"{LastName}, {FirstName}";
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public Gender? Gender { get; set; }
|
public Gender? Gender { get; set; }
|
||||||
|
/// <summary>Quellsystembezogene Kennungen, z.B. für wiederholbare Stammdatenimporte.</summary>
|
||||||
|
public Dictionary<string, string> ExternalIds { get; set; } = [];
|
||||||
public List<Contact> Contacts { get; set; } = [];
|
public List<Contact> Contacts { get; set; } = [];
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
@@ -22,6 +24,7 @@ public class Contact
|
|||||||
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
|
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
|
||||||
public string? LetterSalutation { get; set; }
|
public string? LetterSalutation { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Street { get; set; }
|
public string? Street { get; set; }
|
||||||
public string? PostalCode { get; set; }
|
public string? PostalCode { get; set; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestriert den Schülerimport ohne UI-Abhängigkeit: Format erkennen, vollständig
|
||||||
|
/// analysieren, Konflikte beschreiben und erst nach Bestätigung speichern.
|
||||||
|
/// </summary>
|
||||||
|
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<ImportedStudent> _handlers;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
|
private readonly SchoolYearService _schoolYears;
|
||||||
|
|
||||||
|
public StudentImportService(
|
||||||
|
IEnumerable<IImportHandler<ImportedStudent>> handlers,
|
||||||
|
IStudentRepository students,
|
||||||
|
IGroupRepository groups,
|
||||||
|
IGroupMembershipRepository memberships,
|
||||||
|
SchoolYearService schoolYears)
|
||||||
|
{
|
||||||
|
_handlers = new ImportHandlerCatalog<ImportedStudent>(handlers);
|
||||||
|
_students = students;
|
||||||
|
_groups = groups;
|
||||||
|
_memberships = memberships;
|
||||||
|
_schoolYears = schoolYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IImportHandler<ImportedStudent>> Handlers => _handlers.Handlers;
|
||||||
|
public IReadOnlyList<string> SupportedExtensions => _handlers.SupportedExtensions;
|
||||||
|
|
||||||
|
public async Task<StudentImportPreview> 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<StudentImportEntry>();
|
||||||
|
var conflicts = new List<ImportConflict>();
|
||||||
|
|
||||||
|
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<StudentImportApplyResult> ApplyAsync(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
IEnumerable<ImportDecision>? 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<Guid>();
|
||||||
|
var studentIdsByEntry = new Dictionary<string, Guid?>(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<ImportedStudent> imported,
|
||||||
|
ICollection<ImportMessage> 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<Student> 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<Student> FindByExternalId(
|
||||||
|
IEnumerable<Student> 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<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
var differences = new List<string>();
|
||||||
|
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<string> FieldsToSupplement(
|
||||||
|
Student existing,
|
||||||
|
ImportedStudent imported,
|
||||||
|
ImportFormatId formatId)
|
||||||
|
{
|
||||||
|
var fields = new List<string>();
|
||||||
|
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<string> 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<StudentImportGroupAssignment> BuildGroupAssignments(
|
||||||
|
IEnumerable<ImportedStudent> imported,
|
||||||
|
IReadOnlyList<LearningGroup> currentGroups,
|
||||||
|
ICollection<ImportConflict> conflicts,
|
||||||
|
ICollection<ImportMessage> messages)
|
||||||
|
{
|
||||||
|
var result = new List<StudentImportGroupAssignment>();
|
||||||
|
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<string, ImportDecision> BuildDecisionMap(
|
||||||
|
StudentImportPreview preview,
|
||||||
|
IEnumerable<ImportDecision> decisions)
|
||||||
|
{
|
||||||
|
var conflicts = preview.Conflicts.ToDictionary(conflict => conflict.Id, StringComparer.Ordinal);
|
||||||
|
var map = new Dictionary<string, ImportDecision>(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<string, ImportDecision> 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<string, ImportDecision> 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<StudentImportResolution> resolutions,
|
||||||
|
IReadOnlyCollection<Student> 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<Guid?> resolutions,
|
||||||
|
IReadOnlyCollection<LearningGroup> 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<string> 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<Student> 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<LearningGroup> groups,
|
||||||
|
IEnumerable<GroupMembership> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
db.Documentation.Count(d => d.StudentId == studentId));
|
db.Documentation.Count(d => d.StudentId == studentId));
|
||||||
public void Save(Student s)
|
public void Save(Student s)
|
||||||
{
|
{
|
||||||
|
s.ExternalIds ??= [];
|
||||||
|
s.Contacts ??= [];
|
||||||
s.UpdatedAt = DateTime.UtcNow;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
db.Students.Upsert(s);
|
db.Students.Upsert(s);
|
||||||
db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
|
db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s);
|
||||||
|
|||||||
@@ -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<Student>();
|
||||||
|
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<Student>
|
||||||
|
{
|
||||||
|
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<Student> stored,
|
||||||
|
IReadOnlyList<ImportedStudent> 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<ImportedStudent> imported)
|
||||||
|
: IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
public ImportFormatId FormatId { get; } = new("test.students");
|
||||||
|
public string DisplayName => "Testformat";
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(ImportDetection.Match(100));
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(imported));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Importing;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Data.Repositories;
|
using LehrerApp.Data.Repositories;
|
||||||
@@ -111,6 +112,8 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||||
services.AddSingleton<AttendanceBalanceService>();
|
services.AddSingleton<AttendanceBalanceService>();
|
||||||
services.AddSingleton<PersonalDataExportService>();
|
services.AddSingleton<PersonalDataExportService>();
|
||||||
|
services.AddSingleton<IImportHandler<ImportedStudent>, StudentMasterDataCsvImportHandler>();
|
||||||
|
services.AddSingleton<StudentImportService>();
|
||||||
|
|
||||||
// ── Datenbank ─────────────────────────────────────────────────────────
|
// ── Datenbank ─────────────────────────────────────────────────────────
|
||||||
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
|
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
|
||||||
|
|||||||
@@ -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<StudentImportMessageItem> Messages { get; } = [];
|
||||||
|
public ObservableCollection<StudentImportConflictItem> 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<ImportDecision> BuildDecisions() => Conflicts
|
||||||
|
.Select(conflict => new ImportDecision(conflict.Id, conflict.SelectedOption.Id))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
|
||||||
|
public async Task<bool> 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<StudentImportConflictOptionItem> 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);
|
||||||
@@ -572,14 +572,16 @@ public class ContactItem
|
|||||||
public string Relation { get; }
|
public string Relation { get; }
|
||||||
public string LetterSalutation { get; }
|
public string LetterSalutation { get; }
|
||||||
public string? Phone { get; }
|
public string? Phone { get; }
|
||||||
|
public string? MobilePhone { get; }
|
||||||
public string? Email { get; }
|
public string? Email { get; }
|
||||||
public string Address { 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 HasEmail => !string.IsNullOrEmpty(Email);
|
||||||
public bool HasAddress => !string.IsNullOrEmpty(Address);
|
public bool HasAddress => !string.IsNullOrEmpty(Address);
|
||||||
public bool IsInvalid => Model.InvalidSince.HasValue;
|
public bool IsInvalid => Model.InvalidSince.HasValue;
|
||||||
public bool IsValid => !IsInvalid;
|
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 EmailDisplay => Email ?? "";
|
||||||
public string StatusText => IsInvalid
|
public string StatusText => IsInvalid
|
||||||
? $"Ungültig seit {Model.InvalidSince:dd.MM.yyyy} · {InvalidReasonText(Model.InvalidReason)}"
|
? $"Ungültig seit {Model.InvalidSince:dd.MM.yyyy} · {InvalidReasonText(Model.InvalidReason)}"
|
||||||
@@ -595,9 +597,10 @@ public class ContactItem
|
|||||||
Relation = c.Relation;
|
Relation = c.Relation;
|
||||||
LetterSalutation = c.LetterSalutation ?? "";
|
LetterSalutation = c.LetterSalutation ?? "";
|
||||||
Phone = c.Phone;
|
Phone = c.Phone;
|
||||||
|
MobilePhone = c.MobilePhone;
|
||||||
Email = c.Email;
|
Email = c.Email;
|
||||||
Address = FormatAddress(c);
|
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);
|
MailCommand = new RelayCommand(() => OpenUri($"mailto:{Email}"), () => HasEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +699,7 @@ public partial class ContactEntryViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _relation = "";
|
[ObservableProperty] private string _relation = "";
|
||||||
[ObservableProperty] private string _letterSalutation = "";
|
[ObservableProperty] private string _letterSalutation = "";
|
||||||
[ObservableProperty] private string _phone = "";
|
[ObservableProperty] private string _phone = "";
|
||||||
|
[ObservableProperty] private string _mobilePhone = "";
|
||||||
[ObservableProperty] private string _email = "";
|
[ObservableProperty] private string _email = "";
|
||||||
[ObservableProperty] private string _street = "";
|
[ObservableProperty] private string _street = "";
|
||||||
[ObservableProperty] private string _postalCode = "";
|
[ObservableProperty] private string _postalCode = "";
|
||||||
@@ -711,6 +715,7 @@ public partial class ContactEntryViewModel : ObservableObject
|
|||||||
Relation = Relation.Trim(),
|
Relation = Relation.Trim(),
|
||||||
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
|
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
|
||||||
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
||||||
|
MobilePhone = string.IsNullOrWhiteSpace(MobilePhone) ? null : MobilePhone.Trim(),
|
||||||
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
||||||
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
||||||
PostalCode = string.IsNullOrWhiteSpace(PostalCode) ? null : PostalCode.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 _relation = "Elternteil";
|
||||||
[ObservableProperty] private string _letterSalutation = "";
|
[ObservableProperty] private string _letterSalutation = "";
|
||||||
[ObservableProperty] private string _phone = "";
|
[ObservableProperty] private string _phone = "";
|
||||||
|
[ObservableProperty] private string _mobilePhone = "";
|
||||||
[ObservableProperty] private string _email = "";
|
[ObservableProperty] private string _email = "";
|
||||||
[ObservableProperty] private string _street = "";
|
[ObservableProperty] private string _street = "";
|
||||||
[ObservableProperty] private string _postalCode = "";
|
[ObservableProperty] private string _postalCode = "";
|
||||||
@@ -753,6 +759,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
Relation = source.Relation;
|
Relation = source.Relation;
|
||||||
LetterSalutation = source.LetterSalutation ?? "";
|
LetterSalutation = source.LetterSalutation ?? "";
|
||||||
Phone = source.Phone ?? "";
|
Phone = source.Phone ?? "";
|
||||||
|
MobilePhone = source.MobilePhone ?? "";
|
||||||
Email = source.Email ?? "";
|
Email = source.Email ?? "";
|
||||||
Street = source.Street ?? "";
|
Street = source.Street ?? "";
|
||||||
PostalCode = source.PostalCode ?? "";
|
PostalCode = source.PostalCode ?? "";
|
||||||
@@ -804,6 +811,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
Relation = Relation.Trim(),
|
Relation = Relation.Trim(),
|
||||||
LetterSalutation = NullIfEmpty(LetterSalutation),
|
LetterSalutation = NullIfEmpty(LetterSalutation),
|
||||||
Phone = NullIfEmpty(Phone),
|
Phone = NullIfEmpty(Phone),
|
||||||
|
MobilePhone = NullIfEmpty(MobilePhone),
|
||||||
Email = NullIfEmpty(Email),
|
Email = NullIfEmpty(Email),
|
||||||
Street = NullIfEmpty(Street),
|
Street = NullIfEmpty(Street),
|
||||||
PostalCode = NullIfEmpty(PostalCode),
|
PostalCode = NullIfEmpty(PostalCode),
|
||||||
|
|||||||
@@ -75,13 +75,14 @@
|
|||||||
<TextBox Text="{Binding LetterSalutation}"
|
<TextBox Text="{Binding LetterSalutation}"
|
||||||
PlaceholderText="Briefanrede, z.B. Sehr geehrte Frau Mustermann,"
|
PlaceholderText="Briefanrede, z.B. Sehr geehrte Frau Mustermann,"
|
||||||
FontSize="12"/>
|
FontSize="12"/>
|
||||||
<!-- Zeile 2: Telefon + E-Mail -->
|
<!-- Zeile 2: Telefon + Mobiltelefon -->
|
||||||
<Grid ColumnDefinitions="*,10,*">
|
<Grid ColumnDefinitions="*,10,*">
|
||||||
<TextBox Grid.Column="0" Text="{Binding Phone}"
|
<TextBox Grid.Column="0" Text="{Binding Phone}"
|
||||||
PlaceholderText="Telefon / Handy" FontSize="12"/>
|
PlaceholderText="Festnetz" FontSize="12"/>
|
||||||
<TextBox Grid.Column="2" Text="{Binding Email}"
|
<TextBox Grid.Column="2" Text="{Binding MobilePhone}"
|
||||||
PlaceholderText="E-Mail" FontSize="12"/>
|
PlaceholderText="Mobiltelefon" FontSize="12"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<TextBox Text="{Binding Email}" PlaceholderText="E-Mail" FontSize="12"/>
|
||||||
<!-- Zeile 3: Adresse -->
|
<!-- Zeile 3: Adresse -->
|
||||||
<TextBox Text="{Binding Street}" PlaceholderText="Straße und Hausnummer" FontSize="12"/>
|
<TextBox Text="{Binding Street}" PlaceholderText="Straße und Hausnummer" FontSize="12"/>
|
||||||
<Grid ColumnDefinitions="100,10,*">
|
<Grid ColumnDefinitions="100,10,*">
|
||||||
|
|||||||
@@ -39,13 +39,18 @@
|
|||||||
<Grid ColumnDefinitions="*,10,*">
|
<Grid ColumnDefinitions="*,10,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Telefon" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Telefon" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding Phone}" PlaceholderText="Telefon / Handy"/>
|
<TextBox Text="{Binding Phone}" PlaceholderText="Festnetz"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Mobiltelefon" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding MobilePhone}" PlaceholderText="Mobiltelefon"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="E-Mail" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="E-Mail" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding Email}" PlaceholderText="E-Mail"/>
|
<TextBox Text="{Binding Email}" PlaceholderText="E-Mail"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Text="Adresse" FontSize="14" FontWeight="SemiBold"/>
|
<TextBlock Text="Adresse" FontSize="14" FontWeight="SemiBold"/>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Students.StudentImportDialog"
|
||||||
|
x:DataType="vm:StudentImportDialogViewModel"
|
||||||
|
Title="Schüler importieren"
|
||||||
|
Width="820" Height="760" MinWidth="660" MinHeight="580"
|
||||||
|
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="9">
|
||||||
|
<TextBlock Text="Schülerimport prüfen" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding FormatName}" FontSize="12" Opacity="0.65"/>
|
||||||
|
|
||||||
|
<Border Background="#143B82F6" CornerRadius="6" Padding="12,10">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Summary}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding GroupSummary}" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="Vorhandene Werte bleiben erhalten; nur leere Stammdaten werden ergänzt."
|
||||||
|
FontSize="12" Opacity="0.75" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer MaxHeight="130" IsVisible="{Binding HasMessages}">
|
||||||
|
<ItemsControl ItemsSource="{Binding Messages}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StudentImportMessageItem">
|
||||||
|
<TextBlock Text="{Binding Display}" Foreground="{Binding Foreground}"
|
||||||
|
TextWrapping="Wrap" FontSize="12" Margin="0,1"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,16,0,0">
|
||||||
|
<TextBlock Grid.Row="0" Text="Entscheidungen" FontSize="15" FontWeight="SemiBold"
|
||||||
|
Margin="0,0,0,8" IsVisible="{Binding HasConflicts}"/>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" IsVisible="{Binding IsReadyWithoutConflicts}"
|
||||||
|
Background="#1422A06B" CornerRadius="6" Padding="14"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<TextBlock Text="Keine Konflikte – der Import kann angewendet werden."
|
||||||
|
Foreground="#16845B" FontWeight="SemiBold"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" IsVisible="{Binding HasBlockingErrors}"
|
||||||
|
Background="#14C62828" CornerRadius="6" Padding="14"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<TextBlock Text="Die Datei enthält Fehler. Bitte korrigiere sie und starte den Import erneut."
|
||||||
|
Foreground="#C62828" FontWeight="SemiBold" TextWrapping="Wrap"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasConflicts}">
|
||||||
|
<ItemsControl ItemsSource="{Binding Conflicts}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StudentImportConflictItem">
|
||||||
|
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="12" Margin="0,0,0,10">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Title}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding SourceReference}" FontSize="11" Opacity="0.55"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="12" Opacity="0.75"/>
|
||||||
|
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Importiert:" FontSize="12" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ImportedValue}" TextWrapping="Wrap" FontSize="12"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Vorhanden:" FontSize="12" Opacity="0.6"
|
||||||
|
IsVisible="{Binding ExistingValue, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ExistingValue}" TextWrapping="Wrap" FontSize="12"/>
|
||||||
|
</Grid>
|
||||||
|
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StudentImportConflictOptionItem">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding Label}"/>
|
||||||
|
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.6"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Spacing="10" Margin="0,14,0,0">
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="#C62828" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<Grid ColumnDefinitions="*,10,*">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Import anwenden" HorizontalAlignment="Stretch"
|
||||||
|
IsEnabled="{Binding CanApply}" Click="OnApply"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@
|
|||||||
<Border Grid.Row="0" Padding="20,16"
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
BorderThickness="0,0,0,1">
|
BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
<shared:PageHeader Grid.Column="0" Title="Schüler" Subtitle="{Binding CountSummary}"/>
|
<shared:PageHeader Grid.Column="0" Title="Schüler" Subtitle="{Binding CountSummary}"/>
|
||||||
<CheckBox Grid.Column="1" Content="Inaktive anzeigen"
|
<CheckBox Grid.Column="1" Content="Inaktive anzeigen"
|
||||||
IsChecked="{Binding ShowInactive}"
|
IsChecked="{Binding ShowInactive}"
|
||||||
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
<Button Grid.Column="2" Content="+ Neuer Schüler"
|
<Button Grid.Column="2" Content="⇩ Importieren…" Click="OnImportClick"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<Button Grid.Column="3" Content="+ Neuer Schüler"
|
||||||
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/>
|
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -1,3 +1,71 @@
|
|||||||
using Avalonia.Controls;
|
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;
|
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<StudentImportService>();
|
||||||
|
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<bool>(owner)) return;
|
||||||
|
|
||||||
|
list.LoadStudents();
|
||||||
|
var result = dialogVm.Result!;
|
||||||
|
App.Services.GetRequiredService<NotificationService>().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<NotificationService>().ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ImportedStudent>([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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ImportedStudent> 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<string> extensions,
|
||||||
|
int confidence,
|
||||||
|
IReadOnlyList<ImportedStudent> imported) : IImportHandler<ImportedStudent>
|
||||||
|
{
|
||||||
|
public ImportFormatId FormatId => formatId;
|
||||||
|
public string DisplayName => displayName;
|
||||||
|
public IReadOnlyCollection<string> SupportedExtensions => extensions;
|
||||||
|
|
||||||
|
public ValueTask<ImportDetection> DetectAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(ImportDetection.Match(confidence));
|
||||||
|
|
||||||
|
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
|
||||||
|
ImportFile file,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(imported));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InMemoryStudentRepository(IEnumerable<Student>? initial = null)
|
||||||
|
: IStudentRepository
|
||||||
|
{
|
||||||
|
public List<Student> Students { get; } = initial?.ToList() ?? [];
|
||||||
|
|
||||||
|
public Student? GetById(Guid id) => Students.FirstOrDefault(student => student.Id == id);
|
||||||
|
public List<Student> GetAll(bool includeInactive = false) => Students
|
||||||
|
.Where(student => includeInactive || student.IsActive)
|
||||||
|
.ToList();
|
||||||
|
public List<Student> 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<LearningGroup>? initial = null)
|
||||||
|
: IGroupRepository
|
||||||
|
{
|
||||||
|
public List<LearningGroup> Groups { get; } = initial?.ToList() ?? [];
|
||||||
|
public LearningGroup? GetById(Guid id) => Groups.FirstOrDefault(group => group.Id == id);
|
||||||
|
public List<LearningGroup> GetAll(bool includeInactive = false) => Groups
|
||||||
|
.Where(group => includeInactive || group.IsActive)
|
||||||
|
.ToList();
|
||||||
|
public List<LearningGroup> 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<GroupMembership> Memberships { get; } = [];
|
||||||
|
public List<GroupMembership> GetByStudent(Guid studentId) =>
|
||||||
|
Memberships.Where(membership => membership.StudentId == studentId).ToList();
|
||||||
|
public List<GroupMembership> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user