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>
551 lines
19 KiB
PHP
551 lines
19 KiB
PHP
<?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);
|
||
}
|
||
}
|