$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 $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 $filter von, bis, konten[], kategorien[], klassen[] * @return list> */ 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 $filter * @return array{0: string, 1: list} */ 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> $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> $buchungen * @param array $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 $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 $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> $buchungen * @return list> */ 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 $buchungIds * @return array>> */ 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 $buchungIds * @return array> */ 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); } }