KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/) sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf, Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie als Update übernommen, sondern immer als neue Stunde behandelt. Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
280 lines
13 KiB
C#
280 lines
13 KiB
C#
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<CompetencyDomain> ImportedDomains { get; init; }
|
|
public required string ExistingFingerprint { get; init; }
|
|
public List<string> Warnings { get; init; } = [];
|
|
public List<CompetencyCatalogImportConflict> Conflicts { get; init; } = [];
|
|
public int NewDomains { get; init; }
|
|
public int NewCompetencies { get; init; }
|
|
public int UnchangedCompetencies { get; init; }
|
|
}
|
|
|
|
/// <summary>Analysiert Kompetenzkataloge vollständig, bevor bestehende Daten verändert werden.</summary>
|
|
public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository)
|
|
{
|
|
public CompetencyCatalogImportPreview Analyze(
|
|
string json, Guid subjectId, string subjectName, int gradeLevel)
|
|
{
|
|
CatalogDto dto;
|
|
try
|
|
{
|
|
dto = JsonSerializer.Deserialize<CatalogDto>(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<CompetencyDomain>();
|
|
var domainKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var competencyCodes = new HashSet<string>(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<CompetencyItem>();
|
|
var localKeys = new HashSet<string>(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<string>();
|
|
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<CompetencyCatalogImportConflict>();
|
|
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<string>? 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<CompetencyDomain> result = mode == CompetencyCatalogImportMode.Replace
|
|
? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList()
|
|
: Merge(current, preview, useImportedConflicts ?? new HashSet<string>());
|
|
|
|
repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result);
|
|
}
|
|
|
|
private static List<CompetencyDomain> Merge(List<CompetencyDomain> existing,
|
|
CompetencyCatalogImportPreview preview, IReadOnlySet<string> 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<CompetencyDomain> 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<DomainDto>? 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<ItemDto>? Competencies { get; set; }
|
|
}
|
|
|
|
private sealed class ItemDto
|
|
{
|
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
|
[JsonPropertyName("description")] public string? Description { get; set; }
|
|
}
|
|
}
|