Feature: Buchungsvorlagen im Buchungsdialog

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.
This commit is contained in:
fds2610-bot 2026-05-28 21:57:04 +02:00
parent a1eb573e25
commit f9b6a5d303
17 changed files with 1260 additions and 2 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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');

View file

@ -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));

View file

@ -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;
});

View file

@ -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(),
]),
]);
}

View file

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Account;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Models\Buchungsvorlage;
use Steuerkram\Services\VorlageBuchungsService;
use Steuerkram\View;
class BuchungsvorlageController extends BaseController
{
// ── Pflege-UI ──────────────────────────────────────────────────────────
public function index(array $params): void
{
$vorlagen = Buchungsvorlage::findAllIncludingInactive();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Buchungsvorlagen',
'topbarActions' => '<a href="/einstellungen/buchungsvorlagen/neu" class="btn btn-primary">Neue Vorlage</a>',
'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 '<p>Vorlage nicht gefunden.</p>';
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;
}
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Buchungsvorlage
{
public static function findAll(): array
{
return Database::fetchAll(
'SELECT * FROM buchungsvorlagen WHERE aktiv = 1 ORDER BY sort_order, name'
);
}
public static function findAllIncludingInactive(): array
{
return Database::fetchAll(
'SELECT * FROM buchungsvorlagen ORDER BY sort_order, name'
);
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM buchungsvorlagen WHERE id = ?', [$id]);
}
public static function getFelder(int $vorlageId): array
{
return Database::fetchAll(
'SELECT * FROM buchungsvorlage_felder
WHERE vorlage_id = ?
ORDER BY gruppe_nr, sort_order',
[$vorlageId]
);
}
public static function getRegeln(int $vorlageId): array
{
$rows = Database::fetchAll(
'SELECT * FROM buchungsvorlage_regeln
WHERE vorlage_id = ?
ORDER BY sort_order',
[$vorlageId]
);
foreach ($rows as &$row) {
$row['buchungen'] = json_decode($row['buchungen'], true) ?? [];
}
return $rows;
}
public static function create(array $data): int
{
Database::execute(
'INSERT INTO buchungsvorlagen (name, beschreibung, aktiv, sort_order)
VALUES (?, ?, ?, ?)',
[
trim($data['name']),
$data['beschreibung'] ?? null,
isset($data['aktiv']) ? (int)$data['aktiv'] : 1,
(int)($data['sort_order'] ?? 0),
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
Database::execute(
'UPDATE buchungsvorlagen
SET name = ?, beschreibung = ?, aktiv = ?, sort_order = ?
WHERE id = ?',
[
trim($data['name']),
$data['beschreibung'] ?? null,
isset($data['aktiv']) ? (int)$data['aktiv'] : 1,
(int)($data['sort_order'] ?? 0),
$id,
]
);
}
public static function delete(int $id): void
{
Database::execute('DELETE FROM buchungsvorlagen WHERE id = ?', [$id]);
}
public static function saveFelderForVorlage(int $vorlageId, array $felder): void
{
Database::execute('DELETE FROM buchungsvorlage_felder WHERE vorlage_id = ?', [$vorlageId]);
foreach ($felder as $i => $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'] ?? []),
]
);
}
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Steuerkram\Models\Account;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Buchungsvorlage;
use Steuerkram\Models\Supplier;
class VorlageBuchungsService
{
/**
* Führt alle Buchungen einer Vorlage für einen Beleg aus.
*
* @param int $belegId Ziel-Beleg
* @param int $vorlageId Vorlage
* @param array $inputDaten Formulardaten: ['netto_7'=>..., '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,
]);
}
}

View file

@ -374,6 +374,27 @@ $suppliers = $suppliers ?? [];
<div id="ki-badge" style="margin-bottom:1rem"></div>
<?php endif; ?>
<!-- Buchungsvorlagen-Badge -->
<?php if (!$beleg['locked'] && !empty($vorlagen)): ?>
<div class="card mb-2" style="border-left:4px solid var(--color-warning,#f59e0b);margin-bottom:.75rem">
<div class="card-body" style="padding:.65rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem">
<span class="badge badge-yellow" style="font-size:.8rem;white-space:nowrap">Vorlage</span>
<select id="vorlage-select"
data-beleg-id="<?= $h($beleg['id']) ?>"
<?php if (!empty($bankTx)): ?>data-bank-tx="<?= $h($bankTx['id']) ?>"<?php endif; ?>
class="form-control form-control-sm" style="flex:1;max-width:340px">
<option value="">— Vorlage auswählen —</option>
<?php foreach ($vorlagen as $v): ?>
<option value="<?= $h($v['id']) ?>"><?= $h($v['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div id="vorlage-modal-container"></div>
<?php endif; ?>
<!-- Neue Buchungszeile erfassen -->
<?php if (!$beleg['locked']): ?>
<?php

View file

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
$h = fn($v) => 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'];
?>
<form method="post" action="<?= $h($action) ?>">
<?php if (!empty($errors)): ?>
<div class="flash flash-error" style="margin-bottom:1rem">
<?= implode('<br>', array_map(fn($k, $v) => "<strong>$k:</strong> $v", array_keys($errors), $errors)) ?>
</div>
<?php endif; ?>
<!-- Grunddaten -->
<div class="card" style="margin-bottom:1rem">
<div class="card-header"><div class="card-title">Grunddaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Name <span style="color:var(--color-danger)">*</span></label>
<input type="text" name="name" value="<?= $h($vorlage['name'] ?? '') ?>" required class="form-control">
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea name="beschreibung" rows="2" class="form-control"><?= $h($vorlage['beschreibung'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:1.5rem">
<div class="form-group" style="flex:0 0 auto">
<label>Reihenfolge</label>
<input type="number" name="sort_order" value="<?= (int)($vorlage['sort_order'] ?? 0) ?>" min="0" class="form-control" style="width:100px">
</div>
<div class="form-group" style="flex:0 0 auto">
<label>Status</label>
<div style="padding-top:.35rem">
<label style="display:inline-flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" name="aktiv" value="1"<?= !isset($vorlage['aktiv']) || $vorlage['aktiv'] ? ' checked' : '' ?>>
Aktiv
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Felder -->
<div class="card" style="margin-bottom:1rem">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
<div class="card-title">Eingabefelder</div>
<button type="button" class="btn btn-secondary" style="font-size:.8rem;padding:.25rem .6rem" onclick="vorlageAddFeld()">+ Feld</button>
</div>
<div class="card-body" style="padding:0">
<table class="data-table" id="vorlage-felder-table">
<thead>
<tr>
<th>Gruppe</th>
<th>Reihenfolge</th>
<th>Feldname</th>
<th>Label</th>
<th>Typ</th>
<th>USt-%</th>
<th>Meta-Key</th>
<th></th>
</tr>
</thead>
<tbody id="vorlage-felder-body">
<?php foreach ($felder as $i => $feld): ?>
<tr>
<td><input type="number" name="felder[<?= $i ?>][gruppe_nr]" value="<?= (int)$feld['gruppe_nr'] ?>" min="1" max="9" class="form-control form-control-sm" style="width:60px"></td>
<td><input type="number" name="felder[<?= $i ?>][sort_order]" value="<?= (int)$feld['sort_order'] ?>" min="0" class="form-control form-control-sm" style="width:70px"></td>
<td><input type="text" name="felder[<?= $i ?>][feldname]" value="<?= $h($feld['feldname']) ?>" class="form-control form-control-sm" style="width:120px"></td>
<td><input type="text" name="felder[<?= $i ?>][label]" value="<?= $h($feld['label']) ?>" class="form-control form-control-sm"></td>
<td>
<select name="felder[<?= $i ?>][typ]" class="form-control form-control-sm">
<?php foreach ($feldTypen as $t): ?>
<option value="<?= $h($t) ?>"<?= $feld['typ'] === $t ? ' selected' : '' ?>><?= $h($t) ?></option>
<?php endforeach; ?>
</select>
</td>
<td><input type="number" name="felder[<?= $i ?>][ust_satz]" value="<?= $h($feld['ust_satz'] ?? '') ?>" step="0.01" min="0" class="form-control form-control-sm" style="width:70px"></td>
<td><input type="text" name="felder[<?= $i ?>][meta_key]" value="<?= $h($feld['meta_key'] ?? '') ?>" class="form-control form-control-sm" style="width:130px"></td>
<td><button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem" onclick="this.closest('tr').remove()">✕</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Buchungsregeln -->
<div class="card" style="margin-bottom:1rem">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
<div class="card-title">Buchungsregeln</div>
<button type="button" class="btn btn-secondary" style="font-size:.8rem;padding:.25rem .6rem" onclick="vorlageAddRegel()">+ Regel</button>
</div>
<div class="card-body" style="padding:0">
<div id="vorlage-regeln-container">
<?php foreach ($regeln as $ri => $regel): ?>
<div class="vorlage-regel-block" style="border-bottom:1px solid var(--color-border);padding:1rem">
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<strong style="font-size:.85rem">Regel <?= $ri + 1 ?></strong>
<label style="font-size:.85rem;margin:0">Wenn Feld:</label>
<input type="text" name="regeln[<?= $ri ?>][bedingung_feld]" value="<?= $h($regel['bedingung_feld']) ?>"
class="form-control form-control-sm" style="width:120px" placeholder="z.B. netto_7">
<select name="regeln[<?= $ri ?>][bedingung_op]" class="form-control form-control-sm" style="width:120px">
<?php foreach ($bedOptn as $val => $lbl): ?>
<option value="<?= $h($val) ?>"<?= ($regel['bedingung_op'] ?? 'neq_zero') === $val ? ' selected' : '' ?>><?= $h($lbl) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="regeln[<?= $ri ?>][sort_order]" value="<?= (int)($regel['sort_order'] ?? ($ri * 10)) ?>"
class="form-control form-control-sm" style="width:70px" placeholder="Sortierung">
<button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem;margin-left:auto" onclick="this.closest('.vorlage-regel-block').remove()">✕</button>
</div>
<table class="data-table" style="margin:0">
<thead>
<tr>
<th>Betrag-Referenz</th>
<th>Ziel-Kontonummer</th>
<th><button type="button" class="btn btn-secondary" style="font-size:.75rem;padding:.15rem .4rem" onclick="vorlageAddBuchung(this, <?= $ri ?>)">+ Buchung</button></th>
</tr>
</thead>
<tbody id="vorlage-buchungen-<?= $ri ?>">
<?php foreach ($regel['buchungen'] as $bi => $buch): ?>
<tr>
<td><input type="text" name="regeln[<?= $ri ?>][buchungen][<?= $bi ?>][betrag_ref]"
value="<?= $h($buch['betrag_ref']) ?>" class="form-control form-control-sm" placeholder="z.B. netto_7"></td>
<td><input type="text" name="regeln[<?= $ri ?>][buchungen][<?= $bi ?>][nach_konto]"
value="<?= $h($buch['nach_konto']) ?>" class="form-control form-control-sm" placeholder="z.B. 4656"></td>
<td><button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem" onclick="this.closest('tr').remove()">✕</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'Speichern' : 'Anlegen' ?></button>
<a href="/einstellungen/buchungsvorlagen" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
<script>
var _vorlFeldIdx = <?= count($felder) ?>;
var _vorlRegelIdx = <?= count($regeln) ?>;
function vorlageAddFeld() {
var i = _vorlFeldIdx++;
var row = document.createElement('tr');
row.innerHTML = `
<td><input type="number" name="felder[${i}][gruppe_nr]" value="1" min="1" max="9" class="form-control form-control-sm" style="width:60px"></td>
<td><input type="number" name="felder[${i}][sort_order]" value="${i*10}" min="0" class="form-control form-control-sm" style="width:70px"></td>
<td><input type="text" name="felder[${i}][feldname]" value="" class="form-control form-control-sm" style="width:120px"></td>
<td><input type="text" name="felder[${i}][label]" value="" class="form-control form-control-sm"></td>
<td><select name="felder[${i}][typ]" class="form-control form-control-sm">
<?php foreach ($feldTypen as $t): ?><option value="<?= $h($t) ?>"><?= $h($t) ?></option><?php endforeach; ?>
</select></td>
<td><input type="number" name="felder[${i}][ust_satz]" value="" step="0.01" min="0" class="form-control form-control-sm" style="width:70px"></td>
<td><input type="text" name="felder[${i}][meta_key]" value="" class="form-control form-control-sm" style="width:130px"></td>
<td><button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem" onclick="this.closest('tr').remove()">✕</button></td>`;
document.getElementById('vorlage-felder-body').appendChild(row);
}
function vorlageAddRegel() {
var ri = _vorlRegelIdx++;
var div = document.createElement('div');
div.className = 'vorlage-regel-block';
div.style.cssText = 'border-bottom:1px solid var(--color-border);padding:1rem';
div.innerHTML = `
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<strong style="font-size:.85rem">Regel ${ri+1}</strong>
<label style="font-size:.85rem;margin:0">Wenn Feld:</label>
<input type="text" name="regeln[${ri}][bedingung_feld]" value=""
class="form-control form-control-sm" style="width:120px" placeholder="z.B. netto_7">
<select name="regeln[${ri}][bedingung_op]" class="form-control form-control-sm" style="width:120px">
<option value="neq_zero">wenn ≠ 0</option>
<option value="always">immer</option>
</select>
<input type="number" name="regeln[${ri}][sort_order]" value="${ri*10}"
class="form-control form-control-sm" style="width:70px">
<button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem;margin-left:auto" onclick="this.closest('.vorlage-regel-block').remove()">✕</button>
</div>
<table class="data-table" style="margin:0">
<thead><tr>
<th>Betrag-Referenz</th>
<th>Ziel-Kontonummer</th>
<th><button type="button" class="btn btn-secondary" style="font-size:.75rem;padding:.15rem .4rem" onclick="vorlageAddBuchung(this,${ri})">+ Buchung</button></th>
</tr></thead>
<tbody id="vorlage-buchungen-${ri}"></tbody>
</table>`;
document.getElementById('vorlage-regeln-container').appendChild(div);
vorlageAddBuchung(div.querySelector('button[onclick*="vorlageAddBuchung"]'), ri);
}
function vorlageAddBuchung(btn, ri) {
var tbody = document.getElementById('vorlage-buchungen-' + ri);
if (!tbody) return;
var bi = tbody.querySelectorAll('tr').length;
var tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" name="regeln[${ri}][buchungen][${bi}][betrag_ref]"
value="" class="form-control form-control-sm" placeholder="z.B. netto_7"></td>
<td><input type="text" name="regeln[${ri}][buchungen][${bi}][nach_konto]"
value="" class="form-control form-control-sm" placeholder="z.B. 4656"></td>
<td><button type="button" class="btn btn-danger" style="font-size:.75rem;padding:.2rem .5rem" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(tr);
}
</script>

View file

@ -0,0 +1,43 @@
<?php declare(strict_types=1); ?>
<div class="card">
<div class="card-body">
<?php if (empty($vorlagen)): ?>
<p class="text-muted text-center" style="padding:2rem">Keine Buchungsvorlagen vorhanden. <a href="/einstellungen/buchungsvorlagen/neu">Jetzt anlegen →</a></p>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Beschreibung</th>
<th style="text-align:center">Reihenfolge</th>
<th style="text-align:center">Aktiv</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($vorlagen as $v): ?>
<tr>
<td><strong><?= htmlspecialchars($v['name'], ENT_QUOTES) ?></strong></td>
<td class="text-muted text-sm"><?= htmlspecialchars($v['beschreibung'] ?? '', ENT_QUOTES) ?></td>
<td style="text-align:center"><?= (int)$v['sort_order'] ?></td>
<td style="text-align:center">
<?php if ($v['aktiv']): ?>
<span class="badge badge-green">Aktiv</span>
<?php else: ?>
<span class="badge badge-gray">Inaktiv</span>
<?php endif; ?>
</td>
<td style="text-align:right;white-space:nowrap">
<a href="/einstellungen/buchungsvorlagen/<?= (int)$v['id'] ?>/bearbeiten" class="btn btn-secondary" style="font-size:.8rem;padding:.25rem .6rem">Bearbeiten</a>
<form method="post" action="/einstellungen/buchungsvorlagen/<?= (int)$v['id'] ?>/loeschen"
style="display:inline" onsubmit="return confirm('Vorlage wirklich löschen?')">
<button type="submit" class="btn btn-danger" style="font-size:.8rem;padding:.25rem .6rem">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
$h = fn($v) => 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'];
}
?>
<div class="modal-overlay open" id="vorlage-modal">
<div class="modal" style="max-width:680px;width:100%">
<div class="modal-header" style="padding:1.25rem 1.5rem;border-bottom:1px solid var(--color-border);display:flex;align-items:center;justify-content:space-between">
<h3 class="modal-title" style="margin:0;font-size:1rem"><?= $h($vorlage['name']) ?></h3>
<button type="button" class="btn btn-secondary" data-modal-close style="padding:.25rem .6rem;font-size:.85rem">✕</button>
</div>
<div class="modal-body" style="padding:1.25rem 1.5rem">
<form method="post" action="/belege/<?= $h($belegId) ?>/vorlage/<?= $h($vorlage['id']) ?>/buchen" id="vorlage-buchen-form">
<input type="hidden" name="buchungs_datum" value="<?= $h($vorbelegt['belegdatum'] ?: date('Y-m-d')) ?>">
<?php if (!empty($gruppe1)): ?>
<!-- Gruppe 1: Metadaten -->
<div style="margin-bottom:1.25rem">
<div style="font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-muted);margin-bottom:.75rem">Belegdaten</div>
<?php foreach ($gruppe1 as $feld): ?>
<?php if ($feld['typ'] === 'readonly'): ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem">
<label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label>
<span id="vorlage-field-<?= $h($feld['feldname']) ?>"
data-feldname="<?= $h($feld['feldname']) ?>"
style="font-weight:600;font-size:.95rem">0,00 €</span>
</div>
<?php elseif ($feld['typ'] === 'date'): ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem">
<label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label>
<input type="date" name="<?= $h($feld['feldname']) ?>"
value="<?= $h($vorbelegt[$feld['feldname']] ?? '') ?>"
style="flex:1;max-width:200px" class="form-control form-control-sm">
</div>
<?php else: ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem">
<label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label>
<input type="text" name="<?= $h($feld['feldname']) ?>"
value="<?= $h($vorbelegt[$feld['feldname']] ?? '') ?>"
style="flex:1" class="form-control form-control-sm">
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($gruppe2)): ?>
<!-- Gruppe 2: USt-Zeilen -->
<div style="margin-bottom:1.25rem">
<div style="font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-muted);margin-bottom:.75rem">Beträge</div>
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<thead>
<tr style="color:var(--color-text-muted);font-size:.8rem">
<th style="text-align:left;padding:.3rem .5rem;font-weight:500">Steuer</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">Netto (€)</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">Brutto (€)</th>
<th style="text-align:right;padding:.3rem .5rem;font-weight:500">USt (€)</th>
</tr>
</thead>
<tbody>
<?php foreach ($gruppe2 as $feld): ?>
<?php if ($feld['typ'] === 'ust_zeile'): ?>
<?php
$satz = (float)$feld['ust_satz'];
$satzInt = (int)$satz;
$nKey = $feld['feldname'];
$uKey = 'ust_' . $satzInt;
$bKey = 'brutto_' . $satzInt;
?>
<tr data-ust-satz="<?= $h($satz) ?>" class="vorlage-ust-row">
<td style="padding:.3rem .5rem"><?= $h($feld['label']) ?></td>
<td style="padding:.3rem .5rem">
<input type="number" name="<?= $h($nKey) ?>" step="0.01" min="0" value=""
class="form-control form-control-sm vorlage-netto"
style="text-align:right;width:110px"
placeholder="0,00">
</td>
<td style="padding:.3rem .5rem">
<input type="number" name="<?= $h($bKey) ?>" step="0.01" min="0" value=""
class="form-control form-control-sm vorlage-brutto"
style="text-align:right;width:110px"
placeholder="0,00">
</td>
<td style="padding:.3rem .5rem">
<input type="number" name="<?= $h($uKey) ?>" step="0.01" value=""
class="form-control form-control-sm vorlage-ust"
style="text-align:right;width:90px;background:var(--color-bg-subtle)"
readonly placeholder="0,00">
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!empty($gruppe3)): ?>
<!-- Gruppe 3: Zahlungskonto -->
<div style="margin-bottom:1.25rem">
<div style="font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-muted);margin-bottom:.75rem">Zahlung</div>
<?php foreach ($gruppe3 as $feld): ?>
<?php if ($feld['typ'] === 'account_select'): ?>
<div class="form-group" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem">
<label style="min-width:130px;margin:0;font-size:.875rem"><?= $h($feld['label']) ?></label>
<select name="zahlungskonto_id" class="form-control form-control-sm" style="flex:1;max-width:320px">
<?php foreach ($zahlungskonten as $zk): ?>
<option value="<?= $h($zk['id']) ?>"<?= (int)$zk['id'] === $defaultKontoId ? ' selected' : '' ?>>
<?= $h($zk['kontonummer']) ?> – <?= $h($zk['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</form>
</div>
<div class="modal-footer" style="padding:1rem 1.5rem;border-top:1px solid var(--color-border);display:flex;justify-content:flex-end;gap:.75rem">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="submit" form="vorlage-buchen-form" class="btn btn-primary">Buchen</button>
</div>
</div>
</div>

View file

@ -88,7 +88,7 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Einstellungen
</a>
<?php if (str_starts_with($path, '/settings') || str_starts_with($path, '/accounts')): ?>
<?php if (str_starts_with($path, '/settings') || str_starts_with($path, '/accounts') || str_starts_with($path, '/einstellungen')): ?>
<a href="/accounts" class="<?= navActive('/accounts', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg>
Kontenrahmen
@ -97,6 +97,10 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
CSV Bankimport Formate
</a>
<a href="/einstellungen/buchungsvorlagen" class="<?= navActive('/einstellungen/buchungsvorlagen', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
Buchungsvorlagen
</a>
<?php endif; ?>
</nav>
</div>

View file

@ -1 +1 @@
1-260524
1-260528

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
class BuchungsvorlageTest extends TestCase
{
protected string $testName = 'Test 42: Buchungsvorlagen CRUD und Buchungsausführung';
public function run(): void
{
// ── Vorlage anlegen ──────────────────────────────────────────────
$vid = \Steuerkram\Models\Buchungsvorlage::create([
'name' => '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');
}
}