Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()): Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie / Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel entfällt. Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
14 KiB
PHP
313 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Controllers\BuchungenController;
|
||
use Finanzmanager\Models\AbgleichRegel;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Buchungsvorlage;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Kontengruppe;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Vertrag;
|
||
use Finanzmanager\Models\Zahlungsauftrag;
|
||
use Finanzmanager\Services\Auswertung;
|
||
use Finanzmanager\Services\BuchungsExport;
|
||
|
||
/**
|
||
* Umbuchungskonten in jeder Kategorieauswahl (#124).
|
||
*
|
||
* Unter den Kategorien stehen überall die aktiven Umbuchungskonten, sortiert
|
||
* und beschriftet nach Kontengruppe – in der Buchungsmaske, bei den Regeln
|
||
* samt ihren Splitzeilen, im Umsatzabgleich, bei Vorlagen, regelmäßigen
|
||
* Buchungen, Überweisungen und Verträgen sowie in den Filtern von
|
||
* Buchungsbericht und Export.
|
||
*/
|
||
class UmbuchungskontenAuswahlTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 61: Umbuchungskonten in der Kategorieauswahl';
|
||
|
||
private int $giro = 0;
|
||
private int $tagesgeld = 0;
|
||
private int $bauspar = 0;
|
||
private int $alt = 0;
|
||
private int $lebensmittel = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
|
||
// Die Gruppen in umgekehrter Namensreihenfolge sortiert: Die Liste muss
|
||
// der Sortierung der Kontengruppen folgen, nicht dem Alphabet.
|
||
$sparen = Kontengruppe::anlegen('Sparen', '', 1);
|
||
$liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2);
|
||
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Auswahl Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
'kontengruppe_id' => $liquide,
|
||
]);
|
||
$this->tagesgeld = Konto::anlegen([
|
||
'name' => 'Auswahl Tagesgeld', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
'kontengruppe_id' => $sparen,
|
||
]);
|
||
$this->bauspar = Konto::anlegen([
|
||
'name' => 'Auswahl Bausparen', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->alt = Konto::anlegen([
|
||
'name' => 'Auswahl Altkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
|
||
'kontengruppe_id' => $sparen,
|
||
]);
|
||
$this->lebensmittel = Kategorie::anlegen(['name' => 'Auswahl Lebensmittel', 'art' => 'ausgabe']);
|
||
|
||
$this->gruppenNurAktivNachKontengruppe();
|
||
$this->auswahllisteZeigtUmbuchungskonten();
|
||
$this->formularwerteHinUndZurueck();
|
||
$this->regelsplitsMitGegenkonto();
|
||
$this->regelmaskeHatGemeinsameListe();
|
||
$this->buchungsberichtFiltertNachUmbuchungskonto();
|
||
$this->exportFiltertNachUmbuchungskonto();
|
||
$this->ueberweisungAufEigenesKonto();
|
||
$this->vertragUndVorlageMitGegenkonto();
|
||
}
|
||
|
||
private function gruppenNurAktivNachKontengruppe(): void
|
||
{
|
||
$gruppen = Konto::umbuchungsgruppen(Konto::alle());
|
||
|
||
$this->assertEquals(
|
||
['Umbuchungskonten – Sparen', 'Umbuchungskonten – Liquide Mittel', 'Umbuchungskonten – nicht gruppiert'],
|
||
array_keys($gruppen),
|
||
'Die Umbuchungskonten stehen je Kontengruppe in deren Reihenfolge'
|
||
);
|
||
$namen = array_merge(...array_map(
|
||
static fn(array $konten): array => array_column($konten, 'name'),
|
||
array_values($gruppen)
|
||
));
|
||
$this->assert(!in_array('Auswahl Altkonto', $namen, true), 'Ein inaktives Konto wird nicht angeboten');
|
||
|
||
$mitAltem = Konto::umbuchungsgruppen(Konto::alle(), [$this->alt]);
|
||
$this->assertEquals(
|
||
['Auswahl Altkonto', 'Auswahl Tagesgeld'],
|
||
array_column($mitAltem['Umbuchungskonten – Sparen'], 'name'),
|
||
'Ein schon gewähltes inaktives Konto bleibt in seiner Gruppe stehen'
|
||
);
|
||
|
||
$ohneGruppen = Konto::umbuchungsgruppen([
|
||
['id' => 1, 'name' => 'A', 'aktiv' => 1, 'gruppenname' => null],
|
||
]);
|
||
$this->assertEquals(['Umbuchungskonten'], array_keys($ohneGruppen), 'Ohne Kontengruppen eine einzige Überschrift');
|
||
}
|
||
|
||
private function auswahllisteZeigtUmbuchungskonten(): void
|
||
{
|
||
$html = $this->auswahlliste('kategorie:' . $this->lebensmittel, $this->giro);
|
||
|
||
$this->assert(str_contains($html, '<optgroup label="Kategorien">'), 'Die Kategorien stehen oben');
|
||
$kategorien = strpos($html, 'Auswahl Lebensmittel');
|
||
$tagesgeld = strpos($html, '[Auswahl Tagesgeld]');
|
||
$this->assert(
|
||
$kategorien !== false && $tagesgeld !== false && $kategorien < $tagesgeld,
|
||
'Die Umbuchungskonten stehen unter den Kategorien'
|
||
);
|
||
$this->assert(
|
||
str_contains($html, '<optgroup label="Umbuchungskonten – Sparen">'),
|
||
'Die Umbuchungskonten sind nach Kontengruppe beschriftet'
|
||
);
|
||
$this->assert(!str_contains($html, '[Auswahl Giro]'), 'Das eigene Konto wird nicht angeboten');
|
||
$this->assert(
|
||
(bool)preg_match('#<option value="konto:' . $this->alt . '" hidden>#', $html),
|
||
'Ein inaktives Konto bleibt nur als verborgener Eintrag für bestehende Zuordnungen'
|
||
);
|
||
|
||
$gewaehlt = $this->auswahlliste('konto:' . $this->alt, 0);
|
||
$this->assert(
|
||
(bool)preg_match('#<option value="konto:' . $this->alt . '" selected>#', $gewaehlt),
|
||
'Ein schon gewähltes inaktives Konto steht sichtbar und gewählt da'
|
||
);
|
||
}
|
||
|
||
private function formularwerteHinUndZurueck(): void
|
||
{
|
||
$this->assertEquals('konto:7', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => 7]),
|
||
'Das Gegenkonto sticht die Kategorie');
|
||
$this->assertEquals('kategorie:3', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => null]),
|
||
'Ohne Gegenkonto die Kategorie');
|
||
$this->assertEquals('', BuchungenController::zuordnungswert(null), 'Ohne Zeile kein Wert');
|
||
|
||
$this->assertEquals(
|
||
['kategorie_id' => null, 'gegenkonto_id' => 7],
|
||
BuchungenController::zuordnungAusFormular(['zuordnung' => 'konto:7', 'kategorie_id' => '3']),
|
||
'Die gemeinsame Liste hat Vorrang vor einzelnen Feldern'
|
||
);
|
||
$this->assertEquals(
|
||
['kategorie_id' => 3, 'gegenkonto_id' => null],
|
||
BuchungenController::zuordnungAusFormular(['kategorie_id' => '3', 'gegenkonto_id' => '']),
|
||
'Ohne Liste gelten die versteckten Einzelfelder einer Tabellenzeile'
|
||
);
|
||
}
|
||
|
||
private function regelsplitsMitGegenkonto(): void
|
||
{
|
||
$id = AbgleichRegel::anlegen([
|
||
'name' => 'Sparplan aufteilen', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Sparplan',
|
||
'splits' => [
|
||
['kategorie_id' => $this->lebensmittel, 'anteil' => 0.25],
|
||
['gegenkonto_id' => $this->tagesgeld, 'anteil' => 0.75],
|
||
['kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->bauspar, 'anteil' => 0.0],
|
||
],
|
||
]);
|
||
$regel = AbgleichRegel::find($id);
|
||
|
||
$zeilen = AbgleichRegel::splitzeilen($regel);
|
||
$this->assertEquals(2, count($zeilen), 'Eine Zeile ohne Anteil fällt weg, eine mit Gegenkonto bleibt');
|
||
$this->assertEquals($this->tagesgeld, $zeilen[1]['gegenkonto_id'], 'Die Splitzeile behält ihr Gegenkonto');
|
||
$this->assertNull($zeilen[1]['kategorie_id'], 'Die Umbuchungszeile führt keine Kategorie');
|
||
|
||
$zuordnung = AbgleichRegel::anwenden($regel, ['betrag' => -100.0]);
|
||
$this->assertEquals($this->tagesgeld, $zuordnung['splits'][1]['gegenkonto_id'], 'anwenden() reicht das Gegenkonto weiter');
|
||
$this->assertEquals(-75.0, $zuordnung['splits'][1]['betrag'], 'Der Anteil wird auch für die Umbuchung umgerechnet');
|
||
|
||
$buchung = Buchung::anlegen(
|
||
['konto_id' => $this->giro, 'datum' => date('Y-m-d'), 'betrag' => -100.0, 'empfaenger' => 'Sparplan'],
|
||
$zuordnung['splits']
|
||
);
|
||
$splits = Buchung::splits($buchung);
|
||
$this->assertEquals($this->tagesgeld, (int)$splits[1]['gegenkonto_id'], 'Die Splitbuchung aus der Regel bucht auf das Umbuchungskonto');
|
||
|
||
$neu = AbgleichRegel::find((int)AbgleichRegel::ausBuchung($buchung));
|
||
$this->assertEquals(
|
||
$this->tagesgeld,
|
||
AbgleichRegel::splitzeilen($neu)[1]['gegenkonto_id'],
|
||
'Eine Regel aus der Buchung übernimmt die Umbuchungszeile'
|
||
);
|
||
}
|
||
|
||
private function regelmaskeHatGemeinsameListe(): void
|
||
{
|
||
$regel = AbgleichRegel::find(AbgleichRegel::anlegen([
|
||
'name' => 'Umbuchung', 'muster' => 'Tagesgeld', 'gegenkonto_id' => $this->tagesgeld,
|
||
]));
|
||
|
||
$daten = [
|
||
'regel' => $regel,
|
||
'kategorien' => Kategorie::baumFlach(null, true),
|
||
'klassen' => Klasse::alle(),
|
||
'konten' => Konto::alle(),
|
||
'idPrefix' => 'test-',
|
||
];
|
||
extract($daten, EXTR_SKIP);
|
||
ob_start();
|
||
include dirname(__DIR__) . '/src/Views/banking/regelfelder.php';
|
||
$html = (string)ob_get_clean();
|
||
|
||
$this->assert(str_contains($html, 'name="zuordnung"'), 'Regel anlegen: eine Liste Kategorie / Gegenkonto');
|
||
$this->assert(!str_contains($html, 'name="gegenkonto_id"'), 'Das getrennte Gegenkonto-Feld entfällt');
|
||
$this->assert(
|
||
(bool)preg_match('#<option value="konto:' . $this->tagesgeld . '" selected>#', $html),
|
||
'Das Gegenkonto der Regel ist vorausgewählt'
|
||
);
|
||
$this->assert(
|
||
str_contains($html, 'name="split[0][zuordnung]"'),
|
||
'Die Splitzeilen der Regel bieten ebenfalls Umbuchungskonten an'
|
||
);
|
||
}
|
||
|
||
private function buchungsberichtFiltertNachUmbuchungskonto(): void
|
||
{
|
||
$datum = '2026-03-10';
|
||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -50.0,
|
||
'empfaenger' => 'Sparrate', 'gegenkonto_id' => $this->tagesgeld]);
|
||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -20.0,
|
||
'empfaenger' => 'Markt', 'kategorie_id' => $this->lebensmittel]);
|
||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -30.0,
|
||
'empfaenger' => 'Bausparrate', 'gegenkonto_id' => $this->bauspar]);
|
||
|
||
$filter = Auswertung::normalisiere([
|
||
'typ' => 'buchungsbericht', 'kategorien' => ['konto:' . $this->tagesgeld, (string)$this->lebensmittel],
|
||
]);
|
||
$this->assertEquals([$this->tagesgeld], $filter['gegenkonten'], 'konto:… aus der Kategorieauswahl wird zum Umbuchungskonto');
|
||
$this->assertEquals([$this->lebensmittel], $filter['kategorien'], 'Die Kategorien bleiben daneben erhalten');
|
||
|
||
$summe = Auswertung::normalisiere(['typ' => 'summenbericht', 'kategorien' => ['konto:' . $this->tagesgeld]]);
|
||
$this->assertEquals([], $summe['gegenkonten'], 'Nur der Buchungsbericht kennt Umbuchungskonten im Filter');
|
||
|
||
$bericht = Auswertung::bericht(['zeitraum' => 'benutzerdefiniert', 'von' => $datum, 'bis' => $datum] + $filter);
|
||
$empfaenger = [];
|
||
foreach ($bericht['gruppen'] as $gruppe) {
|
||
$empfaenger = array_merge($empfaenger, array_column($gruppe['buchungen'], 'empfaenger'));
|
||
}
|
||
sort($empfaenger);
|
||
$this->assertEquals(
|
||
['Markt', 'Sparrate'],
|
||
$empfaenger,
|
||
'Der Buchungsbericht zeigt Kategorie und gewähltes Umbuchungskonto – auch ohne „Umbuchungen mitzählen“'
|
||
);
|
||
}
|
||
|
||
private function exportFiltertNachUmbuchungskonto(): void
|
||
{
|
||
$basis = ['von' => '2026-03-10', 'bis' => '2026-03-10'];
|
||
|
||
$this->assertEquals(1, BuchungsExport::anzahl($basis + ['gegenkonten' => [$this->bauspar]]),
|
||
'Export: ein Umbuchungskonto filtert seine Umbuchungen');
|
||
$this->assertEquals(
|
||
2,
|
||
BuchungsExport::anzahl($basis + ['kategorien' => [$this->lebensmittel], 'gegenkonten' => [$this->bauspar]]),
|
||
'Export: Kategorie und Umbuchungskonto aus derselben Auswahl verbinden sich mit „oder“'
|
||
);
|
||
}
|
||
|
||
private function ueberweisungAufEigenesKonto(): void
|
||
{
|
||
$id = Zahlungsauftrag::anlegen([
|
||
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
|
||
'betrag' => 100.0, 'kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->tagesgeld,
|
||
]);
|
||
$auftrag = Zahlungsauftrag::find($id);
|
||
$this->assertEquals($this->tagesgeld, (int)$auftrag['gegenkonto_id'], 'Die Überweisung merkt sich das Umbuchungskonto');
|
||
$this->assertNull($auftrag['kategorie_id'], 'Kategorie und Gegenkonto schließen sich aus');
|
||
|
||
Zahlungsauftrag::alsGesendetVermerken($id, 'Angenommen.');
|
||
$buchung = Buchung::find((int)Zahlungsauftrag::find($id)['buchung_id']);
|
||
$this->assertEquals($this->tagesgeld, (int)$buchung['gegenkonto_id'], 'Gesendet wird sie als Umbuchung gebucht');
|
||
|
||
$abgewiesen = false;
|
||
try {
|
||
Zahlungsauftrag::anlegen([
|
||
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
|
||
'betrag' => 1.0, 'gegenkonto_id' => $this->giro,
|
||
]);
|
||
} catch (\InvalidArgumentException) {
|
||
$abgewiesen = true;
|
||
}
|
||
$this->assert($abgewiesen, 'Eine Überweisung auf das Auftragskonto selbst wird schon beim Erfassen abgewiesen');
|
||
}
|
||
|
||
private function vertragUndVorlageMitGegenkonto(): void
|
||
{
|
||
$vertrag = Vertrag::find(Vertrag::anlegen([
|
||
'name' => 'Bausparvertrag', 'betrag' => 50, 'gegenkonto_id' => $this->bauspar,
|
||
]));
|
||
$this->assertEquals($this->bauspar, (int)$vertrag['gegenkonto_id'], 'Ein Vertrag kann auf ein Umbuchungskonto zeigen');
|
||
|
||
Buchungsvorlage::anlegen(['name' => 'Auswahl Sparrate', 'konto_id' => $this->giro, 'betrag' => -50,
|
||
'gegenkonto_id' => $this->tagesgeld]);
|
||
$vorlagen = array_column(Buchungsvorlage::alle('Auswahl Sparrate'), 'gegenkontoname', 'name');
|
||
$this->assertEquals('Auswahl Tagesgeld', $vorlagen['Auswahl Sparrate'] ?? null,
|
||
'Die Vorlagenliste kennt den Namen des Umbuchungskontos');
|
||
}
|
||
|
||
/** Die Auswahlliste rendern, wie sie jede Maske einbindet. */
|
||
private function auswahlliste(string $gewaehlt, int $eigenesKonto): string
|
||
{
|
||
$kategorien = Kategorie::baumFlach(null, true);
|
||
$konten = Konto::alle();
|
||
$mitSplit = false;
|
||
$mitDepot = false;
|
||
|
||
ob_start();
|
||
include dirname(__DIR__) . '/src/Views/buchungen/zuordnungsliste.php';
|
||
return (string)ob_get_clean();
|
||
}
|
||
}
|