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>
780 lines
28 KiB
PHP
780 lines
28 KiB
PHP
<?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']);
|
||
}
|
||
}
|