220 lines
9.9 KiB
C#
220 lines
9.9 KiB
C#
using LehrerApp.Core.Models;
|
|
|
|
namespace LehrerApp.Core.Services;
|
|
|
|
public sealed record CompetencyScore(
|
|
double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount,
|
|
int ExamEvidenceCount = 0, int ParticipationEvidenceCount = 0)
|
|
{
|
|
public double Percent => MaxPoints <= 0 ? 0 : AchievedPoints / MaxPoints * 100.0;
|
|
}
|
|
|
|
public sealed class CompetencyAnalysisItem
|
|
{
|
|
public required string DomainName { get; init; }
|
|
public required string DomainCode { get; init; }
|
|
public required string Code { get; init; }
|
|
public required string Description { get; init; }
|
|
public int PlannedUnitCount { get; init; }
|
|
public int TaughtUnitCount { get; init; }
|
|
public int PlannedExamCount { get; init; }
|
|
public int AssessedExamCount { get; init; }
|
|
public int ParticipationSessionCount { get; init; }
|
|
public CompetencyScore? GroupScore { get; init; }
|
|
public IReadOnlyDictionary<Guid, CompetencyScore> StudentScores { get; init; } =
|
|
new Dictionary<Guid, CompetencyScore>();
|
|
}
|
|
|
|
public sealed class CompetencyAnalysisService
|
|
{
|
|
public List<CompetencyAnalysisItem> Analyze(
|
|
IReadOnlyList<CompetencyDomain> catalog,
|
|
IReadOnlyList<Unit> units,
|
|
IReadOnlyList<Exam> exams,
|
|
IReadOnlyList<ExamResult> results,
|
|
IReadOnlyList<ParticipationSession>? participationSessions = null,
|
|
IReadOnlyList<ParticipationEntry>? participationEntries = null,
|
|
IReadOnlyList<ParticipationAspect>? participationAspects = null)
|
|
{
|
|
var builders = new List<ItemBuilder>();
|
|
var byId = new Dictionary<Guid, ItemBuilder>();
|
|
// Referenzen, die nie einem Katalogeintrag zugeordnet werden konnten (ItemId == Guid.Empty,
|
|
// z.B. aus einem plattformübergreifenden Import) haben keine gemeinsame Id, über die
|
|
// mehrfache Zuordnungen derselben Kompetenz zusammengeführt werden könnten - dafür wird
|
|
// ersatzweise der Code-Text als Merge-Schlüssel verwendet.
|
|
var byUnresolvedCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var domain in catalog.OrderBy(x => x.SortOrder))
|
|
foreach (var item in domain.Items.OrderBy(x => x.SortOrder))
|
|
{
|
|
var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description);
|
|
builders.Add(builder);
|
|
byId.TryAdd(item.Id, builder);
|
|
}
|
|
|
|
// Solange die Referenz noch im aktuellen Katalog auflösbar ist, zählt die LIVE-Fassung
|
|
// (ein Rename wirkt sich damit sofort aus). Ist sie es nicht mehr (gelöscht, andere
|
|
// Bildungsplan-Version, oder nie aufgelöster plattformübergreifender Import), wird ein
|
|
// Platzhalter aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme synthetisiert -
|
|
// dadurch bleibt (anders als bei der bisherigen reinen Code-Textkonserve) sogar die echte
|
|
// historische Beschreibung erhalten.
|
|
ItemBuilder GetOrAdd(CompetencyReference reference)
|
|
{
|
|
var description = string.IsNullOrWhiteSpace(reference.Description)
|
|
? "Diese Kompetenz wird noch verwendet, ist aber nicht (mehr) im aktuellen Katalog enthalten."
|
|
: reference.Description;
|
|
|
|
if (reference.ItemId == Guid.Empty)
|
|
{
|
|
var codeKey = reference.Code.Trim();
|
|
if (byUnresolvedCode.TryGetValue(codeKey, out var existingUnresolved)) return existingUnresolved;
|
|
var addedUnresolved = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
|
|
byUnresolvedCode[codeKey] = addedUnresolved;
|
|
builders.Add(addedUnresolved);
|
|
return addedUnresolved;
|
|
}
|
|
|
|
if (byId.TryGetValue(reference.ItemId, out var existing)) return existing;
|
|
var added = new ItemBuilder("Nicht im Katalog", "", reference.Code, description);
|
|
byId[reference.ItemId] = added;
|
|
builders.Add(added);
|
|
return added;
|
|
}
|
|
|
|
foreach (var unit in units)
|
|
foreach (var reference in NormalizeReferences(unit.Competencies))
|
|
{
|
|
var builder = GetOrAdd(reference);
|
|
if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++;
|
|
else builder.TaughtUnits++;
|
|
}
|
|
|
|
var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList());
|
|
foreach (var exam in exams)
|
|
{
|
|
var examReferences = NormalizeReferences(exam.Tasks.SelectMany(x => x.Competencies));
|
|
foreach (var reference in examReferences)
|
|
{
|
|
var builder = GetOrAdd(reference);
|
|
if (exam.Status == ExamStatus.Planned) builder.PlannedExams++;
|
|
else builder.AssessedExams++;
|
|
}
|
|
|
|
if (exam.Status is ExamStatus.Planned or ExamStatus.Conducted
|
|
|| !resultsByExam.TryGetValue(exam.Id, out var examResults)) continue;
|
|
|
|
foreach (var result in examResults.Where(x => !x.Absent))
|
|
for (var taskIndex = 0; taskIndex < exam.Tasks.Count && taskIndex < result.Points.Count; taskIndex++)
|
|
{
|
|
var task = exam.Tasks[taskIndex];
|
|
if (task.MaxPoints <= 0) continue;
|
|
var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints);
|
|
foreach (var reference in NormalizeReferences(task.Competencies))
|
|
GetOrAdd(reference).AddExamScore(result.StudentId, achieved, task.MaxPoints);
|
|
}
|
|
}
|
|
|
|
var entriesBySession = (participationEntries ?? [])
|
|
.GroupBy(x => x.SessionId)
|
|
.ToDictionary(x => x.Key, x => x.ToList());
|
|
var aspects = participationAspects ?? [];
|
|
|
|
foreach (var session in participationSessions ?? [])
|
|
{
|
|
var references = NormalizeReferences(session.Competencies).ToList();
|
|
foreach (var reference in references)
|
|
GetOrAdd(reference).ParticipationSessions++;
|
|
|
|
if (references.Count == 0 || !entriesBySession.TryGetValue(session.Id, out var entries))
|
|
continue;
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
var estimated = ParticipationCompetencyEstimate.Estimate(
|
|
entry.Ratings, aspects, entry.DayHighlight);
|
|
foreach (var reference in references)
|
|
{
|
|
var explicitRating = entry.CompetencyRatings
|
|
.FirstOrDefault(x => x.ItemId == reference.ItemId)?.Value;
|
|
var rating = explicitRating ?? estimated;
|
|
if (rating is not null)
|
|
GetOrAdd(reference).AddParticipationScore(entry.StudentId, rating.Value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return builders.Select(x => x.Build()).ToList();
|
|
}
|
|
|
|
private static IEnumerable<CompetencyReference> NormalizeReferences(IEnumerable<CompetencyReference> references) =>
|
|
references.DistinctBy(x => x.ItemId);
|
|
|
|
private sealed class ItemBuilder(
|
|
string domainName, string domainCode, string code, string description)
|
|
{
|
|
private readonly Dictionary<Guid, MutableScore> _scores = [];
|
|
|
|
public int PlannedUnits { get; set; }
|
|
public int TaughtUnits { get; set; }
|
|
public int PlannedExams { get; set; }
|
|
public int AssessedExams { get; set; }
|
|
public int ParticipationSessions { get; set; }
|
|
|
|
public void AddExamScore(Guid studentId, double achieved, double maximum) =>
|
|
AddScore(studentId, achieved, maximum, isParticipation: false);
|
|
|
|
public void AddParticipationScore(Guid studentId, int rating) =>
|
|
AddScore(studentId, Math.Clamp(rating, -2, 2) + 2, 4, isParticipation: true);
|
|
|
|
private void AddScore(Guid studentId, double achieved, double maximum, bool isParticipation)
|
|
{
|
|
if (!_scores.TryGetValue(studentId, out var score))
|
|
_scores[studentId] = score = new MutableScore();
|
|
score.Achieved += achieved;
|
|
score.Maximum += maximum;
|
|
score.EvidenceCount++;
|
|
if (isParticipation) score.ParticipationEvidenceCount++;
|
|
else score.ExamEvidenceCount++;
|
|
}
|
|
|
|
public CompetencyAnalysisItem Build()
|
|
{
|
|
var studentScores = _scores.ToDictionary(x => x.Key, x => x.Value.ToScore(1));
|
|
var achieved = _scores.Values.Sum(x => x.Achieved);
|
|
var maximum = _scores.Values.Sum(x => x.Maximum);
|
|
var evidence = _scores.Values.Sum(x => x.EvidenceCount);
|
|
var examEvidence = _scores.Values.Sum(x => x.ExamEvidenceCount);
|
|
var participationEvidence = _scores.Values.Sum(x => x.ParticipationEvidenceCount);
|
|
return new CompetencyAnalysisItem
|
|
{
|
|
DomainName = domainName,
|
|
DomainCode = domainCode,
|
|
Code = code,
|
|
Description = description,
|
|
PlannedUnitCount = PlannedUnits,
|
|
TaughtUnitCount = TaughtUnits,
|
|
PlannedExamCount = PlannedExams,
|
|
AssessedExamCount = AssessedExams,
|
|
ParticipationSessionCount = ParticipationSessions,
|
|
GroupScore = maximum > 0
|
|
? new CompetencyScore(achieved, maximum, evidence, _scores.Count,
|
|
examEvidence, participationEvidence)
|
|
: null,
|
|
StudentScores = studentScores,
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class MutableScore
|
|
{
|
|
public double Achieved { get; set; }
|
|
public double Maximum { get; set; }
|
|
public int EvidenceCount { get; set; }
|
|
public int ExamEvidenceCount { get; set; }
|
|
public int ParticipationEvidenceCount { get; set; }
|
|
public CompetencyScore ToScore(int participantCount) =>
|
|
new(Achieved, Maximum, EvidenceCount, participantCount,
|
|
ExamEvidenceCount, ParticipationEvidenceCount);
|
|
}
|
|
}
|