From f9b6a5d30362ef7f7a39b6ad3859fa2c64142c49 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 28 May 2026 21:57:04 +0200 Subject: [PATCH] Feature: Buchungsvorlagen im Buchungsdialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Vorlagen-Badge zwischen Bank-Badge und Buchungsformular in /belege/{id}. Vorlage auswählen öffnet modalen Dialog mit Feldgruppen (Metadaten, USt-Zeilen mit Live-Berechnung, Zahlungskonto). Buchungen werden per VorlageBuchungsService aus JSON-Regeln in der DB erstellt. Pflege-UI unter /einstellungen/buchungsvorlagen (CRUD inkl. Felder und Regeln). Initiale Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" via Migration 080 eingespielt. Test 42 prüft CRUD und Buchungsausführung. --- Changes.md | 9 + migrations/078_buchungsvorlagen.sql | 13 + .../079_buchungsvorlage_felder_regeln.sql | 27 ++ .../080_buchungsvorlage_seed_essensbeleg.sql | 61 +++++ public/index.php | 11 + public/static/js/app.js | 94 +++++++ src/Controllers/BelegController.php | 2 + src/Controllers/BuchungsvorlageController.php | 239 ++++++++++++++++++ src/Models/Buchungsvorlage.php | 130 ++++++++++ src/Services/VorlageBuchungsService.php | 120 +++++++++ src/Views/belege/detail.php | 21 ++ src/Views/buchungsvorlagen/form.php | 214 ++++++++++++++++ src/Views/buchungsvorlagen/index.php | 43 ++++ src/Views/buchungsvorlagen/modal.php | 150 +++++++++++ src/Views/layout/nav.php | 6 +- src/version.txt | 2 +- tests/42_BuchungsvorlageTest.php | 120 +++++++++ 17 files changed, 1260 insertions(+), 2 deletions(-) create mode 100644 migrations/078_buchungsvorlagen.sql create mode 100644 migrations/079_buchungsvorlage_felder_regeln.sql create mode 100644 migrations/080_buchungsvorlage_seed_essensbeleg.sql create mode 100644 src/Controllers/BuchungsvorlageController.php create mode 100644 src/Models/Buchungsvorlage.php create mode 100644 src/Services/VorlageBuchungsService.php create mode 100644 src/Views/buchungsvorlagen/form.php create mode 100644 src/Views/buchungsvorlagen/index.php create mode 100644 src/Views/buchungsvorlagen/modal.php create mode 100644 tests/42_BuchungsvorlageTest.php diff --git a/Changes.md b/Changes.md index 5b07e85..e23bf68 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,15 @@ --- +## 2026-05-28 – Feature: Buchungsvorlagen im Buchungsdialog + +- **Datenbank:** 3 neue Tabellen `buchungsvorlagen`, `buchungsvorlage_felder`, `buchungsvorlage_regeln` (Migrationen 078–080); initiale Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" per Migration 080 eingespielt +- **Buchungsdialog** (`/belege/{id}`): Neues Vorlagen-Badge (gelb) zwischen Bank-Badge und Buchungsformular; Auswahlbox mit aktiven Vorlagen; Vorlage auswählen öffnet modalen Dialog mit Felder-Gruppen und Live-USt-Berechnung +- **Modal:** Feldgruppe 1 (Metadaten: Lieferant, Rechnungsnr., Belegdatum, Brutto-Anzeige), Feldgruppe 2 (USt-Zeilen mit Netto↔Brutto-Live-Berechnung), Feldgruppe 3 (Zahlungskonto; 1200 vorausgewählt wenn Bankbuchung, sonst 1210) +- **Buchungslogik:** `VorlageBuchungsService` interpretiert JSON-Buchungsregeln und ruft `Booking::create()` direkt auf (keine automatischen Kindzeilen, da Konten explizit); Metafelder werden über `Beleg::updateMeta()` gespeichert +- **Pflege-UI:** `/einstellungen/buchungsvorlagen` – CRUD-Oberfläche für Vorlagen, Felder und Buchungsregeln; Navigationslink unter Einstellungen +- **Test 42:** `42_BuchungsvorlageTest.php` prüft CRUD, Buchungsausführung, korrekte Konten und `neq_zero`-Bedingung + ## 2026-05-24 – Fix: EÜR-Zeilennummern auf amtliche Anlage EÜR 2025 korrigiert - `migrations/075_euer_zeilen_korrektur.sql`: alle `euer_zeile`-Werte in `accounts` gemäß amtlicher Anlage EÜR 2025 neu zugeordnet (zuvor internes Schema ohne Bezug zum amtlichen Formular) diff --git a/migrations/078_buchungsvorlagen.sql b/migrations/078_buchungsvorlagen.sql new file mode 100644 index 0000000..cc03426 --- /dev/null +++ b/migrations/078_buchungsvorlagen.sql @@ -0,0 +1,13 @@ +-- 078_buchungsvorlagen.sql +-- Neue Tabelle für Buchungsvorlagen (Kopfdaten). + +CREATE TABLE IF NOT EXISTS buchungsvorlagen ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + beschreibung TEXT NULL, + aktiv TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_aktiv_sort (aktiv, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/079_buchungsvorlage_felder_regeln.sql b/migrations/079_buchungsvorlage_felder_regeln.sql new file mode 100644 index 0000000..60f728e --- /dev/null +++ b/migrations/079_buchungsvorlage_felder_regeln.sql @@ -0,0 +1,27 @@ +-- 079_buchungsvorlage_felder_regeln.sql +-- Felder und Buchungsregeln für Buchungsvorlagen. + +CREATE TABLE IF NOT EXISTS buchungsvorlage_felder ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + vorlage_id INT UNSIGNED NOT NULL, + gruppe_nr TINYINT UNSIGNED NOT NULL COMMENT '1=Metadaten, 2=USt-Zeilen, 3=Zahlung', + sort_order INT NOT NULL DEFAULT 0, + feldname VARCHAR(100) NOT NULL COMMENT 'Interner Schlüssel z.B. netto_7', + label VARCHAR(255) NOT NULL, + typ ENUM('text','date','number','readonly','ust_zeile','account_select') NOT NULL, + ust_satz DECIMAL(5,2) NULL COMMENT 'Nur für typ=ust_zeile', + meta_key VARCHAR(100) NULL COMMENT 'belege-Spalte z.B. supplier_name', + CONSTRAINT fk_bvf_vorlage FOREIGN KEY (vorlage_id) REFERENCES buchungsvorlagen(id) ON DELETE CASCADE, + INDEX idx_vorlage_gruppe (vorlage_id, gruppe_nr, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS buchungsvorlage_regeln ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + vorlage_id INT UNSIGNED NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + bedingung_feld VARCHAR(100) NOT NULL COMMENT 'Feldname dessen Wert geprüft wird', + bedingung_op VARCHAR(20) NOT NULL DEFAULT 'neq_zero' COMMENT 'neq_zero|always', + buchungen JSON NOT NULL COMMENT '[{"betrag_ref":"netto_7","nach_konto":"4656"},...]', + CONSTRAINT fk_bvr_vorlage FOREIGN KEY (vorlage_id) REFERENCES buchungsvorlagen(id) ON DELETE CASCADE, + INDEX idx_vorlage (vorlage_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/080_buchungsvorlage_seed_essensbeleg.sql b/migrations/080_buchungsvorlage_seed_essensbeleg.sql new file mode 100644 index 0000000..ed622fd --- /dev/null +++ b/migrations/080_buchungsvorlage_seed_essensbeleg.sql @@ -0,0 +1,61 @@ +-- 080_buchungsvorlage_seed_essensbeleg.sql +-- Seed: Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" +-- Idempotent: Vorlage wird nur angelegt wenn nicht vorhanden. + +SET @vorlage_name = 'Essensbeleg n. abzugsfähig (nur USt.)'; + +INSERT INTO buchungsvorlagen (name, beschreibung, aktiv, sort_order) +SELECT @vorlage_name, + 'Essensbelegle bei dem der Nettobetrag steuerlich nicht abzugsfähig ist, die Vorsteuer jedoch geltend gemacht werden kann.', + 1, + 10 +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlagen WHERE name = @vorlage_name); + +SET @vid = (SELECT id FROM buchungsvorlagen WHERE name = @vorlage_name LIMIT 1); + +-- Felder nur einfügen wenn für diese Vorlage noch keine existieren +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 1, 10, 'lieferant', 'Lieferant', 'text', NULL, 'supplier_name' +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'lieferant'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 1, 20, 'rechnungsnr', 'Rechnungsnummer', 'text', NULL, 'rechnungs_nr' +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'rechnungsnr'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 1, 30, 'belegdatum', 'Belegdatum', 'date', NULL, 'beleg_datum' +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'belegdatum'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 1, 40, 'brutto_gesamt', 'Brutto gesamt', 'readonly', NULL, NULL +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'brutto_gesamt'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 2, 10, 'netto_7', '7% USt.', 'ust_zeile', 7.00, NULL +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'netto_7'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 2, 20, 'netto_19', '19% USt.', 'ust_zeile', 19.00, NULL +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'netto_19'); + +INSERT INTO buchungsvorlage_felder (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) +SELECT @vid, 3, 10, 'zahlungskonto', 'Zahlungskonto', 'account_select', NULL, NULL +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_felder WHERE vorlage_id = @vid AND feldname = 'zahlungskonto'); + +-- Regeln: 7% Anteil +INSERT INTO buchungsvorlage_regeln (vorlage_id, sort_order, bedingung_feld, bedingung_op, buchungen) +SELECT @vid, 10, 'netto_7', 'neq_zero', + JSON_ARRAY( + JSON_OBJECT('betrag_ref', 'netto_7', 'nach_konto', '4656'), + JSON_OBJECT('betrag_ref', 'ust_7', 'nach_konto', '1571') + ) +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_regeln WHERE vorlage_id = @vid AND bedingung_feld = 'netto_7'); + +-- Regeln: 19% Anteil +INSERT INTO buchungsvorlage_regeln (vorlage_id, sort_order, bedingung_feld, bedingung_op, buchungen) +SELECT @vid, 20, 'netto_19', 'neq_zero', + JSON_ARRAY( + JSON_OBJECT('betrag_ref', 'netto_19', 'nach_konto', '4654'), + JSON_OBJECT('betrag_ref', 'ust_19', 'nach_konto', '1576') + ) +WHERE NOT EXISTS (SELECT 1 FROM buchungsvorlage_regeln WHERE vorlage_id = @vid AND bedingung_feld = 'netto_19'); diff --git a/public/index.php b/public/index.php index 3e54ec0..4502560 100644 --- a/public/index.php +++ b/public/index.php @@ -27,6 +27,7 @@ use Steuerkram\Controllers\LoginController; use Steuerkram\Controllers\AssetController; use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\BankController; +use Steuerkram\Controllers\BuchungsvorlageController; Config::load(); @@ -172,6 +173,16 @@ $router->post('/settings/bankimport/dismiss-suggestion', fn($p) => (new Settings $router->post('/settings/bankimport/{id}/field', fn($p) => (new SettingsController())->updateBankImportField($p)); $router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($p)); +// ── Buchungsvorlagen ────────────────────────────────────────── +$router->get('/einstellungen/buchungsvorlagen', fn($p) => (new BuchungsvorlageController())->index($p)); +$router->get('/einstellungen/buchungsvorlagen/neu', fn($p) => (new BuchungsvorlageController())->create($p)); +$router->post('/einstellungen/buchungsvorlagen', fn($p) => (new BuchungsvorlageController())->store($p)); +$router->get('/einstellungen/buchungsvorlagen/{id}/bearbeiten', fn($p) => (new BuchungsvorlageController())->edit($p)); +$router->post('/einstellungen/buchungsvorlagen/{id}', fn($p) => (new BuchungsvorlageController())->update($p)); +$router->post('/einstellungen/buchungsvorlagen/{id}/loeschen', fn($p) => (new BuchungsvorlageController())->destroy($p)); +$router->get('/api/buchungsvorlagen/{id}/modal', fn($p) => (new BuchungsvorlageController())->modal($p)); +$router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) => (new BuchungsvorlageController())->buchen($p)); + // ── Login / Logout ──────────────────────────────────────────── $router->get('/login', fn($p) => (new LoginController())->showLogin($p)); $router->post('/login', fn($p) => (new LoginController())->handleLogin($p)); diff --git a/public/static/js/app.js b/public/static/js/app.js index 339daaa..31277e0 100644 --- a/public/static/js/app.js +++ b/public/static/js/app.js @@ -470,3 +470,97 @@ document.addEventListener('DOMContentLoaded', () => { }, 20000); }); }); + +// ── Buchungsvorlagen ────────────────────────────────────────── + +// Vorlage auswählen → Modal per AJAX laden +document.addEventListener('change', function(e) { + const sel = e.target.closest('#vorlage-select'); + if (!sel) return; + const vorlageId = sel.value; + if (!vorlageId) return; + const belegId = sel.dataset.belegId || ''; + const bankTxId = sel.dataset.bankTx || ''; + let url = `/api/buchungsvorlagen/${vorlageId}/modal?beleg_id=${belegId}`; + if (bankTxId) url += `&bank_tx=${bankTxId}`; + fetch(url) + .then(r => r.ok ? r.text() : Promise.reject(r.status)) + .then(html => { + const container = document.getElementById('vorlage-modal-container'); + if (!container) return; + container.innerHTML = html; + // CSRF-Token in das dynamisch geladene Formular injizieren + const token = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + container.querySelectorAll('form').forEach(function(form) { + if (token && !form.querySelector('[name="csrf_token"]')) { + var inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token; + form.appendChild(inp); + } + }); + }) + .catch(() => { + // Selektion zurücksetzen bei Fehler + sel.value = ''; + }); +}); + +// Modal schließen → Vorlage-Select zurücksetzen +document.addEventListener('click', function(e) { + const closeBtn = e.target.closest('[data-modal-close]'); + if (!closeBtn) return; + const overlay = closeBtn.closest('.modal-overlay'); + if (overlay && overlay.id === 'vorlage-modal') { + const sel = document.getElementById('vorlage-select'); + if (sel) sel.value = ''; + const container = document.getElementById('vorlage-modal-container'); + if (container) setTimeout(() => { container.innerHTML = ''; }, 300); + } +}); + +// Live-Berechnungen in USt-Zeilen des Vorlage-Modals +document.addEventListener('input', function(e) { + const inp = e.target; + if (!inp.matches('#vorlage-modal .vorlage-netto, #vorlage-modal .vorlage-brutto')) return; + const row = inp.closest('.vorlage-ust-row'); + if (!row) return; + const satz = parseFloat(row.dataset.ustSatz) / 100; + const netto = row.querySelector('.vorlage-netto'); + const brutto = row.querySelector('.vorlage-brutto'); + const ust = row.querySelector('.vorlage-ust'); + if (inp.classList.contains('vorlage-netto')) { + const n = parseFloat(netto.value) || 0; + brutto.value = (n * (1 + satz)).toFixed(2); + ust.value = (n * satz).toFixed(2); + } else { + const b = parseFloat(brutto.value) || 0; + const n = b / (1 + satz); + netto.value = n.toFixed(2); + ust.value = (b - n).toFixed(2); + } + vorlageUpdateBruttoGesamt(); +}); + +function vorlageUpdateBruttoGesamt() { + const modal = document.getElementById('vorlage-modal'); + if (!modal) return; + const bruttoFelder = modal.querySelectorAll('.vorlage-brutto'); + const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(el.value) || 0), 0); + const anzeige = modal.querySelector('[data-feldname="brutto_gesamt"]'); + if (anzeige) anzeige.textContent = summe.toFixed(2).replace('.', ',') + ' €'; + // Auch das buchungs_datum-Hidden synchronisieren mit belegdatum-Feld falls vorhanden + const datumFeld = modal.querySelector('input[name="belegdatum"]'); + const datumHidden = modal.querySelector('input[name="buchungs_datum"]'); + if (datumFeld && datumHidden && datumFeld.value) { + datumHidden.value = datumFeld.value; + } +} + +// Datum-Feld Änderung → buchungs_datum synchronisieren +document.addEventListener('change', function(e) { + if (!e.target.matches('#vorlage-modal input[name="belegdatum"]')) return; + const modal = document.getElementById('vorlage-modal'); + if (!modal) return; + const hidden = modal.querySelector('input[name="buchungs_datum"]'); + if (hidden) hidden.value = e.target.value; +}); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 09157dd..b89cf76 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -12,6 +12,7 @@ use Steuerkram\Models\Supplier; use Steuerkram\View; use Steuerkram\Services\ImageCropService; use Steuerkram\Models\BankTransaction; +use Steuerkram\Models\Buchungsvorlage; use Steuerkram\Services\KontierungsvorschlagService; class BelegController extends BaseController @@ -275,6 +276,7 @@ class BelegController extends BaseController 'bankTxBrutto' => $bankTxBrutto, 'formData' => $bankFormData, 'linkedBankTx' => $linkedBankTx, + 'vorlagen' => Buchungsvorlage::findAll(), ]), ]); } diff --git a/src/Controllers/BuchungsvorlageController.php b/src/Controllers/BuchungsvorlageController.php new file mode 100644 index 0000000..00670b5 --- /dev/null +++ b/src/Controllers/BuchungsvorlageController.php @@ -0,0 +1,239 @@ +render('layout/base.php', [ + 'pageTitle' => 'Buchungsvorlagen', + 'topbarActions' => 'Neue Vorlage', + 'content' => $this->renderView('buchungsvorlagen/index', ['vorlagen' => $vorlagen]), + ]); + } + + public function create(array $params): void + { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Neue Buchungsvorlage', + 'content' => $this->renderView('buchungsvorlagen/form', [ + 'vorlage' => null, + 'felder' => [], + 'regeln' => [], + 'errors' => [], + ]), + ]); + } + + public function store(array $params): void + { + $data = $_POST; + $errors = $this->validate($data); + + if ($errors) { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Neue Buchungsvorlage', + 'content' => $this->renderView('buchungsvorlagen/form', [ + 'vorlage' => $data, + 'felder' => $this->parseFelderFromPost($data), + 'regeln' => $this->parseRegelnFromPost($data), + 'errors' => $errors, + ]), + ]); + return; + } + + $id = Buchungsvorlage::create($data); + Buchungsvorlage::saveFelderForVorlage($id, $this->parseFelderFromPost($data)); + Buchungsvorlage::saveRegelnForVorlage($id, $this->parseRegelnFromPost($data)); + + View::redirect('/einstellungen/buchungsvorlagen', 'Vorlage gespeichert.'); + } + + public function edit(array $params): void + { + $id = (int)$params['id']; + $vorlage = Buchungsvorlage::find($id); + if (!$vorlage) { http_response_code(404); return; } + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Vorlage bearbeiten', + 'content' => $this->renderView('buchungsvorlagen/form', [ + 'vorlage' => $vorlage, + 'felder' => Buchungsvorlage::getFelder($id), + 'regeln' => Buchungsvorlage::getRegeln($id), + 'errors' => [], + ]), + ]); + } + + public function update(array $params): void + { + $id = (int)$params['id']; + $vorlage = Buchungsvorlage::find($id); + if (!$vorlage) { http_response_code(404); return; } + + $data = $_POST; + $errors = $this->validate($data); + + if ($errors) { + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Vorlage bearbeiten', + 'content' => $this->renderView('buchungsvorlagen/form', [ + 'vorlage' => array_merge($vorlage, $data), + 'felder' => $this->parseFelderFromPost($data), + 'regeln' => $this->parseRegelnFromPost($data), + 'errors' => $errors, + ]), + ]); + return; + } + + Buchungsvorlage::update($id, $data); + Buchungsvorlage::saveFelderForVorlage($id, $this->parseFelderFromPost($data)); + Buchungsvorlage::saveRegelnForVorlage($id, $this->parseRegelnFromPost($data)); + + View::redirect('/einstellungen/buchungsvorlagen', 'Vorlage aktualisiert.'); + } + + public function destroy(array $params): void + { + $id = (int)$params['id']; + Buchungsvorlage::delete($id); + View::redirect('/einstellungen/buchungsvorlagen', 'Vorlage gelöscht.'); + } + + // ── AJAX: Modal-HTML ─────────────────────────────────────────────────── + + public function modal(array $params): void + { + $vorlageId = (int)$params['id']; + $belegId = (int)($_GET['beleg_id'] ?? 0); + + $vorlage = Buchungsvorlage::find($vorlageId); + if (!$vorlage || !$vorlage['aktiv']) { + http_response_code(404); + echo '

