K16 fertig.

Erstellte Dateien:
  - src/Koogle.Application/Interfaces/ICashBookExportService.cs - Interface mit ExportToExcelAsync + ExportToPdfAsync
  - src/Koogle.Application/Services/CashBookExportService.cs - Excel-Export via ClosedXML

  Excel-Struktur (3 Sheets):
  1. Zusammenfassung - Salden, Summen, Statistik
  2. Kategorien - Einnahmen/Ausgaben nach Kategorie
  3. Buchungen - Alle Einträge mit Summenzeile

  NuGet: ClosedXML 0.105.0 hinzugefügt.
This commit is contained in:
beo3000 2026-01-03 21:59:13 +01:00
parent dfe74adf01
commit 5bd0e19f06
5 changed files with 244 additions and 1 deletions

View File

@ -1714,7 +1714,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
| ✓ | K13 | Web | CashBook UI | 3 |
| ✓ | K14 | Web | Categories UI | 2 |
| ✓ | K15 | Web | Reports UI | 2 |
| | K16 | Application | Excel Export (ClosedXML) | 3 |
| | K16 | Application | Excel Export (ClosedXML) | 3 |
| ☐ | K17 | Application | PDF Export (QuestPDF) | 1 |
| ☐ | K18 | Web | Export Controller | 1 |
| ☐ | K19 | Web | Membership Fees Feature | 2 |

View File

@ -42,6 +42,7 @@ namespace Koogle.Application
services.AddScoped<IClubRequestService, ClubRequestService>();
services.AddScoped<IBookingCategoryService, BookingCategoryService>();
services.AddScoped<ICashBookService, CashBookService>();
services.AddScoped<ICashBookExportService, CashBookExportService>();
// Note: Game types are registered in Koogle.Web where Blazor components are defined
// Use services.AddGameType<TDefinition, TLogicService>() to register game types

View File

@ -0,0 +1,25 @@
using Koogle.Application.DTOs;
namespace Koogle.Application.Interfaces;
/// <summary>
/// Service for exporting cash book reports to various formats.
/// </summary>
public interface ICashBookExportService
{
/// <summary>
/// Exports a cash book report to Excel format.
/// </summary>
/// <param name="report">The report data to export.</param>
/// <param name="clubName">Name of the club for the header.</param>
/// <returns>Excel file as byte array.</returns>
Task<byte[]> ExportToExcelAsync(CashBookReportDto report, string clubName);
/// <summary>
/// Exports a cash book report to PDF format.
/// </summary>
/// <param name="report">The report data to export.</param>
/// <param name="clubName">Name of the club for the header.</param>
/// <returns>PDF file as byte array.</returns>
Task<byte[]> ExportToPdfAsync(CashBookReportDto report, string clubName);
}

View File

@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="15.1.0" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>

View File

