$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), 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 ) 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 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 $fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; if (file_exists($fullPath)) unlink($fullPath); Database::execute('DELETE FROM belege WHERE id=?', [$id]); return true; } 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 = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; if (!file_exists($fullPath)) return false; return hash_file('sha256', $fullPath) === $beleg['file_hash']; } }