Steuerkram/src/Models/Beleg.php

391 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Config;
use Steuerkram\Database;
class Beleg
{
// Bestandsdaten mit altem, webroot-relativem Präfix "static/uploads/" bleiben lesbar.
public static function absolutePath(array $beleg): string
{
$relPath = (string)($beleg['storage_path'] ?? '');
if (str_starts_with($relPath, 'static/uploads/')) {
$relPath = substr($relPath, strlen('static/uploads/'));
}
return Config::get('upload_dir') . '/' . $relPath;
}
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['search'])) {
$where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?
OR CAST(b.beleg_datum AS CHAR) LIKE ?
OR s.name LIKE ?
OR b.bewirtungsgrund LIKE ? OR b.teilnehmerliste LIKE ?
OR (SELECT CAST(SUM(bk2s.betrag_brutto) AS CHAR) FROM bookings bk2s
WHERE bk2s.beleg_id = b.id AND bk2s.is_reversal = 0 AND bk2s.parent_booking_id IS NULL
AND NOT EXISTS (SELECT 1 FROM accounts az WHERE az.id = bk2s.zahlungskonto_id AND az.kontonummer = \'1800\')) LIKE ?)';
$s = '%' . $filter['search'] . '%';
$sBrutto = '%' . str_replace(',', '.', $filter['search']) . '%';
$params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s, $s, $sBrutto]);
}
if (!empty($filter['year'])) {
$where[] = '(YEAR(b.beleg_datum) = ? OR b.beleg_datum IS NULL)';
$params[] = (int)$filter['year'];
}
if (!empty($filter['month'])) {
$where[] = '(MONTH(b.beleg_datum) = ? OR b.beleg_datum IS NULL)';
$params[] = (int)$filter['month'];
}
if (isset($filter['booked']) && $filter['booked'] !== '') {
if ((int)$filter['booked'] === 1) {
$where[] = 'EXISTS (SELECT 1 FROM bookings bk2 WHERE bk2.beleg_id = b.id)';
} else {
$where[] = 'NOT EXISTS (SELECT 1 FROM bookings bk2 WHERE bk2.beleg_id = b.id)';
}
}
if (!empty($filter['belegtyp'])) {
$where[] = 'b.belegtyp = ?';
$params[] = $filter['belegtyp'];
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
AND NOT EXISTS (SELECT 1 FROM accounts az WHERE az.id = bk.zahlungskonto_id AND az.kontonummer = \'1800\')
) AS betrag_brutto,
fy.year as fiscal_year_nr,
s.name as supplier_name,
(EXISTS (SELECT 1 FROM bookings bk3 WHERE bk3.beleg_id = b.id)) AS booked
FROM belege b
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
LEFT JOIN suppliers s ON b.supplier_id = s.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY b.created_at DESC, b.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
// Count total
$countSql = 'SELECT COUNT(*) as cnt FROM belege b LEFT JOIN suppliers s ON b.supplier_id = s.id WHERE ' . implode(' AND ', $where);
$countParams = array_slice($params, 0, -2);
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
}
public static function getOffeneFuerBankzuordnung(): array
{
$belege = Database::fetchAll(
'SELECT b.*,
COALESCE(
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
AND NOT EXISTS (SELECT 1 FROM accounts az WHERE az.id = bk.zahlungskonto_id AND az.kontonummer = \'1800\')),
b.betrag_brutto
) AS betrag_brutto,
fy.year as fiscal_year_nr,
s.name as supplier_name
FROM belege b
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
LEFT JOIN suppliers s ON b.supplier_id = s.id
WHERE (
(b.belegtyp = \'Eingangsrechnung\'
AND NOT EXISTS (SELECT 1 FROM bookings bk2 WHERE bk2.beleg_id = b.id))
OR
(b.belegtyp = \'Ausgangsrechnung\'
AND NOT EXISTS (SELECT 1 FROM bank_transaction_belege btb WHERE btb.beleg_id = b.id))
)',
[]
);
$rechnungenOhneBeleg = Database::fetchAll(
'SELECT NULL AS id,
i.id AS invoice_id,
i.rechnungs_nr AS beleg_nr,
i.rechnungs_datum AS beleg_datum,
\'Ausgangsrechnung\' AS belegtyp,
i.betrag_brutto,
COALESCE(NULLIF(TRIM(c.firma), \'\'), CONCAT(TRIM(c.vorname), \' \', TRIM(c.nachname))) AS supplier_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.status IN (\'Finalisiert\', \'Gesendet\')
AND i.beleg_id IS NULL',
[]
);
$alle = array_merge($belege, $rechnungenOhneBeleg);
usort($alle, fn($a, $b) => strcmp($b['beleg_datum'] ?? '', $a['beleg_datum'] ?? ''));
return $alle;
}
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT b.*,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
AND NOT EXISTS (SELECT 1 FROM accounts az WHERE az.id = bk.zahlungskonto_id AND az.kontonummer = \'1800\')
) AS betrag_brutto,
fy.year as fiscal_year_nr,
s.name as supplier_name,
(EXISTS (SELECT 1 FROM bookings bk2 WHERE bk2.beleg_id = b.id)) AS booked
FROM belege b
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
LEFT JOIN suppliers s ON b.supplier_id = s.id
WHERE b.id = ?',
[$id]
);
}
public static function create(array $data): int
{
// Auto-assign fiscal year
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
$invoiceId = isset($data['invoice_id']) && $data['invoice_id'] ? (int)$data['invoice_id'] : null;
Database::execute(
'INSERT INTO belege (invoice_id, beleg_nr, filename, storage_path, mime_type, file_hash,
beleg_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$invoiceId,
$data['beleg_nr'],
$data['filename'],
$data['storage_path'],
$data['mime_type'],
$data['file_hash'],
$data['beleg_datum'] ?: null,
$data['rechnungs_nr'] ?? null,
$data['betrag_brutto'] ?? null,
$data['belegtyp'] ?? 'Eingangsrechnung',
$data['notes'] ?? null,
$fyId,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
$beleg = self::find($id);
if ($beleg && $beleg['locked']) {
throw new \RuntimeException('GoBD: Gesperrter Beleg kann nicht geändert werden.');
}
// Update fiscal year if date changed
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE belege SET beleg_datum=?, rechnungs_nr=?, belegtyp=?, notes=?,
fiscal_year_id=?, updated_at=NOW()
WHERE id=? AND locked=0',
[
$data['beleg_datum'] ?: null,
$data['rechnungs_nr'] ?? null,
$data['belegtyp'] ?? 'Eingangsrechnung',
$data['notes'] ?? null,
$fyId,
$id,
]
);
}
public static function updateMeta(int $id, array $data): void
{
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE belege SET
rechnungs_nr = ?,
belegtyp = ?,
beleg_datum = ?,
supplier_id = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW()
WHERE id = ? AND locked = 0',
[
$data['rechnungs_nr'] ?: null,
$data['belegtyp'],
$data['beleg_datum'] ?: null,
isset($data['supplier_id']) && $data['supplier_id'] !== '' ? (int)$data['supplier_id'] : null,
$fyId,
$id,
]
);
}
public static function updateSupplier(int $id, ?int $supplierId): void
{
Database::execute(
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=? AND locked=0',
[$supplierId, $id]
);
}
/** Setzt den Lieferanten ohne locked-Prüfung, z.B. beim automatischen Vorbelegen aus einer Bankbuchung. */
public static function setSupplierId(int $id, int $supplierId): void
{
Database::execute(
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
[$supplierId, $id]
);
}
public static function updateBewirtung(int $id, ?string $bewirtungsgrund, ?string $teilnehmerliste): void
{
Database::execute(
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
[$bewirtungsgrund, $teilnehmerliste, $id]
);
}
public static function updateEigenanteil30(int $id, int $eigenanteil): void
{
Database::execute(
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
[$eigenanteil, $id]
);
}
public static function updateBetragBrutto(int $id, float $betrag): void
{
Database::execute(
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
[$betrag, $id]
);
}
public static function updateStorage(int $id, string $storagePath, string $filename, string $hash): void
{
Database::execute(
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
[$storagePath, $filename, $hash, $id]
);
}
public static function delete(int $id): bool
{
$beleg = self::find($id);
if (!$beleg) return false;
if ($beleg['locked']) return false;
// Check no bookings exist
$cnt = Database::fetchOne('SELECT COUNT(*) as cnt FROM bookings WHERE beleg_id=?', [$id]);
if ($cnt && $cnt['cnt'] > 0) return false;
// Delete file (Datei-Löschung darf DB-Löschung nicht durch Warnings/Header-Fehler stören)
$fullPath = self::absolutePath($beleg);
if (file_exists($fullPath) && !@unlink($fullPath)) {
error_log('Beleg::delete: Datei konnte nicht gelöscht werden: ' . $fullPath);
}
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
self::releaseBelegNr((string)($beleg['beleg_nr'] ?? ''));
return true;
}
/**
* Gibt die Belegnummer eines gelöschten Belegs wieder frei, sofern sie die
* zuletzt vergebene war – sonst risse jeder gelöschte Beleg eine Lücke in
* den Nummernkreis.
*
* Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert und nur bei
* exakter Übereinstimmung zurückgezählt. Existiert bereits ein jüngerer
* Beleg (oder stammt die Nummer aus einem früheren Jahr), passt die
* Rekonstruktion nicht und der Zähler bleibt stehen; eine Nummer kann
* dadurch nie doppelt vergeben werden.
*/
public static function releaseBelegNr(string $nr): void
{
if ($nr === '') return;
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
if (!$settings) return;
$next = (int)($settings['beleg_nr_next'] ?? 1);
if ($next <= 1) return;
$letzte = sprintf('%s-%s-%04d', $settings['beleg_nr_prefix'] ?? 'BLG', date('Y'), $next - 1);
if ($nr !== $letzte) return;
Database::execute(
'UPDATE company_settings SET beleg_nr_next=? WHERE id=1 AND beleg_nr_next=?',
[$next - 1, $next]
);
}
public static function generateBelegNr(): string
{
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
$prefix = $settings['beleg_nr_prefix'] ?? 'BLG';
$next = (int)($settings['beleg_nr_next'] ?? 1);
$year = date('Y');
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
Database::execute('UPDATE company_settings SET beleg_nr_next = beleg_nr_next + 1 WHERE id=1');
return $nr;
}
public static function saveKiVorschlag(int $id, ?array $vorschlag): void
{
Database::execute(
'UPDATE belege SET ki_vorschlag = ?, ki_vorschlag_at = NOW() WHERE id = ?',
[$vorschlag !== null ? json_encode($vorschlag) : null, $id]
);
}
public static function updateHash(int $id, string $hash): void
{
Database::execute(
'UPDATE belege SET file_hash = ?, updated_at = NOW() WHERE id = ?',
[$hash, $id]
);
}
public static function saveBelegText(int $id, string $text): void
{
Database::execute(
'UPDATE belege SET beleg_text = ? WHERE id = ?',
[$text !== '' ? $text : null, $id]
);
}
public static function verifyIntegrity(int $id): bool
{
$beleg = self::find($id);
if (!$beleg) return false;
$fullPath = self::absolutePath($beleg);
if (!file_exists($fullPath)) return false;
return hash_file('sha256', $fullPath) === $beleg['file_hash'];
}
}