using System.Globalization; using System.Text; using LehrerApp.Core.Importing; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; namespace LehrerApp.Core.Services; /// /// Orchestriert den Schülerimport ohne UI-Abhängigkeit: Format erkennen, vollständig /// analysieren, Konflikte beschreiben und erst nach Bestätigung speichern. /// 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 _handlers; private readonly IStudentRepository _students; private readonly IGroupRepository _groups; private readonly IGroupMembershipRepository _memberships; private readonly SchoolYearService _schoolYears; public StudentImportService( IEnumerable> handlers, IStudentRepository students, IGroupRepository groups, IGroupMembershipRepository memberships, SchoolYearService schoolYears) { _handlers = new ImportHandlerCatalog(handlers); _students = students; _groups = groups; _memberships = memberships; _schoolYears = schoolYears; } public IReadOnlyList> Handlers => _handlers.Handlers; public IReadOnlyList SupportedExtensions => _handlers.SupportedExtensions; public async Task AnalyzeAsync( ImportFile file, CancellationToken cancellationToken = default) => await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false); public async Task AnalyzeAsync( ImportFile file, Guid targetGroupId, CancellationToken cancellationToken = default) => await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false); private async Task AnalyzeInternalAsync( ImportFile file, Guid? targetGroupId, CancellationToken cancellationToken) { 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 groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList(); var targetGroup = targetGroupId is null ? null : groups.SingleOrDefault(group => group.Id == targetGroupId.Value); if (targetGroupId is not null && targetGroup is not { IsActive: true }) messages.Add(new ImportMessage( ImportMessageSeverity.Error, "student.target-group.unavailable", "Die Ziel-Lerngruppe existiert nicht oder ist archiviert.")); 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(); var conflicts = new List(); 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 (candidate.DateOfBirth is null && exact.Count == 0 && targetGroup is not null) { var memberIds = groupMemberships .Where(membership => membership.GroupId == targetGroup.Id) .Select(membership => membership.StudentId) .ToHashSet(); var memberMatches = sameName.Where(student => memberIds.Contains(student.Id)).ToList(); if (memberMatches.Count == 1) exact = memberMatches; } 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 = targetGroup is { IsActive: true } ? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)] : targetGroupId is null ? BuildGroupAssignments(imported, currentGroups, conflicts, messages) : []; return new StudentImportPreview( detected.Handler.FormatId, detected.Handler.DisplayName, entries, messages, conflicts, groupAssignments, targetGroupId, Fingerprint(existing), GroupStateFingerprint(groups, groupMemberships, currentSchoolYear)); } public Task ApplyAsync( StudentImportPreview preview, IEnumerable? 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(); var studentIdsByEntry = new Dictionary(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 (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId) || studentId is null || !TryResolveTargetGroup(preview, entry, groupResolutions, 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 bool TryResolveTargetGroup( StudentImportPreview preview, StudentImportEntry entry, IReadOnlyDictionary groupResolutions, out Guid? groupId) { if (preview.TargetGroupId is not null) { groupId = preview.TargetGroupId; return true; } if (entry.Student.GroupName is { } sourceGroup && groupResolutions.TryGetValue(sourceGroup, out groupId)) return true; groupId = null; return false; } 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 imported, ICollection 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 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 FindByExternalId( IEnumerable 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 messages) { var differences = new List(); 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 FieldsToSupplement( Student existing, ImportedStudent imported, ImportFormatId formatId) { var fields = new List(); 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 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 BuildGroupAssignments( IEnumerable imported, IReadOnlyList currentGroups, ICollection conflicts, ICollection messages) { var result = new List(); 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 BuildDecisionMap( StudentImportPreview preview, IEnumerable decisions) { var conflicts = preview.Conflicts.ToDictionary(conflict => conflict.Id, StringComparer.Ordinal); var map = new Dictionary(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 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 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 resolutions, IReadOnlyCollection 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 resolutions, IReadOnlyCollection 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 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 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 groups, IEnumerable 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(); } }