From 3fe5015027a3c10925f046135dd34b9c1a6f5cbd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 30 May 2026 14:22:48 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Feature:=20Buchungsvorlagen=20=E2=80=93=20B?= =?UTF-8?q?ankbuchung=20verkn=C3=BCpfen,=20Zahldatum,=20Standard-Lieferant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vorlagenbuchung verknüpft jetzt korrekt die Bankbuchung (bank_tx_id) - Buchungs-Redirect bleibt nach Vorlagenbuchung auf dem Beleg - Zahldatum (Wertstellung der Bankbuchung) im Vorlagendialog neben Belegdatum - USt-Felder im Vorlagendialog als Text (nicht mehr als editierbare Inputs) - Standard-Lieferant pro Vorlage speicherbar; wird im Buchungsdialog vorbelegt - Migration 081: spalte standard_lieferant in buchungsvorlagen --- ...081_buchungsvorlage_standard_lieferant.sql | 11 ++++++ public/static/js/app.js | 24 +++++-------- src/Controllers/BuchungsvorlageController.php | 21 ++++++++++-- src/Models/Buchungsvorlage.php | 8 +++-- src/Views/buchungsvorlagen/form.php | 5 +++ src/Views/buchungsvorlagen/modal.php | 34 +++++++++++++------ src/version.txt | 2 +- 7 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 migrations/081_buchungsvorlage_standard_lieferant.sql diff --git a/migrations/081_buchungsvorlage_standard_lieferant.sql b/migrations/081_buchungsvorlage_standard_lieferant.sql new file mode 100644 index 0000000..298fa8d --- /dev/null +++ b/migrations/081_buchungsvorlage_standard_lieferant.sql @@ -0,0 +1,11 @@ +-- 081_buchungsvorlage_standard_lieferant.sql +-- Standard-Lieferant pro Vorlage; wird im Buchungsdialog vorbelegt. + +ALTER TABLE buchungsvorlagen + ADD COLUMN IF NOT EXISTS standard_lieferant VARCHAR(255) NULL DEFAULT NULL AFTER beschreibung; + +-- Bestehende Vorlage "Essensbeleg" mit Standardlieferant "Lebensmittel" vorbelegen. +UPDATE buchungsvorlagen +SET standard_lieferant = 'Lebensmittel' +WHERE name = 'Essensbeleg n. abzugsfähig (nur USt.)' + AND (standard_lieferant IS NULL OR standard_lieferant = ''); diff --git a/public/static/js/app.js b/public/static/js/app.js index 31277e0..cb668b8 100644 --- a/public/static/js/app.js +++ b/public/static/js/app.js @@ -524,10 +524,11 @@ document.addEventListener('input', function(e) { 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'); + 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'); + const ustAnzeige = row.querySelector('.vorlage-ust-anzeige'); if (inp.classList.contains('vorlage-netto')) { const n = parseFloat(netto.value) || 0; brutto.value = (n * (1 + satz)).toFixed(2); @@ -538,6 +539,7 @@ document.addEventListener('input', function(e) { netto.value = n.toFixed(2); ust.value = (b - n).toFixed(2); } + if (ustAnzeige) ustAnzeige.textContent = parseFloat(ust.value).toFixed(2).replace('.', ','); vorlageUpdateBruttoGesamt(); }); @@ -548,19 +550,11 @@ function vorlageUpdateBruttoGesamt() { 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 +// Zahldatum-Feld Änderung → buchungs_datum-Hidden 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 (!e.target.matches('#vorlage-modal #vorlage-zahldatum')) return; + const hidden = document.getElementById('vorlage-buchungs-datum'); if (hidden) hidden.value = e.target.value; }); diff --git a/src/Controllers/BuchungsvorlageController.php b/src/Controllers/BuchungsvorlageController.php index 00670b5..14fb73d 100644 --- a/src/Controllers/BuchungsvorlageController.php +++ b/src/Controllers/BuchungsvorlageController.php @@ -141,14 +141,20 @@ class BuchungsvorlageController extends BaseController $zahlungskonten = Account::all(['kontenart' => 'Zahlungskonto', 'aktiv' => 1]); $zahlungskonten = array_filter($zahlungskonten, fn($a) => (int)$a['kontonummer'] >= 1200 && (int)$a['kontonummer'] <= 1299); + $bankTxId = (int)($_GET['bank_tx'] ?? 0); + $bankTxDatum = ''; $hatBankbuchung = false; if ($beleg) { $linked = BankTransaction::findByBelegId((int)$beleg['id']); $hatBankbuchung = !empty($linked); - if (!$hatBankbuchung && !empty($_GET['bank_tx'])) { + if (!$hatBankbuchung && $bankTxId > 0) { $hatBankbuchung = true; } } + if ($bankTxId > 0) { + $bankTx = BankTransaction::find($bankTxId); + $bankTxDatum = $bankTx['wertstellung'] ?? ''; + } echo $this->renderView('buchungsvorlagen/modal', [ 'vorlage' => $vorlage, @@ -157,6 +163,8 @@ class BuchungsvorlageController extends BaseController 'belegId' => $belegId, 'zahlungskonten' => array_values($zahlungskonten), 'hatBankbuchung' => $hatBankbuchung, + 'bankTxId' => $bankTxId, + 'bankTxDatum' => $bankTxDatum, ]); } @@ -170,17 +178,24 @@ class BuchungsvorlageController extends BaseController $beleg = Beleg::find($belegId); if (!$beleg) { http_response_code(404); return; } - $datum = !empty($_POST['buchungs_datum']) ? $_POST['buchungs_datum'] : date('Y-m-d'); + $datum = !empty($_POST['buchungs_datum']) ? $_POST['buchungs_datum'] : date('Y-m-d'); + $bankTxId = (int)($_POST['bank_tx_id'] ?? 0); try { $svc = new VorlageBuchungsService(); $svc->buchen($belegId, $vorlageId, $_POST, $datum); + + if ($bankTxId > 0) { + $totalBrutto = \Steuerkram\Models\Booking::sumBruttoForBeleg($belegId); + BankTransaction::linkBeleg($bankTxId, $belegId, $totalBrutto); + } } catch (\RuntimeException $e) { View::redirect("/belege/{$belegId}", $e->getMessage(), 'error'); return; } - View::redirect("/belege/{$belegId}", 'Buchungen aus Vorlage erfasst.'); + $msg = $bankTxId > 0 ? 'Buchungen aus Vorlage erfasst und Bankbuchung verknüpft.' : 'Buchungen aus Vorlage erfasst.'; + View::redirect("/belege/{$belegId}", $msg); } // ── Hilfsmethoden ────────────────────────────────────────────────────── diff --git a/src/Models/Buchungsvorlage.php b/src/Models/Buchungsvorlage.php index 60da401..064be85 100644 --- a/src/Models/Buchungsvorlage.php +++ b/src/Models/Buchungsvorlage.php @@ -54,11 +54,12 @@ class Buchungsvorlage public static function create(array $data): int { Database::execute( - 'INSERT INTO buchungsvorlagen (name, beschreibung, aktiv, sort_order) - VALUES (?, ?, ?, ?)', + 'INSERT INTO buchungsvorlagen (name, beschreibung, standard_lieferant, aktiv, sort_order) + VALUES (?, ?, ?, ?, ?)', [ trim($data['name']), $data['beschreibung'] ?? null, + $data['standard_lieferant'] !== '' ? trim($data['standard_lieferant'] ?? '') : null, isset($data['aktiv']) ? (int)$data['aktiv'] : 1, (int)($data['sort_order'] ?? 0), ] @@ -70,11 +71,12 @@ class Buchungsvorlage { Database::execute( 'UPDATE buchungsvorlagen - SET name = ?, beschreibung = ?, aktiv = ?, sort_order = ? + SET name = ?, beschreibung = ?, standard_lieferant = ?, aktiv = ?, sort_order = ? WHERE id = ?', [ trim($data['name']), $data['beschreibung'] ?? null, + $data['standard_lieferant'] !== '' ? trim($data['standard_lieferant'] ?? '') : null, isset($data['aktiv']) ? (int)$data['aktiv'] : 1, (int)($data['sort_order'] ?? 0), $id, diff --git a/src/Views/buchungsvorlagen/form.php b/src/Views/buchungsvorlagen/form.php index 5c44ac3..cf7520b 100644 --- a/src/Views/buchungsvorlagen/form.php +++ b/src/Views/buchungsvorlagen/form.php @@ -27,6 +27,11 @@ $bedOptn = ['neq_zero' => 'wenn ≠ 0', 'always' => 'immer']; +
+ + +
diff --git a/src/Views/buchungsvorlagen/modal.php b/src/Views/buchungsvorlagen/modal.php index e431642..79e5ea7 100644 --- a/src/Views/buchungsvorlagen/modal.php +++ b/src/Views/buchungsvorlagen/modal.php @@ -7,13 +7,17 @@ $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 +// Vorbelegen: Vorlage-Standard-Lieferant hat Vorrang vor Beleg-Lieferant +$standardLieferant = trim($vorlage['standard_lieferant'] ?? ''); $vorbelegt = [ - 'lieferant' => $beleg['supplier_name'] ?? '', + 'lieferant' => $standardLieferant !== '' ? $standardLieferant : ($beleg['supplier_name'] ?? ''), 'rechnungsnr' => $beleg['rechnungs_nr'] ?? '', 'belegdatum' => $beleg['beleg_datum'] ?? date('Y-m-d'), ]; +// Zahldatum: Bankbuchungs-Wertstellung, Fallback heute +$zahldatum = ($bankTxDatum ?? '') ?: date('Y-m-d'); + // Vorauswahl Zahlungskonto $defaultKontonummer = $hatBankbuchung ? '1200' : '1210'; $defaultKontoId = 0; @@ -35,7 +39,10 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
-
+
+ style="width:150px" class="form-control form-control-sm"> + + + +
@@ -78,7 +91,7 @@ if (!$defaultKontoId && !empty($zahlungskonten)) { Steuer Netto (€) Brutto (€) - USt (€) + USt (€) @@ -105,11 +118,10 @@ if (!$defaultKontoId && !empty($zahlungskonten)) { style="text-align:right;width:110px" placeholder="0,00"> - - + + 0,00 + diff --git a/src/version.txt b/src/version.txt index 7976f90..d7504ca 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260528 +1-260530 From af83ae8592b5360ab4c043be944ed83758c15d39 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 30 May 2026 14:22:57 +0200 Subject: [PATCH 2/3] Feature: E-Mail-Betreff und -Text pro Kunde statt global - Felder mail_subject_template / mail_body_template in customers-Tabelle - Kundenformular um E-Mail-Vorlage-Abschnitt erweitert - EmailService verwendet Kundenvorlage mit Fallback auf Standardtext - Globale E-Mail-Text-Einstellung in Einstellungen entfernt - Migration 082: neue Spalten in customers --- migrations/082_customer_mail_templates.sql | 6 ++ public/index.php | 1 - src/Controllers/InvoiceController.php | 5 +- src/Controllers/SettingsController.php | 19 ------ src/Models/Customer.php | 11 +++- src/Services/EmailService.php | 9 +-- src/Views/customers/form.php | 26 ++++++++ src/Views/settings/form.php | 74 ---------------------- 8 files changed, 49 insertions(+), 102 deletions(-) create mode 100644 migrations/082_customer_mail_templates.sql diff --git a/migrations/082_customer_mail_templates.sql b/migrations/082_customer_mail_templates.sql new file mode 100644 index 0000000..d8f0b2a --- /dev/null +++ b/migrations/082_customer_mail_templates.sql @@ -0,0 +1,6 @@ +-- 082_customer_mail_templates.sql +-- E-Mail-Betreff und -Text pro Kunde statt global in company_settings. + +ALTER TABLE customers + ADD COLUMN IF NOT EXISTS mail_subject_template VARCHAR(255) NULL DEFAULT NULL AFTER notes, + ADD COLUMN IF NOT EXISTS mail_body_template TEXT NULL DEFAULT NULL AFTER mail_subject_template; diff --git a/public/index.php b/public/index.php index 4502560..1efd2cb 100644 --- a/public/index.php +++ b/public/index.php @@ -161,7 +161,6 @@ $router->get('/tax/euer/{year}/anlageliste/pdf', fn($p) => (new EuerController() // ── Einstellungen ──────────────────────────────────────────── $router->get('/settings', fn($p) => (new SettingsController())->show($p)); $router->post('/settings', fn($p) => (new SettingsController())->update($p)); -$router->post('/settings/mail-template', fn($p) => (new SettingsController())->mailTemplate($p)); $router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 4c72fb7..4060542 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -258,7 +258,10 @@ class InvoiceController extends BaseController try { $lines = Invoice::lines($id); $settings = Setting::get(); - (new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients); + $customer = !empty($invoice['customer_id']) + ? \Steuerkram\Models\Customer::find((int)$invoice['customer_id']) + : null; + (new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null); if ($invoice['status'] === 'Finalisiert') { \Steuerkram\Database::execute( diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 5e87042..40503aa 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -95,25 +95,6 @@ class SettingsController extends BaseController View::redirect('/settings', 'Logo hochgeladen.'); } - public function mailTemplate(array $params): void - { - header('Content-Type: application/json'); - $subject = trim($_POST['mail_subject_template'] ?? ''); - $body = trim($_POST['mail_body_template'] ?? ''); - - if ($subject === '') { - echo json_encode(['error' => 'Betreff darf nicht leer sein.']); - return; - } - - \Steuerkram\Database::execute( - 'UPDATE company_settings SET mail_subject_template=?, mail_body_template=? WHERE id=1', - [$subject, $body ?: null] - ); - - echo json_encode(['ok' => true]); - } - public function mailTest(array $params): void { header('Content-Type: application/json'); diff --git a/src/Models/Customer.php b/src/Models/Customer.php index 1d6f027..44695c4 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -39,8 +39,8 @@ class Customer $nr = self::generateKundennummer(); Database::execute( 'INSERT INTO customers (kundennummer, firma, anrede, vorname, nachname, strasse, plz, ort, land, - email, telefon, ust_id, steuernummer, payment_days, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + email, telefon, ust_id, steuernummer, payment_days, notes, mail_subject_template, mail_body_template) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $data['kundennummer'] ?? $nr, $data['firma'] ?? null, @@ -57,6 +57,8 @@ class Customer $data['steuernummer'] ?? null, (int)($data['payment_days'] ?? 14), $data['notes'] ?? null, + ($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null, + ($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null, ] ); return (int)Database::lastInsertId(); @@ -66,7 +68,8 @@ class Customer { Database::execute( 'UPDATE customers SET firma=?, anrede=?, vorname=?, nachname=?, strasse=?, plz=?, ort=?, land=?, - email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?, updated_at=NOW() + email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?, + mail_subject_template=?, mail_body_template=?, updated_at=NOW() WHERE id=?', [ $data['firma'] ?? null, @@ -83,6 +86,8 @@ class Customer $data['steuernummer'] ?? null, (int)($data['payment_days'] ?? 14), $data['notes'] ?? null, + ($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null, + ($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null, $id, ] ); diff --git a/src/Services/EmailService.php b/src/Services/EmailService.php index 7941950..a7610fb 100644 --- a/src/Services/EmailService.php +++ b/src/Services/EmailService.php @@ -9,10 +9,11 @@ class EmailService /** * Sendet eine Rechnung als PDF-Anhang an alle Empfänger. * - * @param string[] $recipients E-Mail-Adressen (1–3) + * @param string[] $recipients E-Mail-Adressen (1–3) + * @param array|null $customer Kundendatensatz (für kundenspezifische E-Mail-Vorlage) * @throws \RuntimeException */ - public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients): void + public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients, ?array $customer = null): void { $mailer = $this->buildMailer($settings); @@ -28,8 +29,8 @@ class EmailService . "Mit freundlichen Grüßen\n" . "%INHABER%"; - $subjectTpl = ($settings['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%'; - $bodyTpl = ($settings['mail_body_template'] ?? '') ?: $defaultBody; + $subjectTpl = ($customer['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%'; + $bodyTpl = ($customer['mail_body_template'] ?? '') ?: $defaultBody; $subject = $this->replacePlaceholders($subjectTpl, $invoice, $settings); $body = $this->replacePlaceholders($bodyTpl, $invoice, $settings); diff --git a/src/Views/customers/form.php b/src/Views/customers/form.php index 634ad6c..b0d664f 100644 --- a/src/Views/customers/form.php +++ b/src/Views/customers/form.php @@ -82,6 +82,32 @@
+
+
+
E-Mail-Vorlage für Rechnungsversand
+
+
+

+ Platzhalter: %RNR% = Rechnungsnummer  ·  + %FNAME% = Firmenname  ·  + %INHABER% = Inhaber. + Leer lassen = Standard-Vorlage verwenden. +

+
+ + +
+
+ + +
+
+
+
Abbrechen diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 70a6df9..facb00f 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -1,10 +1,6 @@ View::h($v); ?> - -
@@ -368,7 +364,6 @@
-
@@ -482,29 +477,6 @@
- - -