Merge pull request 'Bank-Import: Dublettenprüfung, Fixes & Beleg-Verknüpfung' (#70) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/70
This commit is contained in:
fds2610 2026-05-15 17:14:54 +02:00
commit 267fa7cd1e
11 changed files with 372 additions and 12 deletions

View file

@ -2,6 +2,33 @@
---
## 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
- `BankController::upload()`: Import läuft jetzt in einer DB-Transaktion – bei Fehler wird alles zurückgerollt statt einen unvollständigen Batch zu hinterlassen
- `BankController::upload()`: Dateiname-Prüfung vor dem Import – erneuter Upload derselben Datei wird mit Fehlermeldung abgelehnt
- `BankTransaction::filenameExists()`: neue Hilfsmethode zur Duplikat-Prüfung anhand Dateiname
---
## 2026-05-15 – Fix: Netto-Neuberechnung bei Kategorie-Wechsel in Bank-Buchungsmaske
- IIFE-Wrapper für `updateAutoUst` in einen `DOMContentLoaded`-Listener verschoben, damit er erst nach der Funktionsdefinition (späteres `<script>`-Tag) gesetzt wird und nicht sofort von ihr überschrieben wird.
---
## 2026-05-14 – Feature: Bank buchen
- Navigation: neuer Eintrag „Bank buchen" unter Belege & Buchungen

View file

@ -0,0 +1,22 @@
-- Duplikate entfernen: pro Gruppe (buchungstag, wertstellung, wert, buchungstext) die niedrigste id behalten
DELETE bt
FROM bank_transactions bt
INNER JOIN (
SELECT MIN(id) AS keep_id, buchungstag, wertstellung, wert, buchungstext
FROM bank_transactions
GROUP BY buchungstag, wertstellung, wert, buchungstext
HAVING COUNT(*) > 1
) dup ON bt.buchungstag = dup.buchungstag
AND bt.wertstellung = dup.wertstellung
AND bt.wert = dup.wert
AND bt.buchungstext = dup.buchungstext
AND bt.id > dup.keep_id;
-- Unvollständige Batches entfernen (row_count stimmt nicht mit tatsächlichen Zeilen überein)
DELETE FROM bank_import_batches
WHERE id NOT IN (SELECT DISTINCT batch_id FROM bank_transactions)
AND row_count > 0;
-- UNIQUE-Constraint gegen Doppel-Importe
ALTER TABLE bank_transactions
ADD CONSTRAINT uq_bank_tx UNIQUE (buchungstag, wertstellung, wert, buchungstext(200));

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

@ -6,6 +6,7 @@ namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Services\BankImportParser;
use Steuerkram\View;
@ -83,9 +84,67 @@ class BankController
View::redirect('/bank/upload', 'Keine Buchungszeilen in der Datei gefunden.', 'error');
}
$batchId = BankTransaction::createBatch($formatId, $origName, count($rows));
foreach ($rows as $row) {
BankTransaction::insertRow($batchId, $row);
$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, $filename, count($rows));
foreach ($rows as $row) {
BankTransaction::insertRow($batchId, $row);
}
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
}
View::redirect('/bank', count($rows) . ' Buchungen importiert.');

View file

@ -243,6 +243,8 @@ class BelegController
}
}
$linkedBankTx = BankTransaction::findByBelegId((int)$beleg['id']);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
@ -260,6 +262,7 @@ class BelegController
'bankTxSupplier' => $bankTxSupplier,
'bankTxBrutto' => $bankTxBrutto,
'formData' => $bankFormData,
'linkedBankTx' => $linkedBankTx,
]),
]);
}

View file

@ -50,10 +50,12 @@ class BankTransaction
)['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,
b.id AS beleg_id, 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 belege b ON b.id = bt.beleg_id
WHERE $whereStr
ORDER BY bt.wertstellung DESC, bt.id DESC
LIMIT ? OFFSET ?",
@ -96,6 +98,68 @@ 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 findByBelegId(int $belegId): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename
FROM bank_transactions bt
JOIN bank_import_batches bib ON bib.id = bt.batch_id
WHERE bt.beleg_id = ?
LIMIT 1',
[$belegId]
);
return $row ?: null;
}
public static function createBatch(int $formatId, string $filename, int $rowCount): int
{
Database::execute(

View file

@ -82,8 +82,11 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
$wert = (float)$row['wert'];
$isPlus = $wert >= 0;
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
$ziel = ($row['status'] === 'gebucht' && !empty($row['beleg_id']))
? '/belege/' . (int)$row['beleg_id']
: '/bank/' . (int)$row['id'];
?>
<tr style="cursor:pointer" onclick="window.location='/bank/<?= (int)$row['id'] ?>'">
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
<td>
<div><?= $h($row['buchungstext']) ?></div>
@ -103,6 +106,9 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
};
?>
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
<?php if ($row['status'] === 'gebucht' && !empty($row['beleg_nr'])): ?>
<div class="text-muted text-sm" style="margin-top:.25rem"><?= $h($row['beleg_nr']) ?></div>
<?php endif; ?>
</td>
<td onclick="event.stopPropagation()">
<?php if ($row['status'] !== 'ignoriert'): ?>

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>

View file

@ -295,6 +295,28 @@ $suppliers = $suppliers ?? [];
</div>
</div>
<!-- Gespeicherte Bankbuchungs-Verknüpfung -->
<?php if ($linkedBankTx ?? null): ?>
<?php $lbt = $linkedBankTx; $lbwert = (float)$lbt['wert']; ?>
<a href="/bank/<?= (int)$lbt['id'] ?>" class="card mb-2"
style="display:block;text-decoration:none;color:inherit;border-left:4px solid var(--color-success)">
<div class="card-body" style="padding:.6rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<span class="badge badge-green" style="font-size:.8rem">Bankbuchung</span>
<span class="td-mono text-sm"><?= View::formatDate($lbt['wertstellung']) ?></span>
<?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"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
</strong>
</div>
</div>
</a>
<?php endif; ?>
<!-- BankBuchung-Badge (ersetzt KI-Badge wenn aus Bank-Kontext) -->
<?php if (!$beleg['locked'] && ($bankTx ?? null)): ?>
<div class="card mb-2" style="border-left:4px solid var(--color-primary,#3b82f6)">
@ -550,12 +572,15 @@ $suppliers = $suppliers ?? [];
nettoInput.addEventListener('input', function () { manualNetto = true; });
ustSelect.addEventListener('change', recalcNetto);
// updateAutoUst setzt den USt-Satz per JS ohne change-Event → ummanteln
const _origAutoUst = window.updateAutoUst;
window.updateAutoUst = function () {
_origAutoUst.apply(this, arguments);
recalcNetto();
};
// updateAutoUst ist in einem späteren <script>-Block definiert und zum jetzigen
// Zeitpunkt noch nicht vorhanden – Wrapper daher erst in DOMContentLoaded setzen.
document.addEventListener('DOMContentLoaded', function () {
const _origAutoUst = window.updateAutoUst;
window.updateAutoUst = function () {
_origAutoUst.apply(this, arguments);
recalcNetto();
};
});
// Zahlungskonto 1200 vorauswählen
const zkSelect = document.getElementById('zahlungskonto_id');

View file

@ -1 +1 @@
1-260514
1-260515