Vorlage nicht gefunden.

'; + return; + } + + $beleg = $belegId > 0 ? Beleg::find($belegId) : null; + + $felder = Buchungsvorlage::getFelder($vorlageId); + $zahlungskonten = Account::all(['kontenart' => 'Zahlungskonto', 'aktiv' => 1]); + $zahlungskonten = array_filter($zahlungskonten, fn($a) => (int)$a['kontonummer'] >= 1200 && (int)$a['kontonummer'] <= 1299); + + $hatBankbuchung = false; + if ($beleg) { + $linked = BankTransaction::findByBelegId((int)$beleg['id']); + $hatBankbuchung = !empty($linked); + if (!$hatBankbuchung && !empty($_GET['bank_tx'])) { + $hatBankbuchung = true; + } + } + + echo $this->renderView('buchungsvorlagen/modal', [ + 'vorlage' => $vorlage, + 'felder' => $felder, + 'beleg' => $beleg, + 'belegId' => $belegId, + 'zahlungskonten' => array_values($zahlungskonten), + 'hatBankbuchung' => $hatBankbuchung, + ]); + } + + // ── Buchungen ausführen ──────────────────────────────────────────────── + + public function buchen(array $params): void + { + $belegId = (int)$params['belegId']; + $vorlageId = (int)$params['vorlageId']; + + $beleg = Beleg::find($belegId); + if (!$beleg) { http_response_code(404); return; } + + $datum = !empty($_POST['buchungs_datum']) ? $_POST['buchungs_datum'] : date('Y-m-d'); + + try { + $svc = new VorlageBuchungsService(); + $svc->buchen($belegId, $vorlageId, $_POST, $datum); + } catch (\RuntimeException $e) { + View::redirect("/belege/{$belegId}", $e->getMessage(), 'error'); + return; + } + + View::redirect("/belege/{$belegId}", 'Buchungen aus Vorlage erfasst.'); + } + + // ── Hilfsmethoden ────────────────────────────────────────────────────── + + private function validate(array $data): array + { + $errors = []; + if (empty(trim($data['name'] ?? ''))) { + $errors['name'] = 'Name ist ein Pflichtfeld.'; + } + return $errors; + } + + private function parseFelderFromPost(array $data): array + { + $felder = []; + $raws = $data['felder'] ?? []; + foreach ($raws as $f) { + if (empty(trim($f['feldname'] ?? ''))) continue; + $felder[] = [ + 'gruppe_nr' => (int)($f['gruppe_nr'] ?? 1), + 'sort_order' => (int)($f['sort_order'] ?? 0), + 'feldname' => trim($f['feldname']), + 'label' => trim($f['label'] ?? ''), + 'typ' => $f['typ'] ?? 'text', + 'ust_satz' => $f['ust_satz'] ?? '', + 'meta_key' => trim($f['meta_key'] ?? ''), + ]; + } + return $felder; + } + + private function parseRegelnFromPost(array $data): array + { + $regeln = []; + $raws = $data['regeln'] ?? []; + foreach ($raws as $r) { + if (empty(trim($r['bedingung_feld'] ?? ''))) continue; + $buchungen = []; + foreach (($r['buchungen'] ?? []) as $b) { + if (empty(trim($b['betrag_ref'] ?? '')) || empty(trim($b['nach_konto'] ?? ''))) continue; + $buchungen[] = [ + 'betrag_ref' => trim($b['betrag_ref']), + 'nach_konto' => trim($b['nach_konto']), + ]; + } + $regeln[] = [ + 'sort_order' => (int)($r['sort_order'] ?? 0), + 'bedingung_feld' => trim($r['bedingung_feld']), + 'bedingung_op' => $r['bedingung_op'] ?? 'neq_zero', + 'buchungen' => $buchungen, + ]; + } + return $regeln; + } +} diff --git a/src/Models/Buchungsvorlage.php b/src/Models/Buchungsvorlage.php new file mode 100644 index 0000000..60da401 --- /dev/null +++ b/src/Models/Buchungsvorlage.php @@ -0,0 +1,130 @@ + $f) { + Database::execute( + 'INSERT INTO buchungsvorlage_felder + (vorlage_id, gruppe_nr, sort_order, feldname, label, typ, ust_satz, meta_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + $vorlageId, + (int)($f['gruppe_nr'] ?? 1), + (int)($f['sort_order'] ?? ($i * 10)), + trim($f['feldname']), + trim($f['label']), + $f['typ'], + isset($f['ust_satz']) && $f['ust_satz'] !== '' ? (float)$f['ust_satz'] : null, + $f['meta_key'] !== '' ? $f['meta_key'] : null, + ] + ); + } + } + + public static function saveRegelnForVorlage(int $vorlageId, array $regeln): void + { + Database::execute('DELETE FROM buchungsvorlage_regeln WHERE vorlage_id = ?', [$vorlageId]); + foreach ($regeln as $i => $r) { + Database::execute( + 'INSERT INTO buchungsvorlage_regeln + (vorlage_id, sort_order, bedingung_feld, bedingung_op, buchungen) + VALUES (?, ?, ?, ?, ?)', + [ + $vorlageId, + (int)($r['sort_order'] ?? ($i * 10)), + trim($r['bedingung_feld']), + $r['bedingung_op'] ?? 'neq_zero', + json_encode($r['buchungen'] ?? []), + ] + ); + } + } +} diff --git a/src/Services/VorlageBuchungsService.php b/src/Services/VorlageBuchungsService.php new file mode 100644 index 0000000..ad69907 --- /dev/null +++ b/src/Services/VorlageBuchungsService.php @@ -0,0 +1,120 @@ +..., 'ust_7'=>..., 'zahlungskonto_id'=>..., ...] + * @param string $datum Buchungsdatum (YYYY-MM-DD) + * @return int[] IDs der erstellten Buchungen + */ + public function buchen(int $belegId, int $vorlageId, array $inputDaten, string $datum): array + { + $vorlage = Buchungsvorlage::find($vorlageId); + if (!$vorlage) { + throw new \RuntimeException('Buchungsvorlage nicht gefunden.'); + } + + $beleg = Beleg::find($belegId); + if (!$beleg) { + throw new \RuntimeException('Beleg nicht gefunden.'); + } + if ($beleg['locked']) { + throw new \RuntimeException('Beleg ist gesperrt.'); + } + + $zahlungskontoId = (int)($inputDaten['zahlungskonto_id'] ?? 0); + if ($zahlungskontoId === 0) { + throw new \RuntimeException('Kein Zahlungskonto gewählt.'); + } + + $regeln = Buchungsvorlage::getRegeln($vorlageId); + if (empty($regeln)) { + throw new \RuntimeException('Vorlage hat keine Buchungsregeln.'); + } + + $buchungsIds = []; + + Database::beginTransaction(); + try { + $this->updateBelegMeta($belegId, $beleg, $inputDaten); + + foreach ($regeln as $regel) { + if (!$this->bedingungErfuellt($regel, $inputDaten)) { + continue; + } + foreach ($regel['buchungen'] as $buchung) { + $betrag = round((float)($inputDaten[$buchung['betrag_ref']] ?? 0), 2); + if ($betrag == 0.0) { + continue; + } + + $nachKonto = Account::findByKontonummer($buchung['nach_konto']); + if (!$nachKonto) { + throw new \RuntimeException("Konto {$buchung['nach_konto']} nicht gefunden."); + } + + $buchungsIds[] = Booking::create([ + 'beleg_id' => $belegId, + 'buchungs_datum' => $datum, + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => (int)$nachKonto['id'], + 'zahlungskonto_id' => $zahlungskontoId, + 'betrag_netto' => $betrag, + 'ust_satz' => 0, + 'steuerart' => 'normal', + 'buchungstext' => $vorlage['name'], + ]); + } + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; + } + + return $buchungsIds; + } + + private function bedingungErfuellt(array $regel, array $inputDaten): bool + { + $feld = $regel['bedingung_feld']; + $op = $regel['bedingung_op'] ?? 'neq_zero'; + + if ($op === 'always') { + return true; + } + return round((float)($inputDaten[$feld] ?? 0), 4) != 0.0; + } + + private function updateBelegMeta(int $belegId, array $beleg, array $inputDaten): void + { + $supplierId = $beleg['supplier_id'] ?? null; + + if (!empty($inputDaten['lieferant'])) { + $supplierId = Supplier::findOrCreate(trim($inputDaten['lieferant'])); + } + + Beleg::updateMeta($belegId, [ + 'rechnungs_nr' => $inputDaten['rechnungsnr'] ?? $beleg['rechnungs_nr'] ?? null, + 'belegtyp' => $beleg['belegtyp'] ?? 'Eingangsrechnung', + 'beleg_datum' => !empty($inputDaten['belegdatum']) ? $inputDaten['belegdatum'] : ($beleg['beleg_datum'] ?? null), + 'supplier_id' => $supplierId, + ]); + } +} diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 0d41759..2f861e9 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -374,6 +374,27 @@ $suppliers = $suppliers ?? [];
+ + +
+
+
+ Vorlage + +
+
+
+
+ + htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); +$isEdit = !empty($vorlage['id']); +$action = $isEdit ? "/einstellungen/buchungsvorlagen/{$vorlage['id']}" : '/einstellungen/buchungsvorlagen'; + +$feldTypen = ['text','date','number','readonly','ust_zeile','account_select']; +$bedOptn = ['neq_zero' => 'wenn ≠ 0', 'always' => 'immer']; +?> +
+ + +
+ ', array_map(fn($k, $v) => "$k: $v", array_keys($errors), $errors)) ?> +
+ + + +
+
Grunddaten
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
Eingabefelder
+ +
+
+ + + + + + + + + + + + + + + $feld): ?> + + + + + + + + + + + + +
GruppeReihenfolgeFeldnameLabelTypUSt-%Meta-Key
+ +
+
+
+ + +
+
+
Buchungsregeln
+ +
+
+
+ $regel): ?> +
+
+ Regel + + + + + +
+ + + + + + + + + + $buch): ?> + + + + + + + +
Betrag-ReferenzZiel-Kontonummer
+
+ +
+
+
+ +
+ + Abbrechen +
+ +
+ + diff --git a/src/Views/buchungsvorlagen/index.php b/src/Views/buchungsvorlagen/index.php new file mode 100644 index 0000000..f336666 --- /dev/null +++ b/src/Views/buchungsvorlagen/index.php @@ -0,0 +1,43 @@ + +
+
+ +

Keine Buchungsvorlagen vorhanden. Jetzt anlegen →

+ + + + + + + + + + + + + + + + + + + + + + +
NameBeschreibungReihenfolgeAktiv
+ + Aktiv + + Inaktiv + + + Bearbeiten +
+ +
+
+ +
+
diff --git a/src/Views/buchungsvorlagen/modal.php b/src/Views/buchungsvorlagen/modal.php new file mode 100644 index 0000000..e431642 --- /dev/null +++ b/src/Views/buchungsvorlagen/modal.php @@ -0,0 +1,150 @@ + htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); + +// Felder nach Gruppen aufteilen +$gruppe1 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 1); +$gruppe2 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 2); +$gruppe3 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 3); + +// Vorbelegen aus Beleg-Metadaten +$vorbelegt = [ + 'lieferant' => $beleg['supplier_name'] ?? '', + 'rechnungsnr' => $beleg['rechnungs_nr'] ?? '', + 'belegdatum' => $beleg['beleg_datum'] ?? date('Y-m-d'), +]; + +// Vorauswahl Zahlungskonto +$defaultKontonummer = $hatBankbuchung ? '1200' : '1210'; +$defaultKontoId = 0; +foreach ($zahlungskonten as $zk) { + if ($zk['kontonummer'] === $defaultKontonummer) { + $defaultKontoId = $zk['id']; + break; + } +} +if (!$defaultKontoId && !empty($zahlungskonten)) { + $defaultKontoId = (int)array_values($zahlungskonten)[0]['id']; +} +?> + diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index 172c5eb..a236fdd 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -88,7 +88,7 @@ function navActive(string $prefix, string $current): string { Einstellungen - + Kontenrahmen @@ -97,6 +97,10 @@ function navActive(string $prefix, string $current): string { CSV Bankimport Formate + + + Buchungsvorlagen + diff --git a/src/version.txt b/src/version.txt index 6163983..7976f90 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260524 +1-260528 diff --git a/tests/42_BuchungsvorlageTest.php b/tests/42_BuchungsvorlageTest.php new file mode 100644 index 0000000..d4e3dd4 --- /dev/null +++ b/tests/42_BuchungsvorlageTest.php @@ -0,0 +1,120 @@ + 'Test-Vorlage Essensbeleg', + 'beschreibung'=> 'Nur für Test', + 'aktiv' => 1, + 'sort_order' => 99, + ]); + $this->assertGreaterThan(0, $vid, 'Vorlage angelegt'); + + $vorlage = \Steuerkram\Models\Buchungsvorlage::find($vid); + $this->assertNotEmpty($vorlage, 'Vorlage findbar'); + $this->assertEquals('Test-Vorlage Essensbeleg', $vorlage['name'], 'Name korrekt'); + + // ── Felder speichern ───────────────────────────────────────────── + \Steuerkram\Models\Buchungsvorlage::saveFelderForVorlage($vid, [ + ['gruppe_nr' => 1, 'sort_order' => 10, 'feldname' => 'lieferant', 'label' => 'Lieferant', 'typ' => 'text', 'ust_satz' => '', 'meta_key' => 'supplier_name'], + ['gruppe_nr' => 2, 'sort_order' => 10, 'feldname' => 'netto_7', 'label' => '7% USt.', 'typ' => 'ust_zeile','ust_satz' => '7','meta_key' => ''], + ['gruppe_nr' => 3, 'sort_order' => 10, 'feldname' => 'zahlungskonto','label' => 'Zahlung', 'typ' => 'account_select','ust_satz'=>'','meta_key'=>''], + ]); + $felder = \Steuerkram\Models\Buchungsvorlage::getFelder($vid); + $this->assertEquals(3, count($felder), '3 Felder gespeichert'); + + // ── Regeln speichern ───────────────────────────────────────────── + \Steuerkram\Models\Buchungsvorlage::saveRegelnForVorlage($vid, [ + [ + 'sort_order' => 10, + 'bedingung_feld' => 'netto_7', + 'bedingung_op' => 'neq_zero', + 'buchungen' => [ + ['betrag_ref' => 'netto_7', 'nach_konto' => '4656'], + ['betrag_ref' => 'ust_7', 'nach_konto' => '1571'], + ], + ], + ]); + $regeln = \Steuerkram\Models\Buchungsvorlage::getRegeln($vid); + $this->assertEquals(1, count($regeln), '1 Regel gespeichert'); + $this->assertEquals(2, count($regeln[0]['buchungen']), '2 Buchungen in Regel'); + + // ── Buchungen ausführen ────────────────────────────────────────── + $bankId = \Steuerkram\Database::fetchOne( + "SELECT id FROM accounts WHERE kontonummer = '1200' LIMIT 1" + ); + $this->assertNotEmpty($bankId, 'Konto 1200 gefunden'); + + $belegNr = 'TEST-VL-' . uniqid(); + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => 'test-essensbeleg.pdf', + 'storage_path' => 'static/uploads/test/test-essensbeleg.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => date('Y-m-d'), + 'belegtyp' => 'Eingangsrechnung', + ]); + $this->assertGreaterThan(0, $belegId, 'Beleg für Vorlage-Test angelegt'); + + $svc = new \Steuerkram\Services\VorlageBuchungsService(); + $ids = $svc->buchen($belegId, $vid, [ + 'netto_7' => '10.00', + 'ust_7' => '0.70', + 'brutto_7' => '10.70', + 'zahlungskonto_id' => (int)$bankId['id'], + 'lieferant' => 'Test-Restaurant', + 'belegdatum' => date('Y-m-d'), + ], date('Y-m-d')); + + $this->assertEquals(2, count($ids), '2 Buchungszeilen erzeugt (Netto + USt)'); + + $bookings = \Steuerkram\Models\Booking::forBeleg($belegId); + $this->assertEquals(2, count($bookings), '2 Buchungen am Beleg abrufbar'); + + $kontonummern = array_column($bookings, 'kategorie_nr'); + sort($kontonummern); + $this->assertEquals(['1571', '4656'], $kontonummern, 'Buchungen auf Konten 1571 und 4656'); + + // ── Bedingung neq_zero: netto_7 = 0 → keine Buchungen ─────────── + $belegNr2 = 'TEST-VL2-' . uniqid(); + $belegId2 = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr2, + 'filename' => 'test2.pdf', + 'storage_path' => 'static/uploads/test/test2.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr2), + 'beleg_datum' => date('Y-m-d'), + 'belegtyp' => 'Eingangsrechnung', + ]); + + $ids2 = $svc->buchen($belegId2, $vid, [ + 'netto_7' => '0', + 'ust_7' => '0', + 'zahlungskonto_id' => (int)$bankId['id'], + ], date('Y-m-d')); + + $this->assertEquals(0, count($ids2), 'Keine Buchungen wenn Betrag = 0'); + + // ── findAll / findAllIncludingInactive ──────────────────────────── + $alle = \Steuerkram\Models\Buchungsvorlage::findAll(); + $gefunden = array_filter($alle, fn($v) => $v['id'] === $vid); + $this->assertNotEmpty($gefunden, 'findAll enthält neue Vorlage'); + + // ── Update ──────────────────────────────────────────────────────── + \Steuerkram\Models\Buchungsvorlage::update($vid, [ + 'name' => 'Test-Vorlage Geändert', + 'aktiv' => 1, + 'sort_order' => 99, + ]); + $aktualisiert = \Steuerkram\Models\Buchungsvorlage::find($vid); + $this->assertEquals('Test-Vorlage Geändert', $aktualisiert['name'], 'Name aktualisiert'); + } +}