From eede667809c8a69ef722b3153a53736c78e4748c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Fri, 15 May 2026 16:58:49 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Dublettenpr=C3=BCfung=20beim=20Bank-?= =?UTF-8?q?Import=20mit=20interaktiver=20Best=C3=A4tigungsseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 12 ++ migrations/052_drop_bank_tx_unique.sql | 3 + public/index.php | 1 + src/Controllers/BankController.php | 53 ++++++++- src/Models/BankTransaction.php | 49 ++++++++ src/Views/bank/upload_confirm.php | 150 +++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 migrations/052_drop_bank_tx_unique.sql create mode 100644 src/Views/bank/upload_confirm.php diff --git a/Changes.md b/Changes.md index 876561a..73b8508 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-05-15 – Feature: Dublettenprüfung beim Bank-Import + +- Nach dem Upload wird geprüft, welche Buchungen bereits in der DB vorhanden sind (Abgleich über Buchungstag, Wertstellung, Betrag, Buchungstext) +- Werden Dubletten gefunden, erscheint eine Prüfseite mit zwei Tabellen: erkannte Dubletten (per Checkbox auswählbar) und neue Buchungen (werden immer importiert) +- „Alle auswählen / abwählen"-Buttons + Gesamtzähler zeigen an, wie viele Buchungen importiert werden +- Erst nach Bestätigung wird importiert; ohne Auswahl wird der Import übersprungen +- Migration 052: UNIQUE-Constraint auf `bank_transactions` entfernt (UI-Prüfung ist die primäre Schutzschicht) +- `BankTransaction::findDuplicates()`: neue Methode mit Batch-Abfrage gegen die DB +- `BankController::doImport()`: gemeinsame Import-Logik extrahiert (wird von `upload()` und `uploadConfirm()` genutzt) + +--- + ## 2026-05-15 – Fix: Bank-Import-Duplikate verhindern - Migration 051: UNIQUE-Constraint `(buchungstag, wertstellung, wert, buchungstext(200))` auf `bank_transactions`; bestehende Duplikate wurden dabei bereinigt diff --git a/migrations/052_drop_bank_tx_unique.sql b/migrations/052_drop_bank_tx_unique.sql new file mode 100644 index 0000000..48cde5f --- /dev/null +++ b/migrations/052_drop_bank_tx_unique.sql @@ -0,0 +1,3 @@ +-- UNIQUE-Constraint entfernt: Dublettenprüfung erfolgt jetzt über die UI vor dem Import. +-- Force-Import durch den Nutzer wäre mit einem UNIQUE-Constraint nicht möglich. +ALTER TABLE bank_transactions DROP INDEX IF EXISTS uq_bank_tx; diff --git a/public/index.php b/public/index.php index e7af523..5e87e91 100644 --- a/public/index.php +++ b/public/index.php @@ -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/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)); $router->post('/bank/{id}/link', fn($p) => (new BankController())->linkBeleg($p)); diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index 2e70883..3f847e2 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -88,9 +88,60 @@ class BankController View::redirect('/bank/upload', 'Diese Datei wurde bereits importiert (' . $origName . ').', 'error'); } + $split = BankTransaction::findDuplicates($rows); + + if (!empty($split['dup'])) { + $_SESSION['bank_import_pending'] = [ + 'format_id' => $formatId, + 'filename' => $origName, + 'new_rows' => $split['new'], + 'dup_rows' => $split['dup'], + ]; + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Dublettenprüfung', + 'content' => $this->renderView('bank/upload_confirm', [ + 'new_rows' => $split['new'], + 'dup_rows' => $split['dup'], + 'filename' => $origName, + ]), + ]); + return; + } + + $this->doImport($formatId, $origName, $rows); + } + + public function uploadConfirm(array $params): void + { + $pending = $_SESSION['bank_import_pending'] ?? null; + if (!$pending) { + View::redirect('/bank/upload', 'Keine ausstehenden Import-Daten gefunden.', 'error'); + } + unset($_SESSION['bank_import_pending']); + + $selectedIdx = array_map('intval', (array)($_POST['dup_idx'] ?? [])); + + $rows = $pending['new_rows']; + foreach ($selectedIdx as $idx) { + if (isset($pending['dup_rows'][$idx])) { + $rows[] = $pending['dup_rows'][$idx]; + } + } + + if (empty($rows)) { + View::redirect('/bank', 'Import abgebrochen – keine Buchungen ausgewählt.'); + } + + $this->doImport($pending['format_id'], $pending['filename'], $rows); + } + + private function doImport(int $formatId, string $filename, array $rows): void + { Database::beginTransaction(); try { - $batchId = BankTransaction::createBatch($formatId, $origName, count($rows)); + $batchId = BankTransaction::createBatch($formatId, $filename, count($rows)); foreach ($rows as $row) { BankTransaction::insertRow($batchId, $row); } diff --git a/src/Models/BankTransaction.php b/src/Models/BankTransaction.php index a011e01..1b6b3b1 100644 --- a/src/Models/BankTransaction.php +++ b/src/Models/BankTransaction.php @@ -96,6 +96,55 @@ class BankTransaction ); } + /** + * Teilt $rows in ['new' => [...], 'dup' => [...]] auf. + * Eine Zeile gilt als Dublette, wenn buchungstag + wertstellung + wert + buchungstext(200) bereits existieren. + */ + public static function findDuplicates(array $rows): array + { + if (empty($rows)) { + return ['new' => [], 'dup' => []]; + } + + // Kandidaten-Abfrage: alle DB-Zeilen, deren (buchungstag, wertstellung, wert) in den Upload-Daten vorkommen + $placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)')); + $params = []; + foreach ($rows as $r) { + $params[] = $r['buchungstag']; + $params[] = $r['wertstellung']; + $params[] = (string)$r['wert']; + } + + $existing = Database::fetchAll( + "SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert, LEFT(buchungstext,200) AS btext + FROM bank_transactions + WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)", + $params + ); + + // Lookup-Set für schnellen Vergleich + $lookup = []; + foreach ($existing as $e) { + $lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true; + } + + $new = []; + $dup = []; + foreach ($rows as $row) { + $key = $row['buchungstag'] . '|' + . $row['wertstellung'] . '|' + . (string)$row['wert'] . '|' + . mb_substr($row['buchungstext'], 0, 200); + if (isset($lookup[$key])) { + $dup[] = $row; + } else { + $new[] = $row; + } + } + + return ['new' => $new, 'dup' => $dup]; + } + public static function filenameExists(string $filename): bool { $row = Database::fetchOne( diff --git a/src/Views/bank/upload_confirm.php b/src/Views/bank/upload_confirm.php new file mode 100644 index 0000000..c419762 --- /dev/null +++ b/src/Views/bank/upload_confirm.php @@ -0,0 +1,150 @@ + View::h($v); ?> + $d ? date('d.m.Y', strtotime($d)) : '–'; +$fmtMoney = function($v) { + $v = (float)$v; + $cls = $v >= 0 ? 'color:var(--color-success)' : 'color:var(--color-danger)'; + return '' . number_format($v, 2, ',', '.') . ' €'; +}; +?> + +
+ +
+ Dublettenprüfung: Beim Import von wurden + bereits vorhandene Buchung gefunden. + 0): ?> + neue Buchung werden in jedem Fall importiert. + + Es gibt keine neuen Buchungen. + +
+ +
+ +
+
+
+

+ Bereits vorhandene Buchungen + +

+
+ + +
+
+ + + + + + + + + + + + + $row): ?> + + + + + + + + + +
+ + DatumBuchungstextVorgangBetrag
+ + + + 120): ?>… +
+
+
+ + 0): ?> +
+
+

+ Neue Buchungen (werden importiert) + +

+ + + + + + + + + + + + + + + + + + + +
DatumBuchungstextVorgangBetrag
+ + 120): ?>… +
+
+
+ + +
+ + Abbrechen + +
+ +
+
+ +