Finanzmanager/src/Controllers/BuchungenController.php
fds2610-bot a5989eb262 Fix #136: Buchungen ohne Bargeld-Tool-Abgleich ermöglichen
Neuer Schalter „Ohne Bargeld-Abgleich“ in der Buchungsmaske für
Sammelbuchungen und Differenzausgleiche. Solche Buchungen werden nicht
gegen das Bargeld-Tool geprüft, ein früherer Status wird gelöscht, und sie
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit. Kontoblatt
und Buchungsbericht zeigen einen leeren grauen Kreis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:58:17 +02:00

780 lines
28 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\Controllers;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View;
/**
* Kontoblatt und Buchungsmaske.
*
* Das Kontoblatt zeigt eigene Buchungen und die Gegenseiten fremder
* Umbuchungen; Letztere lassen sich nur auf ihrem Ursprungskonto bearbeiten,
* damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich geändert wird.
*/
class BuchungenController extends BaseController
{
/** @param array<string,string> $params */
public function kontoblatt(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$filter = [
'suche' => $this->query('suche'),
'von' => $this->query('von'),
'bis' => $this->query('bis'),
'kategorie_id' => $this->query('kategorie_id'),
'klasse_id' => $this->query('klasse_id'),
'nur_offene' => $this->query('nur_offene') === '1',
];
$zeilen = Buchung::kontoblatt((int)$konto['id'], $filter);
// Die neuesten Buchungen zuerst zeigen, den Saldo aber chronologisch
// aufbauen – deshalb erst rechnen, dann umdrehen.
$zeilen = array_reverse($zeilen);
$grenze = Einstellung::getInt('zeilen_pro_seite', 100);
$gesamt = count($zeilen);
$seite = max(1, (int)$this->query('seite', '1'));
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
// Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der
// Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie
// nachzuladen.
$gesplittete = [];
foreach ($zeilen as $zeile) {
if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) {
$gesplittete[] = (int)$zeile['id'];
}
}
$this->render('buchungen/kontoblatt.php', [
'titel' => 'Kontoblatt: ' . $konto['name'],
'konto' => Konto::mitSalden($konto),
'zeilen' => $zeilen,
'gesamt' => $gesamt,
'seite' => $seite,
'seiten' => max(1, (int)ceil($gesamt / max(1, $grenze))),
'filter' => $filter,
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
'splits' => Buchung::splitsJeBuchung($gesplittete),
// Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet.
'wertpapiere' => Wertpapier::alle(true),
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
'bargeld' => BargeldAbgleich::eingerichtet(),
// Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske,
// steht hier die frisch erzeugte Regel zum Prüfen im Dialog.
'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')),
]);
}
/** @param array<string,string> $params */
public function neu(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$this->maske($konto, null);
}
/** @param array<string,string> $params */
public function anlegen(array $params): void
{
$this->verlangeAnmeldung();
$kontoId = (int)$params['id'];
if (Konto::find($kontoId) === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
try {
[$daten, $splits] = $this->formulardaten($kontoId);
$id = Buchung::anlegen($daten, $splits);
} catch (\Throwable $e) {
// Keine Weiterleitung: Die schickte den Browser auf eine neue,
// leere Maske und riss damit die gerade eingegebenen Splitzeilen
// heraus. Stattdessen zeichnet dieselbe Anfrage die Maske mit den
// eigenen Eingaben neu (Fix #95).
$this->maske(Konto::find($kontoId), null, $_POST, $e->getMessage());
return;
}
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId);
return;
}
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect(
$this->kontoblattZiel($kontoId, $id),
'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich)
);
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$this->maske(Konto::find((int)$buchung['konto_id']), $buchung);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
$buchung = Buchung::find($id);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
try {
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
Buchung::speichern($id, $daten, $splits);
} catch (\Throwable $e) {
// Wie in anlegen(): die Maske bleibt mit den eigenen Eingaben
// stehen, statt sie über eine Weiterleitung zu verlieren (Fix #95).
$this->maske(Konto::find((int)$buchung['konto_id']), $buchung, $_POST, $e->getMessage());
return;
}
// Ein Depot in der Zuordnungsliste ist der Anfang eines Geschäfts: Die
// Buchung steht jetzt mit ihrem Betrag fest, die Wertpapiermaske baut
// darauf auf.
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId);
return;
}
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect(
$this->kontoblattZiel((int)$buchung['konto_id'], $id),
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
);
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
Buchung::loeschen((int)$buchung['id']);
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
}
/**
* Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer
* Buchung setzen, ohne die Maske zu öffnen.
*
* @param array<string,string> $params
*/
public function zuordnung(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
// Ein Depot ist keine Zuordnung, sondern der Anfang eines Geschäfts:
// Stückzahl, Kurs und Nebenkosten fehlen noch. Mit JavaScript öffnet
// das Kontoblatt dafür einen Dialog und kommt hier gar nicht an; ohne
// führt derselbe Weg auf die Seite mit denselben Feldern.
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . $depotId);
return;
}
try {
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
Buchung::zuordnen(
(int)$buchung['id'],
$zuordnung['kategorie_id'],
$zuordnung['gegenkonto_id']
);
} catch (\Throwable $e) {
$this->schnellantwort($ziel, $e->getMessage(), 'fehler');
return;
}
// Die zugewiesene Kategorie kann eine sein, die gegen das Bargeld-Tool
// zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach
// als Symbol im Kontoblatt.
$abgleich = BargeldAbgleich::pruefe((int)$buchung['id']);
$bargeldHtml = null;
if (BargeldAbgleich::eingerichtet()) {
$frisch = Buchung::find((int)$buchung['id']);
$bargeldHtml = View::bargeldMarke(
$frisch['bargeld_status'] ?? null,
$frisch['bargeld_hinweis'] ?? null,
!empty($frisch['ohne_bargeld_abgleich'])
);
}
$this->schnellantwort(
$ziel,
'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich),
'erfolg',
['bargeldHtml' => $bargeldHtml]
);
}
/**
* Schnellauswahl aus dem Kontoblatt: die Klasse einer Buchung setzen. Ein
* leerer Wert löscht die vorhandene Klasse.
*
* @param array<string,string> $params
*/
public function klasse(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
$klasseId = (int)$this->post('klasse_id');
try {
Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null);
} catch (\Throwable $e) {
$this->schnellantwort($ziel, $e->getMessage(), 'fehler');
return;
}
$this->schnellantwort($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.');
}
/**
* Antwort auf eine Schnellzuordnung aus dem Kontoblatt: kontoblatt.js
* schickt sie im Hintergrund und braucht nur die Flash-Meldung als JSON,
* um die Seite ohne Neuladen an Ort und Stelle zu aktualisieren – Text und
* Zuordnung der Zelle kennt es selbst aus der gewählten Option. Ohne
* JavaScript bleibt es bei der bisherigen Weiterleitung.
*
* @param array<string,mixed> $daten zusätzliche Felder für die JSON-Antwort
*/
private function schnellantwort(string $ziel, string $meldung, string $typ = 'erfolg', array $daten = []): void
{
if ($this->istAjax()) {
if ($typ === 'fehler') {
http_response_code(422);
}
$this->json($daten + ['meldung' => $meldung, 'typ' => $typ]);
return;
}
View::redirect($ziel, $meldung, $typ);
}
/**
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
* Ohne Zeilen wird die Splitbuchung aufgehoben.
*
* Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit
* `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die
* Aufteilung, und die Buchung steht danach ohne Zuordnung da.
*
* @param array<string,string> $params
*/
public function splitbuchung(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
$loeschen = $this->post('aktion') === 'loeschen';
try {
Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen());
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
// Eine Splitbuchung trägt keine eigene Kategorie mehr; ein früherer
// Abgleichstatus wäre danach eine Aussage über eine Zuordnung, die es
// nicht mehr gibt. `pruefe()` räumt ihn weg.
BargeldAbgleich::pruefe((int)$buchung['id']);
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
}
/**
* Die Wertpapiermaske zu einer Buchung als eigene Seite – der Weg ohne
* JavaScript. Sie zeigt dieselben Felder wie der Dialog des Kontoblatts und
* schickt sie an dasselbe Ziel.
*
* @param array<string,string> $params
*/
public function wertpapierFormular(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$depot = Konto::find((int)$this->query('depot'));
if ($depot === null || (string)$depot['kontoart'] !== 'depot') {
View::redirect(
$this->kontoblattPfad((int)$buchung['konto_id']),
'Für ein Wertpapiergeschäft fehlt das Depot.',
'fehler'
);
return;
}
$this->render('buchungen/wertpapier.php', [
'titel' => 'Wertpapiergeschäft',
'buchung' => $buchung,
'konto' => Konto::find((int)$buchung['konto_id']),
'depot' => $depot,
'wertpapiere' => Wertpapier::alle(true),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
]);
}
/**
* Ein Wertpapiergeschäft zu einer vorhandenen Buchung.
*
* Die Buchung bleibt, wie die Bank sie geliefert hat – ihr Betrag ist die
* feste Größe. Aus ihm werden die Nebenkosten herausgerechnet; was übrig
* bleibt, ist der Kurswert und damit der Einstandspreis. Beides erscheint
* als Splitbuchung: je Kostenart eine Zeile in ihrer Kategorie, der Rest als
* Umbuchung ins Depot.
*
* @param array<string,string> $params
*/
public function wertpapier(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
try {
$geschaeft = Wertpapiergeschaeft::buchen($buchung, $this->wertpapiereingaben());
} catch (\Throwable $e) {
View::redirect(
'/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . (int)$this->post('depot_id'),
$e->getMessage(),
'fehler'
);
return;
}
View::redirect($ziel, sprintf(
'%s gebucht – Einstandspreis %s €.',
Depotbuchung::VORGAENGE[$geschaeft['vorgang']],
number_format($geschaeft['betrag'], 2, ',', '.')
));
}
/**
* Die Formulareingaben des Wertpapierdialogs, wie der Dienst sie erwartet.
*
* @return array<string,mixed>
*/
private function wertpapiereingaben(): array
{
$kosten = [];
foreach (array_keys(Wertpapierkosten::arten()) as $art) {
$kosten[$art] = KontenController::zahl($this->post('kosten_' . $art, ''));
}
return [
'depot_id' => (int)$this->post('depot_id'),
'wertpapier_id' => (int)$this->post('wertpapier_id'),
'wertpapier_name' => $this->post('wertpapier_name'),
'isin' => $this->post('isin'),
'wkn' => $this->post('wkn'),
'art' => $this->post('art', 'aktie'),
'vorgang' => $this->post('vorgang'),
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'stueck' => KontenController::zahl($this->post('stueck', '0')),
'kurs' => KontenController::zahl($this->post('kurs', '0')),
'notiz' => $this->post('notiz'),
'kosten' => $kosten,
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
'klasse_id' => (int)$this->post('klasse_id') ?: null,
];
}
/**
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
*
* @param array<string,string> $params
*/
public function auswahlLoeschen(array $params): void
{
$this->verlangeAnmeldung();
$kontoId = (int)$params['id'];
if (Konto::find($kontoId) === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad($kontoId);
$ids = $this->postIds('buchungen');
if ($ids === []) {
View::redirect($ziel, 'Keine Buchung ausgewählt.', 'warnung');
return;
}
$anzahl = 0;
foreach ($ids as $id) {
$buchung = Buchung::find($id);
// Nur eigene Buchungen dieses Kontos: Die Gegenseite einer fremden
// Umbuchung gehört dem Ursprungskonto und wird auch nur dort
// gelöscht.
if ($buchung === null || (int)$buchung['konto_id'] !== $kontoId) {
continue;
}
try {
Buchung::loeschen($id);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
$anzahl++;
}
View::redirect($ziel, $anzahl === 1
? 'Buchung gelöscht.'
: $anzahl . ' Buchungen gelöscht.');
}
/**
* Führt zwei angehakte Buchungen dieses Kontoblatts zusammen: eine schon
* fertige Umbuchung und eine einzeln erfasste Buchung, die denselben
* Vorgang doppelt zeigen, weil sie zu weit auseinanderlagen, um sich
* automatisch zu finden.
*
* @param array<string,string> $params
*/
public function verknuepfen(array $params): void
{
$this->verlangeAnmeldung();
$kontoId = (int)$params['id'];
if (Konto::find($kontoId) === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad($kontoId);
$ids = $this->postIds('buchungen');
if (count($ids) !== 2) {
View::redirect($ziel, 'Zum Verknüpfen bitte genau zwei Buchungen ankreuzen.', 'warnung');
return;
}
try {
Buchung::verknuepfen($kontoId, $ids[0], $ids[1]);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
View::redirect($ziel, 'Buchungen verknüpft.');
}
/**
* Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite.
*
* Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden
* hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel
* eingeschleust werden.
*/
private function kontoblattPfad(int $kontoId): string
{
$query = array_filter([
'suche' => $this->query('suche'),
'von' => $this->query('von'),
'bis' => $this->query('bis'),
'kategorie_id' => $this->query('kategorie_id'),
'klasse_id' => $this->query('klasse_id'),
'nur_offene' => $this->query('nur_offene') === '1' ? '1' : '',
'seite' => $this->query('seite'),
], static fn(string $wert): bool => $wert !== '');
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
}
/**
* Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter
* neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster
* (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt
* dann das gewöhnliche.
*/
private function kontoblattZiel(int $kontoId, int $buchungId): string
{
$ziel = '/konten/' . $kontoId;
if (!isset($_POST['regel_anlegen'])) {
return $ziel;
}
$regelId = AbgleichRegel::ausBuchung($buchungId);
return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId;
}
/**
* @param array<string,mixed>|null $konto
* @param array<string,mixed>|null $buchung
* @param array<string,mixed>|null $eingaben die zuletzt abgeschickten
* Formularwerte ($_POST), sobald das Speichern gerade an einer
* Validierung gescheitert ist – die Maske zeigt dann diese statt der
* gespeicherten Werte, damit nichts verloren geht (Fix #95).
*/
private function maske(?array $konto, ?array $buchung, ?array $eingaben = null, string $fehler = ''): void
{
if ($konto === null) {
$this->nichtGefunden('/konten');
return;
}
$daten = [
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
'konto' => $konto,
'buchung' => $buchung,
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
'eingaben' => $eingaben,
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
// Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein
// Bargeld-Tool gibt (#136).
'bargeld' => BargeldAbgleich::eingerichtet(),
];
if ($fehler !== '') {
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
}
$this->render('buchungen/formular.php', $daten);
}
/**
* @return array{0: array<string,mixed>, 1: list<array<string,mixed>>}
*/
private function formulardaten(int $kontoId): array
{
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
// bestimmt die Auswahl Einnahme / Ausgabe.
$betrag = abs(KontenController::zahl($this->post('betrag', '0')));
if ($this->post('art', 'ausgabe') !== 'einnahme') {
$betrag = -$betrag;
}
// Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste
// macht die Buchung zur Umbuchung – wie das Kategoriefeld im
// FinanzManager, das `[Konto]` annimmt.
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
$daten = [
'konto_id' => $kontoId,
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'betrag' => $betrag,
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'abgeglichen' => isset($_POST['abgeglichen']),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell',
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']),
];
return [$daten, $this->splitzeilen()];
}
/**
* Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen
* nicht – so bleiben die leeren Zeilen der Maske folgenlos.
*
* @return list<array<string,mixed>>
*/
private function splitzeilen(): array
{
$zeilen = $_POST['split'] ?? [];
if (!is_array($zeilen)) {
return [];
}
$splits = [];
foreach ($zeilen as $zeile) {
if (!is_array($zeile)) {
continue;
}
$wert = trim((string)($zeile['betrag'] ?? ''));
if ($wert === '') {
continue;
}
$zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? ''));
$splits[] = [
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => $zeile['klasse_id'] ?? null,
'betrag' => KontenController::zahl($wert),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
/**
* Der umgekehrte Weg zu zuordnungAus(): der Auswahlwert für eine Zeile mit
* `kategorie_id` und `gegenkonto_id` – das Gegenkonto sticht, wie in der
* Buchungsmaske.
*
* @param array<string,mixed>|null $zeile
*/
public static function zuordnungswert(?array $zeile): string
{
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
}
/**
* Kategorie und Gegenkonto aus einem Formular, das entweder die gemeinsame
* Liste `zuordnung` schickt oder – etwa aus versteckten Feldern einer
* Tabellenzeile – die beiden Kennungen einzeln.
*
* @param array<string,mixed> $daten meist $_POST oder eine Splitzeile daraus
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAusFormular(array $daten): array
{
if (array_key_exists('zuordnung', $daten)) {
return self::zuordnungAus((string)$daten['zuordnung']);
}
$id = static fn(mixed $wert): ?int => is_scalar($wert) && (int)$wert > 0 ? (int)$wert : null;
return [
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
];
}
/**
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
* `kategorie:12`, `konto:7` oder leer.
*
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAus(string $wert): array
{
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
if ((int)$id <= 0) {
return $leer;
}
return match ($art) {
'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null],
'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id],
default => $leer,
};
}
/**
* Das Depot aus einem Auswahlwert `depot:7` – oder null, wenn der Wert für
* eine gewöhnliche Zuordnung steht.
*/
public static function depotAus(string $wert): ?int
{
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
return $art === 'depot' && (int)$id > 0 ? (int)$id : null;
}
private function nichtGefunden(string $pfad): void
{
http_response_code(404);
$this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']);
}
}