Files
adminandClaude Opus 5.5 d9eac0d1d2
CI / build-and-test (push) Canceled after 0s
Zwischenstand: vorläufige Noten zu Stichtag und Anlass
Ein Zwischenstand ist ein Durchgang aus Stichtag, Anlass und
Schülerauswahl (ganze Gruppe, Teilgruppe oder Einzelfall, mit
Schnellfilter für gefährdete Schüler). Der Zeugnisnoten-Assistent geht
die gewählten Schüler im Zwischenstand-Modus durch und notiert
vorläufige schriftliche, mündliche und Gesamtnote samt Notiz.

Gespeichert wird das als eigenes Modell InterimGrade, getrennt von der
Zeugnisnote. Die Berechnung endet am Stichtag und rechnet den laufenden
Mitarbeits-Abschnitt mit ein. Zwischenstände erscheinen im Assistenten
als Liste und Marker im Verlauf sowie als graue Spalte in der
Notenübersicht (nicht in "Gesamt", nicht im PDF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:34 +02:00

242 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;
}
public string Round(double value, RoundingRule rounding) => _grading.RoundToGrade(value, _gradingSystem, rounding);
/// <param name="examGrades">Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne
/// Angabe gelten die am Jahres-Datensatz gespeicherten.</param>
/// <param name="asOf">Stichtag für ein Halbjahr: nur Leistungen bis einschließlich dieses
/// Tages zählen.</param>
/// <param name="runningParticipation">Vorläufige Mitarbeitsnote des laufenden, noch nicht
/// abgeschlossenen Abschnitts — zählt für ein Halbjahr wie eine weitere Abschnittsnote.</param>
public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding,
YearExamGrades? examGrades = null, DateOnly? asOf = null, string? runningParticipation = 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);
if (asOf is { } cutoff && cutoff < to) to = cutoff;
_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();
if (!string.IsNullOrWhiteSpace(runningParticipation)) participation.Add((runningParticipation, 1.0));
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();
}