feat: add student master data import

This commit is contained in:
2026-08-17 13:12:03 +02:00
parent 9286bfa3b5
commit 37f4fee574
18 changed files with 2359 additions and 13 deletions
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
namespace LehrerApp.Core.Importing;
/// <summary>
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
/// </summary>
public readonly record struct ImportFormatId
{
public string Value { get; }
public ImportFormatId(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value ?? "";
}
/// <summary>Eine vollständig in den Speicher geladene Importdatei.</summary>
public sealed class ImportFile
{
public string FileName { get; }
public ReadOnlyMemory<byte> Content { get; }
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
public ImportFile(string fileName, ReadOnlyMemory<byte> content)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
FileName = Path.GetFileName(fileName);
Content = content;
}
}
public enum ImportMessageSeverity { Information, Warning, Error }
public sealed record ImportMessage(
ImportMessageSeverity Severity,
string Code,
string Message,
string? SourceReference = null);
/// <summary>
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
/// </summary>
public sealed record ImportDetection
{
public int Confidence { get; }
public string? Reason { get; }
public bool IsMatch => Confidence > 0;
public ImportDetection(int confidence, string? reason = null)
{
if (confidence is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(confidence));
Confidence = confidence;
Reason = reason;
}
public static ImportDetection NoMatch(string? reason = null) => new(0, reason);
public static ImportDetection Match(int confidence, string? reason = null) =>
new(confidence, reason);
}
public sealed class ImportParseResult<T>
{
public IReadOnlyList<T> Items { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
public ImportParseResult(IEnumerable<T> items, IEnumerable<ImportMessage>? messages = null)
{
ArgumentNullException.ThrowIfNull(items);
Items = ReadOnly(items);
Messages = ReadOnly(messages ?? []);
}
private static IReadOnlyList<TItem> ReadOnly<TItem>(IEnumerable<TItem> values) =>
new ReadOnlyCollection<TItem>(values.ToList());
}
/// <summary>
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
/// normalisiertes fachliches Importmodell.
/// </summary>
public interface IImportHandler<T>
{
ImportFormatId FormatId { get; }
string DisplayName { get; }
IReadOnlyCollection<string> SupportedExtensions { get; }
ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default);
ValueTask<ImportParseResult<T>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default);
}
public sealed record DetectedImportHandler<T>(
IImportHandler<T> Handler,
ImportDetection Detection);
public abstract class ImportFormatException(string message) : Exception(message);
public sealed class UnsupportedImportFormatException(string fileName)
: ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden.");
public sealed class AmbiguousImportFormatException(string fileName, IEnumerable<string> formats)
: ImportFormatException(
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
/// <summary>
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
/// </summary>
public sealed class ImportHandlerCatalog<T>
{
private readonly IReadOnlyList<IImportHandler<T>> _handlers;
public ImportHandlerCatalog(IEnumerable<IImportHandler<T>> handlers)
{
ArgumentNullException.ThrowIfNull(handlers);
var materialized = handlers.ToList();
var duplicate = materialized
.GroupBy(handler => handler.FormatId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
throw new ArgumentException(
$"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers));
_handlers = new ReadOnlyCollection<IImportHandler<T>>(materialized);
}
public IReadOnlyList<IImportHandler<T>> Handlers => _handlers;
public IReadOnlyList<string> SupportedExtensions => _handlers
.SelectMany(handler => handler.SupportedExtensions)
.Select(NormalizeExtension)
.Where(extension => extension.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
public async ValueTask<DetectedImportHandler<T>> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
var matches = new List<Candidate>();
foreach (var handler in _handlers)
{
cancellationToken.ThrowIfCancellationRequested();
var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false);
if (detection.IsMatch)
matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension)));
}
if (matches.Count == 0)
throw new UnsupportedImportFormatException(file.FileName);
var highestConfidence = matches.Max(match => match.Detection.Confidence);
var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList();
if (best.Count > 1 && best.Any(match => match.ExtensionMatches))
best = best.Where(match => match.ExtensionMatches).ToList();
if (best.Count != 1)
throw new AmbiguousImportFormatException(
file.FileName,
best.Select(match => match.Handler.DisplayName));
return new DetectedImportHandler<T>(best[0].Handler, best[0].Detection);
}
private static bool SupportsExtension(IImportHandler<T> handler, string extension) =>
handler.SupportedExtensions
.Select(NormalizeExtension)
.Contains(extension, StringComparer.OrdinalIgnoreCase);
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrWhiteSpace(extension)) return "";
var trimmed = extension.Trim().ToLowerInvariant();
return trimmed.StartsWith('.') ? trimmed : $".{trimmed}";
}
private sealed record Candidate(
IImportHandler<T> Handler,
ImportDetection Detection,
bool ExtensionMatches);
}
public sealed record ImportConflictOption(string Id, string Label, string? Description = null);
public sealed record ImportConflict(
string Id,
string Title,
string Description,
string ImportedValue,
string? ExistingValue,
IReadOnlyList<ImportConflictOption> Options,
string DefaultOptionId,
string? SourceReference = null);
/// <summary>Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.</summary>
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);
@@ -0,0 +1,99 @@
using System.Collections.ObjectModel;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
public sealed record ImportedStudentContact
{
public string? Email { get; init; }
public string? MobilePhone { get; init; }
public string? Phone { get; init; }
public string? Street { get; init; }
public string? PostalCode { get; init; }
public string? City { get; init; }
public bool HasData => new[] { Email, MobilePhone, Phone, Street, PostalCode, City }
.Any(value => !string.IsNullOrWhiteSpace(value));
}
/// <summary>Formatunabhängige Schülerdaten, die ein konkreter Handler erzeugt.</summary>
public sealed record ImportedStudent
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public DateOnly? DateOfBirth { get; init; }
public Gender? Gender { get; init; }
public string? ExternalId { get; init; }
public string? GroupName { get; init; }
public ImportedStudentContact? Contact { get; init; }
public string? SourceReference { get; init; }
}
public static class StudentImportFormats
{
public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv");
}
public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip }
public sealed record StudentImportResolution(
StudentImportResolutionKind Kind,
Guid? ExistingStudentId = null);
public sealed record StudentImportEntry(
string Id,
ImportedStudent Student,
StudentImportResolution? AutomaticResolution,
string? ConflictId,
IReadOnlyList<string> FieldsToSupplement);
public sealed record StudentImportGroupAssignment(
string SourceGroupName,
Guid? AutomaticGroupId,
string? ConflictId);
public sealed class StudentImportPreview
{
public ImportFormatId FormatId { get; }
public string FormatName { get; }
public IReadOnlyList<StudentImportEntry> Entries { get; }
public IReadOnlyList<ImportMessage> Messages { get; }
public IReadOnlyList<ImportConflict> Conflicts { get; }
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
internal string ExistingStudentsFingerprint { get; }
internal string GroupStateFingerprint { get; }
public bool CanApply =>
Entries.Count > 0 && Messages.All(message => message.Severity != ImportMessageSeverity.Error);
internal StudentImportPreview(
ImportFormatId formatId,
string formatName,
IEnumerable<StudentImportEntry> entries,
IEnumerable<ImportMessage> messages,
IEnumerable<ImportConflict> conflicts,
IEnumerable<StudentImportGroupAssignment> groupAssignments,
string existingStudentsFingerprint,
string groupStateFingerprint)
{
FormatId = formatId;
FormatName = formatName;
Entries = ReadOnly(entries);
Messages = ReadOnly(messages);
Conflicts = ReadOnly(conflicts);
GroupAssignments = ReadOnly(groupAssignments);
ExistingStudentsFingerprint = existingStudentsFingerprint;
GroupStateFingerprint = groupStateFingerprint;
}
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> values) =>
new ReadOnlyCollection<T>(values.ToList());
}
public sealed record StudentImportApplyResult(
int CreatedStudents,
int UpdatedStudents,
int MatchedExistingStudents,
int SkippedStudents,
int CreatedMemberships,
IReadOnlyList<Guid> CreatedStudentIds);
@@ -0,0 +1,298 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
/// <summary>
/// Liest den tabulatorgetrennten Stammdatenexport mit Spalten wie „longName“,
/// „foreName“, „birthDate“ und „klasse.name“. Die Datei trägt historisch die
/// Endung .csv, obwohl als Trennzeichen ein Tabulator verwendet wird.
/// </summary>
public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["longName", "foreName", "gender", "birthDate", "klasse.name", "externKey"];
private static readonly UTF8Encoding StrictUtf8 = new(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true);
public ImportFormatId FormatId => StudentImportFormats.MasterDataCsv;
public string DisplayName => "Schüler-Stammdatenliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string text;
try
{
text = StrictUtf8.GetString(file.Content.Span);
}
catch (DecoderFallbackException)
{
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
}
var firstLineEnd = text.IndexOfAny(['\r', '\n']);
var firstLine = (firstLineEnd >= 0 ? text[..firstLineEnd] : text).TrimStart('\uFEFF');
var headers = firstLine.Split('\t')
.Select(header => header.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!SignatureHeaders.All(headers.Contains))
return ValueTask.FromResult(ImportDetection.NoMatch("Die erwarteten Stammdatenspalten fehlen."));
return ValueTask.FromResult(ImportDetection.Match(
file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
"Die charakteristischen Stammdatenspalten wurden gefunden."));
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = new List<ImportMessage>();
string text;
try
{
text = StrictUtf8.GetString(file.Content.Span);
}
catch (DecoderFallbackException)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.encoding",
"Die Stammdatenliste ist nicht als gültiges UTF-8 gespeichert."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.structure",
parseError ?? "Die Stammdatenliste ist syntaktisch ungültig."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
if (records.Count == 0)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.empty",
"Die Stammdatenliste ist leer."));
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
var headers = records[0].Fields
.Select((header, index) => (Name: header.Trim().TrimStart('\uFEFF'), Index: index))
.ToList();
var duplicateHeader = headers
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateHeader is not null)
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.header.duplicate",
$"Die Spalte „{duplicateHeader.Key}“ kommt mehrfach vor.",
"Kopfzeile"));
var columns = headers
.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase);
foreach (var required in SignatureHeaders.Where(required => !columns.ContainsKey(required)))
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.header.missing",
$"Die erforderliche Spalte „{required}“ fehlt.",
"Kopfzeile"));
if (messages.Any(message => message.Severity == ImportMessageSeverity.Error))
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
var students = new List<ImportedStudent>();
foreach (var record in records.Skip(1))
{
cancellationToken.ThrowIfCancellationRequested();
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
var source = $"Zeile {record.LineNumber}";
if (record.Fields.Count != headers.Count)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.columns",
$"Die Zeile enthält {record.Fields.Count} statt {headers.Count} Spalten.",
source));
continue;
}
var gender = ParseGender(Value(record, columns, "gender"), source, messages);
var dateOfBirth = ParseDate(Value(record, columns, "birthDate"), source, messages);
var externalId = FirstNotEmpty(
Value(record, columns, "externKey"),
Value(record, columns, "id"));
students.Add(new ImportedStudent
{
FirstName = Value(record, columns, "foreName"),
LastName = Value(record, columns, "longName"),
Gender = gender,
DateOfBirth = dateOfBirth,
ExternalId = externalId,
GroupName = NullIfWhiteSpace(Value(record, columns, "klasse.name")),
Contact = new ImportedStudentContact
{
Email = NullIfWhiteSpace(Value(record, columns, "address.email")),
MobilePhone = NullIfWhiteSpace(Value(record, columns, "address.mobile")),
Phone = NullIfWhiteSpace(Value(record, columns, "address.phone")),
City = NullIfWhiteSpace(Value(record, columns, "address.city")),
PostalCode = NullIfWhiteSpace(Value(record, columns, "address.postCode")),
Street = NullIfWhiteSpace(Value(record, columns, "address.street")),
},
SourceReference = source,
});
}
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
}
private static Gender? ParseGender(
string value,
string source,
ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim().ToLowerInvariant() switch
{
"m" or "männlich" or "maennlich" => Gender.M,
"w" or "weiblich" => Gender.W,
"d" or "divers" => Gender.D,
_ => UnknownGender(value, source, messages),
};
}
private static Gender? UnknownGender(
string value,
string source,
ICollection<ImportMessage> messages)
{
messages.Add(new ImportMessage(
ImportMessageSeverity.Warning,
"master-data.gender.unknown",
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.",
source));
return null;
}
private static DateOnly? ParseDate(
string value,
string source,
ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (DateOnly.TryParseExact(
value.Trim(),
"dd.MM.yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date))
return date;
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"master-data.birth-date.invalid",
$"Das Geburtsdatum „{value.Trim()}“ besitzt nicht das erwartete Format TT.MM.JJJJ.",
source));
return null;
}
private static string Value(
DelimitedRecord record,
IReadOnlyDictionary<string, int> columns,
string column) =>
columns.TryGetValue(column, out var index) && index < record.Fields.Count
? record.Fields[index].Trim()
: "";
private static string? FirstNotEmpty(params string[] values) =>
values.Select(NullIfWhiteSpace).FirstOrDefault(value => value is not null);
private static string? NullIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
private static class DelimitedTextParser
{
public static bool TryParse(
string text,
char delimiter,
out List<DelimitedRecord> records,
out string? error)
{
records = [];
error = null;
var fields = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var line = 1;
var recordStartLine = 1;
for (var index = 0; index < text.Length; index++)
{
var current = text[index];
if (current == '"')
{
if (inQuotes && index + 1 < text.Length && text[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (current == delimiter && !inQuotes)
{
fields.Add(field.ToString());
field.Clear();
continue;
}
if ((current == '\r' || current == '\n') && !inQuotes)
{
if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
index++;
fields.Add(field.ToString());
field.Clear();
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
fields.Clear();
line++;
recordStartLine = line;
continue;
}
if (current == '\n') line++;
field.Append(current);
}
if (inQuotes)
{
error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen.";
return false;
}
if (field.Length > 0 || fields.Count > 0)
{
fields.Add(field.ToString());
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
}
return true;
}
}
}
+3
View File
@@ -8,6 +8,8 @@ public class Student
public string FullName => $"{LastName}, {FirstName}";
public DateOnly? DateOfBirth { 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 string? Notes { get; set; }
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>
public string? LetterSalutation { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Email { get; set; }
public string? Street { get; set; }
public string? PostalCode { get; set; }
@@ -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();
}
}