Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs
T
adminandClaude Opus 5.5 67c5a906d1
CI / build-and-test (push) Canceled after 0s
Zeugnisnoten-Assistent mit Mitarbeitsverlauf und Prüfungsnoten Klasse 10
Neuer Assistent (Einstieg im Zeugnisnoten-Dialog) geht die Lerngruppe
Schüler für Schüler durch: berechnete Note mit ungerundetem Wert und
Teilbereichen, Mitarbeitsgraph abschnittsweise oder als Verlauf, Arbeiten
und Einzelnoten. Das 1. Halbjahr erscheint im 2. Halbjahr nur als Kontext.

Die Endjahresnote wird jetzt in Dialog und Assistent aus den
Halbjahresnoten verrechnet (gemeinsamer ReportGradeCalculator). In Klasse 10
kommen Projektprüfung, schriftliche/mündliche Abschlussprüfung und
zusätzliche mündliche Prüfung hinzu, gewichtet nach Oberschulverordnung und
PrüfV Sek. I; gerundet wird dabei bewusst nicht automatisch.

Die Zeitleisten-Erzeugung des Mitarbeits-Assistenten liegt dafür im
gemeinsamen ParticipationTimelineBuilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:09:33 +02:00

234 lines
13 KiB
C#

using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public sealed record ReportGradeResult(double? Value, string? Rounded, double? Written, double? Participation, double? Other);
/// Eine Teilnote der Endjahresnote: Halbjahresnote oder Prüfungsleistung.
/// <param name="Value">Rechenwert, wenn er sich nicht aus <paramref name="Final"/> ergibt —
/// etwa eine Prüfungsleistung, die zwischen zwei Noten liegt.</param>
public sealed record YearPart(string Label, string? Final, string Hint, double Weight = 1.0, double? Value = null);
/// Prüfungsnoten der Klasse 10, soweit in diesem Fach abgelegt.
public readonly record struct YearExamGrades(string? Project, string? Oral,
string? Written = null, string? AdditionalOral = null);
/// <summary>
/// Gemeinsame Zeugnisnoten-Berechnung für Zeugnisnoten-Dialog (2.4) und Zeugnisnoten-Assistent
/// (2.7), damit beide für denselben Datensatz denselben Wert zeigen.
///
/// Halbjahre sind eigenständig und werden aus den Einzelleistungen ihres Zeitraums gewichtet.
/// Die Endjahresnote wird dagegen aus den Halbjahresnoten verrechnet (gleich gewichtet).
///
/// Klasse 10 (Bremen): In einem Prüfungsfach ergibt sich die Gesamtnote zu zwei Dritteln aus der
/// unterrichtlichen Leistung und zu einem Drittel aus der Prüfungsleistung (§ 14 Abs. 3 der
/// Verordnung über die Sekundarstufe I der Oberschule) — die Prüfung zählt also wie ein drittes
/// Halbjahr. Die Prüfungsleistung ist die Note der schriftlichen bzw. mündlichen
/// Abschlussprüfung. Kommt in einem schriftlichen Prüfungsfach eine zusätzliche mündliche
/// Prüfung hinzu, werden beide 1:1 verrechnet (§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne
/// Abschlussprüfung zählt eine zusätzliche mündliche Prüfung selbst als Prüfungsleistung, also
/// 2:1 (§ 13 Abs. 3 PrüfV Sek. I). Die Projektprüfung zählt nach Schulpraxis als weiteres
/// Halbjahr.
///
/// Bewusst nicht umgesetzt sind die Rundungsvorgaben der Verordnungen: Noten sind
/// Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine
/// Prüfungsleistung zwischen zwei Noten geht deshalb ungerundet in die Rechnung ein, und für die
/// Endnote gilt die von der Lehrkraft gewählte Rundung samt Möglichkeit zum Übersteuern.
/// </summary>
public sealed class ReportGradeCalculator
{
public const string ProjectExamLabel = "Projektprüfung";
public const string OralExamLabel = "Mündliche Prüfung";
public const string WrittenExamLabel = "Schriftliche Prüfung";
public const string AdditionalOralExamLabel = "Zusätzliche mündliche Prüfung";
private readonly IGradeRepository _grades;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
private readonly IReadOnlyDictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GroupType _groupType;
private readonly GradingSystem _gradingSystem;
private readonly string _schoolYear;
public GradingScheme Scheme { get; private set; } = new();
public List<Exam> Exams { get; private set; } = [];
public Dictionary<Guid, Dictionary<Guid, ExamResult>> ResultsByExam { get; private set; } = [];
public List<Grade> Grades { get; private set; } = [];
public string SchemeSummary =>
$"Klausuren {Scheme.ExamsPercent:0.#} % · Mitarbeit {Scheme.ParticipationPercent:0.#} % · " +
$"Sonstige {Scheme.OtherPercent:0.#} %";
public ReportGradeCalculator(IGradeRepository grades, IExamRepository exams, IExamResultRepository results,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IReadOnlyDictionary<Guid, GroupMembership> membershipsByStudent, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _schemes = schemes;
_reportGrades = reportGrades; _membershipsByStudent = membershipsByStudent; _grading = grading;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _schoolYear = schoolYear;
Reload();
}
/// Liest Schema, Arbeiten und Noten neu ein — vor jeder Neuberechnung einer ganzen Ansicht.
public void Reload()
{
Scheme = _schemes.GetByGroup(_groupId)
?? _schemes.GetDefaultForType(_groupType)
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
Exams = _exams.GetByGroup(_groupId).OrderBy(e => e.Date).ToList();
ResultsByExam = Exams.ToDictionary(e => e.Id,
e => _results.GetByExam(e.Id).GroupBy(r => r.StudentId).ToDictionary(g => g.Key, g => g.First()));
Grades = _grades.GetByGroup(_groupId).OrderBy(g => g.Date).ToList();
}
public (DateOnly From, DateOnly To) Range(ParticipationPeriod period) =>
GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
{
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
_ => SchoolYearPeriodKind.FullYear,
});
public static string PeriodLabel(ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => "1. Halbjahr",
ParticipationPeriod.H2 => "2. Halbjahr",
_ => "Gesamtes Schuljahr",
};
public static bool ExamApplies(Exam exam, GroupMembership? membership)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau;
}
/// <param name="examGrades">Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne
/// Angabe gelten die am Jahres-Datensatz gespeicherten.</param>
public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding,
YearExamGrades? examGrades = null)
{
if (period == ParticipationPeriod.FullYear)
{
var parts = YearParts(studentId, rounding, examGrades)
.Select(p => (Value: p.Value ?? (p.Final is null ? null : GradingService.ParseGradeValue(p.Final)), p.Weight))
.Where(p => p.Value.HasValue).ToList();
if (parts.Count == 0) return new ReportGradeResult(null, null, null, null, null);
var mean = parts.Sum(p => p.Value!.Value * p.Weight) / parts.Sum(p => p.Weight);
return new ReportGradeResult(mean, _grading.RoundToGrade(mean, _gradingSystem, rounding), null, null, null);
}
var (from, to) = Range(period);
_membershipsByStudent.TryGetValue(studentId, out var membership);
var examEntries = new List<(Exam Exam, string Grade)>();
foreach (var exam in Exams.Where(e => e.Date >= from && e.Date <= to))
{
if (!ExamApplies(exam, membership)) continue;
if (!ResultsByExam[exam.Id].TryGetValue(studentId, out var result)) continue;
if (result.Absent || result.Grade is null) continue;
examEntries.Add((exam, result.Grade));
}
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
var written = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
var studentGrades = Grades
.Where(g => g.StudentId == studentId && g.Date >= from && g.Date <= to)
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
.ToList();
var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation)
.Select(g => (Grade: g.Value, g.Weight)).ToList();
var other = studentGrades.Where(g => g.Category != GradeCategory.Participation)
.Select(g => (Grade: g.Value, g.Weight)).ToList();
// Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
other.AddRange(testGrades.Select(grade => (Grade: grade, Weight: 1.0)));
var value = _grading.CalculateReportGradeValue(written, participation, other, Scheme);
return new ReportGradeResult(value,
value is null ? null : _grading.RoundToGrade(value.Value, _gradingSystem, rounding),
written.Count > 0 ? _grading.WeightedAverage(written) : null,
participation.Count > 0 ? _grading.WeightedAverage(participation) : null,
other.Count > 0 ? _grading.WeightedAverage(other) : null);
}
/// Die Teilnoten der Endjahresnote. Bei den Halbjahren geht eine gespeicherte Entscheidung
/// (Übersteuerung bzw. festgeschriebener Wert) der Live-Berechnung vor.
public List<YearPart> YearParts(Guid studentId, RoundingRule rounding, YearExamGrades? examGrades = null)
{
var parts = new List<YearPart>();
foreach (var half in new[] { ParticipationPeriod.H1, ParticipationPeriod.H2 })
{
var label = PeriodLabel(half);
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, label);
var overridden = NullIfEmpty(record?.OverrideValue);
if (overridden is not null)
parts.Add(new YearPart(label, overridden, record!.IsLocked ? "festgeschrieben" : "übersteuert"));
else if (record is { IsLocked: true })
parts.Add(new YearPart(label, NullIfEmpty(record.CalculatedValue), "festgeschrieben"));
else
parts.Add(new YearPart(label, Calculate(studentId, half, rounding).Rounded, "berechnet"));
}
// Die unterrichtliche Leistung zählt immer doppelt gegenüber einer Prüfung — auch wenn
// nur eine der beiden Halbjahresnoten vorliegt.
if (parts.Count(p => p.Final is not null) == 1)
parts = parts.Select(p => p.Final is null ? p : p with { Weight = 2.0 }).ToList();
var exams = examGrades ?? StoredExamGrades(studentId);
if (NullIfEmpty(exams.Project) is { } project)
parts.Add(new YearPart(ProjectExamLabel, project, "zählt wie ein Halbjahr"));
if (ExamPerformance(exams) is { } performance)
parts.Add(performance);
return parts;
}
/// Die Prüfungsleistung des Fachs, die mit einem Drittel in die Gesamtnote eingeht.
private YearPart? ExamPerformance(YearExamGrades exams)
{
var written = NullIfEmpty(exams.Written);
var oral = NullIfEmpty(exams.Oral);
var additional = NullIfEmpty(exams.AdditionalOral);
if (written is not null && additional is not null)
{
// § 13 Abs. 2 PrüfV Sek. I: 1:1. Liegt das Ergebnis zwischen zwei Noten, wird hier
// nicht gerundet — es geht so in die Endnote ein, entschieden wird dort.
var mean = _grading.WeightedAverage([(written, 1.0), (additional, 1.0)]);
var between = BetweenGrades(mean);
return new YearPart("Prüfungsleistung", mean.ToString("0.##", German),
$"schriftlich {written} · mündlich {additional}" + (between is null ? "" : $" — {between}"),
Value: mean);
}
if (written is not null) return new YearPart(WrittenExamLabel, written, "ein Drittel der Note");
if (oral is not null) return new YearPart(OralExamLabel, oral, "ein Drittel der Note");
if (additional is not null) return new YearPart(AdditionalOralExamLabel, additional, "ein Drittel der Note");
return null;
}
public YearExamGrades StoredExamGrades(Guid studentId)
{
var year = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, PeriodLabel(ParticipationPeriod.FullYear));
return new YearExamGrades(year?.ProjectExamGrade, year?.OralExamGrade,
year?.WrittenExamGrade, year?.AdditionalOralExamGrade);
}
/// "zwischen 4 und 5", wenn der Wert genau auf der Grenze zweier Noten liegt — dann gibt
/// es rechnerisch keine richtige Seite, nur eine pädagogische Entscheidung.
public static string? BetweenGrades(double value)
{
var lower = Math.Floor(value);
return Math.Abs(value - lower - 0.5) < 0.0001 ? $"zwischen {lower:0} und {lower + 1:0}" : null;
}
private static readonly System.Globalization.CultureInfo German =
System.Globalization.CultureInfo.GetCultureInfo("de-DE");
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}