Merge pull request 'Feature: Buchungsvorlagen im Buchungsdialog' (#94) from fds2610-bot/Steuerkram:feature/buchungsvorlagen into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/94
This commit is contained in:
commit
797f896705
18 changed files with 1260 additions and 17 deletions
|
|
@ -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
|
## 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)
|
- `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)
|
||||||
|
|
|
||||||
13
migrations/078_buchungsvorlagen.sql
Normal file
13
migrations/078_buchungsvorlagen.sql
Normal 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;
|
||||||
27
migrations/079_buchungsvorlage_felder_regeln.sql
Normal file
27
migrations/079_buchungsvorlage_felder_regeln.sql
Normal 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;
|
||||||
61
migrations/080_buchungsvorlage_seed_essensbeleg.sql
Normal file
61
migrations/080_buchungsvorlage_seed_essensbeleg.sql
Normal 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');
|
||||||
|
|
@ -27,6 +27,7 @@ use Steuerkram\Controllers\LoginController;
|
||||||
use Steuerkram\Controllers\AssetController;
|
use Steuerkram\Controllers\AssetController;
|
||||||
use Steuerkram\Controllers\UserAdminController;
|
use Steuerkram\Controllers\UserAdminController;
|
||||||
use Steuerkram\Controllers\BankController;
|
use Steuerkram\Controllers\BankController;
|
||||||
|
use Steuerkram\Controllers\BuchungsvorlageController;
|
||||||
|
|
||||||
Config::load();
|
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}/field', fn($p) => (new SettingsController())->updateBankImportField($p));
|
||||||
$router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($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 ────────────────────────────────────────────
|
// ── Login / Logout ────────────────────────────────────────────
|
||||||
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
|
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
|
||||||
$router->post('/login', fn($p) => (new LoginController())->handleLogin($p));
|
$router->post('/login', fn($p) => (new LoginController())->handleLogin($p));
|
||||||
|
|
|
||||||
|
|
@ -470,3 +470,97 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}, 20000);
|
}, 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;
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Steuerkram\Models\Supplier;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
use Steuerkram\Services\ImageCropService;
|
use Steuerkram\Services\ImageCropService;
|
||||||
use Steuerkram\Models\BankTransaction;
|
use Steuerkram\Models\BankTransaction;
|
||||||
|
use Steuerkram\Models\Buchungsvorlage;
|
||||||
use Steuerkram\Services\KontierungsvorschlagService;
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
class BelegController extends BaseController
|
class BelegController extends BaseController
|
||||||
|
|
@ -275,6 +276,7 @@ class BelegController extends BaseController
|
||||||
'bankTxBrutto' => $bankTxBrutto,
|
'bankTxBrutto' => $bankTxBrutto,
|
||||||
'formData' => $bankFormData,
|
'formData' => $bankFormData,
|
||||||
'linkedBankTx' => $linkedBankTx,
|
'linkedBankTx' => $linkedBankTx,
|
||||||
|
'vorlagen' => Buchungsvorlage::findAll(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
239
src/Controllers/BuchungsvorlageController.php
Normal file
239
src/Controllers/BuchungsvorlageController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Models/Buchungsvorlage.php
Normal file
130
src/Models/Buchungsvorlage.php
Normal 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'] ?? []),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Services/VorlageBuchungsService.php
Normal file
120
src/Services/VorlageBuchungsService.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -374,6 +374,27 @@ $suppliers = $suppliers ?? [];
|
||||||
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||||
<?php endif; ?>
|
<?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 -->
|
<!-- Neue Buchungszeile erfassen -->
|
||||||
<?php if (!$beleg['locked']): ?>
|
<?php if (!$beleg['locked']): ?>
|
||||||
<?php
|
<?php
|
||||||
|
|
|
||||||
214
src/Views/buchungsvorlagen/form.php
Normal file
214
src/Views/buchungsvorlagen/form.php
Normal 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>
|
||||||
43
src/Views/buchungsvorlagen/index.php
Normal file
43
src/Views/buchungsvorlagen/index.php
Normal 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>
|
||||||
150
src/Views/buchungsvorlagen/modal.php
Normal file
150
src/Views/buchungsvorlagen/modal.php
Normal 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>
|
||||||
|
|
@ -77,21 +77,6 @@ $fmt = fn($v) => View::formatMoney($v);
|
||||||
<div id="dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
|
<div id="dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schnellzugriff -->
|
|
||||||
<div style="display:flex;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap">
|
|
||||||
<a href="/belege/upload" class="btn btn-primary" title="Beleg hochladen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="/invoices/new" class="btn btn-primary" title="Rechnung erstellen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="/tax/ustva" class="btn btn-secondary">UStVA erstellen</a>
|
|
||||||
<a href="/tax/euer/<?= date('Y') ?>" class="btn btn-secondary">EÜR <?= date('Y') ?></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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
|
Einstellungen
|
||||||
</a>
|
</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">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg>
|
||||||
Kontenrahmen
|
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>
|
<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
|
CSV Bankimport Formate
|
||||||
</a>
|
</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; ?>
|
<?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1-260524
|
1-260528
|
||||||
|
|
|
||||||
120
tests/42_BuchungsvorlageTest.php
Normal file
120
tests/42_BuchungsvorlageTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue