From 122d4de8096a989ecf3b2fd1afeefb54befe2701 Mon Sep 17 00:00:00 2001 From: troogs Date: Mon, 2 Nov 2026 06:25:49 +0100 Subject: [PATCH] fix(pdf): align active entries periods and balances across exports (U03) --- docs/usability-umsetzung.md | 4 ++ .../Services/PdfStatementService.cs | 18 ++++-- .../DashboardPdfStatementServiceTests.cs | 56 +++++++++++++++++++ .../Duempelkas.Tests/Duempelkas.Tests.csproj | 1 + 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 0ee010a..7d1388c 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -23,3 +23,7 @@ Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buc ## U02 – Dateiaktionen Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden. + +## U03 – Gleiche Bedeutung in PDF und Oberfläche + +Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft. diff --git a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs index fc878f1..22ef43c 100644 --- a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs +++ b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs @@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService { col.Item().Text(clubName).Bold().FontSize(20); col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1); + col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}" : "Zeitraum: Alle Jahre").FontSize(9); + col.Item().Text("Nur wirksame Buchungen. Umbuchungen zählen im einzelnen Konto als Zu- oder Abgang.").FontSize(8); col.Item().PaddingTop(5).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium); col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); }); @@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService .PaddingBottom(5).AlignRight().Text("Betrag").SemiBold(); }); - foreach (var entry in entries) + foreach (var entry in entries.Where(e => !e.IsDeleted)) { var titleText = entry.Title; if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName)) @@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance); } + else + { + SummaryRow(summaryCol, "Anfangsbestand:", balance.CarryoverBalance); + } + SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue); SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue); summaryCol.Item().PaddingTop(5).Row(row => { - row.RelativeItem().Text("Saldo:").Bold().FontSize(12); + row.RelativeItem().Text(year.HasValue ? $"Kontostand am 31.12.{year}:" : "Kontostand nach allen Buchungen:").Bold().FontSize(12); row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12) .FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1); }); @@ -302,6 +309,8 @@ public class PdfStatementService : IPdfStatementService ? $"Übersicht aller Konten ({year.Value})" : "Übersicht aller Konten"; col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1); + col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}; Endstände am 31.12.{year}." : "Zeitraum: Alle Jahre; Endstände nach allen Buchungen.").FontSize(9); + col.Item().Text("Nur wirksame Buchungen. Interne Umbuchungen erscheinen einmal mit Minus und Plus; die Vereinssumme bleibt unverändert.").FontSize(8); col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}") .FontSize(10) .SemiBold() @@ -338,15 +347,14 @@ public class PdfStatementService : IPdfStatementService } }); - if (year.HasValue && carryoverByAccountId is not null) { BodyCell(table, string.Empty); BodyCell(table, string.Empty); - BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true); + BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true); foreach (var account in accounts) { - BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id)); + BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance); } } diff --git a/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs b/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs index 10e6ca3..c34a982 100644 --- a/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs +++ b/tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs @@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF"); } + [Fact] + public async Task Exports_ExcludeDeleted_ExplainTransfers_AndMatchBalances() + { + var source = new Account { Name = "Barkasse", CarryoverBalance = 100m }; + var target = new Account { Name = "Girokonto" }; + _db.Accounts.AddRange(source, target); + await _db.SaveChangesAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(_connectionString).Options; + var factory = new TestDbContextFactory(options); + var entries = new EntryService(factory); + await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 1), "Mitgliedsbeitrag", 12.50m); + await entries.CreateEntryAsync(source.Id, EntryType.Expense, new(2026, 1, 2), "Porto", 4.20m); + await entries.CreateTransferAsync(source.Id, target.Id, new(2026, 1, 3), "Zur Bank", 50m); + var deleted = await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 4), "GELOESCHTE_TESTBUCHUNG", 999m); + await entries.DeleteEntryAsync(deleted.Id); + await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2027, 1, 1), "ZUKUNFT_TESTBUCHUNG", 1m); + var balance = await new BalanceQueryService(factory).GetAccountBalanceAsync(source.Id, 2026); + Assert.Equal(58.30m, balance.TotalBalance); + var single = await _pdfService.GenerateStatementAsync(source.Id, 2026); + var combined = await _pdfService.GenerateDashboardStatementAsync(2026); + foreach (var pdf in new[] { single, combined }) + { + var text = Extract(pdf); + Assert.Contains("Mitgliedsbeitrag", text); + Assert.Contains("Porto", text); + Assert.Contains("Zur Bank", text); + Assert.DoesNotContain("GELOESCHTE_TESTBUCHUNG", text); + Assert.DoesNotContain("ZUKUNFT_TESTBUCHUNG", text); + Assert.Contains("58,30", text); + Assert.Contains("31.12.2026", text); + } + Assert.Contains("108,30", Extract(combined)); + Assert.Contains("an Girokonto", Extract(single)); + var all = Extract(await _pdfService.GenerateStatementAsync(source.Id, (int?)null)); + Assert.Contains("Alle Jahre", all); + Assert.Contains("Anfangsbestand", all); + Assert.Contains("59,30", all); + var evidence = Environment.GetEnvironmentVariable("DUEMPELKAS_PDF_EVIDENCE"); + if (evidence != null) + { + Directory.CreateDirectory(evidence); + await File.WriteAllBytesAsync(Path.Combine(evidence, "single.pdf"), single); + await File.WriteAllBytesAsync(Path.Combine(evidence, "combined.pdf"), combined); + // Additional pages exercise repeated headers and the final summary. + for (var i = 0; i < 90; i++) + await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 2, 1), $"Lesbarkeitsprüfung Buchung {i + 1}", 1m); + await File.WriteAllBytesAsync(Path.Combine(evidence, "multipage.pdf"), await _pdfService.GenerateStatementAsync(source.Id, 2026)); + } + } + + private static string Extract(byte[] pdf) + { + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + return string.Join("\n", document.GetPages().Select(page => UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page))); + } + public void Dispose() { _db.Database.CloseConnection(); diff --git a/tests/Duempelkas.Tests/Duempelkas.Tests.csproj b/tests/Duempelkas.Tests/Duempelkas.Tests.csproj index fe7a1ee..117d37e 100644 --- a/tests/Duempelkas.Tests/Duempelkas.Tests.csproj +++ b/tests/Duempelkas.Tests/Duempelkas.Tests.csproj @@ -9,6 +9,7 @@ + all