Add unit tests for DashboardPdfStatementService to validate PDF generation with entries and transfers

This commit is contained in:
2026-04-03 12:33:48 +02:00
parent 69181e66b0
commit 9aa1fee49e
9 changed files with 448 additions and 3 deletions
@@ -1,6 +1,7 @@
using System.Globalization;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Entities;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -138,6 +139,182 @@ public class PdfStatementService : IPdfStatementService
return document.GeneratePdf();
}
public async Task<byte[]> GenerateDashboardStatementAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
.OrderBy(a => a.Name)
.ToListAsync();
var accountById = accounts.ToDictionary(a => a.Id);
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
var transfers = await db.TransferLinks
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
.ToListAsync();
var transferByEntryId = new Dictionary<int, TransferLink>();
foreach (var transfer in transfers)
{
transferByEntryId[transfer.SourceEntryId] = transfer;
transferByEntryId[transfer.TargetEntryId] = transfer;
}
var entries = await db.Entries
.Include(e => e.Account)
.Where(e => !e.IsDeleted)
.OrderBy(e => e.Date)
.ThenBy(e => e.CreatedUtc)
.ToListAsync();
var movementByAccountId = entries
.GroupBy(e => e.AccountId)
.ToDictionary(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
var processedTransferIds = new HashSet<int>();
var rows = new List<DashboardStatementRow>();
foreach (var entry in entries)
{
if (!transferByEntryId.TryGetValue(entry.Id, out var transfer))
{
rows.Add(new DashboardStatementRow(
entry.DisplayId,
entry.Date,
entry.Title,
new Dictionary<int, decimal>
{
[entry.AccountId] = entry.Type == EntryType.Income ? entry.Amount : -entry.Amount
}));
continue;
}
if (!processedTransferIds.Add(transfer.Id))
continue;
var source = transfer.SourceEntry;
var target = transfer.TargetEntry;
if (source.IsDeleted || target.IsDeleted)
continue;
var transferTitle = $"{source.Title} ({source.Account.Name} -> {target.Account.Name})";
rows.Add(new DashboardStatementRow(
source.DisplayId,
source.Date,
transferTitle,
new Dictionary<int, decimal>
{
[source.AccountId] = -source.Amount,
[target.AccountId] = target.Amount
}));
}
rows = rows
.OrderBy(r => ParseDisplayIdYear(r.DisplayId))
.ThenBy(r => ParseDisplayIdSequence(r.DisplayId))
.ThenBy(r => r.Date)
.ThenBy(r => r.Title)
.ToList();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.MarginHorizontal(24);
page.MarginVertical(20);
page.DefaultTextStyle(x => x.FontSize(9));
page.Header().Column(col =>
{
col.Item().Text(clubName).Bold().FontSize(18);
col.Item().Text("Übersicht aller Konten").FontSize(13).FontColor(Colors.Grey.Darken1);
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10)
.SemiBold()
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
col.Item().PaddingTop(4).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(8).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
page.Content().PaddingTop(10).Column(content =>
{
content.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(70);
columns.ConstantColumn(70);
columns.RelativeColumn(2);
foreach (var _ in accounts)
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("LFDNR").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("Datum").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("Titel").SemiBold();
foreach (var account in accounts)
{
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).AlignRight().Text(account.Name).SemiBold();
}
});
foreach (var row in rows)
{
BodyCell(table, row.DisplayId);
BodyCell(table, row.Date.ToString("dd.MM.yyyy"));
BodyCell(table, row.Title);
foreach (var account in accounts)
{
if (row.AmountByAccountId.TryGetValue(account.Id, out var amount))
{
BodyAmountCell(table, amount);
}
else
{
BodyCell(table, string.Empty, alignRight: true);
}
}
}
});
content.Item().PaddingTop(8).AlignRight().Text(text =>
{
text.Span("Gesamtvermögen: ").SemiBold();
text.Span(FormatCurrency(totalClubBalance))
.SemiBold()
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.Span("Seite ");
text.CurrentPageNumber();
text.Span(" von ");
text.TotalPages();
});
});
});
return document.GeneratePdf();
}
private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
{
col.Item().PaddingVertical(2).Row(row =>
@@ -152,4 +329,40 @@ public class PdfStatementService : IPdfStatementService
{
return amount.ToString("N2", DeLocale) + " €";
}
private static void BodyCell(TableDescriptor table, string text, bool alignRight = false)
{
var cell = table.Cell()
.BorderBottom(0.5f)
.BorderColor(Colors.Grey.Lighten2)
.PaddingVertical(3);
var content = alignRight ? cell.AlignRight() : cell;
content.Text(text);
}
private static void BodyAmountCell(TableDescriptor table, decimal amount)
{
table.Cell()
.BorderBottom(0.5f)
.BorderColor(Colors.Grey.Lighten2)
.PaddingVertical(3)
.AlignRight()
.Text($"{(amount >= 0 ? "+" : "−")}{FormatCurrency(Math.Abs(amount))}")
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
}
private static int ParseDisplayIdYear(string displayId)
{
var parts = displayId.Split('-');
return parts.Length == 2 && int.TryParse(parts[0], out var year) ? year : int.MaxValue;
}
private static int ParseDisplayIdSequence(string displayId)
{
var parts = displayId.Split('-');
return parts.Length == 2 && int.TryParse(parts[1], out var seq) ? seq : int.MaxValue;
}
private sealed record DashboardStatementRow(string DisplayId, DateTime Date, string Title, Dictionary<int, decimal> AmountByAccountId);
}