@ -0,0 +1,216 @@
using ClosedXML.Excel;
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
namespace Koogle.Application.Services;
/// <summary>
/// Service for exporting cash book reports to Excel and PDF formats.
/// </summary>
public class CashBookExportService : ICashBookExportService
{
/// <inheritdoc />
public Task<byte[]> ExportToExcelAsync(CashBookReportDto report, string clubName)
{
using var workbook = new XLWorkbook();
CreateSummarySheet(workbook, report, clubName);
CreateCategorySheet(workbook, report);
CreateEntriesSheet(workbook, report);
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(CashBookReportDto report, string clubName)
{
// Will be implemented in K17 with QuestPDF
throw new NotImplementedException("PDF export will be available in a future version.");
}
private static void CreateSummarySheet(XLWorkbook workbook, CashBookReportDto report, string clubName)
{
var ws = workbook.Worksheets.Add("Zusammenfassung");
// Header
ws.Cell("A1").Value = "Kassenbericht";
ws.Cell("A1").Style.Font.Bold = true;
ws.Cell("A1").Style.Font.FontSize = 16;
ws.Cell("A2").Value = clubName;
ws.Cell("A2").Style.Font.FontSize = 12;
ws.Cell("A3").Value = $"Zeitraum: {report.ReportStart:dd.MM.yyyy} - {report.ReportEnd:dd.MM.yyyy}";
// Summary table
var row = 5;
ws.Cell(row, 1).Value = "Kennzahl";
ws.Cell(row, 2).Value = "Betrag";
ws.Range(row, 1, row, 2).Style.Font.Bold = true;
ws.Range(row, 1, row, 2).Style.Fill.BackgroundColor = XLColor.LightGray;
row++;
ws.Cell(row, 1).Value = "Anfangssaldo";
ws.Cell(row, 2).Value = report.OpeningBalance;
ws.Cell(row, 2).Style.NumberFormat.Format = "#,##0.00 €";
row++;
ws.Cell(row, 1).Value = "Einnahmen";
ws.Cell(row, 2).Value = report.TotalIncome;
ws.Cell(row, 2).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 2).Style.Font.FontColor = XLColor.DarkGreen;
row++;
ws.Cell(row, 1).Value = "Ausgaben";
ws.Cell(row, 2).Value = report.TotalExpense;
ws.Cell(row, 2).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 2).Style.Font.FontColor = XLColor.DarkRed;
row++;
ws.Cell(row, 1).Value = "Differenz";
ws.Cell(row, 2).Value = report.TotalIncome - report.TotalExpense;
ws.Cell(row, 2).Style.NumberFormat.Format = "#,##0.00 €";
row++;
ws.Cell(row, 1).Value = "Endsaldo";
ws.Cell(row, 2).Value = report.ClosingBalance;
ws.Cell(row, 2).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 2).Style.Font.Bold = true;
// Statistics
row += 2;
ws.Cell(row, 1).Value = "Anzahl Buchungen";
ws.Cell(row, 2).Value = report.Entries.Count;
ws.Columns().AdjustToContents();
}
private static void CreateCategorySheet(XLWorkbook workbook, CashBookReportDto report)
{
var ws = workbook.Worksheets.Add("Kategorien");
var row = 1;
// Income categories
ws.Cell(row, 1).Value = "Einnahmen nach Kategorie";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Cell(row, 1).Style.Font.FontSize = 14;
row += 2;
ws.Cell(row, 1).Value = "Kategorie";
ws.Cell(row, 2).Value = "Anzahl";
ws.Cell(row, 3).Value = "Summe";
ws.Range(row, 1, row, 3).Style.Font.Bold = true;
ws.Range(row, 1, row, 3).Style.Fill.BackgroundColor = XLColor.LightGreen;
row++;
foreach (var cat in report.IncomeByCategory.OrderByDescending(c => c.Total))
{
ws.Cell(row, 1).Value = cat.CategoryName;
ws.Cell(row, 2).Value = cat.Count;
ws.Cell(row, 3).Value = cat.Total;
ws.Cell(row, 3).Style.NumberFormat.Format = "#,##0.00 €";
row++;
}
// Total income
ws.Cell(row, 1).Value = "Gesamt";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Cell(row, 2).Value = report.IncomeByCategory.Sum(c => c.Count);
ws.Cell(row, 3).Value = report.TotalIncome;
ws.Cell(row, 3).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 3).Style.Font.Bold = true;
// Expense categories
row += 3;
ws.Cell(row, 1).Value = "Ausgaben nach Kategorie";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Cell(row, 1).Style.Font.FontSize = 14;
row += 2;
ws.Cell(row, 1).Value = "Kategorie";
ws.Cell(row, 2).Value = "Anzahl";
ws.Cell(row, 3).Value = "Summe";
ws.Range(row, 1, row, 3).Style.Font.Bold = true;
ws.Range(row, 1, row, 3).Style.Fill.BackgroundColor = XLColor.LightCoral;
row++;
foreach (var cat in report.ExpenseByCategory.OrderByDescending(c => c.Total))
{
ws.Cell(row, 1).Value = cat.CategoryName;
ws.Cell(row, 2).Value = cat.Count;
ws.Cell(row, 3).Value = cat.Total;
ws.Cell(row, 3).Style.NumberFormat.Format = "#,##0.00 €";
row++;
}
// Total expense
ws.Cell(row, 1).Value = "Gesamt";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Cell(row, 2).Value = report.ExpenseByCategory.Sum(c => c.Count);
ws.Cell(row, 3).Value = report.TotalExpense;
ws.Cell(row, 3).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 3).Style.Font.Bold = true;
ws.Columns().AdjustToContents();
}
private static void CreateEntriesSheet(XLWorkbook workbook, CashBookReportDto report)
{
var ws = workbook.Worksheets.Add("Buchungen");
// Header
var headers = new[] { "Datum", "Kategorie", "Typ", "Beschreibung", "Person", "Beleg", "Betrag" };
for (int i = 0; i < headers.Length; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
}
ws.Range(1, 1, 1, headers.Length).Style.Font.Bold = true;
ws.Range(1, 1, 1, headers.Length).Style.Fill.BackgroundColor = XLColor.LightGray;
// Data rows
var row = 2;
foreach (var entry in report.Entries.OrderBy(e => e.BookingDate))
{
ws.Cell(row, 1).Value = entry.BookingDate;
ws.Cell(row, 1).Style.NumberFormat.Format = "dd.MM.yyyy";
ws.Cell(row, 2).Value = entry.CategoryName;
ws.Cell(row, 3).Value = entry.EntryType == CashBookEntryType.Income ? "Einnahme" : "Ausgabe";
ws.Cell(row, 3).Style.Font.FontColor = entry.EntryType == CashBookEntryType.Income
? XLColor.DarkGreen
: XLColor.DarkRed;
ws.Cell(row, 4).Value = entry.Comment ?? string.Empty;
ws.Cell(row, 5).Value = entry.PersonName ?? string.Empty;
ws.Cell(row, 6).Value = entry.ReceiptReference ?? string.Empty;
var amount = entry.EntryType == CashBookEntryType.Income ? entry.Amount : -entry.Amount;
ws.Cell(row, 7).Value = amount;
ws.Cell(row, 7).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 7).Style.Font.FontColor = entry.EntryType == CashBookEntryType.Income
? XLColor.DarkGreen
: XLColor.DarkRed;
row++;
}
// Sum row
if (report.Entries.Count > 0)
{
ws.Cell(row, 6).Value = "Summe:";
ws.Cell(row, 6).Style.Font.Bold = true;
ws.Cell(row, 7).FormulaA1 = $"=SUM(G2:G{row - 1})";
ws.Cell(row, 7).Style.NumberFormat.Format = "#,##0.00 €";
ws.Cell(row, 7).Style.Font.Bold = true;
}
// Auto-fit and table styling
ws.Columns().AdjustToContents();
ws.Column(4).Width = 40; // Description column wider
}
}