$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']; } }