Finanzmanager/src/Controllers/BuchungenController.php
fds2610-bot 057075368c Fix #94: Regel direkt aus einer Buchung anlegen
Toggle „Regel anlegen“ in der Buchungsmaske (Fix #94); beim Speichern
entsteht daraus eine Regel (AbgleichRegel::ausBuchung()), die das
Kontoblatt danach in einem modalen Dialog zum Prüfen und Anpassen zeigt.
Abbruch (auch per Escape) verwirft die Regel wieder, Speichern übernimmt
Änderungen. Die Regelfelder sind aus regeln.php in regelfelder.php
ausgelagert, damit beide Formulare dieselbe Bauart teilen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:11:06 +02:00

713 lines
25 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) {
View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler');
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) {
View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler');
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
);
}
$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(),
'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,
];
}
/**
* 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 */
private function maske(?array $konto, ?array $buchung): void
{
if ($konto === null) {
$this->nichtGefunden('/konten');
return;
}
$this->render('buchungen/formular.php', [
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
'konto' => $konto,
'buchung' => $buchung,
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
]);
}
/**
* @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',
];
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;
}
/**
* 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']);
}
}