Compare commits
3
Commits
317c696e70
...
122d4de809
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122d4de809 | ||
|
|
5e0ad197f0 | ||
|
|
a3c1bee09e |
@@ -0,0 +1,29 @@
|
|||||||
|
# Umsetzung U01–U07
|
||||||
|
|
||||||
|
Reihenfolge und je ein eigener Commit:
|
||||||
|
|
||||||
|
1. **U01:** Gemeinsamer Datenpfad, SQLite-Online-Sicherung, geprüfte Wiederherstellung mit Rückfallsicherung und koordinierten Schreibzugriffen. Nur isolierte Testdaten wiederherstellen.
|
||||||
|
2. **U02:** Strukturierte Ergebnisse (Erfolg, Abbruch, Fehler), sichtbare Meldungen, sichere Dateiaktionen und Sperre während Dateioperationen.
|
||||||
|
3. **U03:** Nur wirksame Buchungen exportieren; Zeitraum, Anfangs-/Endstand und interne Umbuchungen erklären. PDF-Inhalte und Darstellung prüfen.
|
||||||
|
4. **U04:** Gemeinsamer Jahreskontext, sichtbarer Zeitraum, Hinweis und Rückweg bei abweichendem Buchungsjahr; Datum beibehalten.
|
||||||
|
5. **U05:** Alltagssprache, flexible Aktionsleiste, Wiederherstellung unter weiteren Aktionen, Hinweis zur Umbuchung.
|
||||||
|
6. **U06:** Konto und Art anzeigen, feldnahe Validierung. Komma oder Punkt als Dezimaltrenner, keine Tausendertrenner; höchstens zwei Nachkommastellen, positive Beträge. Art beim Bearbeiten fest: falsche Buchung löschen und neu erfassen.
|
||||||
|
7. **U07:** Speichersperre, Fortschritt, Fehler mit erhaltenen Eingaben, ausdrückliche Erfolgsmeldung und Hervorhebung.
|
||||||
|
|
||||||
|
Prüfung: gezielte automatisierte Regressionen, Debug/Release, Desktop-Prüfung und PDF-Sichtprüfung soweit verfügbar. Anschließend Commits pushen und PR-Beschreibung mit Ergebnissen und Grenzen aktualisieren. U08–U16 bleiben außerhalb dieses Auftrags.
|
||||||
|
|
||||||
|
## U01 – Sicherungsumfang
|
||||||
|
|
||||||
|
Die Sicherung enthält Konten, Anfangsbestände, Buchungen einschließlich gelöschter Zeilen, Umbuchungsverknüpfungen und Datenbankschema. Vereinsname, Farbdarstellung und Fenstergröße liegen außerhalb der Datenbank und sind **nicht enthalten**. Eine Kopie auf derselben Festplatte schützt nicht gegen Geräteausfall; dafür die Sicherung zusätzlich auf einem unabhängigen Datenträger aufbewahren.
|
||||||
|
|
||||||
|
Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden.
|
||||||
|
|
||||||
|
Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt.
|
||||||
|
|
||||||
|
## U02 – Dateiaktionen
|
||||||
|
|
||||||
|
Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden.
|
||||||
|
|
||||||
|
## U03 – Gleiche Bedeutung in PDF und Oberfläche
|
||||||
|
|
||||||
|
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@inject IFileSaveService FileSaveService
|
||||||
|
|
||||||
|
@if (Result != null)
|
||||||
|
{
|
||||||
|
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
<p>@Result.Message</p>
|
||||||
|
@if (Result.Path != null)
|
||||||
|
{
|
||||||
|
<p class="text-break">@Result.Path</p>
|
||||||
|
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
|
||||||
|
}
|
||||||
|
@if (Result.Details != null)
|
||||||
|
{
|
||||||
|
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
|
||||||
|
}
|
||||||
|
@if (openResult != null)
|
||||||
|
{
|
||||||
|
<p role="alert">@openResult.Message</p>
|
||||||
|
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public FileOperationResult? Result { get; set; }
|
||||||
|
private FileOperationResult? openResult;
|
||||||
|
private FileOperationResult? previous;
|
||||||
|
private bool busy;
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (previous != Result) { openResult = null; previous = Result; }
|
||||||
|
}
|
||||||
|
private async Task OpenFolder()
|
||||||
|
{
|
||||||
|
if (busy || Result?.Path == null) return;
|
||||||
|
busy = true;
|
||||||
|
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
<OperationNotice Result="fileResult" />
|
||||||
|
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||||
|
|
||||||
@if (account == null)
|
@if (account == null)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
||||||
<i class="bi bi-arrow-left-right"></i> Umbuchung
|
<i class="bi bi-arrow-left-right"></i> Umbuchung
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
|
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public partial class AccountDetail
|
|||||||
private string? confirmRestoreEntryTitle;
|
private string? confirmRestoreEntryTitle;
|
||||||
|
|
||||||
private string? savedPdfPath;
|
private string? savedPdfPath;
|
||||||
|
private FileOperationResult? fileResult;
|
||||||
|
private bool fileBusy;
|
||||||
|
|
||||||
private EntryDto? editingEntry;
|
private EntryDto? editingEntry;
|
||||||
private EntryDto? editingTransferEntry;
|
private EntryDto? editingTransferEntry;
|
||||||
@@ -178,19 +180,39 @@ public partial class AccountDetail
|
|||||||
|
|
||||||
private async Task HandleExport()
|
private async Task HandleExport()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
||||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
||||||
|
|
||||||
|
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleOpenSavedPdf()
|
private async Task HandleOpenSavedPdf()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
savedPdfPath = null;
|
savedPdfPath = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelOpenSavedPdf()
|
private void CancelOpenSavedPdf()
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
<OperationNotice Result="fileResult" />
|
||||||
|
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
|
||||||
@@ -25,14 +28,14 @@
|
|||||||
<i class="bi bi-plus-lg"></i> Neues Konto
|
<i class="bi bi-plus-lg"></i> Neues Konto
|
||||||
</button>
|
</button>
|
||||||
|
|
|
|
||||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||||
<i class="bi bi-save"></i> Backup
|
<i class="bi bi-save"></i> Backup
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||||
<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">
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
|
||||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
|
|
||||||
@@ -54,12 +57,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
|
||||||
{
|
|
||||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
|
||||||
@operationMessage
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (accounts == null)
|
@if (accounts == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ public partial class Dashboard
|
|||||||
private int? selectedYear = DateTime.Now.Year;
|
private int? selectedYear = DateTime.Now.Year;
|
||||||
private List<int> availableYears = [];
|
private List<int> availableYears = [];
|
||||||
private string clubName = string.Empty;
|
private string clubName = string.Empty;
|
||||||
private string? operationMessage;
|
|
||||||
private string operationMessageClass = "alert-info";
|
|
||||||
private string? savedPdfPath;
|
private string? savedPdfPath;
|
||||||
|
private FileOperationResult? fileResult;
|
||||||
|
private bool fileBusy;
|
||||||
private string currentTheme = "light";
|
private string currentTheme = "light";
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -100,11 +100,16 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleBackupAsync()
|
private async Task HandleBackupAsync()
|
||||||
{
|
{
|
||||||
var message = await BackupService.CreateBackupAsync();
|
if (fileBusy) return;
|
||||||
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
SetOperationMessage(message, false);
|
fileResult = await BackupService.CreateBackupAsync();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleRestoreAsync()
|
private Task HandleRestoreAsync()
|
||||||
@@ -120,25 +125,36 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleConfirmRestore()
|
private async Task HandleConfirmRestore()
|
||||||
{
|
{
|
||||||
showRestoreConfirm = false;
|
if (fileBusy) return;
|
||||||
var message = await BackupService.RestoreBackupAsync();
|
fileBusy = true;
|
||||||
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
showRestoreConfirm = false;
|
||||||
|
fileResult = await BackupService.RestoreBackupAsync();
|
||||||
|
if (fileResult.Status == FileOperationStatus.Success) await LoadAll();
|
||||||
|
|
||||||
if (isSuccess)
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SetOperationMessage(message, isSuccess);
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleDashboardExportAsync()
|
private async Task HandleDashboardExportAsync()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
||||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
|
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
|
||||||
|
|
||||||
|
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenYearFilterDialog()
|
private void OpenYearFilterDialog()
|
||||||
@@ -160,12 +176,21 @@ public partial class Dashboard
|
|||||||
|
|
||||||
private async Task HandleOpenSavedPdf()
|
private async Task HandleOpenSavedPdf()
|
||||||
{
|
{
|
||||||
|
if (fileBusy) return;
|
||||||
|
fileBusy = true;
|
||||||
|
fileResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||||
{
|
{
|
||||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
savedPdfPath = null;
|
savedPdfPath = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||||
|
finally { fileBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelOpenSavedPdf()
|
private void CancelOpenSavedPdf()
|
||||||
@@ -177,12 +202,6 @@ public partial class Dashboard
|
|||||||
|
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
private void SetOperationMessage(string message, bool success)
|
|
||||||
{
|
|
||||||
operationMessage = message;
|
|
||||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCurrency(decimal amount)
|
private static string FormatCurrency(decimal amount)
|
||||||
{
|
{
|
||||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
|
||||||
namespace Duempelkas.App.Services;
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
public interface IBackupService
|
public interface IBackupService
|
||||||
{
|
{
|
||||||
Task<string> CreateBackupAsync();
|
Task<FileOperationResult> CreateBackupAsync();
|
||||||
Task<string> RestoreBackupAsync();
|
Task<FileOperationResult> RestoreBackupAsync();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
|
public interface IFileDialogService
|
||||||
|
{
|
||||||
|
Task<string?> SaveAsync(string suggestedFileName);
|
||||||
|
Task<string?> OpenBackupAsync();
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
|
||||||
namespace Duempelkas.App.Services;
|
namespace Duempelkas.App.Services;
|
||||||
|
|
||||||
public interface IFileSaveService
|
public interface IFileSaveService
|
||||||
{
|
{
|
||||||
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
|
Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
|
||||||
Task OpenFileAsync(string filePath);
|
Task<FileOperationResult> OpenFileAsync(string filePath);
|
||||||
|
Task<FileOperationResult> OpenFolderAsync(string filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Duempelkas.App.Services.Models;
|
||||||
|
|
||||||
|
public enum FileOperationStatus { Success, Cancelled, Error }
|
||||||
|
|
||||||
|
public sealed record FileOperationResult(FileOperationStatus Status, string Message, string? Path = null, string? Details = null)
|
||||||
|
{
|
||||||
|
public static FileOperationResult Cancelled() => new(FileOperationStatus.Cancelled, "Vorgang abgebrochen. Es wurde nichts geändert.");
|
||||||
|
public static FileOperationResult Error(Exception error) => new(FileOperationStatus.Error,
|
||||||
|
"Die Dateiaktion konnte nicht abgeschlossen werden. Bitte den Speicherort und die Zugriffsrechte prüfen oder einen anderen Speicherort wählen.", Details: error.Message);
|
||||||
|
}
|
||||||
@@ -38,11 +38,15 @@ class Program
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Copy(legacyDbPath, dbPath);
|
using var source = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={legacyDbPath};Mode=ReadOnly");
|
||||||
|
using var target = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath}");
|
||||||
|
source.Open();
|
||||||
|
target.Open();
|
||||||
|
source.BackupDatabase(target);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Ignore copy errors and continue with a fresh DB.
|
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton(new DatabaseLocation(connectionString));
|
||||||
services.AddDbContext<FinanceDbContext>(options =>
|
services.AddDbContext<FinanceDbContext>(options =>
|
||||||
options.UseSqlite(connectionString));
|
options.UseSqlite(connectionString));
|
||||||
services.AddDbContextFactory<FinanceDbContext>(options =>
|
services.AddDbContextFactory<FinanceDbContext>(options =>
|
||||||
@@ -19,6 +20,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IEntryService, EntryService>();
|
services.AddScoped<IEntryService, EntryService>();
|
||||||
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
||||||
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
||||||
|
services.AddSingleton<IFileDialogService, FileDialogService>();
|
||||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||||
services.AddSingleton<IBackupService, BackupService>();
|
services.AddSingleton<IBackupService, BackupService>();
|
||||||
services.AddSingleton<ISettingsService, SettingsService>();
|
services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
||||||
|
|
||||||
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
private readonly DatabaseLocation? _location;
|
||||||
|
|
||||||
|
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_location = location;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||||
{
|
{
|
||||||
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var accounts = await db.Accounts
|
var accounts = await db.Accounts
|
||||||
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
@@ -87,6 +95,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
|
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = new Account { Name = name };
|
var account = new Account { Name = name };
|
||||||
@@ -97,6 +106,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task RenameAccountAsync(int accountId, string newName)
|
public async Task RenameAccountAsync(int accountId, string newName)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -107,6 +117,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
|
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -123,6 +134,7 @@ public class AccountService : IAccountService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts.FindAsync(accountId)
|
var account = await db.Accounts.FindAsync(accountId)
|
||||||
@@ -138,6 +150,7 @@ public class AccountService : IAccountService
|
|||||||
|
|
||||||
public async Task DeleteAccountAsync(int accountId)
|
public async Task DeleteAccountAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Duempelkas.App.Services;
|
using Duempelkas.App.Services;
|
||||||
@@ -7,140 +8,126 @@ namespace Duempelkas.Infrastructure.Services;
|
|||||||
|
|
||||||
public class BackupService : IBackupService
|
public class BackupService : IBackupService
|
||||||
{
|
{
|
||||||
private static readonly string DbPath = Path.Combine(
|
private readonly DatabaseLocation location;
|
||||||
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
|
|
||||||
"duempelkas.db");
|
|
||||||
|
|
||||||
public async Task<string> CreateBackupAsync()
|
private readonly IFileDialogService dialogs;
|
||||||
|
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
|
||||||
{
|
{
|
||||||
if (!File.Exists(DbPath))
|
this.location = location;
|
||||||
{
|
this.dialogs = dialogs ?? new FileDialogService();
|
||||||
return "Keine Datenbankdatei gefunden.";
|
|
||||||
}
|
|
||||||
|
|
||||||
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
|
|
||||||
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(backupPath))
|
|
||||||
{
|
|
||||||
return "Backup wurde abgebrochen.";
|
|
||||||
}
|
|
||||||
|
|
||||||
backupPath = Path.ChangeExtension(backupPath, ".bak");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Copy(DbPath, backupPath, overwrite: true);
|
|
||||||
return $"Backup gespeichert: {backupPath}";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"Backup fehlgeschlagen: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> RestoreBackupAsync()
|
public async Task<FileOperationResult> CreateBackupAsync()
|
||||||
{
|
{
|
||||||
var backupPath = await ShowOpenDialogAsync();
|
|
||||||
if (string.IsNullOrWhiteSpace(backupPath))
|
|
||||||
{
|
|
||||||
return "Wiederherstellung wurde abgebrochen.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(backupPath))
|
|
||||||
{
|
|
||||||
return "Die gewählte Backup-Datei wurde nicht gefunden.";
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Ensure no pooled connection keeps stale handles to the previous DB file.
|
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
|
||||||
SqliteConnection.ClearAllPools();
|
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||||
|
await CreateBackupToAsync(path);
|
||||||
var targetDirectory = Path.GetDirectoryName(DbPath);
|
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
|
||||||
if (!string.IsNullOrWhiteSpace(targetDirectory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(targetDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Copy(backupPath, DbPath, overwrite: true);
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
public async Task<FileOperationResult> RestoreBackupAsync()
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
try
|
||||||
{
|
{
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
var path = await dialogs.OpenBackupAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||||
|
var fallback = await RestoreFromAsync(path);
|
||||||
|
return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateBackupToAsync(string path)
|
||||||
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(location);
|
||||||
|
RejectActivePath(path);
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp";
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
Snapshot(location.Path, staged);
|
||||||
return Path.Combine(documentsPath, suggestedFileName);
|
File.Move(staged, path, overwrite: true);
|
||||||
}
|
}
|
||||||
|
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||||
var script = $@"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
|
||||||
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
|
|
||||||
$dialog.DefaultExt = 'bak'
|
|
||||||
$dialog.AddExtension = $true
|
|
||||||
$dialog.OverwritePrompt = $true
|
|
||||||
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
|
|
||||||
$dialog.Title = 'Backup speichern'
|
|
||||||
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
|
|
||||||
";
|
|
||||||
|
|
||||||
return RunPowerShellDialog(script);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<string?> ShowOpenDialogAsync()
|
public async Task<string> RestoreFromAsync(string path)
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
using var lease = await DatabaseLease.AcquireAsync(location);
|
||||||
|
return await Task.Run(() =>
|
||||||
{
|
{
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
RejectActivePath(path);
|
||||||
|
// Stage the selected database first, so a changing/removable source cannot
|
||||||
|
// change between validation and restore. BackupDatabase includes WAL data.
|
||||||
|
var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db");
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return null;
|
Snapshot(path, staged);
|
||||||
|
Validate(staged);
|
||||||
|
var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak";
|
||||||
|
Snapshot(location.Path, fallback);
|
||||||
|
// SQLite's online backup atomically replaces database contents, retaining
|
||||||
|
// the active file/handles and coordinating locks. Never copy over an open DB.
|
||||||
|
Snapshot(staged, location.Path);
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
finally { if (File.Exists(staged)) File.Delete(staged); }
|
||||||
var script = @"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
$dialog = New-Object System.Windows.Forms.OpenFileDialog
|
|
||||||
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
|
|
||||||
$dialog.Multiselect = $false
|
|
||||||
$dialog.Title = 'Backup auswählen'
|
|
||||||
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
|
|
||||||
";
|
|
||||||
|
|
||||||
return RunPowerShellDialog(script);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? RunPowerShellDialog(string script)
|
private void RejectActivePath(string path)
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo
|
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
|
||||||
{
|
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
|
||||||
FileName = "powershell",
|
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
|
||||||
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = Process.Start(psi);
|
|
||||||
if (process == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = process.StandardOutput.ReadToEnd().Trim();
|
|
||||||
process.WaitForExit();
|
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static SqliteConnection Open(string path, SqliteOpenMode mode)
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false
|
||||||
|
}.ToString());
|
||||||
|
try { connection.Open(); return connection; }
|
||||||
|
catch { connection.Dispose(); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Snapshot(string source, string destination)
|
||||||
|
{
|
||||||
|
if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein.");
|
||||||
|
using var input = Open(source, SqliteOpenMode.ReadOnly);
|
||||||
|
using var output = Open(destination, SqliteOpenMode.ReadWriteCreate);
|
||||||
|
input.BackupDatabase(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(string path)
|
||||||
|
{
|
||||||
|
using var connection = Open(path, SqliteOpenMode.ReadOnly);
|
||||||
|
using var check = connection.CreateCommand();
|
||||||
|
check.CommandText = "PRAGMA integrity_check";
|
||||||
|
if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException("Die Sicherung ist beschädigt.");
|
||||||
|
check.CommandText = "PRAGMA foreign_key_check";
|
||||||
|
using (var reader = check.ExecuteReader())
|
||||||
|
if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen.");
|
||||||
|
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
|
||||||
|
Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection);
|
||||||
|
using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options);
|
||||||
|
var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray();
|
||||||
|
var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray();
|
||||||
|
if (!expected.SequenceEqual(applied))
|
||||||
|
throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion.");
|
||||||
|
// Materialize every mapped column to reject a forged/missing schema before touching active data.
|
||||||
|
_ = db.Accounts.ToArray();
|
||||||
|
_ = db.Entries.ToArray();
|
||||||
|
_ = db.TransferLinks.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Duempelkas.Infrastructure.Services;
|
||||||
|
|
||||||
|
internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable
|
||||||
|
{
|
||||||
|
public static async Task<DatabaseLease> AcquireAsync(DatabaseLocation? location)
|
||||||
|
{
|
||||||
|
if (location != null) await location.Writes.WaitAsync();
|
||||||
|
return new DatabaseLease(location?.Writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => gate?.Release();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Duempelkas.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class DatabaseLocation(string connectionString)
|
||||||
|
{
|
||||||
|
public string Path { get; } = System.IO.Path.GetFullPath(new SqliteConnectionStringBuilder(connectionString).DataSource);
|
||||||
|
public string ConnectionString { get; } = connectionString;
|
||||||
|
// All application mutations and restore share this gate. SQLite's backup API
|
||||||
|
// also coordinates readers and external connections through SQLite locks.
|
||||||
|
public SemaphoreSlim Writes { get; } = new(1, 1);
|
||||||
|
}
|
||||||
@@ -11,7 +11,13 @@ public class EntryService : IEntryService
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
|
||||||
|
|
||||||
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
private readonly DatabaseLocation? _location;
|
||||||
|
|
||||||
|
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_location = location;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
|
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
|
||||||
{
|
{
|
||||||
@@ -21,6 +27,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
|
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var query = db.Entries.Where(e => e.AccountId == accountId);
|
var query = db.Entries.Where(e => e.AccountId == accountId);
|
||||||
@@ -67,6 +74,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<List<int>> GetEntryYearsAsync(int accountId)
|
public async Task<List<int>> GetEntryYearsAsync(int accountId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
return await db.Entries
|
return await db.Entries
|
||||||
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<List<int>> GetAllEntryYearsAsync()
|
public async Task<List<int>> GetAllEntryYearsAsync()
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
return await db.Entries
|
return await db.Entries
|
||||||
@@ -90,6 +99,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||||
@@ -114,6 +124,7 @@ public class EntryService : IEntryService
|
|||||||
if (sourceAccountId == targetAccountId)
|
if (sourceAccountId == targetAccountId)
|
||||||
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
|
||||||
|
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -163,6 +174,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task DeleteEntryAsync(int entryId)
|
public async Task DeleteEntryAsync(int entryId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -185,6 +197,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task RestoreEntryAsync(int entryId)
|
public async Task RestoreEntryAsync(int entryId)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -207,6 +220,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
@@ -236,6 +250,7 @@ public class EntryService : IEntryService
|
|||||||
|
|
||||||
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
|
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
|
||||||
{
|
{
|
||||||
|
using var lease = await DatabaseLease.AcquireAsync(_location);
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var entry = await db.Entries.FindAsync(entryId)
|
var entry = await db.Entries.FindAsync(entryId)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using Duempelkas.App.Services;
|
||||||
|
|
||||||
|
namespace Duempelkas.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class FileDialogService : IFileDialogService
|
||||||
|
{
|
||||||
|
public Task<string?> SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName);
|
||||||
|
public Task<string?> OpenBackupAsync() => ShowAsync(null);
|
||||||
|
|
||||||
|
private static Task<string?> ShowAsync(string? suggestedFileName) => Task.Run(() =>
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
throw new PlatformNotSupportedException("Der Dateidialog ist auf diesem System nicht verfügbar.");
|
||||||
|
var save = suggestedFileName != null;
|
||||||
|
var extension = save ? (Path.GetExtension(suggestedFileName) ?? "") : ".bak";
|
||||||
|
var script = $$"""
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dialog = New-Object System.Windows.Forms.{{(save ? "SaveFileDialog" : "OpenFileDialog")}}
|
||||||
|
$dialog.Filter = 'Dateien (*{{extension.Replace("'", "''")}})|*{{extension.Replace("'", "''")}}'
|
||||||
|
$dialog.Title = '{{(save ? "Datei speichern" : "Sicherung auswählen")}}'
|
||||||
|
{{(save ? "$dialog.OverwritePrompt = $true\n$dialog.AddExtension = $true\n$dialog.DefaultExt = '" + extension.TrimStart('.').Replace("'", "''") + "'\n$dialog.FileName = '" + suggestedFileName!.Replace("'", "''") + "'" : "$dialog.Multiselect = $false")}}
|
||||||
|
try { if ($dialog.ShowDialog() -eq 'OK') { [Console]::Write($dialog.FileName) } } finally { $dialog.Dispose() }
|
||||||
|
""";
|
||||||
|
var start = new ProcessStartInfo("powershell.exe")
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true, RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add("-NoProfile");
|
||||||
|
start.ArgumentList.Add("-STA");
|
||||||
|
start.ArgumentList.Add("-EncodedCommand");
|
||||||
|
start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
|
||||||
|
using var process = Process.Start(start) ?? throw new IOException("Der Dateidialog konnte nicht gestartet werden.");
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0) throw new IOException("Der Dateidialog konnte nicht geöffnet werden. " + error);
|
||||||
|
return string.IsNullOrWhiteSpace(output) ? null : output;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,90 +1,49 @@
|
|||||||
|
using Duempelkas.App.Services.Models;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Duempelkas.App.Services;
|
using Duempelkas.App.Services;
|
||||||
|
|
||||||
namespace Duempelkas.Infrastructure.Services;
|
namespace Duempelkas.Infrastructure.Services;
|
||||||
|
|
||||||
public class FileSaveService : IFileSaveService
|
public class FileSaveService(IFileDialogService dialogs) : IFileSaveService
|
||||||
{
|
{
|
||||||
public async Task<string?> SaveFileAsync(byte[] content, string suggestedFileName)
|
public async Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName)
|
||||||
{
|
{
|
||||||
var filePath = await ShowSaveDialogAsync(suggestedFileName);
|
string? staged = null;
|
||||||
if (filePath == null) return null;
|
try
|
||||||
|
|
||||||
await File.WriteAllBytesAsync(filePath, content);
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task OpenFileAsync(string filePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
var path = await dialogs.SaveAsync(suggestedFileName);
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
|
||||||
|
staged = path + $".{Guid.NewGuid():N}.tmp";
|
||||||
|
await File.WriteAllBytesAsync(staged, content);
|
||||||
|
File.Move(staged, path, overwrite: true);
|
||||||
|
return new(FileOperationStatus.Success, "Datei gespeichert.", path);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { return FileOperationResult.Error(ex); }
|
||||||
return Task.Run(() =>
|
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
|
||||||
{
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = filePath,
|
|
||||||
UseShellExecute = true
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
||||||
{
|
|
||||||
Process.Start("open", filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Process.Start("xdg-open", filePath);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
public Task<FileOperationResult> OpenFileAsync(string filePath) => OpenAsync(filePath, false);
|
||||||
|
public Task<FileOperationResult> OpenFolderAsync(string filePath) => OpenAsync(filePath, true);
|
||||||
|
|
||||||
|
private static Task<FileOperationResult> OpenAsync(string filePath, bool folder) => Task.Run(() =>
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
try
|
||||||
{
|
{
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
|
||||||
{
|
if (folder ? !Directory.Exists(path) : !File.Exists(path))
|
||||||
// Fallback: save to Documents folder
|
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
|
||||||
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
var start = new ProcessStartInfo { UseShellExecute = true };
|
||||||
return Path.Combine(documentsPath, suggestedFileName);
|
if (OperatingSystem.IsWindows()) start.FileName = path;
|
||||||
}
|
else { start.FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; start.ArgumentList.Add(path); }
|
||||||
|
using var process = Process.Start(start) ?? throw new IOException("Kein Programm zum Öffnen gefunden.");
|
||||||
|
return new FileOperationResult(FileOperationStatus.Success, folder ? "Ordner geöffnet." : "Datei geöffnet.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new FileOperationResult(FileOperationStatus.Error,
|
||||||
|
"Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var extension = Path.GetExtension(suggestedFileName);
|
|
||||||
var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien";
|
|
||||||
var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*";
|
|
||||||
|
|
||||||
var script = $@"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
|
||||||
$dialog.Filter = '{filter}'
|
|
||||||
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
|
|
||||||
$dialog.Title = 'Datei speichern'
|
|
||||||
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
|
|
||||||
";
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "powershell",
|
|
||||||
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"",
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = Process.Start(psi);
|
|
||||||
if (process == null) return null;
|
|
||||||
|
|
||||||
var result = process.StandardOutput.ReadToEnd().Trim();
|
|
||||||
process.WaitForExit();
|
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
{
|
{
|
||||||
col.Item().Text(clubName).Bold().FontSize(20);
|
col.Item().Text(clubName).Bold().FontSize(20);
|
||||||
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1);
|
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(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);
|
col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||||
});
|
});
|
||||||
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries.Where(e => !e.IsDeleted))
|
||||||
{
|
{
|
||||||
var titleText = entry.Title;
|
var titleText = entry.Title;
|
||||||
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
|
||||||
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
|
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 ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
|
||||||
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
||||||
summaryCol.Item().PaddingTop(5).Row(row =>
|
summaryCol.Item().PaddingTop(5).Row(row =>
|
||||||
{
|
{
|
||||||
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
|
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)
|
row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
|
||||||
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||||
});
|
});
|
||||||
@@ -302,6 +309,8 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
? $"Übersicht aller Konten ({year.Value})"
|
? $"Übersicht aller Konten ({year.Value})"
|
||||||
: "Übersicht aller Konten";
|
: "Übersicht aller Konten";
|
||||||
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
|
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)}")
|
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
|
||||||
.FontSize(10)
|
.FontSize(10)
|
||||||
.SemiBold()
|
.SemiBold()
|
||||||
@@ -338,15 +347,14 @@ public class PdfStatementService : IPdfStatementService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (year.HasValue && carryoverByAccountId is not null)
|
|
||||||
{
|
{
|
||||||
BodyCell(table, string.Empty);
|
BodyCell(table, string.Empty);
|
||||||
BodyCell(table, string.Empty);
|
BodyCell(table, string.Empty);
|
||||||
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
|
BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true);
|
||||||
|
|
||||||
foreach (var account in accounts)
|
foreach (var account in accounts)
|
||||||
{
|
{
|
||||||
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
|
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Duempelkas.Infrastructure;
|
||||||
|
using Duempelkas.Infrastructure.Persistence;
|
||||||
|
using Duempelkas.Infrastructure.Services;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public sealed class BackupServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly ServiceProvider services;
|
||||||
|
private readonly BackupService backup;
|
||||||
|
private readonly EntryService entries;
|
||||||
|
private readonly int accountId;
|
||||||
|
|
||||||
|
public BackupServiceTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
services = new ServiceCollection().AddInfrastructure($"Data Source={Path.Combine(folder, "active.db")};Pooling=False").BuildServiceProvider();
|
||||||
|
using var db = services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
|
||||||
|
db.Database.Migrate();
|
||||||
|
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
|
||||||
|
db.Accounts.Add(new() { Name = "Isolierte Testkasse" });
|
||||||
|
db.SaveChanges();
|
||||||
|
accountId = db.Accounts.Single().Id;
|
||||||
|
backup = new BackupService(services.GetRequiredService<DatabaseLocation>());
|
||||||
|
entries = (EntryService)services.GetRequiredService<Duempelkas.App.Services.IEntryService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WalSnapshot_RestoresExactState_AndKeepsFallback()
|
||||||
|
{
|
||||||
|
var first = await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Gesichert", 12.50m);
|
||||||
|
var path = Path.Combine(folder, "snapshot.bak");
|
||||||
|
await backup.CreateBackupToAsync(path);
|
||||||
|
await entries.CreateEntryAsync(accountId, EntryType.Expense, new(2026, 1, 2), "Später", 4.20m);
|
||||||
|
var fallback = await backup.RestoreFromAsync(path);
|
||||||
|
var restored = await entries.GetEntriesAsync(accountId, (int?)null);
|
||||||
|
Assert.Single(restored);
|
||||||
|
Assert.Equal(first, restored[0]);
|
||||||
|
using var old = new SqliteConnection($"Data Source={fallback};Mode=ReadOnly;Pooling=False");
|
||||||
|
old.Open();
|
||||||
|
using var command = old.CreateCommand();
|
||||||
|
command.CommandText = "SELECT COUNT(*) FROM Entries";
|
||||||
|
Assert.Equal(2L, command.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public async Task InvalidBackup_DoesNotChangeActiveData(bool sqlite)
|
||||||
|
{
|
||||||
|
await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Bleibt", 10m);
|
||||||
|
var path = Path.Combine(folder, "invalid.bak");
|
||||||
|
if (sqlite)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "CREATE TABLE Unrelated (Id INTEGER)";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
else await File.WriteAllTextAsync(path, "not a database");
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(path));
|
||||||
|
Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MissingSource_AndActiveDestination_AreRejected()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(Path.Combine(folder, "missing.bak")));
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => backup.CreateBackupToAsync(Path.Combine(folder, "active.db")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
services.Dispose();
|
||||||
|
Directory.Delete(folder, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable
|
|||||||
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Exports_ExcludeDeleted_ExplainTransfers_AndMatchBalances()
|
||||||
|
{
|
||||||
|
var source = new Account { Name = "Barkasse", CarryoverBalance = 100m };
|
||||||
|
var target = new Account { Name = "Girokonto" };
|
||||||
|
_db.Accounts.AddRange(source, target);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<FinanceDbContext>().UseSqlite(_connectionString).Options;
|
||||||
|
var factory = new TestDbContextFactory(options);
|
||||||
|
var entries = new EntryService(factory);
|
||||||
|
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 1), "Mitgliedsbeitrag", 12.50m);
|
||||||
|
await entries.CreateEntryAsync(source.Id, EntryType.Expense, new(2026, 1, 2), "Porto", 4.20m);
|
||||||
|
await entries.CreateTransferAsync(source.Id, target.Id, new(2026, 1, 3), "Zur Bank", 50m);
|
||||||
|
var deleted = await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 4), "GELOESCHTE_TESTBUCHUNG", 999m);
|
||||||
|
await entries.DeleteEntryAsync(deleted.Id);
|
||||||
|
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2027, 1, 1), "ZUKUNFT_TESTBUCHUNG", 1m);
|
||||||
|
var balance = await new BalanceQueryService(factory).GetAccountBalanceAsync(source.Id, 2026);
|
||||||
|
Assert.Equal(58.30m, balance.TotalBalance);
|
||||||
|
var single = await _pdfService.GenerateStatementAsync(source.Id, 2026);
|
||||||
|
var combined = await _pdfService.GenerateDashboardStatementAsync(2026);
|
||||||
|
foreach (var pdf in new[] { single, combined })
|
||||||
|
{
|
||||||
|
var text = Extract(pdf);
|
||||||
|
Assert.Contains("Mitgliedsbeitrag", text);
|
||||||
|
Assert.Contains("Porto", text);
|
||||||
|
Assert.Contains("Zur Bank", text);
|
||||||
|
Assert.DoesNotContain("GELOESCHTE_TESTBUCHUNG", text);
|
||||||
|
Assert.DoesNotContain("ZUKUNFT_TESTBUCHUNG", text);
|
||||||
|
Assert.Contains("58,30", text);
|
||||||
|
Assert.Contains("31.12.2026", text);
|
||||||
|
}
|
||||||
|
Assert.Contains("108,30", Extract(combined));
|
||||||
|
Assert.Contains("an Girokonto", Extract(single));
|
||||||
|
var all = Extract(await _pdfService.GenerateStatementAsync(source.Id, (int?)null));
|
||||||
|
Assert.Contains("Alle Jahre", all);
|
||||||
|
Assert.Contains("Anfangsbestand", all);
|
||||||
|
Assert.Contains("59,30", all);
|
||||||
|
var evidence = Environment.GetEnvironmentVariable("DUEMPELKAS_PDF_EVIDENCE");
|
||||||
|
if (evidence != null)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(evidence);
|
||||||
|
await File.WriteAllBytesAsync(Path.Combine(evidence, "single.pdf"), single);
|
||||||
|
await File.WriteAllBytesAsync(Path.Combine(evidence, "combined.pdf"), combined);
|
||||||
|
// Additional pages exercise repeated headers and the final summary.
|
||||||
|
for (var i = 0; i < 90; i++)
|
||||||
|
await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 2, 1), $"Lesbarkeitsprüfung Buchung {i + 1}", 1m);
|
||||||
|
await File.WriteAllBytesAsync(Path.Combine(evidence, "multipage.pdf"), await _pdfService.GenerateStatementAsync(source.Id, 2026));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Extract(byte[] pdf)
|
||||||
|
{
|
||||||
|
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
||||||
|
return string.Join("\n", document.GetPages().Select(page => UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page)));
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_db.Database.CloseConnection();
|
_db.Database.CloseConnection();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||||
|
<PackageReference Include="PdfPig" Version="0.1.16" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Duempelkas.App.Services;
|
||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class FileOperationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_IsNeutral()
|
||||||
|
{
|
||||||
|
var service = new FileSaveService(new Dialog(null));
|
||||||
|
Assert.Equal(FileOperationStatus.Cancelled, (await service.SaveFileAsync([1], "test.pdf")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAndFailure_AreDistinct_AndOpeningMissingFileIsError()
|
||||||
|
{
|
||||||
|
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-files-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Path.Combine(folder, "Prüfung.pdf");
|
||||||
|
var service = new FileSaveService(new Dialog(path));
|
||||||
|
Assert.Equal(FileOperationStatus.Success, (await service.SaveFileAsync([1, 2, 3], "test.pdf")).Status);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||||
|
// A directory cannot be overwritten as a file; the existing file is preserved.
|
||||||
|
Assert.Equal(FileOperationStatus.Error, (await new FileSaveService(new Dialog(folder)).SaveFileAsync([4], "test.pdf")).Status);
|
||||||
|
Assert.Equal(FileOperationStatus.Error, (await service.OpenFileAsync(Path.Combine(folder, "missing.pdf"))).Status);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(folder, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Dialog(string? path) : IFileDialogService
|
||||||
|
{
|
||||||
|
public Task<string?> SaveAsync(string suggestedFileName) => Task.FromResult(path);
|
||||||
|
public Task<string?> OpenBackupAsync() => Task.FromResult(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user