Finanzmanager/src/Services/BuchungsExport.php
fds2610-bot 2c86e8522b Import und Export in QIF und CSV, Housekeeping für Importdateien
Aus der Kachel QIF-Import wird Import/Export mit je einem Knopf für beide
Richtungen; davor steht die Wahl des Dateiformats (QIF oder CSV), die als
Dialog und ohne Skript als eigene Seite funktioniert.

- CsvBuchungsImport/CsvTabelle: Buchungen samt Splitzeilen, Spalten über die
  Kopfzeile, dieselbe Dublettenerkennung und der Trockenlauf wie beim QIF.
- BuchungsExport/ExportController: Filter nach Zeitraum, Konto, Kategorie und
  Klasse; die Datei entsteht im Speicher, ein Exportverzeichnis gibt es nicht.
- Housekeeping: Hochgeladene Importdateien und data/tmp werden nach der
  eingestellten Frist gelöscht (Vorgabe 14 Tage, 0 schaltet ab).

Migration 013, Test 29 sowie E2E-Spec importexport.spec.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:01:30 +02:00

551 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Buchungen herausgeben – als QIF oder als CSV, gefiltert nach Zeitraum,
* Konto, Kategorie und Klasse.
*
* Die Datei entsteht im Speicher und geht unmittelbar an den Browser; auf der
* Platte bleibt nichts liegen. Ein Exportverzeichnis gibt es deshalb bewusst
* nicht: Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen
* bleibt, ist eine Altlast, die niemand aufräumt.
*
* Eine Umbuchung steht – wie im Datenmodell und im QIF des FinanzManagers –
* genau einmal in der Datei, beim erfassenden Konto; die Gegenseite ist als
* `[Kontoname]` benannt. Deshalb greift der Kontofilter auch auf das
* Gegenkonto: Wer ein Konto exportiert, will die Umbuchungen dorthin mithaben.
*/
class BuchungsExport
{
/** Spalten des CSV-Exports, in dieser Reihenfolge. */
public const SPALTEN = [
'Art', 'Datum', 'Valuta', 'Konto', 'Nummer', 'Empfänger', 'Buchungstext',
'Kategorie', 'Klasse', 'Gegenkonto', 'Betrag', 'Abgeglichen',
];
/** Zeilenart in der ersten Spalte des CSV. */
public const ART_BUCHUNG = 'Buchung';
public const ART_SPLIT = 'Splitzeile';
/**
* Die Datei, fertig zum Ausliefern.
*
* @param array<string,mixed> $filter
* @return array{name: string, typ: string, inhalt: string, anzahl: int}
*/
public static function datei(string $format, array $filter): array
{
if (!Dateiformat::gueltig($format)) {
throw new \InvalidArgumentException('Unbekanntes Exportformat: ' . $format);
}
$buchungen = self::buchungen($filter);
return [
'name' => self::dateiname($format, $filter),
'typ' => $format === 'qif' ? 'application/qif' : 'text/csv; charset=utf-8',
'inhalt' => $format === 'qif' ? self::qif($buchungen, $filter) : self::csv($buchungen),
'anzahl' => count($buchungen),
];
}
/**
* Wie viele Buchungen trifft der Filter? Für die Anzeige im Dialog – die
* Buchungen selbst zu laden, nur um sie zu zählen, wäre bei einem Bestand
* über mehrere Jahre die ganze Datei umsonst.
*
* @param array<string,mixed> $filter
*/
public static function anzahl(array $filter): int
{
[$wo, $params] = self::bedingung($filter);
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b WHERE ' . $wo,
$params,
0
);
}
/**
* Die gefilterten Buchungen samt Splitzeilen und Adressen.
*
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
* @return list<array<string,mixed>>
*/
public static function buchungen(array $filter): array
{
[$wo, $params] = self::bedingung($filter);
$buchungen = Database::fetchAll(
'SELECT b.*, k.name AS kontoname, k.kontoart,
g.name AS gegenkontoname, kat.pfad AS kategoriepfad, kl.name AS klassenname
FROM buchungen b
JOIN konten k ON k.id = b.konto_id
LEFT JOIN konten g ON g.id = b.gegenkonto_id
LEFT JOIN kategorien kat ON kat.id = b.kategorie_id
LEFT JOIN klassen kl ON kl.id = b.klasse_id
WHERE ' . $wo . '
ORDER BY k.name, b.datum, b.id',
$params
);
if ($buchungen === []) {
return [];
}
$ids = array_map(static fn(array $b): int => (int)$b['id'], $buchungen);
$splits = self::splits($ids);
$adressen = self::adressen($ids);
foreach ($buchungen as $nr => $buchung) {
$id = (int)$buchung['id'];
$buchungen[$nr]['splits'] = $splits[$id] ?? [];
$buchungen[$nr]['adressen'] = $adressen[$id] ?? [];
}
return $buchungen;
}
/**
* Die WHERE-Klausel des Filters samt Parametern – einmal geschrieben,
* damit Zählen und Laden nie auseinanderlaufen.
*
* @param array<string,mixed> $filter
* @return array{0: string, 1: list<mixed>}
*/
private static function bedingung(array $filter): array
{
$wo = ['b.benutzer_id = ?'];
$params = [Mandant::id()];
$von = self::datum($filter['von'] ?? null);
$bis = self::datum($filter['bis'] ?? null);
if ($von !== null) {
$wo[] = 'b.datum >= ?';
$params[] = $von;
}
if ($bis !== null) {
$wo[] = 'b.datum <= ?';
$params[] = $bis;
}
$konten = self::ids($filter['konten'] ?? []);
if ($konten !== []) {
$platzhalter = self::platzhalter($konten);
$wo[] = '(b.konto_id IN (' . $platzhalter . ') OR b.gegenkonto_id IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.gegenkonto_id IN (' . $platzhalter . ')))';
$params = array_merge($params, $konten, $konten, $konten);
}
// Kategorie und Klasse können in der Buchung oder in einer ihrer
// Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus
// dem Filter, obwohl sie die gesuchte Kategorie führt.
foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) {
$ids = self::ids($filter[$schluessel] ?? []);
if ($ids === []) {
continue;
}
$platzhalter = self::platzhalter($ids);
$wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.' . $spalte . ' IN (' . $platzhalter . ')))';
$params = array_merge($params, $ids, $ids);
}
return [implode(' AND ', $wo), $params];
}
/**
* @param list<array<string,mixed>> $buchungen
*/
public static function csv(array $buchungen): string
{
$zeilen = [self::SPALTEN];
foreach ($buchungen as $buchung) {
$zeilen[] = [
self::ART_BUCHUNG,
self::datumDeutsch((string)$buchung['datum']),
self::datumDeutsch((string)($buchung['valuta'] ?? '')),
(string)$buchung['kontoname'],
(string)$buchung['nummer'],
(string)$buchung['empfaenger'],
(string)($buchung['buchungstext'] ?? ''),
(string)($buchung['kategoriepfad'] ?? ''),
(string)($buchung['klassenname'] ?? ''),
(string)($buchung['gegenkontoname'] ?? ''),
CsvTabelle::zahl((float)$buchung['betrag']),
(int)$buchung['abgeglichen'] === 1 ? 'ja' : 'nein',
];
// Die Splitzeilen folgen unmittelbar auf ihre Buchung und lassen
// alles leer, was schon oben steht. So bleibt die Zuordnung auch in
// einer Tabellenkalkulation erkennbar, ohne eine zweite Datei.
foreach ($buchung['splits'] as $split) {
$zeilen[] = [
self::ART_SPLIT,
'', '', '', '', '',
(string)$split['notiz'],
(string)($split['kategoriepfad'] ?? ''),
(string)($split['klassenname'] ?? ''),
(string)($split['gegenkontoname'] ?? ''),
CsvTabelle::zahl((float)$split['betrag']),
'',
];
}
}
return CsvTabelle::schreibe($zeilen);
}
/**
* QIF im Dialekt des Lexware FinanzManagers – dieselbe Datei, die der
* eigene Import wieder liest.
*
* Stammdaten kommen vollständig mit (Klassen und Kategorien), die Konten
* dagegen nur, soweit der Filter sie zulässt: Ohne die Stammdaten ließe
* sich die Datei auf einer leeren Installation nicht einspielen, mit
* fremden Konten wäre sie kein Auszug mehr.
*
* @param list<array<string,mixed>> $buchungen
* @param array<string,mixed> $filter
*/
public static function qif(array $buchungen, array $filter = []): string
{
$text = "!Option:DMY\n";
$text .= "!Type:Class\n";
foreach (Klasse::alle() as $klasse) {
$text .= 'N' . $klasse['name'] . "\n";
if (($klasse['beschreibung'] ?? '') !== '') {
$text .= 'D' . $klasse['beschreibung'] . "\n";
}
$text .= "^\n";
}
$text .= "!Type:Cat\n";
foreach (Kategorie::alle() as $kategorie) {
$text .= 'N' . $kategorie['pfad'] . "\n";
if (($kategorie['beschreibung'] ?? '') !== '') {
$text .= 'D' . $kategorie['beschreibung'] . "\n";
}
$text .= ($kategorie['art'] === 'einnahme' ? 'I' : 'E') . "\n";
if ((int)$kategorie['steuerrelevant'] === 1) {
$text .= "T\n";
}
$text .= "^\n";
}
$konten = self::kontenDesExports($buchungen, $filter);
// Erst die Kontenliste, dann je Konto der Abschnitt mit den Buchungen –
// dieselbe Reihenfolge wie im Export des FinanzManagers. Der Parser
// braucht sie so: Der Kontobezug einer Buchung steckt allein darin,
// welcher !Account-Abschnitt zuletzt kam.
$text .= "!Option:AutoSwitch\n!Account\n";
foreach ($konten as $konto) {
$text .= self::kontoblock($konto);
}
$text .= "!Clear:AutoSwitch\n";
$jeKonto = [];
foreach ($buchungen as $buchung) {
$jeKonto[(string)$buchung['kontoname']][] = $buchung;
}
foreach ($konten as $konto) {
$eigene = $jeKonto[(string)$konto['name']] ?? [];
if ($eigene === []) {
continue;
}
$text .= "!Account\n" . self::kontoblock($konto);
$text .= '!Type:' . self::qifTyp((string)$konto['kontoart']) . "\n";
foreach ($eigene as $buchung) {
$text .= self::buchungsblock($buchung);
}
}
return $text;
}
/** Dateiname ohne Pfad, aus Format und Zeitraum. */
public static function dateiname(string $format, array $filter): string
{
$von = self::datum($filter['von'] ?? null);
$bis = self::datum($filter['bis'] ?? null);
$teile = ['buchungen'];
if ($von !== null) {
$teile[] = $von;
}
if ($bis !== null) {
$teile[] = 'bis-' . $bis;
}
if ($von === null && $bis === null) {
$teile[] = date('Y-m-d');
}
return implode('-', $teile) . '.' . $format;
}
// ── QIF-Bausteine ────────────────────────────────────────────────────────
/** @param array<string,mixed> $konto */
private static function kontoblock(array $konto): string
{
$block = 'N' . $konto['name'] . "\n"
. 'T' . self::qifTyp((string)$konto['kontoart']) . "\n";
// Der FinanzManager legt die Kontonummer in die Beschreibung; der
// eigene Import liest sie dort wieder heraus.
if (($konto['kontonummer'] ?? '') !== '') {
$block .= 'DKontonr. ' . $konto['kontonummer'] . "\n";
} elseif (($konto['beschreibung'] ?? '') !== '') {
$block .= 'D' . $konto['beschreibung'] . "\n";
}
if (($konto['kreditlimit'] ?? null) !== null) {
$block .= 'L' . self::zahlQif((float)$konto['kreditlimit']) . "\n";
}
return $block . "^\n";
}
/** @param array<string,mixed> $buchung */
private static function buchungsblock(array $buchung): string
{
$block = 'D' . self::datumDeutsch((string)$buchung['datum']) . "\n";
if (($buchung['valuta'] ?? null) !== null) {
$block .= 'V' . self::datumDeutsch((string)$buchung['valuta']) . "\n";
}
$betrag = self::zahlQif((float)$buchung['betrag']);
$block .= 'T' . $betrag . "\n" . 'U' . $betrag . "\n";
if ((string)$buchung['empfaenger'] !== '') {
$block .= 'P' . $buchung['empfaenger'] . "\n";
}
if ((string)($buchung['buchungstext'] ?? '') !== '') {
$block .= 'M' . self::einzeilig((string)$buchung['buchungstext']) . "\n";
}
if ((string)$buchung['nummer'] !== '') {
$block .= 'N' . $buchung['nummer'] . "\n";
}
if ((int)$buchung['abgeglichen'] === 1) {
$block .= "C*\n";
}
$ziel = self::zielangabe(
(string)($buchung['gegenkontoname'] ?? ''),
(string)($buchung['kategoriepfad'] ?? ''),
(string)($buchung['klassenname'] ?? '')
);
if ($ziel !== '') {
$block .= 'L' . $ziel . "\n";
}
foreach ($buchung['splits'] as $split) {
$block .= 'S' . self::zielangabe(
(string)($split['gegenkontoname'] ?? ''),
(string)($split['kategoriepfad'] ?? ''),
(string)($split['klassenname'] ?? '')
) . "\n";
$block .= 'E' . self::einzeilig((string)$split['notiz']) . "\n";
$block .= '$' . self::zahlQif((float)$split['betrag']) . "\n";
}
foreach ($buchung['adressen'] as $adresse) {
$block .= 'A' . $adresse . "\n";
}
return $block . "^\n";
}
/**
* Die L- oder S-Zeile: entweder ein Gegenkonto in eckigen Klammern oder ein
* Kategoriepfad – die Klasse hängt mit Schrägstrich daran.
*/
private static function zielangabe(string $gegenkonto, string $kategorie, string $klasse): string
{
$ziel = $gegenkonto !== '' ? '[' . $gegenkonto . ']' : $kategorie;
if ($klasse === '') {
return $ziel;
}
return $ziel . '/' . $klasse;
}
/**
* Kontoart → QIF-Typ, die Umkehrung von Konto::QIF_ARTEN. Zwei Arten stehen
* dort doppelt („Oth E“/„Oth F“ und „Port“/„Invst“); genommen wird der
* erste Treffer, damit die Zuordnung eindeutig bleibt.
*/
private static function qifTyp(string $kontoart): string
{
$typ = array_search($kontoart, Konto::QIF_ARTEN, true);
return $typ === false ? 'Bank' : $typ;
}
/** Beträge stehen im QIF im US-Format, ohne Tausendertrenner. */
private static function zahlQif(float $wert): string
{
return number_format($wert, 2, '.', '');
}
/**
* Ein Zeilenumbruch im Buchungstext beendete im QIF den Eintrag. Er wird
* durch ein Leerzeichen ersetzt – der Text bleibt lesbar, die Datei heil.
*/
private static function einzeilig(string $text): string
{
return trim((string)preg_replace('/\s*[\r\n]+\s*/', ' ', $text));
}
// ── Hilfen ───────────────────────────────────────────────────────────────
/**
* Welche Konten führt die Exportdatei?
*
* Die Buchungen liefern die Namen, und zwar von beiden Seiten: Ein Konto,
* das nur Umbuchungsziel ist, führt in der Datei keine eigene Zeile – ohne
* seinen Eintrag in der Kontenliste bezöge sich die Datei aber auf ein
* Konto, das der Import nicht kennt, und die Umbuchung ginge verloren.
*
* Ein Kontofilter zieht die gewählten Konten hinzu, damit auch ein leer
* ausgefallener Auszug die Kontenliste mitbringt.
*
* @param list<array<string,mixed>> $buchungen
* @return list<array<string,mixed>>
*/
private static function kontenDesExports(array $buchungen, array $filter): array
{
$gewaehlt = self::ids($filter['konten'] ?? []);
$namen = [];
foreach ($buchungen as $buchung) {
$namen[(string)$buchung['kontoname']] = true;
if (($buchung['gegenkontoname'] ?? null) !== null) {
$namen[(string)$buchung['gegenkontoname']] = true;
}
foreach ($buchung['splits'] as $split) {
if (($split['gegenkontoname'] ?? null) !== null) {
$namen[(string)$split['gegenkontoname']] = true;
}
}
}
$konten = [];
foreach (Konto::alle() as $konto) {
if (isset($namen[(string)$konto['name']]) || in_array((int)$konto['id'], $gewaehlt, true)) {
$konten[] = $konto;
}
}
return $konten;
}
/**
* @param list<int> $buchungIds
* @return array<int, list<array<string,mixed>>>
*/
private static function splits(array $buchungIds): array
{
$zeilen = Database::fetchAll(
'SELECT s.*, kat.pfad AS kategoriepfad, kl.name AS klassenname, g.name AS gegenkontoname
FROM buchung_splits s
LEFT JOIN kategorien kat ON kat.id = s.kategorie_id
LEFT JOIN klassen kl ON kl.id = s.klasse_id
LEFT JOIN konten g ON g.id = s.gegenkonto_id
WHERE s.buchung_id IN (' . self::platzhalter($buchungIds) . ')
ORDER BY s.buchung_id, s.sortierung, s.id',
$buchungIds
);
$jeBuchung = [];
foreach ($zeilen as $zeile) {
$jeBuchung[(int)$zeile['buchung_id']][] = $zeile;
}
return $jeBuchung;
}
/**
* @param list<int> $buchungIds
* @return array<int, list<string>>
*/
private static function adressen(array $buchungIds): array
{
$zeilen = Database::fetchAll(
'SELECT buchung_id, text FROM buchungsadressen
WHERE buchung_id IN (' . self::platzhalter($buchungIds) . ')
ORDER BY buchung_id, zeile',
$buchungIds
);
$jeBuchung = [];
foreach ($zeilen as $zeile) {
$jeBuchung[(int)$zeile['buchung_id']][] = (string)$zeile['text'];
}
return $jeBuchung;
}
/** @param mixed $werte */
private static function ids(mixed $werte): array
{
if (!is_array($werte)) {
return [];
}
$ids = [];
foreach ($werte as $wert) {
$id = (int)$wert;
if ($id > 0) {
$ids[$id] = $id;
}
}
return array_values($ids);
}
private static function platzhalter(array $werte): string
{
return implode(',', array_fill(0, max(1, count($werte)), '?'));
}
private static function datum(mixed $wert): ?string
{
$wert = trim((string)$wert);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : null;
}
private static function datumDeutsch(string $iso): string
{
if ($iso === '') {
return '';
}
$zeit = strtotime($iso);
return $zeit === false ? '' : date('d.m.Y', $zeit);
}
}