Merge pull request 'Feature: Bank-Import mit konfigurierbaren Formaten, Buchungstext-Templates und Dublettenprüfung' (#75) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/75
This commit is contained in:
commit
d784e46af8
51 changed files with 2734 additions and 320 deletions
43
Changes.md
43
Changes.md
|
|
@ -2,6 +2,49 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-05-17 – Feature: Automatischer Format-Vorschlag beim Bank-Import
|
||||
|
||||
- **Format-Vorschlag**: Wenn eine hochgeladene CSV keinem bekannten Format eindeutig entspricht, analysiert `BankImportParser::suggestFormat()` die Datei automatisch (Headerzeilen-Erkennung bei skip_rows 0–10, Delimiter-Erkennung, Keyword-basiertes Spalten-Mapping)
|
||||
- **Session-gestützter Vorschlags-Flow**: Klick auf „Formate bearbeiten" speichert den Vorschlag via AJAX in der Session und öffnet die Einstellungsseite
|
||||
- **Vorschlags-Card**: Auf `/settings/bankimport` erscheint der Vorschlag oberhalb bestehender Formate als ausfüllbares Formular mit vorausgefüllten Feldern (skip_rows, Delimiter, Spalten-Mapping mit erkannten Headernamen)
|
||||
- **Explizites Speichern**: Neues Format wird erst nach Klick auf „Speichern" in der DB angelegt (POST `/settings/bankimport/create`); Verwerfen löscht die Session-Suggestion
|
||||
- Neue Routen: `POST /settings/bankimport/suggest`, `POST /settings/bankimport/create`, `POST /settings/bankimport/dismiss-suggestion`
|
||||
- Neues Model: `BankImportFormat::create()`; neuer Test 35
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-17 – Feature: Automatische Format-Erkennung beim Bank-Import
|
||||
|
||||
- **Format-Erkennung**: Hochgeladene Datei wird automatisch gegen alle gespeicherten Formate geprüft; bei eindeutigem Match wird sofort importiert
|
||||
- **Vorschau-Panel**: Wenn kein Format passt, zeigt die Upload-Seite Überschriften-Zeile, erste Werte-Zeile und die Spalten-Zuordnung (Wertstellung, Buchungstext, Wert) für das gewählte Format an; wechselt live beim Umschalten der Formatauswahl
|
||||
- **„Formate bearbeiten"-Button** oben rechts verlinkt direkt auf die Einstellungsseite für Bankimport-Formate
|
||||
- Neuer AJAX-Endpunkt `POST /bank/detect-format` in `BankController`; neue Methoden `peekRows()` und `isFormatMatch()` in `BankImportParser`
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-17 – Feature: DKB-Importformat für Bankbuchungen
|
||||
|
||||
- **Neues Format „DKB Bank"**: 4 Skip-Zeilen, Semikolon-getrennt, Anführungszeichen-eingeschlossen
|
||||
- Buchungstext wird konditionell zusammengesetzt: bei negativem Betrag aus Zahlungsempfänger + Verwendungszweck, sonst aus Zahlungspflichtiger + Verwendungszweck
|
||||
- Migration 054 ergänzt drei neue Nullable-Spalten in `bank_import_formats` (`col_zahlungspflichtiger`, `col_zahlungsempfaenger`, `col_verwendungszweck`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz
|
||||
|
||||
- **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input
|
||||
- **Path Traversal** in `BelegController::serveFile()` abgesichert (`realpath()` + Prefix-Check gegen Upload-Basisverzeichnis)
|
||||
- **`mime_content_type()`** (deprecated) durch `finfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)` in `BelegController`, `SettingsController`, `BankController` und `ImageCropService` ersetzt
|
||||
- **XSS** in `topbarActions`: `View::buildQuery()` (neuer Helper) statt rohem `http_build_query($_GET)`
|
||||
- **`@`-Unterdrückung** in `SettingsController` entfernt (ersetzt durch `file_exists()`-Prüfung)
|
||||
- **BaseController**: gemeinsame `renderView()`-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt
|
||||
- **DB-Transaktionen**: `Booking::create()` und `Booking::reverse()` laufen jetzt in atomaren Transaktionen; `Database::beginTransaction/commit/rollBack` unterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt)
|
||||
- **`src/Constants.php`**: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings in `Booking.php` ersetzt
|
||||
- **Dokumentation**: `docs/funktionen.md` – Abschnitt „Bank buchen" ergänzt; UStVA-Kennzahlen (81, 89, 66, 61, 83) korrigiert; Eigenanteil-Konten (4920/4925) korrigiert
|
||||
- **Tests**: `32_BankTransactionTest` (linkBeleg, unlinkBeleg, recalcStatus, findDuplicates) und `33_BankImportParserTest` (CSV-Parsing, BOM, Tausendertrenner, Fehlerfall) neu
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen
|
||||
|
||||
- Migration 053: Junction-Tabelle `bank_transaction_belege` (bank_tx_id, beleg_id, betrag); `bank_transactions.beleg_id` entfernt; Status-ENUM um `teilweise` erweitert; bestehende Verknüpfungen automatisch migriert
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ services:
|
|||
- ./tests:/var/www/html/tests
|
||||
- ./scripts:/var/www/html/scripts
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
DB_HOST: db
|
||||
DB_NAME: steuerkram
|
||||
DB_USER: steuerkram
|
||||
|
|
@ -29,6 +30,7 @@ services:
|
|||
db:
|
||||
image: mariadb:11
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
MARIADB_ROOT_PASSWORD: rootpass
|
||||
MARIADB_DATABASE: steuerkram
|
||||
MARIADB_USER: steuerkram
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto.
|
|||
|
||||
- **Steuerarten** für Auslandsumsätze: Inland, steuerfreie ig. Lieferung (Kz 41), steuerfreie Ausfuhr (Kz 44), ig. Erwerb 19 % (Kz 89/91), ig. Erwerb 7 % (Kz 93/94)
|
||||
- **Bewirtungsausgaben**: automatischer Split 70 %/30 % bei Konten 4650, 4651, 4652
|
||||
- **Eigenanteil** (30 %) bei Telefon/Internet (Konto 4940): optional zubuchbar
|
||||
- **Eigenanteil** (30 %) bei Telefon/Internet (Konten 4920/4925): optional zubuchbar
|
||||
- **Stornobuchungen**: GoBD-konforme Gegenbuchung statt Löschung
|
||||
- **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze
|
||||
- **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export
|
||||
|
|
@ -62,9 +62,24 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
|
|||
|
||||
---
|
||||
|
||||
## Bank buchen
|
||||
|
||||
- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank)
|
||||
- **Unterstützte Importformate**: Comdirect, DKB Bank
|
||||
- **Automatische Format-Erkennung**: beim Datei-Upload wird das Format anhand von Datumsfeldern und Betragsspalten automatisch erkannt; bei eindeutigem Treffer wird der Import direkt gestartet
|
||||
- **Format-Vorschau**: bei unbekanntem Format zeigt die Upload-Seite Überschriften- und erste Werte-Zeile sowie die konkrete Spalten-Zuordnung für das gewählte Format an (wechselt live)
|
||||
- **Automatischer Format-Vorschlag**: wenn kein gespeichertes Format passt, analysiert das System die CSV automatisch (Headerzeilen-Erkennung, Keyword-Matching); über „Formate bearbeiten" öffnet sich die Einstellungsseite mit einem vorausgefüllten Vorschlags-Formular – das neue Format wird erst nach explizitem Klick auf „Speichern" angelegt
|
||||
- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert
|
||||
- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst
|
||||
- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden
|
||||
- **Status-Logik**: `offen` → keine Zuordnung, `teilweise` → Restsaldo > 0,01 €, `gebucht` → vollständig zugeordnet
|
||||
- Direkter Beleg-Upload aus der Transaktionsansicht heraus (erzeugt neuen Beleg und verknüpft ihn)
|
||||
|
||||
---
|
||||
|
||||
## UStVA (Umsatzsteuervoranmeldung)
|
||||
|
||||
- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 83, 86, 84, 35, 41, 44, 61, 62, 67, 66, 69)
|
||||
- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 89, 66, 61, 83)
|
||||
- ELSTER XML-Export
|
||||
- Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen
|
||||
|
||||
|
|
|
|||
20
migrations/054_bank_import_format_dkb.sql
Normal file
20
migrations/054_bank_import_format_dkb.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- DKB-Importformat: drei neue Spalten für konditionelle Buchungstext-Zusammensetzung
|
||||
ALTER TABLE bank_import_formats
|
||||
ADD COLUMN IF NOT EXISTS col_zahlungspflichtiger INT NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS col_zahlungsempfaenger INT NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS col_verwendungszweck INT NULL DEFAULT NULL;
|
||||
|
||||
-- DKB Bank: 4 Skip-Zeilen, dann Headerzeile
|
||||
-- "Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";
|
||||
-- "Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"
|
||||
INSERT INTO bank_import_formats
|
||||
(name, skip_rows, has_header_row, delimiter, enclosure,
|
||||
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
|
||||
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
|
||||
date_format, decimal_separator, thousands_separator)
|
||||
VALUES
|
||||
('DKB Bank', 4, 1, ';', '"',
|
||||
0, 1, 6, 5, 8,
|
||||
3, 4, 5,
|
||||
'd.m.Y', ',', '.')
|
||||
ON DUPLICATE KEY UPDATE name = name;
|
||||
13
migrations/055_buchungstext_template.sql
Normal file
13
migrations/055_buchungstext_template.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Buchungstext-Templates: konfigurierbare Zusammensetzung per {SpaltenüberschriftName}
|
||||
ALTER TABLE bank_import_formats
|
||||
ADD COLUMN IF NOT EXISTS buchungstext_template_debit VARCHAR(500) NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS buchungstext_template_credit VARCHAR(500) NULL DEFAULT NULL;
|
||||
|
||||
-- DKB Bank auf Templates umstellen; DKB-spezifische Spaltenindices werden nicht mehr benötigt
|
||||
UPDATE bank_import_formats SET
|
||||
buchungstext_template_debit = 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}',
|
||||
buchungstext_template_credit = 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}',
|
||||
col_zahlungspflichtiger = NULL,
|
||||
col_zahlungsempfaenger = NULL,
|
||||
col_verwendungszweck = NULL
|
||||
WHERE name = 'DKB Bank';
|
||||
5
migrations/056_comdirect_buchungstext_template.sql
Normal file
5
migrations/056_comdirect_buchungstext_template.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Comdirect: Buchungstext-Templates setzen (CSV-Spalte via Platzhalter {Buchungstext})
|
||||
UPDATE bank_import_formats SET
|
||||
buchungstext_template_debit = '{Buchungstext}',
|
||||
buchungstext_template_credit = '{Buchungstext}'
|
||||
WHERE name = 'Comdirect';
|
||||
5
migrations/057_bank_format_kuerzel.sql
Normal file
5
migrations/057_bank_format_kuerzel.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE bank_import_formats
|
||||
ADD COLUMN IF NOT EXISTS kuerzel VARCHAR(10) NOT NULL DEFAULT '' AFTER name;
|
||||
|
||||
UPDATE bank_import_formats SET kuerzel = 'C' WHERE name = 'Comdirect';
|
||||
UPDATE bank_import_formats SET kuerzel = 'DKB' WHERE name = 'DKB Bank';
|
||||
13
migrations/058_fix_dkb_date_format.sql
Normal file
13
migrations/058_fix_dkb_date_format.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- DKB exportiert Datumsfelder zweistellig (dd.mm.yy), nicht vierstellig
|
||||
UPDATE bank_import_formats
|
||||
SET date_format = 'd.m.y'
|
||||
WHERE name = 'DKB Bank';
|
||||
|
||||
-- Bereits importierte DKB-Buchungen mit Jahr 0026 auf 2026 korrigieren
|
||||
UPDATE bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bib.id = bt.batch_id
|
||||
JOIN bank_import_formats bif ON bif.id = bib.format_id
|
||||
SET bt.wertstellung = DATE_ADD(bt.wertstellung, INTERVAL 2000 YEAR),
|
||||
bt.buchungstag = DATE_ADD(bt.buchungstag, INTERVAL 2000 YEAR)
|
||||
WHERE bif.name = 'DKB Bank'
|
||||
AND YEAR(bt.wertstellung) < 100;
|
||||
5
migrations/059_bank_format_farbe.sql
Normal file
5
migrations/059_bank_format_farbe.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE bank_import_formats
|
||||
ADD COLUMN IF NOT EXISTS farbe VARCHAR(7) NOT NULL DEFAULT '#6366f1' AFTER kuerzel;
|
||||
|
||||
UPDATE bank_import_formats SET farbe = '#f59e0b' WHERE name = 'Comdirect';
|
||||
UPDATE bank_import_formats SET farbe = '#dc2626' WHERE name = 'DKB Bank';
|
||||
9
migrations/060_fix_comdirect_skip_rows.sql
Normal file
9
migrations/060_fix_comdirect_skip_rows.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Comdirect CSV hat eine Leerzeile zwischen Metadaten und Headerzeile.
|
||||
-- Mit skip_rows=3 wurde die Leerzeile als Header gelesen → headerMap leer
|
||||
-- → Template {Buchungstext} konnte nicht auflösen → leerer buchungstext.
|
||||
-- Fix: skip_rows auf 4 setzen und Templates entfernen (col_buchungstext=3 genügt direkt).
|
||||
UPDATE bank_import_formats
|
||||
SET skip_rows = 4,
|
||||
buchungstext_template_debit = NULL,
|
||||
buchungstext_template_credit = NULL
|
||||
WHERE name = 'Comdirect';
|
||||
7
migrations/061_bank_format_nullable.sql
Normal file
7
migrations/061_bank_format_nullable.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- format_id in bank_import_batches nullable machen.
|
||||
-- Beim Löschen eines Formats werden vorhandene Batches behalten; format_id wird NULL gesetzt.
|
||||
ALTER TABLE bank_import_batches DROP FOREIGN KEY bank_import_batches_ibfk_1;
|
||||
ALTER TABLE bank_import_batches MODIFY format_id INT UNSIGNED NULL;
|
||||
ALTER TABLE bank_import_batches
|
||||
ADD CONSTRAINT fk_bib_format_id
|
||||
FOREIGN KEY (format_id) REFERENCES bank_import_formats(id) ON DELETE SET NULL;
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
upload_max_filesize = 20M
|
||||
post_max_size = 20M
|
||||
date.timezone = Europe/Berlin
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController(
|
|||
$router->get('/bank', fn($p) => (new BankController())->index($p));
|
||||
$router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p));
|
||||
$router->post('/bank/upload', fn($p) => (new BankController())->upload($p));
|
||||
$router->post('/bank/detect-format', fn($p) => (new BankController())->detectFormat($p));
|
||||
$router->post('/bank/upload/confirm', fn($p) => (new BankController())->uploadConfirm($p));
|
||||
$router->get('/bank/{id}', fn($p) => (new BankController())->show($p));
|
||||
$router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($p));
|
||||
|
|
@ -150,6 +151,12 @@ $router->post('/settings/mail-template', fn($p) => (new SettingsController())->m
|
|||
$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));
|
||||
$router->get('/settings/bankimport', fn($p) => (new SettingsController())->bankImport($p));
|
||||
$router->post('/settings/bankimport/suggest', fn($p) => (new SettingsController())->storeSuggestion($p));
|
||||
$router->post('/settings/bankimport/create', fn($p) => (new SettingsController())->createBankImportFormat($p));
|
||||
$router->post('/settings/bankimport/dismiss-suggestion', fn($p) => (new SettingsController())->dismissSuggestion($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));
|
||||
|
||||
// ── Login / Logout ────────────────────────────────────────────
|
||||
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
|
||||
|
|
|
|||
|
|
@ -316,6 +316,17 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
|||
.badge-gray { background: #f1f5f9; color: #475569; }
|
||||
.badge-purple { background: #ede9fe; color: #6d28d9; }
|
||||
|
||||
.kuerzel-badge {
|
||||
display: inline-block;
|
||||
padding: .15rem .6rem;
|
||||
border-radius: 999px;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Toolbar / Filter bar ────────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
|
|
|
|||
23
src/Auth.php
23
src/Auth.php
|
|
@ -52,4 +52,27 @@ class Auth
|
|||
{
|
||||
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||
}
|
||||
|
||||
public static function generateCsrfToken(): string
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
public static function validateCsrfToken(string $token): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$stored = $_SESSION['csrf_token'] ?? '';
|
||||
if ($stored === '' || !hash_equals($stored, $token)) {
|
||||
http_response_code(403);
|
||||
exit('CSRF-Token ungültig.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/Constants.php
Normal file
22
src/Constants.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram;
|
||||
|
||||
class Constants
|
||||
{
|
||||
// Buchungstypen
|
||||
const BUCHUNGSTYP_EINNAHME = 'Einnahme';
|
||||
const BUCHUNGSTYP_AUSGABE = 'Ausgabe';
|
||||
|
||||
// Vorsteuer-Konten (SKR03)
|
||||
const KONTO_VST_19 = '1576';
|
||||
const KONTO_VST_7 = '1571';
|
||||
const KONTO_VST_IGE_19 = '1574'; // Vorsteuer ig. Erwerb 19 %
|
||||
|
||||
// Umsatzsteuer-Konten (SKR03)
|
||||
const KONTO_UST_19 = '1776';
|
||||
const KONTO_UST_7 = '1771';
|
||||
const KONTO_UST_IGE_19 = '1774'; // USt ig. Erwerb 19 %
|
||||
}
|
||||
|
|
@ -9,11 +9,15 @@ use Steuerkram\Models\Booking;
|
|||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\View;
|
||||
|
||||
class AccountController
|
||||
class AccountController extends BaseController
|
||||
{
|
||||
public function dashboard(array $params): void
|
||||
{
|
||||
$year = (int)date('Y');
|
||||
$month = (int)date('m');
|
||||
$prevMonth = $month === 1 ? 12 : $month - 1;
|
||||
$prevYear = $month === 1 ? $year - 1 : $year;
|
||||
$monthNames = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
|
||||
// Quick stats
|
||||
$stats = [];
|
||||
|
|
@ -28,6 +32,20 @@ class AccountController
|
|||
);
|
||||
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
|
||||
|
||||
$r = \Steuerkram\Database::fetchOne(
|
||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
||||
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
||||
[$year, $month]
|
||||
);
|
||||
$stats['umsatz_monat'] = (float)($r['total'] ?? 0);
|
||||
|
||||
$r = \Steuerkram\Database::fetchOne(
|
||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
||||
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
||||
[$prevYear, $prevMonth]
|
||||
);
|
||||
$stats['umsatz_vormonat'] = (float)($r['total'] ?? 0);
|
||||
|
||||
// Ausgaben
|
||||
$ausgaben = \Steuerkram\Database::fetchOne(
|
||||
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
|
||||
|
|
@ -37,8 +55,27 @@ class AccountController
|
|||
[$year]
|
||||
);
|
||||
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
|
||||
|
||||
$r = \Steuerkram\Database::fetchOne(
|
||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
||||
WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
||||
[$year, $month]
|
||||
);
|
||||
$stats['ausgaben_monat'] = (float)($r['total'] ?? 0);
|
||||
|
||||
$r = \Steuerkram\Database::fetchOne(
|
||||
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
|
||||
WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
|
||||
[$prevYear, $prevMonth]
|
||||
);
|
||||
$stats['ausgaben_vormonat'] = (float)($r['total'] ?? 0);
|
||||
|
||||
$stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben'];
|
||||
$stats['gewinn_monat'] = $stats['umsatz_monat'] - $stats['ausgaben_monat'];
|
||||
$stats['gewinn_vormonat'] = $stats['umsatz_vormonat'] - $stats['ausgaben_vormonat'];
|
||||
$stats['year'] = $year;
|
||||
$stats['month_label'] = $monthNames[$month - 1];
|
||||
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
|
||||
|
||||
// Offene Rechnungen
|
||||
$offen = \Steuerkram\Database::fetchOne(
|
||||
|
|
@ -268,12 +305,5 @@ class AccountController
|
|||
if (empty($data['kontenart'])) $errors['kontenart'] = 'Pflichtfeld';
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use Steuerkram\Models\Asset;
|
|||
use Steuerkram\Models\AssetDepreciation;
|
||||
use Steuerkram\View;
|
||||
|
||||
class AssetController
|
||||
class AssetController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -241,12 +241,5 @@ class AssetController
|
|||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use Steuerkram\Models\Invoice;
|
|||
use Steuerkram\Services\BankImportParser;
|
||||
use Steuerkram\View;
|
||||
|
||||
class BankController
|
||||
class BankController extends BaseController
|
||||
{
|
||||
private const ALLOWED_EXT = ['csv', 'txt', 'xlsx'];
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ class BankController
|
|||
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$result = BankTransaction::all($filter, $page);
|
||||
$formats = BankImportFormat::all();
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -36,6 +37,7 @@ class BankController
|
|||
'pages' => $result['pages'],
|
||||
'page' => $page,
|
||||
'filter' => $filter,
|
||||
'formats' => $formats,
|
||||
]),
|
||||
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
|
||||
]);
|
||||
|
|
@ -47,6 +49,7 @@ class BankController
|
|||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Bank-Buchungen importieren',
|
||||
'topbarActions' => '<a href="/settings/bankimport" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
|
||||
'content' => $this->renderView('bank/upload', [
|
||||
'formats' => $formats,
|
||||
'errors' => [],
|
||||
|
|
@ -54,6 +57,62 @@ class BankController
|
|||
]);
|
||||
}
|
||||
|
||||
public function detectFormat(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$file = $_FILES['bank_file'] ?? null;
|
||||
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['error' => 'Upload fehlgeschlagen.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$origName = $file['name'] ?? 'upload';
|
||||
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($ext, self::ALLOWED_EXT, true)) {
|
||||
echo json_encode(['error' => 'Nicht erlaubtes Dateiformat.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$formats = BankImportFormat::all();
|
||||
$parser = new BankImportParser();
|
||||
|
||||
$formatsData = [];
|
||||
$matchedIds = [];
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$id = (int)$format['id'];
|
||||
$peek = $parser->peekRows($file['tmp_name'], $ext, $format);
|
||||
$isMatch = $parser->isFormatMatch($file['tmp_name'], $ext, $format);
|
||||
|
||||
if ($isMatch) {
|
||||
$matchedIds[] = $id;
|
||||
}
|
||||
|
||||
$formatsData[$id] = [
|
||||
'format' => $format,
|
||||
'header_row' => $peek['header'],
|
||||
'first_data' => $peek['first_data'],
|
||||
'is_match' => $isMatch,
|
||||
];
|
||||
}
|
||||
|
||||
$matchedFormatId = count($matchedIds) === 1 ? $matchedIds[0] : null;
|
||||
|
||||
$suggestedFormat = null;
|
||||
if ($matchedFormatId === null) {
|
||||
$suggestedFormat = $parser->suggestFormat($file['tmp_name'], $ext);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'matched_format_id' => $matchedFormatId,
|
||||
'formats_data' => $formatsData,
|
||||
'filename' => $origName,
|
||||
'suggested_format' => $suggestedFormat,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public function upload(array $params): void
|
||||
{
|
||||
$formatId = (int)($_POST['format_id'] ?? 0);
|
||||
|
|
@ -273,7 +332,9 @@ class BankController
|
|||
'text/xml' => 'xml',
|
||||
];
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)(finfo_file($finfo, $file['tmp_name']) ?: '');
|
||||
finfo_close($finfo);
|
||||
if (!isset($allowedMime[$mime])) {
|
||||
View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error');
|
||||
}
|
||||
|
|
@ -303,12 +364,4 @@ class BankController
|
|||
View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.');
|
||||
}
|
||||
|
||||
private function renderView(string $name, array $data = []): string
|
||||
{
|
||||
$viewFile = dirname(__DIR__) . '/Views/' . $name . '.php';
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include $viewFile;
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
src/Controllers/BaseController.php
Normal file
16
src/Controllers/BaseController.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
class BaseController
|
||||
{
|
||||
protected function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ use Steuerkram\Services\ImageCropService;
|
|||
use Steuerkram\Models\BankTransaction;
|
||||
use Steuerkram\Services\KontierungsvorschlagService;
|
||||
|
||||
class BelegController
|
||||
class BelegController extends BaseController
|
||||
{
|
||||
private const ALLOWED_MIME = [
|
||||
'application/pdf' => 'pdf',
|
||||
|
|
@ -45,7 +45,7 @@ class BelegController
|
|||
'page' => $page,
|
||||
'filter' => $filter,
|
||||
]),
|
||||
'topbarActions' => '<a href="/belege/export?' . http_build_query($filter) . '" class="btn btn-secondary">CSV Export</a> <a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>',
|
||||
'topbarActions' => '<a href="/belege/export?' . View::buildQuery($filter) . '" class="btn btn-secondary">CSV Export</a> <a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +114,10 @@ class BelegController
|
|||
$errors['file'] = 'Keine gültige Datei übermittelt.';
|
||||
} else {
|
||||
$file = $_FILES['beleg_file'];
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
$mimeKey = strtolower($mime);
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
$mimeKey = strtolower((string)$mime);
|
||||
|
||||
if (!isset(self::ALLOWED_MIME[$mimeKey])) {
|
||||
$errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.';
|
||||
|
|
@ -141,7 +143,9 @@ class BelegController
|
|||
}
|
||||
|
||||
$file = $_FILES['beleg_file'];
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
$finfo2 = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)finfo_file($finfo2, $file['tmp_name']);
|
||||
finfo_close($finfo2);
|
||||
$ext = self::ALLOWED_MIME[strtolower($mime)];
|
||||
$belegNr = Beleg::generateBelegNr();
|
||||
$year = !empty($_POST['beleg_datum'])
|
||||
|
|
@ -300,13 +304,20 @@ class BelegController
|
|||
exit;
|
||||
}
|
||||
|
||||
$basePath = (string)realpath(dirname(__DIR__, 2) . '/public/static/uploads');
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
if (!file_exists($absPath)) { http_response_code(404); return; }
|
||||
$realPath = realpath($absPath);
|
||||
|
||||
if ($realPath === false || !str_starts_with($realPath, $basePath)) {
|
||||
http_response_code(403);
|
||||
return;
|
||||
}
|
||||
if (!file_exists($realPath)) { http_response_code(404); return; }
|
||||
|
||||
header('Content-Type: ' . $beleg['mime_type']);
|
||||
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
|
||||
header('Content-Length: ' . filesize($absPath));
|
||||
readfile($absPath);
|
||||
header('Content-Length: ' . filesize($realPath));
|
||||
readfile($realPath);
|
||||
}
|
||||
|
||||
public function updateMeta(array $params): void
|
||||
|
|
@ -440,12 +451,4 @@ class BelegController
|
|||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use Steuerkram\Models\FiscalYear;
|
|||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\View;
|
||||
|
||||
class BookingController
|
||||
class BookingController extends BaseController
|
||||
{
|
||||
public function journal(array $params): void
|
||||
{
|
||||
|
|
@ -324,12 +324,5 @@ class BookingController
|
|||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
|
|||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\View;
|
||||
|
||||
class CustomerController
|
||||
class CustomerController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -137,12 +137,5 @@ class CustomerController
|
|||
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use Steuerkram\Services\EuerCalculator;
|
|||
use Steuerkram\Services\PdfEuerService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class EuerController
|
||||
class EuerController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -70,12 +70,5 @@ class EuerController
|
|||
$svc = new PdfEuerService();
|
||||
$svc->render($euer, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use Steuerkram\Services\EmailService;
|
|||
use Steuerkram\Services\PdfInvoiceService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class InvoiceController
|
||||
class InvoiceController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -581,12 +581,5 @@ class InvoiceController
|
|||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Models\BankImportFormat;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\EmailService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class SettingsController
|
||||
class SettingsController extends BaseController
|
||||
{
|
||||
public function show(array $params): void
|
||||
{
|
||||
$settings = Setting::get();
|
||||
$version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: '');
|
||||
$versionFile = dirname(__DIR__) . '/version.txt';
|
||||
$version = file_exists($versionFile) ? trim(file_get_contents($versionFile)) : '';
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -38,7 +40,8 @@ class SettingsController
|
|||
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
|
||||
|
||||
if ($errors) {
|
||||
$version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: '');
|
||||
$vf = dirname(__DIR__) . '/version.txt';
|
||||
$version = file_exists($vf) ? trim(file_get_contents($vf)) : '';
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Einstellungen',
|
||||
|
|
@ -63,7 +66,9 @@ class SettingsController
|
|||
}
|
||||
|
||||
$file = $_FILES['logo'];
|
||||
$mime = mime_content_type($file['tmp_name']);
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg'])) {
|
||||
View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error');
|
||||
|
|
@ -134,11 +139,250 @@ class SettingsController
|
|||
View::redirect('/settings', 'Logo gelöscht.');
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
public function bankImport(array $params): void
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
$formats = BankImportFormat::all();
|
||||
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Bankimport-Formate',
|
||||
'content' => $this->renderView('settings/bankimport', [
|
||||
'formats' => $formats,
|
||||
'suggestion' => $suggestion,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteBankImportFormat(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$fmt = BankImportFormat::find($id);
|
||||
if (!$fmt) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Format nicht gefunden.']);
|
||||
return;
|
||||
}
|
||||
|
||||
BankImportFormat::delete($id);
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
public function storeSuggestion(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (is_array($data)) {
|
||||
$_SESSION['bank_import_suggestion'] = $data;
|
||||
echo json_encode(['ok' => true]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Keine Daten.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function dismissSuggestion(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
unset($_SESSION['bank_import_suggestion']);
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
public function createBankImportFormat(array $params): void
|
||||
{
|
||||
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
|
||||
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$kuerzel = trim($_POST['kuerzel'] ?? '');
|
||||
$farbe = $_POST['farbe'] ?? '#6366f1';
|
||||
$skipRows = (int)($_POST['skip_rows'] ?? 0);
|
||||
$hasHeader = in_array($_POST['has_header_row'] ?? '1', ['0', '1'], true)
|
||||
? (int)$_POST['has_header_row'] : 1;
|
||||
$delimiter = $_POST['delimiter'] ?? ';';
|
||||
$enclosure = $_POST['enclosure'] ?? '"';
|
||||
$dateFmt = $_POST['date_format'] ?? 'd.m.Y';
|
||||
$decSep = $_POST['decimal_separator'] ?? ',';
|
||||
$thouSep = $_POST['thousands_separator'] ?? '.';
|
||||
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 100) {
|
||||
$errors[] = 'Name darf nicht leer sein (max. 100 Zeichen).';
|
||||
}
|
||||
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
|
||||
$errors[] = 'Kürzel darf nicht leer sein (max. 10 Zeichen).';
|
||||
}
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $farbe)) {
|
||||
$farbe = '#6366f1';
|
||||
}
|
||||
if ($skipRows < 0 || $skipRows > 99) {
|
||||
$skipRows = 0;
|
||||
}
|
||||
if (mb_strlen($delimiter) !== 1) {
|
||||
$errors[] = 'Trennzeichen muss genau ein Zeichen sein.';
|
||||
}
|
||||
if (mb_strlen($enclosure) !== 1) {
|
||||
$errors[] = 'Anführungszeichen muss genau ein Zeichen sein.';
|
||||
}
|
||||
if (!in_array($dateFmt, $dateFormats, true)) {
|
||||
$dateFmt = 'd.m.Y';
|
||||
}
|
||||
if (mb_strlen($decSep) !== 1) {
|
||||
$errors[] = 'Dezimaltrennzeichen muss genau ein Zeichen sein.';
|
||||
}
|
||||
if (mb_strlen($thouSep) > 1) {
|
||||
$thouSep = '';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
// Bei Validierungsfehler zurück mit Fehlermeldung
|
||||
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
|
||||
$formats = BankImportFormat::all();
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Bankimport-Formate',
|
||||
'content' => $this->renderView('settings/bankimport', [
|
||||
'formats' => $formats,
|
||||
'suggestion' => $suggestion,
|
||||
'errors' => $errors,
|
||||
]),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tplDebit = mb_substr(trim($_POST['buchungstext_template_debit'] ?? ''), 0, 500);
|
||||
$tplCredit = mb_substr(trim($_POST['buchungstext_template_credit'] ?? ''), 0, 500);
|
||||
|
||||
$nullInt = function (string $key): ?int {
|
||||
$v = $_POST[$key] ?? '';
|
||||
if ($v === '') return null;
|
||||
return (int)$v;
|
||||
};
|
||||
$intCol = function (string $key, int $default = 0): int {
|
||||
$v = $_POST[$key] ?? '';
|
||||
return $v !== '' ? (int)$v : $default;
|
||||
};
|
||||
|
||||
$id = BankImportFormat::create([
|
||||
'name' => $name,
|
||||
'kuerzel' => $kuerzel,
|
||||
'farbe' => $farbe,
|
||||
'skip_rows' => $skipRows,
|
||||
'has_header_row' => $hasHeader,
|
||||
'delimiter' => $delimiter,
|
||||
'enclosure' => $enclosure,
|
||||
'date_format' => $dateFmt,
|
||||
'decimal_separator' => $decSep,
|
||||
'thousands_separator' => $thouSep,
|
||||
'col_buchungstag' => $intCol('col_buchungstag', 0),
|
||||
'col_wertstellung' => $intCol('col_wertstellung', 1),
|
||||
'col_vorgang' => $intCol('col_vorgang', 2),
|
||||
'col_buchungstext' => 0,
|
||||
'col_wert' => $intCol('col_wert', 4),
|
||||
'col_zahlungspflichtiger' => $nullInt('col_zahlungspflichtiger'),
|
||||
'col_zahlungsempfaenger' => $nullInt('col_zahlungsempfaenger'),
|
||||
'col_verwendungszweck' => $nullInt('col_verwendungszweck'),
|
||||
'buchungstext_template_debit' => $tplDebit !== '' ? $tplDebit : null,
|
||||
'buchungstext_template_credit' => $tplCredit !== '' ? $tplCredit : null,
|
||||
]);
|
||||
|
||||
unset($_SESSION['bank_import_suggestion']);
|
||||
|
||||
header('Location: /settings/bankimport#format-' . $id);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function updateBankImportField(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$fmt = BankImportFormat::find($id);
|
||||
if (!$fmt) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Format nicht gefunden.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$field = $_POST['field'] ?? '';
|
||||
$raw = $_POST['value'] ?? '';
|
||||
|
||||
$intCols = ['col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert'];
|
||||
$nullIntCols = ['col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck'];
|
||||
$charCols = ['delimiter', 'enclosure', 'decimal_separator'];
|
||||
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
|
||||
$tplCols = ['buchungstext_template_debit', 'buchungstext_template_credit'];
|
||||
|
||||
[$ok, $value, $err] = match (true) {
|
||||
$field === 'name'
|
||||
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 100)
|
||||
? [true, trim($raw), '']
|
||||
: [false, null, 'Name darf nicht leer sein (max. 100 Zeichen).'],
|
||||
|
||||
$field === 'kuerzel'
|
||||
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 10)
|
||||
? [true, trim($raw), '']
|
||||
: [false, null, 'Kürzel darf nicht leer sein (max. 10 Zeichen).'],
|
||||
|
||||
$field === 'farbe'
|
||||
=> (bool)preg_match('/^#[0-9a-fA-F]{6}$/', $raw)
|
||||
? [true, $raw, '']
|
||||
: [false, null, 'Ungültige Farbe (Format: #RRGGBB).'],
|
||||
|
||||
$field === 'skip_rows'
|
||||
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
|
||||
? [true, (int)$raw, '']
|
||||
: [false, null, 'Übersprungene Zeilen: ganze Zahl 0–99 erforderlich.'],
|
||||
|
||||
$field === 'has_header_row'
|
||||
=> in_array($raw, ['0', '1'], true)
|
||||
? [true, (int)$raw, '']
|
||||
: [false, null, 'Ungültiger Wert.'],
|
||||
|
||||
in_array($field, $charCols, true)
|
||||
=> (mb_strlen($raw) === 1)
|
||||
? [true, $raw, '']
|
||||
: [false, null, 'Genau ein Zeichen erforderlich.'],
|
||||
|
||||
$field === 'thousands_separator'
|
||||
=> (mb_strlen($raw) <= 1)
|
||||
? [true, $raw, '']
|
||||
: [false, null, 'Maximal ein Zeichen.'],
|
||||
|
||||
in_array($field, $intCols, true)
|
||||
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
|
||||
? [true, (int)$raw, '']
|
||||
: [false, null, 'Gültige Spaltennummer (0–99) erforderlich.'],
|
||||
|
||||
in_array($field, $nullIntCols, true)
|
||||
=> ($raw === '' || $raw === null)
|
||||
? [true, null, '']
|
||||
: (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99
|
||||
? [true, (int)$raw, '']
|
||||
: [false, null, 'Gültige Spaltennummer (0–99) oder leer für nicht gesetzt.']),
|
||||
|
||||
$field === 'date_format'
|
||||
=> in_array($raw, $dateFormats, true)
|
||||
? [true, $raw, '']
|
||||
: [false, null, 'Unbekanntes Datumsformat.'],
|
||||
|
||||
in_array($field, $tplCols, true)
|
||||
=> (mb_strlen($raw) <= 500)
|
||||
? [true, $raw === '' ? null : $raw, '']
|
||||
: [false, null, 'Template zu lang (max. 500 Zeichen).'],
|
||||
|
||||
default => [false, null, 'Unbekanntes Feld.'],
|
||||
};
|
||||
|
||||
if (!$ok) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['error' => $err]);
|
||||
return;
|
||||
}
|
||||
|
||||
BankImportFormat::updateField($id, $field, $value);
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
|
|||
use Steuerkram\Models\Supplier;
|
||||
use Steuerkram\View;
|
||||
|
||||
class SupplierController
|
||||
class SupplierController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -133,12 +133,5 @@ class SupplierController
|
|||
header('Content-Type: application/json');
|
||||
echo json_encode(array_map(fn($s) => ['id' => $s['id'], 'name' => $s['name']], $suppliers));
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
|
|||
use Steuerkram\Models\User;
|
||||
use Steuerkram\View;
|
||||
|
||||
class UserAdminController
|
||||
class UserAdminController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -140,12 +140,5 @@ class UserAdminController
|
|||
$msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.';
|
||||
View::redirect('/useradmin', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use Steuerkram\Services\UstvaCalculator;
|
|||
use Steuerkram\Services\ElsterXmlService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class UstvaController
|
||||
class UstvaController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -148,12 +148,5 @@ class UstvaController
|
|||
header('Content-Length: ' . strlen($xml));
|
||||
echo $xml;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderView(string $template, array $data): string
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use PDOException;
|
|||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
private static int $txDepth = 0;
|
||||
|
||||
public static function get(): PDO
|
||||
{
|
||||
|
|
@ -62,16 +63,27 @@ class Database
|
|||
|
||||
public static function beginTransaction(): void
|
||||
{
|
||||
if (self::$txDepth === 0) {
|
||||
self::get()->beginTransaction();
|
||||
}
|
||||
self::$txDepth++;
|
||||
}
|
||||
|
||||
public static function commit(): void
|
||||
{
|
||||
if (self::$txDepth > 0) {
|
||||
self::$txDepth--;
|
||||
}
|
||||
if (self::$txDepth === 0 && self::get()->inTransaction()) {
|
||||
self::get()->commit();
|
||||
}
|
||||
}
|
||||
|
||||
public static function rollBack(): void
|
||||
{
|
||||
self::$txDepth = 0;
|
||||
if (self::get()->inTransaction()) {
|
||||
self::get()->rollBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,53 @@ class BankImportFormat
|
|||
$row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function updateField(int $id, string $field, mixed $value): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE bank_import_formats SET `{$field}` = ? WHERE id = ?",
|
||||
[$value, $id]
|
||||
);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
Database::execute('DELETE FROM bank_import_formats WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public static function create(array $data): int
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_import_formats
|
||||
(name, kuerzel, farbe, skip_rows, has_header_row, delimiter, enclosure,
|
||||
date_format, decimal_separator, thousands_separator,
|
||||
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
|
||||
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
|
||||
buchungstext_template_debit, buchungstext_template_credit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$data['name'],
|
||||
$data['kuerzel'],
|
||||
$data['farbe'],
|
||||
$data['skip_rows'],
|
||||
$data['has_header_row'],
|
||||
$data['delimiter'],
|
||||
$data['enclosure'],
|
||||
$data['date_format'],
|
||||
$data['decimal_separator'],
|
||||
$data['thousands_separator'],
|
||||
$data['col_buchungstag'],
|
||||
$data['col_wertstellung'],
|
||||
$data['col_vorgang'],
|
||||
$data['col_buchungstext'],
|
||||
$data['col_wert'],
|
||||
$data['col_zahlungspflichtiger'],
|
||||
$data['col_zahlungsempfaenger'],
|
||||
$data['col_verwendungszweck'],
|
||||
$data['buchungstext_template_debit'] ?? null,
|
||||
$data['buchungstext_template_credit'] ?? null,
|
||||
]
|
||||
);
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,22 +42,39 @@ class BankTransaction
|
|||
$params[] = $s;
|
||||
$params[] = $s;
|
||||
}
|
||||
if (!empty($filter['format_id'])) {
|
||||
$where[] = 'bib.format_id = ?';
|
||||
$params[] = (int)$filter['format_id'];
|
||||
}
|
||||
|
||||
$sortMap = [
|
||||
'wertstellung' => 'bt.wertstellung',
|
||||
'buchungstag' => 'bt.buchungstag',
|
||||
'betrag' => 'bt.wert',
|
||||
'buchungstext' => 'bt.buchungstext',
|
||||
'status' => 'bt.status',
|
||||
];
|
||||
$sortCol = $sortMap[$filter['sort'] ?? ''] ?? 'bt.wertstellung';
|
||||
$sortDir = ($filter['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
$whereStr = implode(' AND ', $where);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$total = (int)(Database::fetchOne(
|
||||
"SELECT COUNT(*) AS n FROM bank_transactions bt WHERE $whereStr",
|
||||
"SELECT COUNT(*) AS n
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
WHERE $whereStr",
|
||||
$params
|
||||
)['n'] ?? 0);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name,
|
||||
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe,
|
||||
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
|
||||
b.beleg_nr
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||
LEFT JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||
LEFT JOIN (
|
||||
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
|
||||
FROM bank_transaction_belege
|
||||
|
|
@ -65,7 +82,7 @@ class BankTransaction
|
|||
) btb_agg ON btb_agg.bank_tx_id = bt.id
|
||||
LEFT JOIN belege b ON b.id = btb_agg.beleg_id
|
||||
WHERE $whereStr
|
||||
ORDER BY bt.wertstellung DESC, bt.id DESC
|
||||
ORDER BY $sortCol $sortDir, bt.wertstellung $sortDir, bt.buchungstag $sortDir, bt.id $sortDir
|
||||
LIMIT ? OFFSET ?",
|
||||
array_merge($params, [$perPage, $offset])
|
||||
);
|
||||
|
|
@ -80,7 +97,7 @@ class BankTransaction
|
|||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||
|
|
@ -184,10 +201,11 @@ class BankTransaction
|
|||
public static function findByBelegId(int $belegId): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bt.*, bib.filename AS batch_filename, btb.betrag AS alloc_betrag
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bif.kuerzel, bif.farbe, btb.betrag AS alloc_betrag
|
||||
FROM bank_transaction_belege btb
|
||||
JOIN bank_transactions bt ON bt.id = btb.bank_tx_id
|
||||
JOIN bank_import_batches bib ON bib.id = bt.batch_id
|
||||
JOIN bank_import_formats bif ON bif.id = bib.format_id
|
||||
WHERE btb.beleg_id = ?
|
||||
LIMIT 1',
|
||||
[$belegId]
|
||||
|
|
@ -213,7 +231,7 @@ class BankTransaction
|
|||
}
|
||||
|
||||
$existing = Database::fetchAll(
|
||||
"SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert, LEFT(buchungstext,200) AS btext
|
||||
"SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert
|
||||
FROM bank_transactions
|
||||
WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)",
|
||||
$params
|
||||
|
|
@ -221,7 +239,7 @@ class BankTransaction
|
|||
|
||||
$lookup = [];
|
||||
foreach ($existing as $e) {
|
||||
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
|
||||
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert']] = true;
|
||||
}
|
||||
|
||||
$new = [];
|
||||
|
|
@ -229,8 +247,7 @@ class BankTransaction
|
|||
foreach ($rows as $row) {
|
||||
$key = $row['buchungstag'] . '|'
|
||||
. $row['wertstellung'] . '|'
|
||||
. (string)$row['wert'] . '|'
|
||||
. mb_substr($row['buchungstext'], 0, 200);
|
||||
. (string)$row['wert'];
|
||||
if (isset($lookup[$key])) {
|
||||
$dup[] = $row;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Constants;
|
||||
use Steuerkram\Database;
|
||||
|
||||
class Booking
|
||||
|
|
@ -112,6 +113,8 @@ class Booking
|
|||
|
||||
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
Database::execute(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
|
||||
kategorie_konto_id, zahlungskonto_id,
|
||||
|
|
@ -140,17 +143,17 @@ class Booking
|
|||
|
||||
// VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen
|
||||
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
|
||||
if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true)
|
||||
if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true)
|
||||
&& in_array($ustSatzInt, [7, 19], true)
|
||||
&& $steuerart === 'normal'
|
||||
&& abs($ust) > 0.0
|
||||
) {
|
||||
$isAusgabe = $data['buchungstyp'] === 'Ausgabe';
|
||||
$isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE;
|
||||
if ($isAusgabe) {
|
||||
$steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571';
|
||||
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7;
|
||||
$steuerText = 'Vorsteuer zu ' . $nr;
|
||||
} else {
|
||||
$steuerKontonummer = $ustSatzInt === 19 ? '1776' : '1771';
|
||||
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7;
|
||||
$steuerText = 'USt zu ' . $nr;
|
||||
}
|
||||
$steuerAcc = Account::findByKontonummer($steuerKontonummer);
|
||||
|
|
@ -182,15 +185,15 @@ class Booking
|
|||
}
|
||||
}
|
||||
|
||||
// ig. Erwerb 19 % ReverseCharge: Kindzeilen auf 1574 (VSt, Kz 67) und 1774 (USt, Kz 91)
|
||||
if ($data['buchungstyp'] === 'Ausgabe'
|
||||
// ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91)
|
||||
if ($data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE
|
||||
&& $steuerart === 'ig_erwerb_19'
|
||||
&& abs($netto) > 0.0
|
||||
) {
|
||||
$igeUst = round($netto * 0.19, 2);
|
||||
foreach ([
|
||||
['1574', 'VSt ig.E. zu '],
|
||||
['1774', 'USt ig.E. zu '],
|
||||
[Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '],
|
||||
[Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '],
|
||||
] as [$kontonr, $prefix]) {
|
||||
$igeAcc = Account::findByKontonummer($kontonr);
|
||||
if ($igeAcc) {
|
||||
|
|
@ -205,7 +208,7 @@ class Booking
|
|||
(int)$data['beleg_id'],
|
||||
$mainId,
|
||||
$data['buchungs_datum'],
|
||||
'Ausgabe',
|
||||
Constants::BUCHUNGSTYP_AUSGABE,
|
||||
(int)$igeAcc['id'],
|
||||
(int)$data['zahlungskonto_id'],
|
||||
$igeUst,
|
||||
|
|
@ -221,6 +224,12 @@ class Booking
|
|||
}
|
||||
}
|
||||
|
||||
Database::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Database::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $mainId;
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +255,8 @@ class Booking
|
|||
|
||||
$nr = self::generateBuchungsNr();
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
Database::execute(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
|
||||
kategorie_konto_id, zahlungskonto_id,
|
||||
|
|
@ -270,10 +281,8 @@ class Booking
|
|||
|
||||
$reversalId = (int)Database::lastInsertId();
|
||||
|
||||
// Link original to reversal
|
||||
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
|
||||
|
||||
// Verknüpfte VSt-Buchungszeilen ebenfalls stornieren
|
||||
$vstBookings = Database::fetchAll(
|
||||
'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL',
|
||||
[$id]
|
||||
|
|
@ -307,6 +316,12 @@ class Booking
|
|||
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]);
|
||||
}
|
||||
|
||||
Database::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Database::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $reversalId;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram;
|
||||
|
||||
use Steuerkram\Auth;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
|
@ -52,6 +54,11 @@ class Router
|
|||
$uri = '/' . trim($uri, '/');
|
||||
if ($uri === '') $uri = '/';
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
|
||||
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
Auth::validateCsrfToken($token);
|
||||
}
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,16 @@ class BankImportParser
|
|||
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
|
||||
}
|
||||
|
||||
// Übersprungene Zeilen und optionale Headerzeile entfernen
|
||||
// Übersprungene Zeilen entfernen
|
||||
$skipRows = (int)$format['skip_rows'];
|
||||
$lines = array_slice($lines, $skipRows);
|
||||
if (!empty($format['has_header_row'])) {
|
||||
|
||||
// Headerzeile auslesen (vor dem Entfernen), um {Überschrift}-Templates aufzulösen
|
||||
$headerMap = [];
|
||||
if (!empty($format['has_header_row']) && isset($lines[0])) {
|
||||
foreach ($lines[0] as $idx => $header) {
|
||||
$headerMap[trim($header)] = $idx;
|
||||
}
|
||||
$lines = array_slice($lines, 1);
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +41,9 @@ class BankImportParser
|
|||
$colBuchungstext = (int)$format['col_buchungstext'];
|
||||
$colWert = (int)$format['col_wert'];
|
||||
|
||||
$tplDebit = !empty($format['buchungstext_template_debit']) ? $format['buchungstext_template_debit'] : null;
|
||||
$tplCredit = !empty($format['buchungstext_template_credit']) ? $format['buchungstext_template_credit'] : null;
|
||||
|
||||
$result = [];
|
||||
foreach ($lines as $i => $cells) {
|
||||
// Leerzeilen überspringen
|
||||
|
|
@ -55,11 +64,21 @@ class BankImportParser
|
|||
continue;
|
||||
}
|
||||
|
||||
$activeTpl = $wert < 0 ? $tplDebit : $tplCredit;
|
||||
if ($activeTpl !== null) {
|
||||
$buchungstext = preg_replace_callback('/\{([^}]+)\}/', function ($m) use ($headerMap, $cells) {
|
||||
$idx = $headerMap[$m[1]] ?? null;
|
||||
return $idx !== null ? trim($cells[$idx] ?? '') : '';
|
||||
}, $activeTpl);
|
||||
} else {
|
||||
$buchungstext = trim($cells[$colBuchungstext] ?? '');
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'buchungstag' => $buchungstag,
|
||||
'wertstellung' => $wertstellung,
|
||||
'vorgang' => trim($cells[$colVorgang] ?? ''),
|
||||
'buchungstext' => trim($cells[$colBuchungstext] ?? ''),
|
||||
'buchungstext' => $buchungstext,
|
||||
'wert' => $wert,
|
||||
'raw_line' => implode($format['delimiter'], $cells),
|
||||
];
|
||||
|
|
@ -222,6 +241,175 @@ class BankImportParser
|
|||
return [$col - 1, (int)($m[2] ?? 1) - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest Überschriften- und erste Datenzeile aus einer Datei gemäß Format-Konfiguration.
|
||||
* Gibt ['header' => [...], 'first_data' => [...]] zurück.
|
||||
*/
|
||||
public function peekRows(string $filePath, string $ext, array $format): array
|
||||
{
|
||||
try {
|
||||
$fmtExt = strtolower($ext);
|
||||
if ($fmtExt === 'xlsx') {
|
||||
$lines = $this->readXlsx($filePath);
|
||||
} else {
|
||||
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
|
||||
}
|
||||
|
||||
$skipRows = (int)$format['skip_rows'];
|
||||
$lines = array_slice($lines, $skipRows);
|
||||
|
||||
// Führende Leerzeilen überspringen
|
||||
while (!empty($lines) && trim(implode('', $lines[0])) === '') {
|
||||
array_shift($lines);
|
||||
}
|
||||
|
||||
$header = [];
|
||||
if (!empty($format['has_header_row']) && isset($lines[0])) {
|
||||
$header = array_map('trim', $lines[0]);
|
||||
$lines = array_slice($lines, 1);
|
||||
}
|
||||
|
||||
$firstData = [];
|
||||
foreach ($lines as $line) {
|
||||
if (trim(implode('', $line)) !== '') {
|
||||
$firstData = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['header' => $header, 'first_data' => $firstData];
|
||||
} catch (\Throwable) {
|
||||
return ['header' => [], 'first_data' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert eine unbekannte CSV und versucht, die Headerzeile sowie passende
|
||||
* Spaltenzuordnungen zu erkennen. Gibt null zurück, wenn keine Headerzeile gefunden.
|
||||
*/
|
||||
public function suggestFormat(string $filePath, string $ext): ?array
|
||||
{
|
||||
if (strtolower($ext) === 'xlsx') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'col_buchungstag' => ['buchungstag', 'buchungsdatum', 'datum'],
|
||||
'col_wertstellung' => ['wertstellung'],
|
||||
'col_vorgang' => ['vorgang', 'umsatztyp'],
|
||||
'col_buchungstext' => ['buchungstext'],
|
||||
'col_wert' => ['betrag', 'umsatz'],
|
||||
'col_zahlungspflichtiger' => ['zahlungspflichtige', 'auftraggeber'],
|
||||
'col_zahlungsempfaenger' => ['zahlungsempf', 'empfänger'],
|
||||
'col_verwendungszweck' => ['verwendungszweck'],
|
||||
];
|
||||
|
||||
foreach ([';', ',', "\t"] as $delimiter) {
|
||||
try {
|
||||
$lines = $this->readCsv($filePath, $delimiter, '"');
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count($lines) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($skipRows = 0; $skipRows <= 10; $skipRows++) {
|
||||
$candidate = $lines[$skipRows] ?? null;
|
||||
if ($candidate === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$cells = array_map('trim', $candidate);
|
||||
$nonEmpty = array_filter($cells, fn($c) => $c !== '');
|
||||
|
||||
if (count($nonEmpty) < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zeile gilt als Header wenn Zellen Text enthalten (keine reinen Zahlen/Daten)
|
||||
$textCells = array_filter($nonEmpty, function (string $c): bool {
|
||||
if (is_numeric($c)) return false;
|
||||
if (preg_match('/^\d{1,2}[.\-\/]\d{1,2}[.\-\/]\d{2,4}$/', $c)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (count($textCells) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mindestens ein Banking-Keyword muss vorkommen
|
||||
$cellsLower = array_map('mb_strtolower', $cells);
|
||||
$hasKeyword = false;
|
||||
foreach ($patterns as $keywords) {
|
||||
foreach ($keywords as $kw) {
|
||||
foreach ($cellsLower as $cell) {
|
||||
if (str_contains($cell, $kw)) {
|
||||
$hasKeyword = true;
|
||||
break 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasKeyword) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spalten-Mapping aufbauen
|
||||
$mapping = [];
|
||||
$used = [];
|
||||
foreach ($patterns as $field => $keywords) {
|
||||
foreach ($keywords as $kw) {
|
||||
foreach ($cellsLower as $idx => $cell) {
|
||||
if (!in_array($idx, $used, true) && str_contains($cell, $kw)) {
|
||||
$mapping[$field] = $idx;
|
||||
$used[] = $idx;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Erste Datenzeile nach dem Header ermitteln
|
||||
$firstData = [];
|
||||
for ($di = $skipRows + 1; $di < count($lines); $di++) {
|
||||
$row = array_map('trim', $lines[$di]);
|
||||
if (trim(implode('', $row)) !== '') {
|
||||
$firstData = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'skip_rows' => $skipRows,
|
||||
'delimiter' => $delimiter,
|
||||
'enclosure' => '"',
|
||||
'has_header_row' => 1,
|
||||
'header' => $cells,
|
||||
'first_data' => $firstData,
|
||||
'mapping' => $mapping,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob eine Datei mit dem angegebenen Format mindestens eine gültige Zeile liefert.
|
||||
* Delegiert an parse(), damit Detection und Import immer identisch beurteilen.
|
||||
*/
|
||||
public function isFormatMatch(string $filePath, string $ext, array $format): bool
|
||||
{
|
||||
try {
|
||||
return count($this->parse($filePath, $ext, $format)) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function parseDate(string $value, string $format): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ class ImageCropService
|
|||
*/
|
||||
public function cropToRect(string $path, int $x, int $y, int $w, int $h): bool
|
||||
{
|
||||
$mime = mime_content_type($path);
|
||||
$finfo1 = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)finfo_file($finfo1, $path);
|
||||
finfo_close($finfo1);
|
||||
|
||||
$img = match ($mime) {
|
||||
'image/jpeg' => imagecreatefromjpeg($path),
|
||||
|
|
@ -65,7 +67,9 @@ class ImageCropService
|
|||
*/
|
||||
public function cropWhitespace(string $path): bool
|
||||
{
|
||||
$mime = mime_content_type($path);
|
||||
$finfo2 = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)finfo_file($finfo2, $path);
|
||||
finfo_close($finfo2);
|
||||
|
||||
$img = match ($mime) {
|
||||
'image/jpeg' => imagecreatefromjpeg($path),
|
||||
|
|
|
|||
|
|
@ -60,4 +60,13 @@ class View
|
|||
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||
return $d ? $d->format('d.m.Y') : $date;
|
||||
}
|
||||
|
||||
public static function buildQuery(array $params): string
|
||||
{
|
||||
$safe = [];
|
||||
foreach ($params as $k => $v) {
|
||||
$safe[rawurlencode((string)$k)] = rawurlencode((string)$v);
|
||||
}
|
||||
return http_build_query($safe);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
use Steuerkram\View;
|
||||
$h = fn($v) => View::h($v);
|
||||
$fmt = fn($v) => View::formatMoney($v);
|
||||
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
|
||||
|
||||
$months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
?>
|
||||
|
|
@ -30,6 +31,39 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
|||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<?php
|
||||
$selFmt = (int)($filter['format_id'] ?? 0);
|
||||
$selFmtRow = null;
|
||||
foreach ($formats as $f) { if ((int)$f['id'] === $selFmt) { $selFmtRow = $f; break; } }
|
||||
?>
|
||||
<div class="bank-fmt-filter" style="position:relative">
|
||||
<input type="hidden" name="format_id" id="filter-format-id" value="<?= $selFmt ?: '' ?>">
|
||||
<button type="button" id="filter-format-btn"
|
||||
style="display:inline-flex;align-items:center;gap:.4rem;height:32px;padding:0 .6rem;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;cursor:pointer;white-space:nowrap;font-size:.875rem">
|
||||
<?php if ($selFmtRow): ?>
|
||||
<?php $fc = $selFmtRow['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($selFmtRow['kuerzel']) ?></span>
|
||||
<?= $h($selFmtRow['name']) ?>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--color-muted)">Alle Konten</span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<div id="filter-format-drop"
|
||||
style="display:none;position:absolute;top:100%;left:0;margin-top:2px;z-index:200;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius);box-shadow:0 4px 12px rgba(0,0,0,.08);min-width:180px;padding:.3rem 0">
|
||||
<div class="fmt-opt" data-id="" style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem">
|
||||
<span style="color:var(--color-muted)">Alle Konten</span>
|
||||
</div>
|
||||
<?php foreach ($formats as $f):
|
||||
$fc = $f['farbe'] ?? '#6366f1'; ?>
|
||||
<div class="fmt-opt" data-id="<?= (int)$f['id'] ?>"
|
||||
style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem<?= $selFmt === (int)$f['id'] ? ';background:var(--color-bg)' : '' ?>">
|
||||
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($f['kuerzel']) ?></span>
|
||||
<?= $h($f['name']) ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select name="typ" style="width:110px">
|
||||
<option value="">Alle</option>
|
||||
<option value="einnahme"<?= ($filter['typ'] ?? '') === 'einnahme' ? ' selected' : '' ?>>Einnahmen</option>
|
||||
|
|
@ -45,6 +79,19 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
|||
<option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option>
|
||||
</select>
|
||||
|
||||
<select name="sort" style="width:140px">
|
||||
<option value="wertstellung"<?= ($filter['sort'] ?? 'wertstellung') === 'wertstellung' ? ' selected' : '' ?>>Wertstellung</option>
|
||||
<option value="buchungstag"<?= ($filter['sort'] ?? '') === 'buchungstag' ? ' selected' : '' ?>>Buchungstag</option>
|
||||
<option value="betrag"<?= ($filter['sort'] ?? '') === 'betrag' ? ' selected' : '' ?>>Betrag</option>
|
||||
<option value="buchungstext"<?= ($filter['sort'] ?? '') === 'buchungstext' ? ' selected' : '' ?>>Buchungstext</option>
|
||||
<option value="status"<?= ($filter['sort'] ?? '') === 'status' ? ' selected' : '' ?>>Status</option>
|
||||
</select>
|
||||
|
||||
<select name="dir" style="width:110px">
|
||||
<option value="desc"<?= ($filter['dir'] ?? 'desc') === 'desc' ? ' selected' : '' ?>>↓ Absteigend</option>
|
||||
<option value="asc"<?= ($filter['dir'] ?? 'desc') === 'asc' ? ' selected' : '' ?>>↑ Aufsteigend</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
|
||||
<a href="/bank" class="btn btn-secondary btn-sm">Zurücksetzen</a>
|
||||
</div>
|
||||
|
|
@ -92,7 +139,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
|||
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
|
||||
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
|
||||
<td>
|
||||
<div><?= $h($row['buchungstext']) ?></div>
|
||||
<div style="display:flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
|
||||
<?php if (!empty($row['kuerzel'])): ?>
|
||||
<?php $kf = $row['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($row['kuerzel']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span><?= $h($row['buchungstext']) ?></span>
|
||||
</div>
|
||||
<?php if ($row['vorgang']): ?>
|
||||
<div class="text-muted text-sm"><?= $h($row['vorgang']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
|
@ -149,3 +202,33 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
|||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.getElementById('filter-format-btn');
|
||||
const drop = document.getElementById('filter-format-drop');
|
||||
const input = document.getElementById('filter-format-id');
|
||||
if (!btn || !drop) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
drop.style.display = drop.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
drop.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.fmt-opt');
|
||||
if (!opt) return;
|
||||
input.value = opt.dataset.id || '';
|
||||
btn.closest('form').submit();
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => { drop.style.display = 'none'; });
|
||||
|
||||
drop.querySelectorAll('.fmt-opt').forEach(opt => {
|
||||
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--color-bg)');
|
||||
opt.addEventListener('mouseleave', () => {
|
||||
opt.style.background = opt.dataset.id === input.value ? 'var(--color-bg)' : '';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use Steuerkram\View;
|
|||
use Steuerkram\Models\Setting;
|
||||
$h = fn($v) => View::h($v);
|
||||
$fmt = fn($v) => View::formatMoney($v);
|
||||
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
|
||||
|
||||
$wert = (float)$transaction['wert'];
|
||||
$isPlus = $wert >= 0;
|
||||
|
|
@ -34,7 +35,13 @@ $restFormatted = number_format($remaining, 2, ',', '.');
|
|||
</div>
|
||||
<div style="flex:1;min-width:200px">
|
||||
<span class="text-muted text-sm">Buchungstext</span><br>
|
||||
<span><?= $h($transaction['buchungstext']) ?></span>
|
||||
<span style="display:inline-flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
|
||||
<?php if (!empty($transaction['kuerzel'])): ?>
|
||||
<?php $kf = $transaction['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($transaction['kuerzel']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?= $h($transaction['buchungstext']) ?>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-sm">Gesamt</span><br>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||
|
||||
<div class="card" style="max-width:700px">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
|
||||
|
||||
|
|
@ -25,18 +25,83 @@
|
|||
<div id="bank-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Format-Auswahl -->
|
||||
<div class="form-group">
|
||||
<label for="format_id">Import-Format *</label>
|
||||
<select id="format_id" name="format_id" required>
|
||||
<!-- Format-Erkennung: Ergebnis-Bereich -->
|
||||
<div id="fmt-result" style="display:none">
|
||||
|
||||
<!-- Auto-erkannt -->
|
||||
<div id="fmt-matched" style="display:none;padding:.75rem 1rem;border-radius:var(--radius);background:#f0fdf4;border:1px solid #86efac;margin-bottom:1.25rem">
|
||||
<div style="font-weight:600;color:#15803d">Format erkannt</div>
|
||||
<div id="fmt-matched-name" style="color:#166534;margin-top:.2rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nicht eindeutig erkannt: Auswahl + Vorschau -->
|
||||
<div id="fmt-picker" style="display:none">
|
||||
<div style="padding:.6rem .9rem;border-radius:var(--radius);background:#fffbeb;border:1px solid #fcd34d;margin-bottom:1.25rem;font-size:.875rem;color:#92400e">
|
||||
Das Format konnte nicht eindeutig erkannt werden. Bitte wählen Sie das passende Format:
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:1.25rem">
|
||||
<label for="fmt-select">Import-Format</label>
|
||||
<select id="fmt-select" style="width:100%">
|
||||
<?php foreach ($formats as $fmt): ?>
|
||||
<option value="<?= (int)$fmt['id'] ?>"><?= $h($fmt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-hint">Format der hochgeladenen Datei</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.75rem;margin-top:1.5rem">
|
||||
<!-- Vorschau-Panel -->
|
||||
<div id="fmt-preview-panel" style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius);padding:1rem;margin-bottom:1.25rem">
|
||||
|
||||
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.75rem">
|
||||
Datei-Vorschau
|
||||
</div>
|
||||
|
||||
<!-- Überschriften-Zeile -->
|
||||
<div style="margin-bottom:.75rem">
|
||||
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Überschriften-Zeile</div>
|
||||
<div id="preview-header" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Erste Werte-Zeile -->
|
||||
<div style="margin-bottom:1rem">
|
||||
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Erste Werte-Zeile</div>
|
||||
<div id="preview-firstrow" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Import-Zuordnung -->
|
||||
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.5rem">
|
||||
Importiert als
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:.3rem .4rem .3rem 0;width:130px;color:var(--color-muted)">Wertstellung</td>
|
||||
<td id="map-wertstellung" style="padding:.3rem 0;font-family:monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Buchungstext</td>
|
||||
<td id="map-buchungstext" style="padding:.3rem 0;font-family:monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Wert (€)</td>
|
||||
<td id="map-wert" style="padding:.3rem 0;font-family:monospace"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /fmt-result -->
|
||||
|
||||
<!-- Verstecktes Format-ID Feld -->
|
||||
<input type="hidden" id="format_id" name="format_id" value="">
|
||||
|
||||
<!-- Erkennung läuft -->
|
||||
<div id="fmt-detecting" style="display:none;color:var(--color-muted);font-size:.875rem;margin-bottom:1rem">
|
||||
Format wird erkannt…
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.75rem;margin-top:.5rem">
|
||||
<button type="submit" class="btn btn-primary" id="bank-upload-btn" disabled>Importieren</button>
|
||||
<a href="/bank" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
|
|
@ -50,24 +115,168 @@
|
|||
const input = document.getElementById('bank-dnd-input');
|
||||
const status = document.getElementById('bank-dnd-status');
|
||||
const btn = document.getElementById('bank-upload-btn');
|
||||
const fmtResult = document.getElementById('fmt-result');
|
||||
const fmtMatched = document.getElementById('fmt-matched');
|
||||
const fmtPicker = document.getElementById('fmt-picker');
|
||||
const fmtDetect = document.getElementById('fmt-detecting');
|
||||
const fmtSelect = document.getElementById('fmt-select');
|
||||
const formatId = document.getElementById('format_id');
|
||||
|
||||
let formatsData = {};
|
||||
let suggestedFormat = null;
|
||||
|
||||
function setActive(on) {
|
||||
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
|
||||
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function renderHeaderRow(headerRow, firstData) {
|
||||
if (!headerRow || headerRow.length === 0) {
|
||||
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
|
||||
// Keine Überschriften – zeige Spaltenindizes
|
||||
return firstData.map((_, i) => `<span style="opacity:.5">Sp.${i}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
|
||||
}
|
||||
return headerRow.map((h, i) => `<span title="Spalte ${i}">${esc(h)}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
|
||||
}
|
||||
|
||||
function renderDataRow(firstData, header) {
|
||||
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
|
||||
return firstData.map((v, i) => {
|
||||
const lbl = header && header[i] ? `title="Spalte ${i}: ${esc(header[i])}"` : `title="Spalte ${i}"`;
|
||||
return `<span ${lbl}>${esc(v)}</span>`;
|
||||
}).join(' <span style="color:var(--color-border)">|</span> ');
|
||||
}
|
||||
|
||||
function resolveTemplate(tpl, header, firstData) {
|
||||
if (!tpl) return null;
|
||||
return tpl.replace(/\{([^}]+)\}/g, (_, key) => {
|
||||
const idx = header ? header.indexOf(key) : -1;
|
||||
return idx >= 0 ? (firstData[idx] || '') : `{${key}}`;
|
||||
});
|
||||
}
|
||||
|
||||
function getMappedValue(fmt, header, firstData, col) {
|
||||
const idx = parseInt(fmt[col], 10);
|
||||
if (isNaN(idx) || !firstData || idx >= firstData.length) return '–';
|
||||
return firstData[idx] || '–';
|
||||
}
|
||||
|
||||
function updatePreview(fmtId) {
|
||||
const data = formatsData[fmtId];
|
||||
if (!data) return;
|
||||
|
||||
const fmt = data.format;
|
||||
const header = data.header_row;
|
||||
const firstData = data.first_data;
|
||||
|
||||
document.getElementById('preview-header').innerHTML = renderHeaderRow(header, firstData);
|
||||
document.getElementById('preview-firstrow').innerHTML = renderDataRow(firstData, header);
|
||||
|
||||
// Wertstellung
|
||||
const wsIdx = parseInt(fmt.col_wertstellung, 10);
|
||||
const wsVal = firstData && firstData[wsIdx] !== undefined ? firstData[wsIdx] : '–';
|
||||
document.getElementById('map-wertstellung').textContent = `${wsVal} (Spalte ${wsIdx})`;
|
||||
|
||||
// Buchungstext – Template bevorzugen
|
||||
const wertVal = firstData && firstData[parseInt(fmt.col_wert, 10)] ? firstData[parseInt(fmt.col_wert, 10)] : '0';
|
||||
const isDebit = parseFloat(wertVal.replace(',', '.').replace(/[^\d.\-]/g, '')) < 0;
|
||||
const tpl = isDebit ? (fmt.buchungstext_template_debit || '') : (fmt.buchungstext_template_credit || '');
|
||||
let btVal;
|
||||
if (tpl) {
|
||||
btVal = resolveTemplate(tpl, header, firstData);
|
||||
document.getElementById('map-buchungstext').textContent = `${btVal} (Template)`;
|
||||
} else {
|
||||
const btIdx = parseInt(fmt.col_buchungstext, 10);
|
||||
btVal = firstData && firstData[btIdx] !== undefined ? firstData[btIdx] : '–';
|
||||
document.getElementById('map-buchungstext').textContent = `${btVal} (Spalte ${btIdx})`;
|
||||
}
|
||||
|
||||
// Wert
|
||||
const wIdx = parseInt(fmt.col_wert, 10);
|
||||
const wVal = firstData && firstData[wIdx] !== undefined ? firstData[wIdx] : '–';
|
||||
document.getElementById('map-wert').textContent = `${wVal} (Spalte ${wIdx})`;
|
||||
}
|
||||
|
||||
async function detectFormat(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('bank_file', file);
|
||||
|
||||
fmtDetect.style.display = 'block';
|
||||
fmtResult.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
|
||||
let json;
|
||||
try {
|
||||
const res = await fetch('/bank/detect-format', { method: 'POST', body: fd });
|
||||
json = await res.json();
|
||||
} catch (_) {
|
||||
json = { error: 'Verbindungsfehler bei der Format-Erkennung.' };
|
||||
}
|
||||
|
||||
fmtDetect.style.display = 'none';
|
||||
|
||||
if (json.error) {
|
||||
status.innerHTML = `<span style="color:var(--color-danger)">${esc(json.error)}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
formatsData = json.formats_data || {};
|
||||
suggestedFormat = json.suggested_format || null;
|
||||
fmtResult.style.display = 'block';
|
||||
|
||||
if (json.matched_format_id !== null && json.matched_format_id !== undefined) {
|
||||
// Format eindeutig erkannt
|
||||
const matched = formatsData[json.matched_format_id];
|
||||
const name = matched ? matched.format.name : 'Unbekannt';
|
||||
document.getElementById('fmt-matched-name').textContent = name;
|
||||
formatId.value = json.matched_format_id;
|
||||
fmtMatched.style.display = 'block';
|
||||
fmtPicker.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
// Nicht eindeutig – Auswahl zeigen
|
||||
fmtMatched.style.display = 'none';
|
||||
fmtPicker.style.display = 'block';
|
||||
|
||||
// Passendes Format vorauswählen (erstes mit is_match, sonst erstes)
|
||||
let preselect = null;
|
||||
for (const [id, d] of Object.entries(formatsData)) {
|
||||
if (d.is_match) { preselect = id; break; }
|
||||
}
|
||||
if (!preselect && Object.keys(formatsData).length > 0) {
|
||||
preselect = Object.keys(formatsData)[0];
|
||||
}
|
||||
if (preselect) {
|
||||
fmtSelect.value = preselect;
|
||||
formatId.value = preselect;
|
||||
updatePreview(preselect);
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFile(file) {
|
||||
if (!file) return;
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (!['csv', 'txt', 'xlsx'].includes(ext)) {
|
||||
status.innerHTML = '<span style="color:var(--color-danger)">Nicht unterstütztes Format. Bitte CSV, TXT oder XLSX verwenden.</span>';
|
||||
btn.disabled = true;
|
||||
fmtResult.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
status.innerHTML = '<span style="color:var(--color-success)">✓ ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>';
|
||||
btn.disabled = false;
|
||||
status.innerHTML = `<span style="color:var(--color-success)">✓ ${esc(file.name)} (${(file.size / 1024).toFixed(1)} KB)</span>`;
|
||||
detectFormat(file);
|
||||
}
|
||||
|
||||
fmtSelect.addEventListener('change', () => {
|
||||
formatId.value = fmtSelect.value;
|
||||
updatePreview(fmtSelect.value);
|
||||
});
|
||||
|
||||
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
|
||||
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
|
||||
zone.addEventListener('drop', (e) => {
|
||||
|
|
@ -75,7 +284,6 @@
|
|||
setActive(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
// DataTransfer-Datei in Input-Element spiegeln ist nicht möglich – Form-Submit via DataTransfer
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
|
|
@ -83,5 +291,21 @@
|
|||
}
|
||||
});
|
||||
input.addEventListener('change', () => onFile(input.files[0]));
|
||||
|
||||
// "Formate bearbeiten"-Link: Suggestion in Session speichern, dann navigieren
|
||||
document.addEventListener('click', async (e) => {
|
||||
const link = e.target.closest('a[href="/settings/bankimport"]');
|
||||
if (!link || !suggestedFormat) return;
|
||||
|
||||
e.preventDefault();
|
||||
try {
|
||||
await fetch('/settings/bankimport/suggest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(suggestedFormat),
|
||||
});
|
||||
} catch (_) {}
|
||||
window.location.href = '/settings/bankimport';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use Steuerkram\View;
|
|||
use Steuerkram\Models\Setting;
|
||||
$h = fn($v) => View::h($v);
|
||||
$fmt = fn($v) => View::formatMoney($v);
|
||||
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
|
||||
$testMode = Setting::isTestMode();
|
||||
$suppliers = $suppliers ?? [];
|
||||
?>
|
||||
|
|
@ -307,8 +308,14 @@ $suppliers = $suppliers ?? [];
|
|||
<?php if ($lbt['vorgang']): ?>
|
||||
<span class="text-sm text-muted"><?= $h($lbt['vorgang']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
|
||||
<?php if (!empty($lbt['kuerzel'])): ?>
|
||||
<?php $kf = $lbt['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($lbt['kuerzel']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
|
||||
</span>
|
||||
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
|
||||
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
|
||||
<?php if (!empty($lbt['alloc_betrag']) && (float)$lbt['alloc_betrag'] !== abs($lbwert)): ?>
|
||||
|
|
@ -337,8 +344,14 @@ $suppliers = $suppliers ?? [];
|
|||
<span class="badge badge-blue" style="font-size:.8rem">Bankbuchung</span>
|
||||
<span class="td-mono text-sm"><?= View::formatDate($bankTx['wertstellung']) ?></span>
|
||||
<span class="text-sm"><?= $h($bankTx['vorgang']) ?></span>
|
||||
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
|
||||
<?php if (!empty($bankTx['kuerzel'])): ?>
|
||||
<?php $kf = $bankTx['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($bankTx['kuerzel']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="<?= $h($bankTx['buchungstext']) ?>"><?= $h($bankTx['buchungstext']) ?></span>
|
||||
</span>
|
||||
<?php $bwert = (float)$bankTx['wert']; ?>
|
||||
<strong style="color:<?= $bwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;white-space:nowrap">
|
||||
<?= $bwert >= 0 ? '+' : '' ?><?= number_format($bwert, 2, ',', '.') ?> €
|
||||
|
|
|
|||
|
|
@ -9,11 +9,21 @@ $fmt = fn($v) => View::formatMoney($v);
|
|||
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
|
||||
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
|
||||
<div class="stat-meta">Betriebseinnahmen (netto)</div>
|
||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
||||
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['umsatz_vormonat']) ?>
|
||||
·
|
||||
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['umsatz_monat']) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
|
||||
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
|
||||
<div class="stat-meta">Betriebsausgaben (netto)</div>
|
||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
||||
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['ausgaben_vormonat']) ?>
|
||||
·
|
||||
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['ausgaben_monat']) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
|
||||
|
|
@ -21,6 +31,11 @@ $fmt = fn($v) => View::formatMoney($v);
|
|||
<?= $fmt($stats['gewinn']) ?>
|
||||
</div>
|
||||
<div class="stat-meta">EÜR-Ergebnis</div>
|
||||
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
|
||||
<?= $h($stats['vormonat_label']) ?>: <span style="color:<?= $stats['gewinn_vormonat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_vormonat']) ?></span>
|
||||
·
|
||||
<?= $h($stats['month_label']) ?>: <span style="color:<?= $stats['gewinn_monat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_monat']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Offene Rechnungen</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Steuerkram" />
|
||||
<link rel="manifest" href="/static/favicon/site.webmanifest" />
|
||||
<meta name="csrf-token" content="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
|
|
@ -48,5 +49,31 @@
|
|||
</div>
|
||||
</div>
|
||||
<script src="/static/js/app.js?v=<?= defined('ASSET_VERSION') ? ASSET_VERSION : 'dev' ?>"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
if (!token) return;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
var m = (form.getAttribute('method') || '').toUpperCase();
|
||||
if (m !== 'POST' && m !== 'PUT' && m !== 'DELETE') return;
|
||||
if (!form.querySelector('[name="csrf_token"]')) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||||
form.appendChild(inp);
|
||||
}
|
||||
});
|
||||
});
|
||||
var _fetch = window.fetch;
|
||||
window.fetch = function(url, opts) {
|
||||
opts = opts || {};
|
||||
var method = (opts.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
opts.headers = Object.assign({'X-CSRF-Token': token}, opts.headers || {});
|
||||
}
|
||||
return _fetch(url, opts);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ function navActive(string $prefix, string $current): string {
|
|||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg>
|
||||
Kontenrahmen
|
||||
</a>
|
||||
<a href="/settings/bankimport" class="<?= navActive('/settings/bankimport', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||
<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>
|
||||
Bankimport
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username"
|
||||
|
|
|
|||
784
src/Views/settings/bankimport.php
Normal file
784
src/Views/settings/bankimport.php
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||
|
||||
<?php
|
||||
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
|
||||
|
||||
$dbLabels = [
|
||||
'buchungstag' => 'Buchungstag',
|
||||
'wertstellung' => 'Wertstellung',
|
||||
'vorgang' => 'Vorgang / Umsatztyp',
|
||||
'buchungstext' => 'Buchungstext',
|
||||
'wert' => 'Betrag',
|
||||
'zahlungspflichtiger' => 'Zahlungspflichtiger',
|
||||
'zahlungsempfaenger' => 'Zahlungsempfänger',
|
||||
'verwendungszweck' => 'Verwendungszweck',
|
||||
];
|
||||
|
||||
$knownHeaders = [
|
||||
'Comdirect' => [0=>'Buchungstag',1=>'Wertstellung',2=>'Vorgang',3=>'Buchungstext',4=>'Betrag'],
|
||||
'DKB Bank' => [0=>'Buchungsdatum',1=>'Wertstellung',2=>'Status',3=>'Zahlungspflichtige*r',
|
||||
4=>'Zahlungsempfänger*in',5=>'Verwendungszweck',6=>'Umsatztyp',7=>'IBAN',
|
||||
8=>'Betrag (€)',9=>'Gläubiger-ID',10=>'Mandatsreferenz',11=>'Kundenreferenz'],
|
||||
];
|
||||
|
||||
$dateFormatOptions = [
|
||||
'd.m.Y' => '15.05.2026',
|
||||
'd.m.y' => '15.05.26',
|
||||
'Y-m-d' => '2026-05-15',
|
||||
'd/m/Y' => '15/05/2026',
|
||||
'm/d/Y' => '05/15/2026',
|
||||
'Y.m.d' => '2026.05.15',
|
||||
'd-m-Y' => '15-05-2026',
|
||||
];
|
||||
?>
|
||||
|
||||
<?php if (!empty($errors ?? [])): ?>
|
||||
<div style="padding:.75rem 1rem;border-radius:var(--radius);background:#fee2e2;border:1px solid #fca5a5;margin-bottom:1rem;font-size:.875rem;color:#991b1b">
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div><?= $h($e) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($suggestion)): ?>
|
||||
<?php
|
||||
$sugHeader = $suggestion['header'] ?? [];
|
||||
$sugMapping = $suggestion['mapping'] ?? [];
|
||||
|
||||
$allMappingFields = [
|
||||
'buchungstag' => 'Buchungstag',
|
||||
'wertstellung' => 'Wertstellung',
|
||||
'vorgang' => 'Vorgang / Umsatztyp',
|
||||
'wert' => 'Betrag',
|
||||
'zahlungspflichtiger' => 'Zahlungspflichtiger',
|
||||
'zahlungsempfaenger' => 'Zahlungsempfänger',
|
||||
'verwendungszweck' => 'Verwendungszweck',
|
||||
];
|
||||
?>
|
||||
<div class="card mb-2" id="suggestion-card" style="border:2px solid #6366f1">
|
||||
<div class="card-header" style="background:#eef2ff;display:flex;align-items:center;gap:.75rem">
|
||||
<div class="card-title" style="flex:1">Neues Format (automatischer Vorschlag)</div>
|
||||
<span style="font-size:.75rem;font-weight:600;padding:.2rem .6rem;border-radius:9999px;background:#818cf8;color:#fff">Nicht gespeichert</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/settings/bankimport/create">
|
||||
|
||||
<p style="font-size:.8rem;color:var(--color-muted);margin:0 0 1.25rem">
|
||||
Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet.
|
||||
Bitte prüfe die Einstellungen und klicke auf <strong>Speichern</strong>, um das neue Format anzulegen.
|
||||
</p>
|
||||
|
||||
<div class="bi-section-label">Format-Konfiguration</div>
|
||||
<table class="bi-table" style="margin-bottom:1.5rem">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="bi-label">Name</td>
|
||||
<td><input type="text" name="name" value="Neues Format" required maxlength="100" style="width:220px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Anzeige-Kürzel</td>
|
||||
<td><input type="text" name="kuerzel" value="NEU" required maxlength="10" style="width:80px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Übersprungene Zeilen</td>
|
||||
<td><input type="number" name="skip_rows" value="<?= (int)($suggestion['skip_rows'] ?? 0) ?>" min="0" max="99" style="width:70px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Headerzeile vorhanden</td>
|
||||
<td>
|
||||
<select name="has_header_row" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
|
||||
<option value="1" <?= ($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Ja</option>
|
||||
<option value="0" <?= !($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Nein</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Trennzeichen</td>
|
||||
<td><input type="text" name="delimiter" value="<?= $h($suggestion['delimiter'] ?? ';') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Anführungszeichen</td>
|
||||
<td><input type="text" name="enclosure" value="<?= $h($suggestion['enclosure'] ?? '"') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Datumsformat</td>
|
||||
<td>
|
||||
<select name="date_format" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
|
||||
<?php foreach ($dateFormatOptions as $fmt => $example): ?>
|
||||
<option value="<?= $h($fmt) ?>"><?= $h($fmt) ?> → <?= $h($example) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Dezimaltrennzeichen</td>
|
||||
<td><input type="text" name="decimal_separator" value="," maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Tausendertrennzeichen</td>
|
||||
<td><input type="text" name="thousands_separator" value="." maxlength="1" placeholder="(leer)" style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php
|
||||
$sugFirstData = $suggestion['first_data'] ?? [];
|
||||
if (!empty($sugHeader) || !empty($sugFirstData)):
|
||||
?>
|
||||
<div class="bi-section-label">Erkannte Headerzeile</div>
|
||||
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:.5rem">
|
||||
<?php foreach ($sugHeader as $i => $col): ?>
|
||||
<span title="Spalte <?= $i ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($col) ?></span><?php if ($i < count($sugHeader) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if (!empty($sugFirstData)): ?>
|
||||
<div style="font-size:.75rem;color:var(--color-muted);margin-bottom:.3rem">Erste Datenzeile</div>
|
||||
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:1.5rem">
|
||||
<?php foreach ($sugFirstData as $i => $val): ?>
|
||||
<span title="Spalte <?= $i ?><?= isset($sugHeader[$i]) ? ': ' . htmlspecialchars($sugHeader[$i]) : '' ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($val) ?></span><?php if ($i < count($sugFirstData) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bi-section-label">Feld-Zuordnungen</div>
|
||||
<table class="bi-table" style="margin-bottom:1.5rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bi-label">Datenbankfeld</th>
|
||||
<th style="width:100px">Spalte (0-basiert)</th>
|
||||
<th style="color:var(--color-muted);font-weight:500">Erkannte CSV-Überschrift</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($allMappingFields as $field => $label):
|
||||
$colKey = 'col_' . $field;
|
||||
$colIdx = $sugMapping[$colKey] ?? null;
|
||||
$headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : '';
|
||||
$isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true);
|
||||
?>
|
||||
<tr>
|
||||
<td class="bi-label"><?= $h($label) ?></td>
|
||||
<td>
|
||||
<input type="number" name="col_<?= $h($field) ?>"
|
||||
value="<?= $colIdx !== null ? $colIdx : '' ?>"
|
||||
min="0" max="99"
|
||||
<?= $isNullInt ? '' : 'required' ?>
|
||||
style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
|
||||
</td>
|
||||
<td style="color:var(--color-muted);font-size:.875rem">
|
||||
<?php if ($headerVal !== ''): ?>
|
||||
<?= $h($headerVal) ?>
|
||||
<?php if ($colIdx !== null): ?>
|
||||
<span style="opacity:.5;font-size:.75rem"> (Sp.<?= $colIdx ?>)</span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span style="font-style:italic">–</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($field === 'vorgang'): ?>
|
||||
<tr>
|
||||
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag < 0)</small></td>
|
||||
<td colspan="2" style="padding-right:0">
|
||||
<input type="text" name="buchungstext_template_debit" value=""
|
||||
maxlength="500" placeholder="z.B. {Zahlungsempfänger} · {Verwendungszweck}"
|
||||
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag > 0)</small></td>
|
||||
<td colspan="2" style="padding-right:0">
|
||||
<input type="text" name="buchungstext_template_credit" value=""
|
||||
maxlength="500" placeholder="z.B. {Zahlungspflichtiger} · {Verwendungszweck}"
|
||||
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<tr>
|
||||
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
|
||||
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
|
||||
Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:.75rem">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
<button type="button" id="suggestion-dismiss" class="btn btn-secondary">Verwerfen</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($formats)): ?>
|
||||
<div class="card">
|
||||
<div class="card-body" style="color:var(--color-muted)">Keine Importformate in der Datenbank vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<p style="font-size:.875rem;color:var(--color-muted);margin:0 0 1rem">
|
||||
Klick auf einen Wert zum Bearbeiten — Enter oder Klick außerhalb speichert, Escape bricht ab.
|
||||
</p>
|
||||
|
||||
<?php foreach ($formats as $fmt): ?>
|
||||
<?php
|
||||
$fmtId = (int)$fmt['id'];
|
||||
$hdrs = $knownHeaders[$fmt['name']] ?? [];
|
||||
|
||||
$mappingRowsBefore = [
|
||||
['buchungstag', $fmt['col_buchungstag'], 'int'],
|
||||
['wertstellung', $fmt['col_wertstellung'], 'int'],
|
||||
['vorgang', $fmt['col_vorgang'], 'int'],
|
||||
];
|
||||
$mappingRowsAfter = [
|
||||
['wert', $fmt['col_wert'], 'int'],
|
||||
];
|
||||
?>
|
||||
<div class="card mb-2" id="format-<?= $fmtId ?>" data-format-id="<?= $fmtId ?>">
|
||||
<div class="card-header" style="display:flex;align-items:center;gap:.75rem">
|
||||
<div class="card-title" style="flex:1">
|
||||
<span class="bi-cell" data-field="name" data-type="text" data-value="<?= $h($fmt['name']) ?>"><?= $h($fmt['name']) ?></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm bi-delete-format" data-format-id="<?= $fmtId ?>" data-format-name="<?= $h($fmt['name']) ?>">Löschen</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="bi-section-label">Format-Konfiguration</div>
|
||||
<table class="bi-table" style="margin-bottom:1.5rem">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="bi-label">Anzeige-Kürzel</td>
|
||||
<td>
|
||||
<?php $farbe = $fmt['farbe'] ?? '#6366f1'; ?>
|
||||
<div style="display:inline-flex;align-items:center;gap:.5rem">
|
||||
<span class="kuerzel-badge bi-kuerzel-preview"
|
||||
style="background:<?= $h($farbe) ?>;color:<?= $kTextClr($farbe) ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
|
||||
<span class="bi-cell" data-field="kuerzel" data-type="text"
|
||||
data-value="<?= $h($fmt['kuerzel'] ?? '') ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
|
||||
<input type="color" class="bi-color" data-field="farbe"
|
||||
value="<?= $h($farbe) ?>"
|
||||
title="Tag-Farbe anpassen"
|
||||
style="width:28px;height:28px;padding:2px;border:1px solid var(--color-border);border-radius:4px;cursor:pointer;background:none;vertical-align:middle">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Übersprungene Zeilen</td>
|
||||
<td><span class="bi-cell" data-field="skip_rows" data-type="number" data-min="0" data-max="99" data-value="<?= (int)$fmt['skip_rows'] ?>"><?= (int)$fmt['skip_rows'] ?></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Headerzeile vorhanden</td>
|
||||
<td>
|
||||
<label class="bi-checkbox-label">
|
||||
<input type="checkbox" class="bi-checkbox" data-field="has_header_row"
|
||||
<?= $fmt['has_header_row'] ? 'checked' : '' ?>>
|
||||
<span class="bi-checkbox-text"><?= $fmt['has_header_row'] ? 'Ja' : 'Nein' ?></span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Trennzeichen</td>
|
||||
<td><span class="bi-cell" data-field="delimiter" data-type="char" data-value="<?= $h($fmt['delimiter']) ?>"><code><?= $h($fmt['delimiter']) ?></code></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Anführungszeichen</td>
|
||||
<td><span class="bi-cell" data-field="enclosure" data-type="char" data-value="<?= $h($fmt['enclosure']) ?>"><code><?= $h($fmt['enclosure']) ?></code></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Datumsformat</td>
|
||||
<td>
|
||||
<span class="bi-cell" data-field="date_format" data-type="dateformat" data-value="<?= $h($fmt['date_format']) ?>">
|
||||
<code><?= $h($fmt['date_format']) ?></code>
|
||||
<span style="color:var(--color-muted);margin-left:.4rem">→ <?= $h($dateFormatOptions[$fmt['date_format']] ?? '?') ?></span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Dezimaltrennzeichen</td>
|
||||
<td><span class="bi-cell" data-field="decimal_separator" data-type="char" data-value="<?= $h($fmt['decimal_separator']) ?>"><code><?= $h($fmt['decimal_separator']) ?></code></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="bi-label">Tausendertrennzeichen</td>
|
||||
<td><span class="bi-cell" data-field="thousands_separator" data-type="char0" data-value="<?= $h($fmt['thousands_separator']) ?>">
|
||||
<?php if ($fmt['thousands_separator'] !== ''): ?>
|
||||
<code><?= $h($fmt['thousands_separator']) ?></code>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--color-muted);font-style:italic">keines</span>
|
||||
<?php endif; ?>
|
||||
</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="bi-section-label">Feld-Zuordnungen</div>
|
||||
<table class="bi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bi-label">Datenbankfeld</th>
|
||||
<th style="width:100px">Spalte (0-basiert)</th>
|
||||
<th style="color:var(--color-muted);font-weight:500">CSV-Überschrift</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$renderMappingRow = function (string $field, mixed $colIdx, string $colType) use ($h, $hdrs, $dbLabels): void {
|
||||
$colVal = $colIdx !== null ? (int)$colIdx : null;
|
||||
?>
|
||||
<tr>
|
||||
<td class="bi-label"><?= $h($dbLabels[$field] ?? $field) ?></td>
|
||||
<td>
|
||||
<span class="bi-cell"
|
||||
data-field="col_<?= $h($field) ?>"
|
||||
data-type="<?= $colType === 'nullint' ? 'nullint' : 'number' ?>"
|
||||
data-min="0" data-max="99"
|
||||
data-value="<?= $colVal !== null ? $colVal : '' ?>">
|
||||
<?php if ($colVal !== null): ?>
|
||||
<code><?= $colVal ?></code>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--color-muted);font-style:italic">–</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="color:var(--color-muted);font-size:.875rem">
|
||||
<?php if ($colVal !== null && isset($hdrs[$colVal])): ?>
|
||||
<?= $h($hdrs[$colVal]) ?>
|
||||
<?php else: ?>
|
||||
<span style="font-style:italic">–</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php }; ?>
|
||||
|
||||
<?php foreach ($mappingRowsBefore as [$field, $colIdx, $colType]): ?>
|
||||
<?php $renderMappingRow($field, $colIdx, $colType); ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php
|
||||
// Buchungstext-Template-Zeilen (immer)
|
||||
foreach ([
|
||||
['buchungstext_template_debit', 'Buchungstext <small style="font-weight:400">(Betrag < 0)</small>', $fmt['buchungstext_template_debit'] ?? ''],
|
||||
['buchungstext_template_credit', 'Buchungstext <small style="font-weight:400">(Betrag ≥ 0)</small>', $fmt['buchungstext_template_credit'] ?? ''],
|
||||
] as [$tplField, $tplLabel, $tplValue]):
|
||||
?>
|
||||
<tr>
|
||||
<td class="bi-label"><?= $tplLabel ?></td>
|
||||
<td colspan="2" style="padding-right:0">
|
||||
<span class="bi-cell bi-cell--wide" data-field="<?= $h($tplField) ?>" data-type="template"
|
||||
data-value="<?= $h($tplValue) ?>">
|
||||
<?php if ($tplValue !== ''): ?>
|
||||
<code style="word-break:break-all"><?= $h($tplValue) ?></code>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php foreach ($mappingRowsAfter as [$field, $colIdx, $colType]): ?>
|
||||
<?php $renderMappingRow($field, $colIdx, $colType); ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<tr>
|
||||
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
|
||||
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<style>
|
||||
.bi-section-label {
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
.bi-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.bi-table th,
|
||||
.bi-table td {
|
||||
padding: .35rem .6rem .35rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.bi-table thead th { color: var(--color-muted); font-weight: 500; }
|
||||
.bi-table tbody tr:last-child td { border-bottom: none; }
|
||||
.bi-label { color: var(--color-text); width: 38%; }
|
||||
|
||||
.bi-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
padding: .15rem .3rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color .15s, background .15s;
|
||||
min-height: 1.6rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
.bi-cell:hover {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.bi-cell::after {
|
||||
content: '✎';
|
||||
font-size: .7rem;
|
||||
color: var(--color-muted);
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bi-cell:hover::after { opacity: 1; }
|
||||
|
||||
.bi-cell input,
|
||||
.bi-cell select {
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.bi-cell input[type="number"] { width: 70px; }
|
||||
.bi-cell input[type="text"] { width: 60px; }
|
||||
.bi-cell select { width: auto; min-width: 180px; }
|
||||
|
||||
.bi-cell--saving { opacity: .5; pointer-events: none; }
|
||||
.bi-cell--error { border-color: var(--color-danger) !important; background: #fee2e2 !important; }
|
||||
.bi-cell--wide { display: flex; width: 100%; box-sizing: border-box; }
|
||||
|
||||
.bi-checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
cursor: pointer;
|
||||
padding: .15rem .3rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
transition: border-color .15s, background .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.bi-checkbox-label:hover {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.bi-checkbox { cursor: pointer; }
|
||||
.bi-error-msg {
|
||||
font-size: .8rem;
|
||||
color: var(--color-danger);
|
||||
margin-top: .2rem;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const DATE_FORMAT_OPTIONS = <?= json_encode($dateFormatOptions) ?>;
|
||||
|
||||
function badgeTextColor(hex) {
|
||||
const r = parseInt(hex.slice(1,3),16);
|
||||
const g = parseInt(hex.slice(3,5),16);
|
||||
const b = parseInt(hex.slice(5,7),16);
|
||||
return (0.299*r + 0.587*g + 0.114*b) > 128 ? '#1e293b' : '#ffffff';
|
||||
}
|
||||
|
||||
function updatePreview(colorInput, hex) {
|
||||
const preview = colorInput.closest('div')?.querySelector('.bi-kuerzel-preview');
|
||||
if (!preview) return;
|
||||
preview.style.background = hex;
|
||||
preview.style.color = badgeTextColor(hex);
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────
|
||||
|
||||
function getFormatId(cell) {
|
||||
return cell.closest('[data-format-id]')?.dataset.formatId;
|
||||
}
|
||||
|
||||
function showError(cell, msg) {
|
||||
cell.classList.add('bi-cell--error');
|
||||
let err = cell.parentElement.querySelector('.bi-error-msg');
|
||||
if (!err) { err = document.createElement('span'); err.className = 'bi-error-msg'; cell.after(err); }
|
||||
err.textContent = msg;
|
||||
setTimeout(() => { cell.classList.remove('bi-cell--error'); err.remove(); }, 3000);
|
||||
}
|
||||
|
||||
async function saveField(formatId, field, value) {
|
||||
const body = new URLSearchParams({ field, value: value ?? '' });
|
||||
const res = await fetch(`/settings/bankimport/${formatId}/field`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function displayForCell(type, value, field) {
|
||||
if (type === 'number' || type === 'nullint') {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return '<span style="color:var(--color-muted);font-style:italic">–</span>';
|
||||
}
|
||||
return `<code>${value}</code>`;
|
||||
}
|
||||
if (type === 'char' || type === 'char0') {
|
||||
if (value === '') return '<span style="color:var(--color-muted);font-style:italic">keines</span>';
|
||||
return `<code>${value.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</code>`;
|
||||
}
|
||||
if (type === 'dateformat') {
|
||||
const example = DATE_FORMAT_OPTIONS[value] || '?';
|
||||
return `<code>${value}</code><span style="color:var(--color-muted);margin-left:.4rem">→ ${example}</span>`;
|
||||
}
|
||||
if (type === 'template') {
|
||||
if (!value) return '<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>';
|
||||
const esc = value.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return `<code style="word-break:break-all">${esc}</code>`;
|
||||
}
|
||||
if (type === 'text') {
|
||||
return value.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Klick-Handler für .bi-cell ───────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const cell = e.target.closest('.bi-cell');
|
||||
if (!cell || cell.querySelector('input,select')) return;
|
||||
|
||||
const field = cell.dataset.field;
|
||||
const type = cell.dataset.type;
|
||||
const rawValue = cell.dataset.value ?? '';
|
||||
const formatId = getFormatId(cell);
|
||||
if (!formatId) return;
|
||||
|
||||
const originalHTML = cell.innerHTML;
|
||||
cell.innerHTML = '';
|
||||
cell.classList.remove('bi-cell--error');
|
||||
|
||||
let el;
|
||||
|
||||
if (type === 'number' || type === 'nullint') {
|
||||
el = document.createElement('input');
|
||||
el.type = 'number';
|
||||
el.min = cell.dataset.min ?? '0';
|
||||
el.max = cell.dataset.max ?? '99';
|
||||
el.value = rawValue;
|
||||
} else if (type === 'char') {
|
||||
el = document.createElement('input');
|
||||
el.type = 'text';
|
||||
el.maxLength = 1;
|
||||
el.value = rawValue;
|
||||
} else if (type === 'char0') {
|
||||
el = document.createElement('input');
|
||||
el.type = 'text';
|
||||
el.maxLength = 1;
|
||||
el.value = rawValue;
|
||||
el.placeholder = '(leer)';
|
||||
el.style.width = '70px';
|
||||
} else if (type === 'dateformat') {
|
||||
el = document.createElement('select');
|
||||
for (const [fmt, example] of Object.entries(DATE_FORMAT_OPTIONS)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = fmt;
|
||||
opt.textContent = `${fmt} → ${example}`;
|
||||
if (fmt === rawValue) opt.selected = true;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
} else if (type === 'template') {
|
||||
el = document.createElement('input');
|
||||
el.type = 'text';
|
||||
el.value = rawValue;
|
||||
el.style.width = '100%';
|
||||
el.style.boxSizing = 'border-box';
|
||||
} else {
|
||||
el = document.createElement('input');
|
||||
el.type = 'text';
|
||||
el.value = rawValue;
|
||||
el.style.width = '180px';
|
||||
}
|
||||
|
||||
cell.appendChild(el);
|
||||
el.focus();
|
||||
if (el.tagName === 'INPUT' && el.type === 'text') el.select();
|
||||
|
||||
let done = false;
|
||||
|
||||
async function commit() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
|
||||
const newVal = el.tagName === 'SELECT' ? el.value : el.value;
|
||||
cell.classList.add('bi-cell--saving');
|
||||
cell.innerHTML = '';
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = await saveField(formatId, field, newVal);
|
||||
} catch (_) {
|
||||
json = { error: 'Verbindungsfehler.' };
|
||||
}
|
||||
|
||||
cell.classList.remove('bi-cell--saving');
|
||||
|
||||
if (json.error) {
|
||||
cell.innerHTML = originalHTML;
|
||||
showError(cell, json.error);
|
||||
return;
|
||||
}
|
||||
|
||||
cell.dataset.value = newVal;
|
||||
cell.innerHTML = displayForCell(type, newVal, field);
|
||||
|
||||
// Formatname im Card-Header synchronisieren
|
||||
if (field === 'name') {
|
||||
const title = cell.closest('.card')?.querySelector('.card-title');
|
||||
if (title) title.dataset.value = newVal;
|
||||
}
|
||||
// Kürzel-Vorschau-Badge aktualisieren
|
||||
if (field === 'kuerzel') {
|
||||
const preview = cell.closest('div')?.querySelector('.bi-kuerzel-preview');
|
||||
if (preview) preview.textContent = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cell.innerHTML = originalHTML;
|
||||
}
|
||||
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.addEventListener('change', commit);
|
||||
el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); cancel(); } });
|
||||
el.addEventListener('blur', () => { if (!done) commit(); });
|
||||
} else {
|
||||
el.addEventListener('blur', commit);
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); el.blur(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); cancel(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Colorpicker für farbe ────────────────────────────────────────
|
||||
document.addEventListener('input', (e) => {
|
||||
const ci = e.target.closest('.bi-color');
|
||||
if (ci) updatePreview(ci, ci.value);
|
||||
});
|
||||
|
||||
document.addEventListener('change', async (e) => {
|
||||
const ci = e.target.closest('.bi-color');
|
||||
if (!ci) return;
|
||||
|
||||
const formatId = getFormatId(ci);
|
||||
const field = ci.dataset.field;
|
||||
const value = ci.value;
|
||||
ci.disabled = true;
|
||||
|
||||
let json;
|
||||
try { json = await saveField(formatId, field, value); }
|
||||
catch (_) { json = { error: 'Verbindungsfehler.' }; }
|
||||
|
||||
ci.disabled = false;
|
||||
|
||||
if (json.error) {
|
||||
showError(ci, json.error);
|
||||
} else {
|
||||
updatePreview(ci, value);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Format löschen ──────────────────────────────────────────────
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.bi-delete-format');
|
||||
if (!btn) return;
|
||||
|
||||
const formatId = btn.dataset.formatId;
|
||||
const formatName = btn.dataset.formatName;
|
||||
|
||||
if (!confirm(`Format „${formatName}" wirklich löschen?`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
let json;
|
||||
try {
|
||||
const res = await fetch(`/settings/bankimport/${formatId}/delete`, { method: 'POST' });
|
||||
json = await res.json();
|
||||
} catch (_) {
|
||||
json = { error: 'Verbindungsfehler.' };
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
alert(json.error);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.closest('.card').remove();
|
||||
});
|
||||
|
||||
// ── Vorschlags-Card verwerfen ────────────────────────────────────
|
||||
const dismissBtn = document.getElementById('suggestion-dismiss');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', async () => {
|
||||
dismissBtn.disabled = true;
|
||||
try {
|
||||
await fetch('/settings/bankimport/dismiss-suggestion', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
const card = document.getElementById('suggestion-card');
|
||||
if (card) card.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checkboxen für has_header_row ────────────────────────────────
|
||||
document.addEventListener('change', async (e) => {
|
||||
const cb = e.target.closest('.bi-checkbox');
|
||||
if (!cb) return;
|
||||
|
||||
const label = cb.closest('.bi-checkbox-label');
|
||||
const textSpan = label?.querySelector('.bi-checkbox-text');
|
||||
const field = cb.dataset.field;
|
||||
const formatId = getFormatId(cb);
|
||||
if (!formatId) return;
|
||||
|
||||
const newVal = cb.checked ? '1' : '0';
|
||||
cb.disabled = true;
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = await saveField(formatId, field, newVal);
|
||||
} catch (_) {
|
||||
json = { error: 'Verbindungsfehler.' };
|
||||
}
|
||||
|
||||
cb.disabled = false;
|
||||
|
||||
if (json.error) {
|
||||
cb.checked = !cb.checked; // Revert
|
||||
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
|
||||
alert(json.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -1 +1 @@
|
|||
1-260515
|
||||
1-260517
|
||||
|
|
|
|||
125
tests/32_BankTransactionTest.php
Normal file
125
tests/32_BankTransactionTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 32: BankTransaction – linkBeleg, unlinkBeleg, recalcStatus, findDuplicates.
|
||||
*
|
||||
* Legt eine Test-Bankbuchung an und prüft die Zuordnungslogik komplett durch.
|
||||
*/
|
||||
class BankTransactionTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 32: BankTransaction – Zuordnungslogik';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$formatId = (int)(\Steuerkram\Database::fetchOne('SELECT id FROM bank_import_formats LIMIT 1')['id'] ?? 0);
|
||||
if (!$formatId) {
|
||||
$this->assert(false, 'Kein bank_import_format vorhanden – Test übersprungen');
|
||||
return;
|
||||
}
|
||||
$this->assert(true, 'bank_import_format vorhanden');
|
||||
|
||||
// ── Hilfsfunktionen ────────────────────────────────────────────
|
||||
$batchId = \Steuerkram\Models\BankTransaction::createBatch($formatId, 'test-32.csv', 1);
|
||||
$this->assertGreaterThan(0, $batchId, 'Batch angelegt (id > 0)');
|
||||
|
||||
$date = date('Y-m-d');
|
||||
\Steuerkram\Models\BankTransaction::insertRow($batchId, [
|
||||
'buchungstag' => $date,
|
||||
'wertstellung' => $date,
|
||||
'vorgang' => 'Lastschrift',
|
||||
'buchungstext' => 'Test-Lieferant Rechnung 32',
|
||||
'wert' => '-119.00',
|
||||
'raw_line' => null,
|
||||
]);
|
||||
|
||||
$tx = \Steuerkram\Database::fetchOne(
|
||||
'SELECT id FROM bank_transactions WHERE batch_id = ? LIMIT 1',
|
||||
[$batchId]
|
||||
);
|
||||
$this->assert($tx !== false, 'Bankbuchung in DB gefunden');
|
||||
$txId = (int)$tx['id'];
|
||||
|
||||
// ── Status initial: offen ──────────────────────────────────────
|
||||
\Steuerkram\Models\BankTransaction::recalcStatus($txId);
|
||||
$row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]);
|
||||
$this->assertEquals('offen', $row['status'], 'Status nach Anlage: offen');
|
||||
|
||||
// ── Beleg anlegen ──────────────────────────────────────────────
|
||||
$belegId = \Steuerkram\Models\Beleg::create([
|
||||
'beleg_nr' => 'BT32-' . uniqid(),
|
||||
'filename' => 'bt32.pdf',
|
||||
'storage_path' => '',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', 'bt32-' . uniqid()),
|
||||
'beleg_datum' => $date,
|
||||
'belegtyp' => 'Eingangsrechnung',
|
||||
]);
|
||||
|
||||
// ── Teilzuordnung (60 €) → Status: teilweise ─────────────────
|
||||
\Steuerkram\Models\BankTransaction::linkBeleg($txId, $belegId, 60.00);
|
||||
$row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]);
|
||||
$this->assertEquals('teilweise', $row['status'], 'Status nach Teilzuordnung (60/119): teilweise');
|
||||
|
||||
$remaining = \Steuerkram\Models\BankTransaction::getRemainingBetrag($txId);
|
||||
$this->assertEquals(59.00, $remaining, 'Restbetrag nach 60-€-Zuordnung = 59,00');
|
||||
|
||||
// ── Vollzuordnung (restliche 59 €) → Status: gebucht ─────────
|
||||
$belegId2 = \Steuerkram\Models\Beleg::create([
|
||||
'beleg_nr' => 'BT32B-' . uniqid(),
|
||||
'filename' => 'bt32b.pdf',
|
||||
'storage_path' => '',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', 'bt32b-' . uniqid()),
|
||||
'beleg_datum' => $date,
|
||||
'belegtyp' => 'Eingangsrechnung',
|
||||
]);
|
||||
\Steuerkram\Models\BankTransaction::linkBeleg($txId, $belegId2, 59.00);
|
||||
$row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]);
|
||||
$this->assertEquals('gebucht', $row['status'], 'Status nach Vollzuordnung (60+59=119): gebucht');
|
||||
|
||||
// ── Verknüpfte Belege prüfen ───────────────────────────────────
|
||||
$linked = \Steuerkram\Models\BankTransaction::getLinkedBelege($txId);
|
||||
$this->assertEquals(2, count($linked), 'Anzahl verknüpfter Belege = 2');
|
||||
|
||||
// ── Zuordnung aufheben → Status: teilweise ────────────────────
|
||||
\Steuerkram\Models\BankTransaction::unlinkBeleg($txId, $belegId2);
|
||||
$row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]);
|
||||
$this->assertEquals('teilweise', $row['status'], 'Status nach unlinkBeleg: teilweise');
|
||||
|
||||
// ── Alle Zuordnungen aufheben → Status: offen ─────────────────
|
||||
\Steuerkram\Models\BankTransaction::unlinkBeleg($txId, $belegId);
|
||||
$row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]);
|
||||
$this->assertEquals('offen', $row['status'], 'Status nach vollständigem Unlink: offen');
|
||||
|
||||
// ── findDuplicates: leere Eingabe ─────────────────────────────
|
||||
$result = \Steuerkram\Models\BankTransaction::findDuplicates([]);
|
||||
$this->assertEquals(0, count($result['new']), 'findDuplicates([]) → new leer');
|
||||
$this->assertEquals(0, count($result['dup']), 'findDuplicates([]) → dup leer');
|
||||
|
||||
// ── findDuplicates: identische Buchung ist Duplikat ───────────
|
||||
$dupRow = [
|
||||
'buchungstag' => $date,
|
||||
'wertstellung' => $date,
|
||||
'vorgang' => 'Lastschrift',
|
||||
'buchungstext' => 'Test-Lieferant Rechnung 32',
|
||||
'wert' => '-119.00',
|
||||
];
|
||||
$result2 = \Steuerkram\Models\BankTransaction::findDuplicates([$dupRow]);
|
||||
$this->assertEquals(1, count($result2['dup']), 'Identische Buchung als Duplikat erkannt');
|
||||
$this->assertEquals(0, count($result2['new']), 'Keine neue Buchung bei Duplikat');
|
||||
|
||||
// ── findDuplicates: neue Buchung ist kein Duplikat ────────────
|
||||
$newRow = [
|
||||
'buchungstag' => $date,
|
||||
'wertstellung' => $date,
|
||||
'vorgang' => 'Gutschrift',
|
||||
'buchungstext' => 'Einnahme Test 32',
|
||||
'wert' => '500.00',
|
||||
];
|
||||
$result3 = \Steuerkram\Models\BankTransaction::findDuplicates([$newRow]);
|
||||
$this->assertEquals(1, count($result3['new']), 'Neue Buchung korrekt als neu erkannt');
|
||||
$this->assertEquals(0, count($result3['dup']), 'Keine Duplikate bei neuer Buchung');
|
||||
}
|
||||
}
|
||||
123
tests/33_BankImportParserTest.php
Normal file
123
tests/33_BankImportParserTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 33: BankImportParser – CSV-Parsing, Leerzeilen, ungültiges Format.
|
||||
*
|
||||
* Testet den BankImportParser mit echten CSV-Strings (als temp. Datei),
|
||||
* ohne eine Datei hochzuladen.
|
||||
*/
|
||||
class BankImportParserTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 33: BankImportParser – CSV-Parsing';
|
||||
|
||||
private function makeTempCsv(string $content): string
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/test33_' . uniqid() . '.csv';
|
||||
file_put_contents($path, $content);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function comdirectFormat(): array
|
||||
{
|
||||
return [
|
||||
'skip_rows' => 3,
|
||||
'has_header_row' => 1,
|
||||
'delimiter' => ';',
|
||||
'enclosure' => '"',
|
||||
'col_buchungstag' => 0,
|
||||
'col_wertstellung' => 1,
|
||||
'col_vorgang' => 2,
|
||||
'col_buchungstext' => 3,
|
||||
'col_wert' => 4,
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
];
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$parser = new \Steuerkram\Services\BankImportParser();
|
||||
$fmt = $this->comdirectFormat();
|
||||
|
||||
// ── Normales CSV: 2 Buchungen ─────────────────────────────────
|
||||
$csv = "Zeile1\n"
|
||||
. "Zeile2\n"
|
||||
. "Zeile3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "15.05.2026;15.05.2026;Lastschrift;Lieferant A;\"-119,00\"\n"
|
||||
. "14.05.2026;14.05.2026;Gutschrift;Kunde B;\"500,00\"\n"
|
||||
. "\n";
|
||||
|
||||
$tmp = $this->makeTempCsv($csv);
|
||||
$rows = $parser->parse($tmp, 'csv', $fmt);
|
||||
unlink($tmp);
|
||||
|
||||
$this->assertEquals(2, count($rows), 'Normales CSV: 2 Buchungen geparsed');
|
||||
$this->assertEquals('2026-05-15', $rows[0]['buchungstag'], 'Buchungstag korrekt geparst');
|
||||
$this->assertEquals('2026-05-15', $rows[0]['wertstellung'], 'Wertstellung korrekt geparst');
|
||||
$this->assertEquals('Lastschrift', $rows[0]['vorgang'], 'Vorgang korrekt geparst');
|
||||
$this->assertEquals(-119.00, $rows[0]['wert'], 'Betrag -119,00 korrekt geparst');
|
||||
$this->assertEquals(500.00, $rows[1]['wert'], 'Betrag 500,00 korrekt geparst');
|
||||
|
||||
// ── Leerzeilen werden übersprungen ────────────────────────────
|
||||
$csvMitLeeren = "Z1\nZ2\nZ3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "15.05.2026;15.05.2026;Lastschrift;Lieferant;\"-50,00\"\n"
|
||||
. ";;;\n"
|
||||
. "\n"
|
||||
. "14.05.2026;14.05.2026;Gutschrift;Kunde;\"200,00\"\n";
|
||||
|
||||
$tmp2 = $this->makeTempCsv($csvMitLeeren);
|
||||
$rows2 = $parser->parse($tmp2, 'csv', $fmt);
|
||||
unlink($tmp2);
|
||||
|
||||
$this->assertEquals(2, count($rows2), 'Leerzeilen werden übersprungen (2 von 3 Zeilen)');
|
||||
|
||||
// ── Leere Datei gibt leeres Array zurück ──────────────────────
|
||||
$csvLeer = "Z1\nZ2\nZ3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "\n";
|
||||
|
||||
$tmp3 = $this->makeTempCsv($csvLeer);
|
||||
$rows3 = $parser->parse($tmp3, 'csv', $fmt);
|
||||
unlink($tmp3);
|
||||
|
||||
$this->assertEquals(0, count($rows3), 'Nur-Header-CSV: leeres Ergebnis');
|
||||
|
||||
// ── Ungültiger Dateipfad wirft RuntimeException ───────────────
|
||||
$exceptionThrown = false;
|
||||
try {
|
||||
$parser->parse('/tmp/nicht_vorhanden_xyz_33.csv', 'csv', $fmt);
|
||||
} catch (\RuntimeException $e) {
|
||||
$exceptionThrown = true;
|
||||
}
|
||||
$this->assert($exceptionThrown, 'Nicht-vorhandene Datei wirft RuntimeException');
|
||||
|
||||
// ── UTF-8 BOM wird korrekt entfernt ───────────────────────────
|
||||
$csvBom = "\xEF\xBB\xBFZ1\nZ2\nZ3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "13.05.2026;13.05.2026;Lastschrift;BOM-Test;\"-99,00\"\n";
|
||||
|
||||
$tmp4 = $this->makeTempCsv($csvBom);
|
||||
$rows4 = $parser->parse($tmp4, 'csv', $fmt);
|
||||
unlink($tmp4);
|
||||
|
||||
$this->assertEquals(1, count($rows4), 'CSV mit UTF-8-BOM: 1 Buchung geparst');
|
||||
$this->assertEquals(-99.00, $rows4[0]['wert'], 'Betrag trotz BOM korrekt geparst');
|
||||
|
||||
// ── Tausendertrennzeichen korrekt entfernt ────────────────────
|
||||
$csvTausend = "Z1\nZ2\nZ3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "12.05.2026;12.05.2026;Gutschrift;Grosskunde;\"-1.234,56\"\n";
|
||||
|
||||
$tmp5 = $this->makeTempCsv($csvTausend);
|
||||
$rows5 = $parser->parse($tmp5, 'csv', $fmt);
|
||||
unlink($tmp5);
|
||||
|
||||
$this->assertEquals(1, count($rows5), 'CSV mit Tausendertrenner: 1 Buchung');
|
||||
$this->assertEquals(-1234.56, $rows5[0]['wert'], 'Betrag -1.234,56 korrekt geparst');
|
||||
}
|
||||
}
|
||||
145
tests/34_BankImportDkbTest.php
Normal file
145
tests/34_BankImportDkbTest.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 34: BankImportParser – DKB-Format mit Template-basiertem Buchungstext.
|
||||
*/
|
||||
class BankImportDkbTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 34: BankImportParser – DKB-Format (Template)';
|
||||
|
||||
private function makeTempCsv(string $content): string
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/test34_' . uniqid() . '.csv';
|
||||
file_put_contents($path, $content);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function dkbFormat(): array
|
||||
{
|
||||
return [
|
||||
'skip_rows' => 4,
|
||||
'has_header_row' => 1,
|
||||
'delimiter' => ';',
|
||||
'enclosure' => '"',
|
||||
'col_buchungstag' => 0,
|
||||
'col_wertstellung' => 1,
|
||||
'col_vorgang' => 6,
|
||||
'col_buchungstext' => 5,
|
||||
'col_wert' => 8,
|
||||
'buchungstext_template_debit' => 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}',
|
||||
'buchungstext_template_credit' => 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}',
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
];
|
||||
}
|
||||
|
||||
private function dkbHeader(): string
|
||||
{
|
||||
return '"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";'
|
||||
. '"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";'
|
||||
. '"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"';
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$parser = new \Steuerkram\Services\BankImportParser();
|
||||
$fmt = $this->dkbFormat();
|
||||
|
||||
// ── Negativer Betrag: Zahlungsempfänger*in im Buchungstext ───────
|
||||
$csv = "Skip1\nSkip2\nSkip3\nSkip4\n"
|
||||
. $this->dkbHeader() . "\n"
|
||||
. '"15.05.2026";"15.05.2026";"Gebucht";"Max Muster";"Amazon GmbH";"Abo Monatsbeitrag";"Lastschrift";"DE123";"- 9,99";"GL01";"MR01";"KR01"' . "\n";
|
||||
|
||||
$tmp = $this->makeTempCsv($csv);
|
||||
$rows = $parser->parse($tmp, 'csv', $fmt);
|
||||
unlink($tmp);
|
||||
|
||||
$this->assertEquals(1, count($rows), 'DKB negativer Betrag: 1 Buchung geparst');
|
||||
$this->assertEquals('2026-05-15', $rows[0]['buchungstag'], 'Buchungstag korrekt');
|
||||
$this->assertEquals('2026-05-15', $rows[0]['wertstellung'], 'Wertstellung korrekt');
|
||||
$this->assertEquals('Lastschrift', $rows[0]['vorgang'], 'Umsatztyp als vorgang');
|
||||
$this->assertEquals(-9.99, $rows[0]['wert'], 'Betrag -9,99 korrekt');
|
||||
$this->assertEquals(
|
||||
'Auftraggeber: Amazon GmbH Buchungstext: Abo Monatsbeitrag',
|
||||
$rows[0]['buchungstext'],
|
||||
'Negativer Betrag: Template debit mit Zahlungsempfänger*in'
|
||||
);
|
||||
|
||||
// ── Positiver Betrag: Zahlungspflichtige*r im Buchungstext ───────
|
||||
$csv2 = "Skip1\nSkip2\nSkip3\nSkip4\n"
|
||||
. $this->dkbHeader() . "\n"
|
||||
. '"14.05.2026";"14.05.2026";"Gebucht";"Kunde AG";"Eigenkonto";"Rechnung 2026-042";"Gutschrift";"DE456";"1.500,00";"";"";"KR02"' . "\n";
|
||||
|
||||
$tmp2 = $this->makeTempCsv($csv2);
|
||||
$rows2 = $parser->parse($tmp2, 'csv', $fmt);
|
||||
unlink($tmp2);
|
||||
|
||||
$this->assertEquals(1, count($rows2), 'DKB positiver Betrag: 1 Buchung geparst');
|
||||
$this->assertEquals(1500.00, $rows2[0]['wert'], 'Betrag 1.500,00 korrekt');
|
||||
$this->assertEquals(
|
||||
'Auftraggeber: Kunde AG Buchungstext: Rechnung 2026-042',
|
||||
$rows2[0]['buchungstext'],
|
||||
'Positiver Betrag: Template credit mit Zahlungspflichtige*r'
|
||||
);
|
||||
|
||||
// ── Betrag 0: credit-Template (>= 0) ────────────────────────────
|
||||
$csv3 = "Skip1\nSkip2\nSkip3\nSkip4\n"
|
||||
. $this->dkbHeader() . "\n"
|
||||
. '"13.05.2026";"13.05.2026";"Gebucht";"Nullbetrag GmbH";"Gegenkonto";"Storno";"Umbuchung";"DE789";"0,00";"";"";"KR03"' . "\n";
|
||||
|
||||
$tmp3 = $this->makeTempCsv($csv3);
|
||||
$rows3 = $parser->parse($tmp3, 'csv', $fmt);
|
||||
unlink($tmp3);
|
||||
|
||||
$this->assertEquals(1, count($rows3), 'DKB Betrag 0: 1 Buchung geparst');
|
||||
$this->assertEquals(
|
||||
'Auftraggeber: Nullbetrag GmbH Buchungstext: Storno',
|
||||
$rows3[0]['buchungstext'],
|
||||
'Betrag 0: credit-Template genutzt'
|
||||
);
|
||||
|
||||
// ── Unbekannte Placeholder werden als leer aufgelöst ─────────────
|
||||
$fmtUnknown = $fmt;
|
||||
$fmtUnknown['buchungstext_template_debit'] = 'Test: {NichtVorhanden} Ende';
|
||||
$csv5 = "Skip1\nSkip2\nSkip3\nSkip4\n"
|
||||
. $this->dkbHeader() . "\n"
|
||||
. '"12.05.2026";"12.05.2026";"Gebucht";"A";"B";"C";"Lastschrift";"DE";"- 1,00";"";"";"KR05"' . "\n";
|
||||
|
||||
$tmp5 = $this->makeTempCsv($csv5);
|
||||
$rows5 = $parser->parse($tmp5, 'csv', $fmtUnknown);
|
||||
unlink($tmp5);
|
||||
|
||||
$this->assertEquals('Test: Ende', $rows5[0]['buchungstext'],
|
||||
'Unbekannter Placeholder wird als leerer String aufgelöst');
|
||||
|
||||
// ── Format ohne Template: col_buchungstext wird direkt genutzt ───
|
||||
$fmtStd = [
|
||||
'skip_rows' => 3,
|
||||
'has_header_row' => 1,
|
||||
'delimiter' => ';',
|
||||
'enclosure' => '"',
|
||||
'col_buchungstag' => 0,
|
||||
'col_wertstellung' => 1,
|
||||
'col_vorgang' => 2,
|
||||
'col_buchungstext' => 3,
|
||||
'col_wert' => 4,
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
];
|
||||
|
||||
$csv4 = "Z1\nZ2\nZ3\n"
|
||||
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
|
||||
. "15.05.2026;15.05.2026;Lastschrift;Normaler Text;\"-50,00\"\n";
|
||||
|
||||
$tmp4 = $this->makeTempCsv($csv4);
|
||||
$rows4 = $parser->parse($tmp4, 'csv', $fmtStd);
|
||||
unlink($tmp4);
|
||||
|
||||
$this->assertEquals('Normaler Text', $rows4[0]['buchungstext'],
|
||||
'Format ohne Template: col_buchungstext direkt genutzt');
|
||||
}
|
||||
}
|
||||
71
tests/35_BankImportSuggestTest.php
Normal file
71
tests/35_BankImportSuggestTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Services\BankImportParser;
|
||||
|
||||
/**
|
||||
* Test 35: BankImportParser::suggestFormat() – automatische Format-Erkennung.
|
||||
*/
|
||||
class BankImportSuggestTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 35: BankImportParser – automatische Format-Erkennung';
|
||||
|
||||
private function makeTempCsv(string $content): string
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/test35_' . uniqid() . '.csv';
|
||||
file_put_contents($path, $content);
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$parser = new BankImportParser();
|
||||
|
||||
// CSV mit 2 Metazeilen vor dem Header (DKB-ähnlich)
|
||||
$csv = implode("\n", [
|
||||
'"Kontonummer:";"DE12 3456 7890 1234 5678 90";',
|
||||
'"Von:";"01.01.2026";"Bis:";"31.01.2026";',
|
||||
'"Kontostand vom 31.01.2026:";"1.234,56 EUR";',
|
||||
'"";',
|
||||
'"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)"',
|
||||
'"15.01.2026";"15.01.2026";"Gebucht";"Max Mustermann";"REWE";"Lebensmittel";"Lastschrift";"DE11111";"−45,30"',
|
||||
]);
|
||||
|
||||
$file = $this->makeTempCsv($csv);
|
||||
$result = $parser->suggestFormat($file, 'csv');
|
||||
|
||||
$this->assert($result !== null, 'suggestFormat() gibt ein Ergebnis zurück');
|
||||
$this->assertEquals(4, $result['skip_rows'], 'skip_rows korrekt erkannt (4)');
|
||||
$this->assertEquals(';', $result['delimiter'], 'Delimiter korrekt erkannt (;)');
|
||||
$this->assertEquals(1, $result['has_header_row'], 'has_header_row gesetzt');
|
||||
$this->assertNotEmpty($result['header'], 'header-Array nicht leer');
|
||||
$this->assertNotEmpty($result['mapping'], 'mapping-Array nicht leer');
|
||||
$this->assert(isset($result['mapping']['col_buchungstag']), 'col_buchungstag gemappt');
|
||||
$this->assert(isset($result['mapping']['col_wertstellung']), 'col_wertstellung gemappt');
|
||||
$this->assert(isset($result['mapping']['col_wert']), 'col_wert gemappt (Betrag)');
|
||||
|
||||
unlink($file);
|
||||
|
||||
// CSV ohne Banking-Keywords → kein Vorschlag
|
||||
$csvOhneKeywords = implode("\n", [
|
||||
'Foo;Bar;Baz',
|
||||
'1;2;3',
|
||||
]);
|
||||
$file2 = $this->makeTempCsv($csvOhneKeywords);
|
||||
$result2 = $parser->suggestFormat($file2, 'csv');
|
||||
$this->assert($result2 === null, 'suggestFormat() gibt null zurück wenn kein Banking-Header erkannt');
|
||||
unlink($file2);
|
||||
|
||||
// Comdirect-ähnliche CSV (kein skip_rows)
|
||||
$csvComdirect = implode("\n", [
|
||||
'"Buchungstag";"Wertstellung";"Vorgang";"Buchungstext";"Betrag (€)"',
|
||||
'"10.01.2026";"10.01.2026";"Lastschrift";"Amazon";"−19,99"',
|
||||
]);
|
||||
$file3 = $this->makeTempCsv($csvComdirect);
|
||||
$result3 = $parser->suggestFormat($file3, 'csv');
|
||||
$this->assert($result3 !== null, 'suggestFormat() erkennt Comdirect-ähnliche CSV');
|
||||
$this->assertEquals(0, $result3['skip_rows'], 'skip_rows=0 bei direktem Header');
|
||||
unlink($file3);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue