Feature: Dublettenprüfung beim Bank-Import mit interaktiver Bestätigungsseite
This commit is contained in:
parent
9d54e92632
commit
eede667809
6 changed files with 267 additions and 1 deletions
12
Changes.md
12
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
|
||||
|
|
|
|||
3
migrations/052_drop_bank_tx_unique.sql
Normal file
3
migrations/052_drop_bank_tx_unique.sql
Normal 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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
150
src/Views/bank/upload_confirm.php
Normal file
150
src/Views/bank/upload_confirm.php
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue