feat: add student master data import
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user