Fix: Dublettenprüfung verwendet nur (buchungstag, wertstellung, wert) ohne buchungstext, da Vorlagenänderungen sonst zu falschen Duplikaten führen.
287 lines
9.7 KiB
PHP
287 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Steuerkram\Models;
|
|
|
|
use Steuerkram\Database;
|
|
|
|
class BankTransaction
|
|
{
|
|
public static function all(array $filter = [], int $page = 1, int $perPage = 50): array
|
|
{
|
|
$where = ['1=1'];
|
|
$params = [];
|
|
|
|
$status = $filter['status'] ?? 'pending';
|
|
if ($status === 'pending') {
|
|
$where[] = "bt.status IN ('offen','teilweise')";
|
|
} elseif ($status !== '') {
|
|
$where[] = 'bt.status = ?';
|
|
$params[] = $status;
|
|
}
|
|
|
|
if (!empty($filter['year'])) {
|
|
$where[] = 'YEAR(bt.wertstellung) = ?';
|
|
$params[] = (int)$filter['year'];
|
|
}
|
|
if (!empty($filter['month'])) {
|
|
$where[] = 'MONTH(bt.wertstellung) = ?';
|
|
$params[] = (int)$filter['month'];
|
|
}
|
|
if (!empty($filter['typ'])) {
|
|
if ($filter['typ'] === 'einnahme') {
|
|
$where[] = 'bt.wert > 0';
|
|
} elseif ($filter['typ'] === 'ausgabe') {
|
|
$where[] = 'bt.wert < 0';
|
|
}
|
|
}
|
|
if (!empty($filter['search'])) {
|
|
$where[] = '(bt.buchungstext LIKE ? OR bt.vorgang LIKE ?)';
|
|
$s = '%' . $filter['search'] . '%';
|
|
$params[] = $s;
|
|
$params[] = $s;
|
|
}
|
|
if (!empty($filter['format_id'])) {
|
|
$where[] = 'bib.format_id = ?';
|
|
$params[] = (int)$filter['format_id'];
|
|
}
|
|
|
|
$sortMap = [
|
|
'wertstellung' => 'bt.wertstellung',
|
|
'buchungstag' => 'bt.buchungstag',
|
|
'betrag' => 'bt.wert',
|
|
'buchungstext' => 'bt.buchungstext',
|
|
'status' => 'bt.status',
|
|
];
|
|
$sortCol = $sortMap[$filter['sort'] ?? ''] ?? 'bt.wertstellung';
|
|
$sortDir = ($filter['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
|
|
|
|
$whereStr = implode(' AND ', $where);
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
$total = (int)(Database::fetchOne(
|
|
"SELECT COUNT(*) AS n
|
|
FROM bank_transactions bt
|
|
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
|
WHERE $whereStr",
|
|
$params
|
|
)['n'] ?? 0);
|
|
|
|
$rows = Database::fetchAll(
|
|
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe,
|
|
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
|
|
b.beleg_nr
|
|
FROM bank_transactions bt
|
|
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
|
LEFT JOIN bank_import_formats bif ON bib.format_id = bif.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
|
|
ORDER BY $sortCol $sortDir, bt.wertstellung $sortDir, bt.buchungstag $sortDir, bt.id $sortDir
|
|
LIMIT ? OFFSET ?",
|
|
array_merge($params, [$perPage, $offset])
|
|
);
|
|
|
|
return [
|
|
'rows' => $rows,
|
|
'total' => $total,
|
|
'pages' => (int)ceil($total / $perPage),
|
|
];
|
|
}
|
|
|
|
public static function find(int $id): ?array
|
|
{
|
|
$row = Database::fetchOne(
|
|
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe
|
|
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
|
|
WHERE bt.id = ?',
|
|
[$id]
|
|
);
|
|
return $row ?: null;
|
|
}
|
|
|
|
public static function updateStatus(int $id, string $status): void
|
|
{
|
|
Database::execute(
|
|
"UPDATE bank_transactions SET status = ? WHERE id = ?",
|
|
[$status, $id]
|
|
);
|
|
}
|
|
|
|
/** Verknüpft einen Beleg mit zugeordnetem Betrag und aktualisiert den Status. */
|
|
public static function linkBeleg(int $txId, int $belegId, float $betrag): void
|
|
{
|
|
Database::execute(
|
|
'INSERT INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
|
|
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, bif.kuerzel, bif.farbe, 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
|
|
JOIN bank_import_formats bif ON bif.id = bib.format_id
|
|
WHERE btb.beleg_id = ?
|
|
LIMIT 1',
|
|
[$belegId]
|
|
);
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Teilt $rows in ['new' => [...], 'dup' => [...]] auf.
|
|
*/
|
|
public static function findDuplicates(array $rows): array
|
|
{
|
|
if (empty($rows)) {
|
|
return ['new' => [], 'dup' => []];
|
|
}
|
|
|
|
$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
|
|
FROM bank_transactions
|
|
WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)",
|
|
$params
|
|
);
|
|
|
|
$lookup = [];
|
|
foreach ($existing as $e) {
|
|
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert']] = true;
|
|
}
|
|
|
|
$new = [];
|
|
$dup = [];
|
|
foreach ($rows as $row) {
|
|
$key = $row['buchungstag'] . '|'
|
|
. $row['wertstellung'] . '|'
|
|
. (string)$row['wert'];
|
|
if (isset($lookup[$key])) {
|
|
$dup[] = $row;
|
|
} else {
|
|
$new[] = $row;
|
|
}
|
|
}
|
|
|
|
return ['new' => $new, 'dup' => $dup];
|
|
}
|
|
|
|
public static function createBatch(int $formatId, string $filename, int $rowCount): int
|
|
{
|
|
Database::execute(
|
|
'INSERT INTO bank_import_batches (format_id, filename, row_count) VALUES (?, ?, ?)',
|
|
[$formatId, $filename, $rowCount]
|
|
);
|
|
return (int)Database::lastInsertId();
|
|
}
|
|
|
|
public static function insertRow(int $batchId, array $data): void
|
|
{
|
|
Database::execute(
|
|
'INSERT INTO bank_transactions
|
|
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
[
|
|
$batchId,
|
|
$data['buchungstag'],
|
|
$data['wertstellung'],
|
|
$data['vorgang'],
|
|
$data['buchungstext'],
|
|
$data['wert'],
|
|
$data['raw_line'] ?? null,
|
|
]
|
|
);
|
|
}
|
|
}
|