Feature: Dublettenprüfung beim Bank-Import mit interaktiver Bestätigungsseite

This commit is contained in:
fds2610-bot 2026-05-15 16:58:49 +02:00
parent 9d54e92632
commit eede667809
6 changed files with 267 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,150 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php
$fmtDate = fn($d) => $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 '<span style="' . $cls . '">' . number_format($v, 2, ',', '.') . ' €</span>';
};
?>
<div style="max-width:900px">
<div class="flash flash-warning" style="margin-bottom:1.5rem">
<strong>Dublettenprüfung:</strong> Beim Import von <em><?= $h($filename) ?></em> wurden
<strong><?= count($dup_rows) ?></strong> bereits vorhandene Buchung<?= count($dup_rows) !== 1 ? 'en' : '' ?> gefunden.
<?php if (count($new_rows) > 0): ?>
<strong><?= count($new_rows) ?></strong> neue Buchung<?= count($new_rows) !== 1 ? 'en' : '' ?> werden in jedem Fall importiert.
<?php else: ?>
Es gibt keine neuen Buchungen.
<?php endif; ?>
</div>
<form method="post" action="/bank/upload/confirm" id="confirm-form">
<div class="card" style="margin-bottom:1.5rem">
<div class="card-body" style="padding-bottom:.75rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
<h3 style="margin:0;font-size:1rem">
Bereits vorhandene Buchungen
<span class="badge badge-warning" style="margin-left:.5rem"><?= count($dup_rows) ?></span>
</h3>
<div style="display:flex;gap:.5rem">
<button type="button" class="btn btn-secondary btn-sm" id="btn-all">Alle auswählen</button>
<button type="button" class="btn btn-secondary btn-sm" id="btn-none">Alle abwählen</button>
</div>
</div>
<table class="table" style="margin-bottom:0">
<thead>
<tr>
<th style="width:2.5rem;text-align:center">
<input type="checkbox" id="cb-all" title="Alle umschalten">
</th>
<th>Datum</th>
<th>Buchungstext</th>
<th>Vorgang</th>
<th class="td-right">Betrag</th>
</tr>
</thead>
<tbody>
<?php foreach ($dup_rows as $idx => $row): ?>
<tr class="dup-row">
<td style="text-align:center">
<input type="checkbox" name="dup_idx[]" value="<?= $idx ?>" class="dup-cb">
</td>
<td class="td-mono" style="white-space:nowrap"><?= $fmtDate($row['wertstellung']) ?></td>
<td style="font-size:.85rem;max-width:400px">
<?= $h(mb_substr($row['buchungstext'], 0, 120)) ?>
<?php if (mb_strlen($row['buchungstext']) > 120): ?>…<?php endif; ?>
</td>
<td style="font-size:.85rem;color:var(--color-text-muted)"><?= $h($row['vorgang']) ?></td>
<td class="td-right td-mono"><?= $fmtMoney($row['wert']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (count($new_rows) > 0): ?>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-body" style="padding-bottom:.75rem">
<h3 style="margin:0 0 .75rem;font-size:1rem">
Neue Buchungen (werden importiert)
<span class="badge badge-success" style="margin-left:.5rem"><?= count($new_rows) ?></span>
</h3>
<table class="table" style="margin-bottom:0">
<thead>
<tr>
<th>Datum</th>
<th>Buchungstext</th>
<th>Vorgang</th>
<th class="td-right">Betrag</th>
</tr>
</thead>
<tbody>
<?php foreach ($new_rows as $row): ?>
<tr>
<td class="td-mono" style="white-space:nowrap"><?= $fmtDate($row['wertstellung']) ?></td>
<td style="font-size:.85rem;max-width:400px">
<?= $h(mb_substr($row['buchungstext'], 0, 120)) ?>
<?php if (mb_strlen($row['buchungstext']) > 120): ?>…<?php endif; ?>
</td>
<td style="font-size:.85rem;color:var(--color-text-muted)"><?= $h($row['vorgang']) ?></td>
<td class="td-right td-mono"><?= $fmtMoney($row['wert']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div style="display:flex;gap:.75rem;align-items:center">
<button type="submit" class="btn btn-primary" id="btn-submit">
Import bestätigen
</button>
<a href="/bank/upload" class="btn btn-secondary">Abbrechen</a>
<span id="submit-hint" class="text-muted text-sm" style="margin-left:.25rem"></span>
</div>
</form>
</div>
<script>
(function () {
const cbs = () => document.querySelectorAll('.dup-cb');
const cbAll = document.getElementById('cb-all');
const hint = document.getElementById('submit-hint');
const btnAll = document.getElementById('btn-all');
const btnNone = document.getElementById('btn-none');
const newCount = <?= count($new_rows) ?>;
function updateHint() {
const checked = document.querySelectorAll('.dup-cb:checked').length;
const total = checked + newCount;
hint.textContent = total > 0
? total + ' Buchung' + (total !== 1 ? 'en' : '') + ' werden importiert'
: 'Keine Buchungen ausgewählt – Import wird übersprungen';
}
function setAll(val) {
cbs().forEach(cb => { cb.checked = val; });
cbAll.checked = val;
updateHint();
}
cbAll.addEventListener('change', () => setAll(cbAll.checked));
btnAll.addEventListener('click', () => setAll(true));
btnNone.addEventListener('click', () => setAll(false));
document.addEventListener('change', e => {
if (e.target.classList.contains('dup-cb')) {
cbAll.checked = document.querySelectorAll('.dup-cb:not(:checked)').length === 0;
updateHint();
}
});
updateHint();
})();
</script>