Feature: Bankbuchung auf mehrere Belege aufteilen mit Restsaldo und Status teilweise
This commit is contained in:
parent
ce7a64534b
commit
702b0e4664
10 changed files with 273 additions and 52 deletions
12
Changes.md
12
Changes.md
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Neuer Status `teilweise`: wird gesetzt wenn mind. ein Beleg verknüpft ist, aber noch Restbetrag offen bleibt
|
||||||
|
- Standardfilter in der Bankübersicht zeigt jetzt „Offen & Teilweise"
|
||||||
|
- `bank/show.php`: Sektion „Verknüpfte Belege" mit Betrag je Beleg und Entfernen-Button; Restsaldo-Anzeige; Betrag-Eingabefeld pro Beleg in der Offene-Belege-Liste
|
||||||
|
- Beim Verknüpfen wird der zugeordnete Betrag an die Buchungsmaske weitergegeben (`alloc_betrag`)
|
||||||
|
- `BankTransaction::linkBeleg()` schreibt jetzt in Junction-Tabelle mit Betrag; `recalcStatus()` setzt Status automatisch; `unlinkBeleg()` entfernt Verknüpfung; `getLinkedBelege()` / `getRemainingBetrag()` neu
|
||||||
|
- Neue Route `POST /bank/{id}/unlink`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-05-15 – Feature: Dublettenprüfung beim Bank-Import
|
## 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)
|
- Nach dem Upload wird geprüft, welche Buchungen bereits in der DB vorhanden sind (Abgleich über Buchungstag, Wertstellung, Betrag, Buchungstext)
|
||||||
|
|
|
||||||
24
migrations/053_bank_transaction_belege.sql
Normal file
24
migrations/053_bank_transaction_belege.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- n:m-Verknüpfung Bankbuchung <-> Belege mit zugewiesenem Betrag
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_transaction_belege (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
bank_tx_id INT UNSIGNED NOT NULL,
|
||||||
|
beleg_id INT UNSIGNED NOT NULL,
|
||||||
|
betrag DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tx_beleg (bank_tx_id, beleg_id),
|
||||||
|
FOREIGN KEY (bank_tx_id) REFERENCES bank_transactions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (beleg_id) REFERENCES belege(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Bestehende 1:1-Verknüpfungen migrieren
|
||||||
|
INSERT IGNORE INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
|
||||||
|
SELECT bt.id, bt.beleg_id, ABS(bt.wert)
|
||||||
|
FROM bank_transactions bt
|
||||||
|
WHERE bt.beleg_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Neuer Status 'teilweise'
|
||||||
|
ALTER TABLE bank_transactions
|
||||||
|
MODIFY COLUMN status ENUM('offen','teilweise','gebucht','ignoriert') NOT NULL DEFAULT 'offen';
|
||||||
|
|
||||||
|
-- Altes Einzelfeld entfernen
|
||||||
|
ALTER TABLE bank_transactions DROP COLUMN beleg_id;
|
||||||
|
|
@ -72,6 +72,7 @@ $router->post('/bank/upload/confirm', fn($p) => (new BankController())->up
|
||||||
$router->get('/bank/{id}', fn($p) => (new BankController())->show($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}/status', fn($p) => (new BankController())->updateStatus($p));
|
||||||
$router->post('/bank/{id}/link', fn($p) => (new BankController())->linkBeleg($p));
|
$router->post('/bank/{id}/link', fn($p) => (new BankController())->linkBeleg($p));
|
||||||
|
$router->post('/bank/{id}/unlink', fn($p) => (new BankController())->unlinkBeleg($p));
|
||||||
$router->post('/bank/{id}/beleg', fn($p) => (new BankController())->uploadBeleg($p));
|
$router->post('/bank/{id}/beleg', fn($p) => (new BankController())->uploadBeleg($p));
|
||||||
|
|
||||||
// ── Buchungen ────────────────────────────────────────────────
|
// ── Buchungen ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class BankController
|
||||||
{
|
{
|
||||||
$filter = $_GET;
|
$filter = $_GET;
|
||||||
if (!isset($filter['status'])) {
|
if (!isset($filter['status'])) {
|
||||||
$filter['status'] = 'offen';
|
$filter['status'] = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
|
@ -159,15 +159,18 @@ class BankController
|
||||||
View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
|
View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offene Belege (noch nicht gebucht)
|
$linkedBelege = BankTransaction::getLinkedBelege($id);
|
||||||
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200);
|
$remainingBetrag = BankTransaction::getRemainingBetrag($id);
|
||||||
|
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200);
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
'pageTitle' => 'Bankbuchung zuordnen',
|
'pageTitle' => 'Bankbuchung zuordnen',
|
||||||
'content' => $this->renderView('bank/show', [
|
'content' => $this->renderView('bank/show', [
|
||||||
'transaction' => $transaction,
|
'transaction' => $transaction,
|
||||||
'offeneBelege' => $offeneBelege['rows'],
|
'linkedBelege' => $linkedBelege,
|
||||||
|
'remainingBetrag' => $remainingBetrag,
|
||||||
|
'offeneBelege' => $offeneBelege['rows'],
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +180,7 @@ class BankController
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$status = $_POST['status'] ?? '';
|
$status = $_POST['status'] ?? '';
|
||||||
|
|
||||||
if (!in_array($status, ['offen', 'gebucht', 'ignoriert'], true)) {
|
if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) {
|
||||||
View::redirect('/bank', 'Ungültiger Status.', 'error');
|
View::redirect('/bank', 'Ungültiger Status.', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,13 +193,25 @@ class BankController
|
||||||
{
|
{
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$belegId = (int)($_POST['beleg_id'] ?? 0);
|
$belegId = (int)($_POST['beleg_id'] ?? 0);
|
||||||
|
$betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2);
|
||||||
|
|
||||||
if (!$belegId) {
|
if (!$belegId) {
|
||||||
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
|
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
|
||||||
}
|
}
|
||||||
|
if ($betrag <= 0) {
|
||||||
|
View::redirect("/bank/$id", 'Bitte einen gültigen Betrag eingeben.', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
BankTransaction::linkBeleg($id, $belegId);
|
View::redirect("/belege/$belegId?bank_tx=$id&alloc_betrag=$betrag");
|
||||||
View::redirect('/bank', 'Beleg verknüpft und Buchung als gebucht markiert.');
|
}
|
||||||
|
|
||||||
|
public function unlinkBeleg(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$belegId = (int)($_POST['beleg_id'] ?? 0);
|
||||||
|
|
||||||
|
BankTransaction::unlinkBeleg($id, $belegId);
|
||||||
|
View::redirect("/bank/$id", 'Beleg-Verknüpfung entfernt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uploadBeleg(array $params): void
|
public function uploadBeleg(array $params): void
|
||||||
|
|
@ -248,8 +263,9 @@ class BankController
|
||||||
'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung',
|
'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
BankTransaction::linkBeleg($id, $belegId);
|
$betrag = BankTransaction::getRemainingBetrag($id) ?: abs((float)$transaction['wert']);
|
||||||
View::redirect('/bank', 'Beleg hochgeladen und Buchung als gebucht markiert.');
|
BankTransaction::linkBeleg($id, $belegId, $betrag);
|
||||||
|
View::redirect('/bank', 'Beleg hochgeladen und Buchung verknüpft.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderView(string $name, array $data = []): string
|
private function renderView(string $name, array $data = []): string
|
||||||
|
|
|
||||||
|
|
@ -223,12 +223,15 @@ class BelegController
|
||||||
if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) {
|
if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) {
|
||||||
$bankTxSupplier = trim($m[1]);
|
$bankTxSupplier = trim($m[1]);
|
||||||
}
|
}
|
||||||
$bankTxBrutto = abs((float)$bankTx['wert']);
|
// alloc_betrag aus URL hat Vorrang (Teilbetrag bei Aufteilung), sonst voller Betrag
|
||||||
|
$allocBetrag = isset($_GET['alloc_betrag']) ? round((float)$_GET['alloc_betrag'], 2) : 0.0;
|
||||||
|
$bankTxBrutto = $allocBetrag > 0 ? $allocBetrag : abs((float)$bankTx['wert']);
|
||||||
$netto = round($bankTxBrutto / 1.19, 2);
|
$netto = round($bankTxBrutto / 1.19, 2);
|
||||||
|
|
||||||
$bankFormData = [
|
$bankFormData = [
|
||||||
'buchungs_datum' => $bankTx['wertstellung'],
|
'buchungs_datum' => $bankTx['wertstellung'],
|
||||||
'betrag_netto' => number_format($netto, 2, '.', ''),
|
'betrag_netto' => number_format($netto, 2, '.', ''),
|
||||||
|
'alloc_betrag' => number_format($bankTxBrutto, 2, '.', ''),
|
||||||
'zahlungs_kontonummer'=> '1200',
|
'zahlungs_kontonummer'=> '1200',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,13 @@ class BookingController
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
||||||
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
||||||
|
$allocBetrag = round((float)str_replace(',', '.', $data['alloc_betrag'] ?? ''), 2);
|
||||||
if ($bankTxId > 0) {
|
if ($bankTxId > 0) {
|
||||||
BankTransaction::linkBeleg($bankTxId, $belegId);
|
if ($allocBetrag <= 0) {
|
||||||
|
$allocBetrag = BankTransaction::getRemainingBetrag($bankTxId) ?: abs((float)$data['betrag_netto'] ?? 0);
|
||||||
|
}
|
||||||
|
BankTransaction::linkBeleg($bankTxId, $belegId, $allocBetrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($newAssetId !== null) {
|
if ($newAssetId !== null) {
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ class BankTransaction
|
||||||
$where = ['1=1'];
|
$where = ['1=1'];
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
$status = $filter['status'] ?? 'offen';
|
$status = $filter['status'] ?? 'pending';
|
||||||
if ($status !== '') {
|
if ($status === 'pending') {
|
||||||
|
$where[] = "bt.status IN ('offen','teilweise')";
|
||||||
|
} elseif ($status !== '') {
|
||||||
$where[] = 'bt.status = ?';
|
$where[] = 'bt.status = ?';
|
||||||
$params[] = $status;
|
$params[] = $status;
|
||||||
}
|
}
|
||||||
|
|
@ -51,11 +53,17 @@ class BankTransaction
|
||||||
|
|
||||||
$rows = Database::fetchAll(
|
$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
|
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
|
||||||
|
b.beleg_nr
|
||||||
FROM bank_transactions bt
|
FROM bank_transactions bt
|
||||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||||
JOIN bank_import_formats bif ON bib.format_id = bif.id
|
JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||||
LEFT JOIN belege b ON b.id = bt.beleg_id
|
LEFT JOIN (
|
||||||
|
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
|
||||||
|
FROM bank_transaction_belege
|
||||||
|
GROUP BY bank_tx_id
|
||||||
|
) btb_agg ON btb_agg.bank_tx_id = bt.id
|
||||||
|
LEFT JOIN belege b ON b.id = btb_agg.beleg_id
|
||||||
WHERE $whereStr
|
WHERE $whereStr
|
||||||
ORDER BY bt.wertstellung DESC, bt.id DESC
|
ORDER BY bt.wertstellung DESC, bt.id DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
|
|
@ -90,17 +98,105 @@ class BankTransaction
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function linkBeleg(int $id, int $belegId): void
|
/** Verknüpft einen Beleg mit zugeordnetem Betrag und aktualisiert den Status. */
|
||||||
|
public static function linkBeleg(int $txId, int $belegId, float $betrag): void
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
"UPDATE bank_transactions SET beleg_id = ?, status = 'gebucht' WHERE id = ?",
|
'INSERT INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
|
||||||
[$belegId, $id]
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)',
|
||||||
|
[$txId, $belegId, $betrag]
|
||||||
);
|
);
|
||||||
|
self::recalcStatus($txId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt eine Beleg-Verknüpfung und aktualisiert den Status. */
|
||||||
|
public static function unlinkBeleg(int $txId, int $belegId): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'DELETE FROM bank_transaction_belege WHERE bank_tx_id = ? AND beleg_id = ?',
|
||||||
|
[$txId, $belegId]
|
||||||
|
);
|
||||||
|
self::recalcStatus($txId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet Status neu: 0 Links→offen, Restsaldo>0→teilweise, Restsaldo=0→gebucht. */
|
||||||
|
public static function recalcStatus(int $txId): void
|
||||||
|
{
|
||||||
|
$row = Database::fetchOne(
|
||||||
|
'SELECT ABS(bt.wert) AS brutto, COALESCE(SUM(btb.betrag), 0) AS allocated
|
||||||
|
FROM bank_transactions bt
|
||||||
|
LEFT JOIN bank_transaction_belege btb ON btb.bank_tx_id = bt.id
|
||||||
|
WHERE bt.id = ?
|
||||||
|
GROUP BY bt.id',
|
||||||
|
[$txId]
|
||||||
|
);
|
||||||
|
if (!$row) return;
|
||||||
|
|
||||||
|
$remaining = round((float)$row['brutto'] - (float)$row['allocated'], 2);
|
||||||
|
$allocated = (float)$row['allocated'];
|
||||||
|
|
||||||
|
if ($allocated <= 0) {
|
||||||
|
$status = 'offen';
|
||||||
|
} elseif ($remaining > 0.01) {
|
||||||
|
$status = 'teilweise';
|
||||||
|
} else {
|
||||||
|
$status = 'gebucht';
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE bank_transactions SET status = ? WHERE id = ?",
|
||||||
|
[$status, $txId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt alle verknüpften Belege mit ihren Beträgen zurück. */
|
||||||
|
public static function getLinkedBelege(int $txId): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT btb.betrag, b.id AS beleg_id, b.beleg_nr, b.beleg_datum,
|
||||||
|
b.belegtyp, b.betrag_brutto, s.name AS supplier_name
|
||||||
|
FROM bank_transaction_belege btb
|
||||||
|
JOIN belege b ON b.id = btb.beleg_id
|
||||||
|
LEFT JOIN suppliers s ON s.id = b.supplier_id
|
||||||
|
WHERE btb.bank_tx_id = ?
|
||||||
|
ORDER BY b.beleg_nr',
|
||||||
|
[$txId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt den noch nicht zugeordneten Restbetrag zurück. */
|
||||||
|
public static function getRemainingBetrag(int $txId): float
|
||||||
|
{
|
||||||
|
$row = Database::fetchOne(
|
||||||
|
'SELECT ABS(bt.wert) AS brutto, COALESCE(SUM(btb.betrag), 0) AS allocated
|
||||||
|
FROM bank_transactions bt
|
||||||
|
LEFT JOIN bank_transaction_belege btb ON btb.bank_tx_id = bt.id
|
||||||
|
WHERE bt.id = ?
|
||||||
|
GROUP BY bt.id',
|
||||||
|
[$txId]
|
||||||
|
);
|
||||||
|
if (!$row) return 0.0;
|
||||||
|
return round(max(0.0, (float)$row['brutto'] - (float)$row['allocated']), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sucht die Bankbuchung, die mit einem bestimmten Beleg verknüpft ist. */
|
||||||
|
public static function findByBelegId(int $belegId): ?array
|
||||||
|
{
|
||||||
|
$row = Database::fetchOne(
|
||||||
|
'SELECT bt.*, bib.filename AS batch_filename, 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
|
||||||
|
WHERE btb.beleg_id = ?
|
||||||
|
LIMIT 1',
|
||||||
|
[$belegId]
|
||||||
|
);
|
||||||
|
return $row ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teilt $rows in ['new' => [...], 'dup' => [...]] auf.
|
* 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
|
public static function findDuplicates(array $rows): array
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +204,6 @@ class BankTransaction
|
||||||
return ['new' => [], 'dup' => []];
|
return ['new' => [], 'dup' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kandidaten-Abfrage: alle DB-Zeilen, deren (buchungstag, wertstellung, wert) in den Upload-Daten vorkommen
|
|
||||||
$placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)'));
|
$placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)'));
|
||||||
$params = [];
|
$params = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
|
|
@ -124,7 +219,6 @@ class BankTransaction
|
||||||
$params
|
$params
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lookup-Set für schnellen Vergleich
|
|
||||||
$lookup = [];
|
$lookup = [];
|
||||||
foreach ($existing as $e) {
|
foreach ($existing as $e) {
|
||||||
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
|
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
|
||||||
|
|
@ -147,19 +241,6 @@ class BankTransaction
|
||||||
return ['new' => $new, 'dup' => $dup];
|
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
|
public static function createBatch(int $formatId, string $filename, int $rowCount): int
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
||||||
<option value="ausgabe"<?= ($filter['typ'] ?? '') === 'ausgabe' ? ' selected' : '' ?>>Ausgaben</option>
|
<option value="ausgabe"<?= ($filter['typ'] ?? '') === 'ausgabe' ? ' selected' : '' ?>>Ausgaben</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select name="status" style="width:110px">
|
<select name="status" style="width:140px">
|
||||||
<option value="offen"<?= ($filter['status'] ?? 'offen') === 'offen' ? ' selected' : '' ?>>Offen</option>
|
<option value="pending"<?= ($filter['status'] ?? 'pending') === 'pending' ? ' selected' : '' ?>>Offen & Teilweise</option>
|
||||||
|
<option value="offen"<?= ($filter['status'] ?? '') === 'offen' ? ' selected' : '' ?>>Nur Offen</option>
|
||||||
|
<option value="teilweise"<?= ($filter['status'] ?? '') === 'teilweise' ? ' selected' : '' ?>>Teilweise</option>
|
||||||
<option value="gebucht"<?= ($filter['status'] ?? '') === 'gebucht' ? ' selected' : '' ?>>Gebucht</option>
|
<option value="gebucht"<?= ($filter['status'] ?? '') === 'gebucht' ? ' selected' : '' ?>>Gebucht</option>
|
||||||
<option value="ignoriert"<?= ($filter['status'] ?? '') === 'ignoriert' ? ' selected' : '' ?>>Ignoriert</option>
|
<option value="ignoriert"<?= ($filter['status'] ?? '') === 'ignoriert' ? ' selected' : '' ?>>Ignoriert</option>
|
||||||
<option value=""<?= ($filter['status'] ?? 'offen') === '' ? ' selected' : '' ?>>Alle</option>
|
<option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
|
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
|
||||||
|
|
@ -82,9 +84,10 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
||||||
$wert = (float)$row['wert'];
|
$wert = (float)$row['wert'];
|
||||||
$isPlus = $wert >= 0;
|
$isPlus = $wert >= 0;
|
||||||
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
|
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
|
||||||
$ziel = ($row['status'] === 'gebucht' && !empty($row['beleg_id']))
|
$ziel = '/bank/' . (int)$row['id'];
|
||||||
? '/belege/' . (int)$row['beleg_id']
|
if ($row['status'] === 'gebucht' && !empty($row['beleg_id']) && ($row['beleg_count'] ?? 1) == 1) {
|
||||||
: '/bank/' . (int)$row['id'];
|
$ziel = '/belege/' . (int)$row['beleg_id'];
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
|
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
|
||||||
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
|
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
|
||||||
|
|
@ -101,13 +104,20 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
|
||||||
<?php
|
<?php
|
||||||
$badgeClass = match($row['status']) {
|
$badgeClass = match($row['status']) {
|
||||||
'gebucht' => 'badge-green',
|
'gebucht' => 'badge-green',
|
||||||
|
'teilweise' => 'badge-warning',
|
||||||
'ignoriert' => 'badge-gray',
|
'ignoriert' => 'badge-gray',
|
||||||
default => 'badge-blue',
|
default => 'badge-blue',
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
|
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
|
||||||
<?php if ($row['status'] === 'gebucht' && !empty($row['beleg_nr'])): ?>
|
<?php if (in_array($row['status'], ['gebucht','teilweise']) && !empty($row['beleg_nr'])): ?>
|
||||||
<div class="text-muted text-sm" style="margin-top:.25rem"><?= $h($row['beleg_nr']) ?></div>
|
<div class="text-muted text-sm" style="margin-top:.25rem">
|
||||||
|
<?php if (($row['beleg_count'] ?? 1) > 1): ?>
|
||||||
|
<?= (int)$row['beleg_count'] ?> Belege
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $h($row['beleg_nr']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="event.stopPropagation()">
|
<td onclick="event.stopPropagation()">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ $color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
$vorschauBreite = (int)($settings['beleg_vorschau_breite'] ?? 200);
|
$vorschauBreite = (int)($settings['beleg_vorschau_breite'] ?? 200);
|
||||||
$vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
$vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
|
|
||||||
|
$remaining = (float)$remainingBetrag;
|
||||||
|
$totalBrutto = abs($wert);
|
||||||
|
$restFormatted = number_format($remaining, 2, ',', '.');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Bankbuchung Info-Bar -->
|
<!-- Bankbuchung Info-Bar -->
|
||||||
|
|
@ -33,16 +37,23 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
<span><?= $h($transaction['buchungstext']) ?></span>
|
<span><?= $h($transaction['buchungstext']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted text-sm">Wert</span><br>
|
<span class="text-muted text-sm">Gesamt</span><br>
|
||||||
<strong style="color:<?= $color ?>;font-size:1.1rem">
|
<strong style="color:<?= $color ?>;font-size:1.1rem">
|
||||||
<?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> €
|
<?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> €
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Verbleibend</span><br>
|
||||||
|
<strong style="color:<?= $remaining <= 0.01 ? 'var(--color-success)' : 'var(--color-warning,#d97706)' ?>;font-size:1.1rem">
|
||||||
|
<?= $h($restFormatted) ?> €
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted text-sm">Status</span><br>
|
<span class="text-muted text-sm">Status</span><br>
|
||||||
<?php
|
<?php
|
||||||
$badgeClass = match($transaction['status']) {
|
$badgeClass = match($transaction['status']) {
|
||||||
'gebucht' => 'badge-green',
|
'gebucht' => 'badge-green',
|
||||||
|
'teilweise' => 'badge-warning',
|
||||||
'ignoriert' => 'badge-gray',
|
'ignoriert' => 'badge-gray',
|
||||||
default => 'badge-blue',
|
default => 'badge-blue',
|
||||||
};
|
};
|
||||||
|
|
@ -63,9 +74,59 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($linkedBelege)): ?>
|
||||||
|
<!-- Bereits verknüpfte Belege -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Verknüpfte Belege</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Belegnr.</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Lieferant</th>
|
||||||
|
<th class="td-right">Belegbetrag</th>
|
||||||
|
<th class="td-right">Zugeordnet</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($linkedBelege as $lb): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="td-mono">
|
||||||
|
<a href="/belege/<?= (int)$lb['beleg_id'] ?>"><?= $h($lb['beleg_nr']) ?></a>
|
||||||
|
</td>
|
||||||
|
<td class="td-mono"><?= $h(View::formatDate($lb['beleg_datum'])) ?></td>
|
||||||
|
<td><span class="badge badge-gray"><?= $h($lb['belegtyp']) ?></span></td>
|
||||||
|
<td><?= $lb['supplier_name'] ? $h($lb['supplier_name']) : '<span class="text-muted">–</span>' ?></td>
|
||||||
|
<td class="td-right td-mono">
|
||||||
|
<?= $lb['betrag_brutto'] ? $fmt($lb['betrag_brutto']) . ' €' : '<span class="text-muted">–</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td class="td-right td-mono" style="font-weight:600">
|
||||||
|
<?= $h(number_format((float)$lb['betrag'], 2, ',', '.')) ?> €
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/unlink"
|
||||||
|
data-confirm="Verknüpfung mit <?= $h($lb['beleg_nr']) ?> wirklich entfernen?">
|
||||||
|
<input type="hidden" name="beleg_id" value="<?= (int)$lb['beleg_id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($remaining > 0.01): ?>
|
||||||
<!-- Beleg hochladen -->
|
<!-- Beleg hochladen -->
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Beleg hochladen</div></div>
|
<div class="card-header"><div class="card-title">Neuen Beleg hochladen & verknüpfen</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/beleg" enctype="multipart/form-data">
|
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/beleg" enctype="multipart/form-data">
|
||||||
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
|
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
|
@ -102,6 +163,7 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
<th>Lieferant</th>
|
<th>Lieferant</th>
|
||||||
<th class="td-right">Brutto</th>
|
<th class="td-right">Brutto</th>
|
||||||
|
<th class="td-right" style="width:130px">Zuzuordnen (€)</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -120,11 +182,19 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
|
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-right td-mono">
|
<td class="td-right td-mono">
|
||||||
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) : '<span class="text-muted">–</span>' ?>
|
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) . ' €' : '<span class="text-muted">–</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="event.stopPropagation()">
|
<td class="td-right" onclick="event.stopPropagation()">
|
||||||
<a href="/belege/<?= (int)$b['id'] ?>?bank_tx=<?= (int)$transaction['id'] ?>"
|
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/link"
|
||||||
class="btn btn-primary btn-sm">Verknüpfen</a>
|
style="display:flex;gap:.4rem;align-items:center;justify-content:flex-end">
|
||||||
|
<input type="hidden" name="beleg_id" value="<?= (int)$b['id'] ?>">
|
||||||
|
<input type="number" name="alloc_betrag"
|
||||||
|
value="<?= $h(number_format($remaining, 2, '.', '')) ?>"
|
||||||
|
min="0.01" step="0.01"
|
||||||
|
style="width:90px;text-align:right"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Verknüpfen</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -168,12 +238,10 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
const id = row.dataset.belegId;
|
const id = row.dataset.belegId;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
// Hervorhebung wechseln
|
|
||||||
if (activeRow) activeRow.style.background = '';
|
if (activeRow) activeRow.style.background = '';
|
||||||
activeRow = row;
|
activeRow = row;
|
||||||
row.style.background = 'var(--color-primary-light, #eff6ff)';
|
row.style.background = 'var(--color-primary-light, #eff6ff)';
|
||||||
|
|
||||||
// Vorschau laden
|
|
||||||
hint.style.display = 'none';
|
hint.style.display = 'none';
|
||||||
frame.style.display = 'block';
|
frame.style.display = 'block';
|
||||||
frame.src = '/belege/' + id + '/file';
|
frame.src = '/belege/' + id + '/file';
|
||||||
|
|
@ -181,3 +249,4 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,7 @@ $suppliers = $suppliers ?? [];
|
||||||
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
|
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
|
||||||
<?php if ($bankTx ?? null): ?>
|
<?php if ($bankTx ?? null): ?>
|
||||||
<input type="hidden" name="bank_tx_id" value="<?= (int)$bankTx['id'] ?>">
|
<input type="hidden" name="bank_tx_id" value="<?= (int)$bankTx['id'] ?>">
|
||||||
|
<input type="hidden" name="alloc_betrag" value="<?= $h($formData['alloc_betrag'] ?? '') ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Buchungstyp -->
|
<!-- Buchungstyp -->
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue