391 lines
15 KiB
PHP
391 lines
15 KiB
PHP
<?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'];
|
||
}
|
||
}
|