Add unit tests for DashboardPdfStatementService to validate PDF generation with entries and transfers
This commit is contained in:
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
|||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.dll",
|
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.exe",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
|
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
|
||||||
"stopAtEntry": false,
|
"stopAtEntry": false,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
@inject IBackupService BackupService
|
@inject IBackupService BackupService
|
||||||
@inject ISettingsService SettingsService
|
@inject ISettingsService SettingsService
|
||||||
|
@inject IPdfStatementService PdfStatementService
|
||||||
|
@inject IFileSaveService FileSaveService
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
@@ -29,8 +32,22 @@
|
|||||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
||||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (accounts != null && accounts.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
||||||
|
<strong>Summe aller Konten:</strong>
|
||||||
|
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||||
|
@FormatCurrency(TotalClubBalance)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
||||||
{
|
{
|
||||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
<div class="alert @operationMessageClass mb-3" role="alert">
|
||||||
@@ -68,6 +85,18 @@
|
|||||||
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
|
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
|
{
|
||||||
|
<ConfirmDialog Title="PDF öffnen"
|
||||||
|
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||||
|
ConfirmText="Ja, öffnen"
|
||||||
|
CancelText="Nein"
|
||||||
|
ConfirmButtonClass="btn btn-primary"
|
||||||
|
ConfirmIconClass="bi bi-box-arrow-up-right"
|
||||||
|
OnConfirm="HandleOpenSavedPdf"
|
||||||
|
OnCancel="CancelOpenSavedPdf" />
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<AccountSummaryDto>? accounts;
|
private List<AccountSummaryDto>? accounts;
|
||||||
private bool showAddAccount;
|
private bool showAddAccount;
|
||||||
@@ -75,7 +104,9 @@
|
|||||||
private string clubName = string.Empty;
|
private string clubName = string.Empty;
|
||||||
private string? operationMessage;
|
private string? operationMessage;
|
||||||
private string operationMessageClass = "alert-info";
|
private string operationMessageClass = "alert-info";
|
||||||
|
private string? savedPdfPath;
|
||||||
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
|
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
|
||||||
|
private decimal TotalClubBalance => accounts?.Sum(a => a.TotalBalance) ?? 0m;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -136,9 +167,35 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleDashboardExportAsync()
|
||||||
|
{
|
||||||
|
var pdf = await PdfStatementService.GenerateDashboardStatementAsync();
|
||||||
|
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleOpenSavedPdf()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
|
{
|
||||||
|
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
savedPdfPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelOpenSavedPdf()
|
||||||
|
{
|
||||||
|
savedPdfPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void SetOperationMessage(string message, bool success)
|
private void SetOperationMessage(string message, bool success)
|
||||||
{
|
{
|
||||||
operationMessage = message;
|
operationMessage = message;
|
||||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
operationMessageClass = success ? "alert-success" : "alert-danger";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatCurrency(decimal amount)
|
||||||
|
{
|
||||||
|
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ namespace Duempelkas.App.Services;
|
|||||||
public interface IPdfStatementService
|
public interface IPdfStatementService
|
||||||
{
|
{
|
||||||
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
|
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
|
||||||
|
Task<byte[]> GenerateDashboardStatementAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -3,6 +3,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
|
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
|
||||||
|
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
|
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
<Content Include="wwwroot\**">
|
<Content Include="wwwroot\**">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Assets\app-icon.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -3,18 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Photino.Blazor;
|
using Photino.Blazor;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Duempelkas.Desktop;
|
namespace Duempelkas.Desktop;
|
||||||
|
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
|
||||||
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
static void Main(string[] args)
|
static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
// Ensure Windows taskbar does not group this process under generic dotnet host identity.
|
||||||
|
SetCurrentProcessExplicitAppUserModelID("Duempelkas.Desktop");
|
||||||
|
}
|
||||||
|
|
||||||
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
|
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
|
||||||
|
|
||||||
var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
// Use a per-user writable DB location so debugging via dotnet host works reliably.
|
||||||
var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
|
var appDataDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"Duempelkas");
|
||||||
|
Directory.CreateDirectory(appDataDir);
|
||||||
|
|
||||||
|
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||||
|
|
||||||
|
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
||||||
|
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||||
|
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||||
|
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(legacyDbPath, dbPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore copy errors and continue with a fresh DB.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
||||||
|
|
||||||
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||||
@@ -23,6 +54,13 @@ class Program
|
|||||||
|
|
||||||
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
||||||
|
|
||||||
|
// Force the native window icon for title bar + taskbar representation.
|
||||||
|
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app-icon.ico");
|
||||||
|
if (File.Exists(iconPath))
|
||||||
|
{
|
||||||
|
app.MainWindow.SetIconFile(iconPath);
|
||||||
|
}
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Duempelkas.App.Services;
|
using Duempelkas.App.Services;
|
||||||
using Duempelkas.App.Services.Models;
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Domain.Entities;
|
||||||
using Duempelkas.Domain.Enums;
|
using Duempelkas.Domain.Enums;
|
||||||
using Duempelkas.Infrastructure.Persistence;
|
using Duempelkas.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -138,6 +139,182 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
return document.GeneratePdf();
|
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)
|
private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
|
||||||
{
|
{
|
||||||
col.Item().PaddingVertical(2).Row(row =>
|
col.Item().PaddingVertical(2).Row(row =>
|
||||||
@@ -152,4 +329,40 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
{
|
{
|
||||||
return amount.ToString("N2", DeLocale) + " €";
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Duempelkas.Domain.Entities;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Duempelkas.Infrastructure.Persistence;
|
||||||
|
using Duempelkas.Infrastructure.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class DashboardPdfStatementServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FinanceDbContext _db;
|
||||||
|
private readonly PdfStatementService _pdfService;
|
||||||
|
private readonly string _connectionString = $"Data Source=duempelkas-dashboard-pdf-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||||
|
|
||||||
|
public DashboardPdfStatementServiceTests()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||||
|
.UseSqlite(_connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
_db = new FinanceDbContext(options);
|
||||||
|
_db.Database.OpenConnection();
|
||||||
|
_db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var dbFactory = new TestDbContextFactory(options);
|
||||||
|
var entryService = new EntryService(dbFactory);
|
||||||
|
var balanceQueryService = new BalanceQueryService(dbFactory);
|
||||||
|
var settingsService = new FixedSettingsService("Testverein");
|
||||||
|
|
||||||
|
_pdfService = new PdfStatementService(dbFactory, entryService, balanceQueryService, settingsService);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateDashboardStatementAsync_WithBookingsAndTransfer_ReturnsPdf()
|
||||||
|
{
|
||||||
|
var barkasse = new Account { Name = "Barkasse" };
|
||||||
|
var girokonto = new Account { Name = "Girokonto" };
|
||||||
|
_db.Accounts.AddRange(barkasse, girokonto);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_db.Entries.Add(new Entry
|
||||||
|
{
|
||||||
|
AccountId = barkasse.Id,
|
||||||
|
DisplayId = "2026-001",
|
||||||
|
Type = EntryType.Income,
|
||||||
|
Date = new DateTime(2026, 1, 12),
|
||||||
|
Title = "Einkuenfte Sommerfest",
|
||||||
|
Amount = 604.60m
|
||||||
|
});
|
||||||
|
|
||||||
|
var transferExpense = new Entry
|
||||||
|
{
|
||||||
|
AccountId = barkasse.Id,
|
||||||
|
DisplayId = "2026-002",
|
||||||
|
Type = EntryType.Expense,
|
||||||
|
Date = new DateTime(2026, 2, 16),
|
||||||
|
Title = "Einzahlung",
|
||||||
|
Amount = 600.00m
|
||||||
|
};
|
||||||
|
|
||||||
|
var transferIncome = new Entry
|
||||||
|
{
|
||||||
|
AccountId = girokonto.Id,
|
||||||
|
DisplayId = "2026-002",
|
||||||
|
Type = EntryType.Income,
|
||||||
|
Date = new DateTime(2026, 2, 16),
|
||||||
|
Title = "Einzahlung",
|
||||||
|
Amount = 600.00m
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Entries.AddRange(transferExpense, transferIncome);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var transferLink = new TransferLink
|
||||||
|
{
|
||||||
|
SourceEntryId = transferExpense.Id,
|
||||||
|
TargetEntryId = transferIncome.Id,
|
||||||
|
Note = "Umbuchung"
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.TransferLinks.Add(transferLink);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
transferExpense.TransferLinkId = transferLink.Id;
|
||||||
|
transferIncome.TransferLinkId = transferLink.Id;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var pdf = await _pdfService.GenerateDashboardStatementAsync();
|
||||||
|
|
||||||
|
pdf.Should().NotBeNull();
|
||||||
|
pdf.Length.Should().BeGreaterThan(1000);
|
||||||
|
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Database.CloseConnection();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||||
|
{
|
||||||
|
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||||
|
|
||||||
|
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinanceDbContext CreateDbContext() => new(_options);
|
||||||
|
|
||||||
|
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new FinanceDbContext(_options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedSettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
private readonly string? _clubName;
|
||||||
|
|
||||||
|
public FixedSettingsService(string? clubName)
|
||||||
|
{
|
||||||
|
_clubName = clubName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetClubNameAsync() => Task.FromResult(_clubName);
|
||||||
|
|
||||||
|
public Task SetClubNameAsync(string? clubName) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user