Add unit tests for DashboardPdfStatementService to validate PDF generation with entries and transfers
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user