489 lines
20 KiB
C#
489 lines
20 KiB
C#
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;
|
||
using QuestPDF.Fluent;
|
||
using QuestPDF.Helpers;
|
||
using QuestPDF.Infrastructure;
|
||
|
||
namespace Duempelkas.Infrastructure.Services;
|
||
|
||
public class PdfStatementService : IPdfStatementService
|
||
{
|
||
static PdfStatementService()
|
||
{
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
}
|
||
|
||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
||
private readonly IEntryService _entryService;
|
||
private readonly IBalanceQueryService _balanceQueryService;
|
||
private readonly ISettingsService _settingsService;
|
||
private static readonly CultureInfo DeLocale = new("de-DE");
|
||
|
||
public PdfStatementService(IDbContextFactory<FinanceDbContext> dbFactory, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService)
|
||
{
|
||
_dbFactory = dbFactory;
|
||
_entryService = entryService;
|
||
_balanceQueryService = balanceQueryService;
|
||
_settingsService = settingsService;
|
||
}
|
||
|
||
public Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
|
||
{
|
||
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
|
||
return GenerateStatementAsync(accountId, year);
|
||
}
|
||
|
||
public async Task<byte[]> GenerateStatementAsync(int accountId, int? year)
|
||
{
|
||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||
|
||
var account = await db.Accounts.FindAsync(accountId)
|
||
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
|
||
|
||
var entries = await _entryService.GetEntriesAsync(accountId, year);
|
||
var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year);
|
||
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
|
||
|
||
var title = year.HasValue
|
||
? $"{account.Name} – Auszug {year.Value}"
|
||
: $"{account.Name} – Gesamtauszug";
|
||
|
||
var document = Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Size(PageSizes.A4);
|
||
page.MarginHorizontal(40);
|
||
page.MarginVertical(30);
|
||
page.DefaultTextStyle(x => x.FontSize(10));
|
||
|
||
page.Header().Column(col =>
|
||
{
|
||
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);
|
||
});
|
||
|
||
page.Content().PaddingTop(15).Column(col =>
|
||
{
|
||
col.Item().Table(table =>
|
||
{
|
||
table.ColumnsDefinition(columns =>
|
||
{
|
||
columns.ConstantColumn(80);
|
||
columns.ConstantColumn(80);
|
||
columns.RelativeColumn();
|
||
columns.ConstantColumn(100);
|
||
});
|
||
|
||
table.Header(header =>
|
||
{
|
||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||
.PaddingBottom(5).Text("Datum").SemiBold();
|
||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||
.PaddingBottom(5).Text("Nr.").SemiBold();
|
||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||
.PaddingBottom(5).Text("Bezeichnung").SemiBold();
|
||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
||
});
|
||
|
||
foreach (var entry in entries.Where(e => !e.IsDeleted))
|
||
{
|
||
var titleText = entry.Title;
|
||
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
||
{
|
||
var prefix = entry.Type == EntryType.Expense ? "an" : "von";
|
||
titleText += $" ({prefix} {entry.LinkedAccountName})";
|
||
}
|
||
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2)
|
||
.PaddingVertical(3).Text(entry.Date.ToString("dd.MM.yyyy"));
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2)
|
||
.PaddingVertical(3).Text(entry.DisplayId);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2)
|
||
.PaddingVertical(3).Text(titleText);
|
||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2)
|
||
.PaddingVertical(3).AlignRight()
|
||
.Text($"{(entry.Type == EntryType.Income ? "+" : "−")}{FormatCurrency(entry.Amount)}")
|
||
.FontColor(entry.Type == EntryType.Income ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||
}
|
||
});
|
||
|
||
col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||
col.Item().PaddingTop(10).Column(summaryCol =>
|
||
{
|
||
var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome;
|
||
var expenseValue = year.HasValue ? balance.CurrentYearExpense : balance.TotalExpense;
|
||
|
||
if (year.HasValue)
|
||
{
|
||
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(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);
|
||
});
|
||
});
|
||
});
|
||
|
||
page.Footer().AlignCenter().Text(text =>
|
||
{
|
||
text.Span("Seite ");
|
||
text.CurrentPageNumber();
|
||
text.Span(" von ");
|
||
text.TotalPages();
|
||
});
|
||
});
|
||
});
|
||
|
||
return document.GeneratePdf();
|
||
}
|
||
|
||
public Task<byte[]> GenerateDashboardStatementAsync()
|
||
{
|
||
return GenerateDashboardStatementAsync(null);
|
||
}
|
||
|
||
public async Task<byte[]> GenerateDashboardStatementAsync(int? year)
|
||
{
|
||
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 entriesQuery = db.Entries
|
||
.Include(e => e.Account)
|
||
.Where(e => !e.IsDeleted);
|
||
|
||
if (year.HasValue)
|
||
{
|
||
entriesQuery = entriesQuery.Where(e => e.Date.Year == year.Value);
|
||
}
|
||
|
||
var entries = await entriesQuery
|
||
.OrderBy(e => e.Date)
|
||
.ThenBy(e => e.CreatedUtc)
|
||
.ToListAsync();
|
||
|
||
Dictionary<int, decimal> balanceByAccountId;
|
||
Dictionary<int, decimal>? carryoverByAccountId = null;
|
||
|
||
if (year.HasValue)
|
||
{
|
||
var movementBeforeYearByAccountId = await db.Entries
|
||
.Where(e => !e.IsDeleted && e.Date.Year < year.Value)
|
||
.GroupBy(e => e.AccountId)
|
||
.ToDictionaryAsync(
|
||
g => g.Key,
|
||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||
|
||
var movementInYearByAccountId = entries
|
||
.GroupBy(e => e.AccountId)
|
||
.ToDictionary(
|
||
g => g.Key,
|
||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||
|
||
carryoverByAccountId = accounts
|
||
.ToDictionary(
|
||
a => a.Id,
|
||
a => a.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(a.Id));
|
||
|
||
balanceByAccountId = accounts
|
||
.ToDictionary(
|
||
a => a.Id,
|
||
a => a.CarryoverBalance
|
||
+ movementBeforeYearByAccountId.GetValueOrDefault(a.Id)
|
||
+ movementInYearByAccountId.GetValueOrDefault(a.Id));
|
||
}
|
||
else
|
||
{
|
||
var movementByAccountId = entries
|
||
.GroupBy(e => e.AccountId)
|
||
.ToDictionary(
|
||
g => g.Key,
|
||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||
|
||
balanceByAccountId = accounts
|
||
.ToDictionary(
|
||
a => a.Id,
|
||
a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
|
||
}
|
||
|
||
var totalClubBalance = balanceByAccountId.Values.Sum();
|
||
|
||
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);
|
||
var headerTitle = year.HasValue
|
||
? $"Ü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()
|
||
.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();
|
||
}
|
||
});
|
||
|
||
{
|
||
BodyCell(table, string.Empty);
|
||
BodyCell(table, string.Empty);
|
||
BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true);
|
||
|
||
foreach (var account in accounts)
|
||
{
|
||
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add one summary line with the final balance for each account column.
|
||
FooterCell(table, string.Empty);
|
||
FooterCell(table, string.Empty);
|
||
FooterCell(table, "Kontostand", alignRight: true);
|
||
|
||
foreach (var account in accounts)
|
||
{
|
||
FooterAmountCell(table, balanceByAccountId.GetValueOrDefault(account.Id));
|
||
}
|
||
});
|
||
|
||
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 =>
|
||
{
|
||
row.RelativeItem().Text(label);
|
||
row.AutoItem().Text(FormatCurrency(value))
|
||
.FontColor(value >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||
});
|
||
}
|
||
|
||
private static string FormatCurrency(decimal amount)
|
||
{
|
||
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 void FooterCell(TableDescriptor table, string text, bool alignRight = false)
|
||
{
|
||
var cell = table.Cell()
|
||
.BorderTop(1)
|
||
.BorderColor(Colors.Grey.Darken1)
|
||
.PaddingTop(5)
|
||
.PaddingBottom(2);
|
||
|
||
var content = alignRight ? cell.AlignRight() : cell;
|
||
content.Text(text).SemiBold();
|
||
}
|
||
|
||
private static void FooterAmountCell(TableDescriptor table, decimal amount)
|
||
{
|
||
table.Cell()
|
||
.BorderTop(1)
|
||
.BorderColor(Colors.Grey.Darken1)
|
||
.PaddingTop(5)
|
||
.PaddingBottom(2)
|
||
.AlignRight()
|
||
.Text(FormatCurrency(amount))
|
||
.SemiBold()
|
||
.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);
|
||
}
|