using System.Text.Json; using System.Text.Json.Serialization; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; namespace LehrerApp.Core.Services; public enum CompetencyCatalogImportMode { Merge, Replace } public sealed record CompetencyCatalogImportConflict( string Id, string Location, string ExistingValue, string ImportedValue); public sealed class CompetencyCatalogImportPreview { public required Guid SubjectId { get; init; } public required string SubjectName { get; init; } public required int GradeLevel { get; init; } public required List ImportedDomains { get; init; } public required string ExistingFingerprint { get; init; } public List Warnings { get; init; } = []; public List Conflicts { get; init; } = []; public int NewDomains { get; init; } public int NewCompetencies { get; init; } public int UnchangedCompetencies { get; init; } } /// Analysiert Kompetenzkataloge vollständig, bevor bestehende Daten verändert werden. public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository) { public CompetencyCatalogImportPreview Analyze( string json, Guid subjectId, string subjectName, int gradeLevel) { CatalogDto dto; try { dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("Die JSON-Datei ist leer."); } catch (JsonException ex) { throw new InvalidDataException($"Ungültiges JSON: {ex.Message}", ex); } if (dto.Domains is null || dto.Domains.Count == 0) throw new InvalidDataException("Der Import enthält keine Kompetenzbereiche."); var imported = new List(); var domainKeys = new HashSet(StringComparer.OrdinalIgnoreCase); var competencyCodes = new HashSet(StringComparer.OrdinalIgnoreCase); for (var domainIndex = 0; domainIndex < dto.Domains.Count; domainIndex++) { var sourceDomain = dto.Domains[domainIndex]; var name = sourceDomain.Name?.Trim() ?? ""; var code = sourceDomain.Code?.Trim() ?? ""; if (name.Length == 0) throw new InvalidDataException($"Kompetenzbereich {domainIndex + 1} besitzt keinen Namen."); var domainKey = DomainKey(code, name); if (!domainKeys.Add(domainKey)) throw new InvalidDataException($"Der Kompetenzbereich „{name}“ kommt mehrfach vor."); var items = new List(); var localKeys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (sourceItem, itemIndex) in (sourceDomain.Competencies ?? []).Select((item, index) => (item, index))) { var itemCode = sourceItem.Code?.Trim() ?? ""; var description = sourceItem.Description?.Trim() ?? ""; if (description.Length == 0) throw new InvalidDataException( $"Kompetenz {itemIndex + 1} im Bereich „{name}“ besitzt keine Beschreibung."); var itemKey = ItemKey(itemCode, description); if (!localKeys.Add(itemKey)) throw new InvalidDataException( $"Die Kompetenz „{(itemCode.Length > 0 ? itemCode : description)}“ kommt im Bereich „{name}“ mehrfach vor."); if (itemCode.Length > 0 && !competencyCodes.Add(itemCode)) throw new InvalidDataException($"Der Kompetenzcode „{itemCode}“ kommt in mehreren Bereichen vor."); items.Add(new CompetencyItem { Code = itemCode, Description = description, SortOrder = itemIndex, }); } imported.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = domainIndex, Items = items, }); } var warnings = new List(); if (!string.IsNullOrWhiteSpace(dto.Subject) && !dto.Subject.Trim().Equals(subjectName, StringComparison.OrdinalIgnoreCase)) warnings.Add($"Die Datei nennt das Fach „{dto.Subject.Trim()}“, importiert wird in „{subjectName}“."); if (dto.GradeLevel is > 0 and <= 13 && dto.GradeLevel != gradeLevel) warnings.Add($"Die Datei nennt Klassenstufe {dto.GradeLevel}, ausgewählt ist Klassenstufe {gradeLevel}."); var existing = repository.GetBySubjectAndGrade(subjectId, gradeLevel); var conflicts = new List(); var newDomains = 0; var newItems = 0; var unchanged = 0; var existingItemsByCode = existing .SelectMany(domain => domain.Items.Select(item => (Domain: domain, Item: item))) .Where(x => !string.IsNullOrWhiteSpace(x.Item.Code)) .GroupBy(x => x.Item.Code, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); foreach (var importedDomain in imported) { var domainKey = DomainKey(importedDomain.Code, importedDomain.Name); var existingDomain = existing.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey); if (existingDomain is null) newDomains++; else if (existingDomain.Name != importedDomain.Name) conflicts.Add(new($"domain:{domainKey}", $"Bereich {importedDomain.Code}", existingDomain.Name, importedDomain.Name)); foreach (var importedItem in importedDomain.Items) { if (importedItem.Code.Length > 0 && existingItemsByCode.TryGetValue(importedItem.Code, out var found)) { if (found.Item.Description == importedItem.Description && existingDomain?.Id == found.Domain.Id) unchanged++; else conflicts.Add(new($"item:{importedItem.Code.ToUpperInvariant()}", $"Kompetenz {importedItem.Code}", $"{found.Item.Description} [{found.Domain.Name}]", $"{importedItem.Description} [{importedDomain.Name}]")); continue; } var existingWithoutCode = existingDomain?.Items.FirstOrDefault(i => i.Code.Length == 0 && i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase)); if (existingWithoutCode is not null) unchanged++; else newItems++; } } return new CompetencyCatalogImportPreview { SubjectId = subjectId, SubjectName = subjectName, GradeLevel = gradeLevel, ImportedDomains = imported, ExistingFingerprint = Fingerprint(existing), Warnings = warnings, Conflicts = conflicts.DistinctBy(c => c.Id).ToList(), NewDomains = newDomains, NewCompetencies = newItems, UnchangedCompetencies = unchanged, }; } public void Apply(CompetencyCatalogImportPreview preview, CompetencyCatalogImportMode mode, IReadOnlySet? useImportedConflicts = null) { var current = repository.GetBySubjectAndGrade(preview.SubjectId, preview.GradeLevel); if (Fingerprint(current) != preview.ExistingFingerprint) throw new InvalidOperationException( "Der Kompetenzkatalog wurde seit der Vorschau verändert. Bitte öffne den Import erneut."); List result = mode == CompetencyCatalogImportMode.Replace ? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList() : Merge(current, preview, useImportedConflicts ?? new HashSet()); repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result); } private static List Merge(List existing, CompetencyCatalogImportPreview preview, IReadOnlySet useImported) { var result = existing.Select(ClonePreservingIds).OrderBy(d => d.SortOrder).ToList(); foreach (var importedDomain in preview.ImportedDomains) { var domainKey = DomainKey(importedDomain.Code, importedDomain.Name); var target = result.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey); if (target is null) { target = CloneAsNew(importedDomain, preview.SubjectId, preview.GradeLevel, result.Count); target.Items.Clear(); result.Add(target); } else if (useImported.Contains($"domain:{domainKey}")) target.Name = importedDomain.Name; foreach (var importedItem in importedDomain.Items) { if (importedItem.Code.Length == 0) { if (!target.Items.Any(i => i.Code.Length == 0 && i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase))) target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count)); continue; } var located = result.SelectMany(d => d.Items.Select(i => (Domain: d, Item: i))) .FirstOrDefault(x => x.Item.Code.Equals(importedItem.Code, StringComparison.OrdinalIgnoreCase)); if (located.Item is null) { target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count)); continue; } var conflictId = $"item:{importedItem.Code.ToUpperInvariant()}"; if (!useImported.Contains(conflictId)) continue; located.Domain.Items.Remove(located.Item); located.Item.Description = importedItem.Description; located.Item.Code = importedItem.Code; located.Item.SortOrder = target.Items.Count; target.Items.Add(located.Item); NormalizeItemOrder(located.Domain); } } for (var i = 0; i < result.Count; i++) result[i].SortOrder = i; return result; } private static CompetencyDomain ClonePreservingIds(CompetencyDomain source) => new() { Id = source.Id, SubjectId = source.SubjectId, GradeLevel = source.GradeLevel, Name = source.Name, Code = source.Code, SortOrder = source.SortOrder, Items = source.Items.OrderBy(i => i.SortOrder).Select(i => new CompetencyItem { Id = i.Id, Code = i.Code, Description = i.Description, SortOrder = i.SortOrder, }).ToList(), }; private static CompetencyDomain CloneAsNew(CompetencyDomain source, Guid subjectId, int gradeLevel, int sortOrder) => new() { SubjectId = subjectId, GradeLevel = gradeLevel, Name = source.Name, Code = source.Code, SortOrder = sortOrder, Items = source.Items.OrderBy(i => i.SortOrder).Select((i, index) => CloneItemAsNew(i, index)).ToList(), }; private static CompetencyItem CloneItemAsNew(CompetencyItem source, int sortOrder) => new() { Code = source.Code, Description = source.Description, SortOrder = sortOrder, }; private static void NormalizeItemOrder(CompetencyDomain domain) { for (var i = 0; i < domain.Items.Count; i++) domain.Items[i].SortOrder = i; } private static string DomainKey(string? code, string? name) => !string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"name:{name?.Trim().ToLowerInvariant()}"; private static string ItemKey(string? code, string description) => !string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"description:{description.ToLowerInvariant()}"; private static string Fingerprint(IEnumerable domains) => string.Join("|", domains.OrderBy(d => d.SortOrder).ThenBy(d => d.Id).Select(d => $"{d.Id:N}:{d.UpdatedAt.Ticks}:{d.Name}:{d.Code}:" + string.Join(";", d.Items.OrderBy(i => i.SortOrder).Select(i => $"{i.Id:N}:{i.Code}:{i.Description}:{i.SortOrder}")))); private sealed class CatalogDto { [JsonPropertyName("subject")] public string? Subject { get; set; } [JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; } [JsonPropertyName("domains")] public List? Domains { get; set; } } private sealed class DomainDto { [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("competencies")] public List? Competencies { get; set; } } private sealed class ItemDto { [JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("description")] public string? Description { get; set; } } }