'pdf', 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/tiff' => 'tif', 'application/xml' => 'xml', 'text/xml' => 'xml', ]; public function index(array $params): void { $filter = $_GET; if (!isset($filter['year'])) $filter['year'] = (string)date('Y'); if (!isset($filter['month'])) $filter['month'] = (string)date('n'); $page = max(1, (int)($_GET['page'] ?? 1)); $result = Beleg::all($filter, $page); $totalAll = Beleg::all([], 1, 1)['total']; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Belege', 'content' => $this->renderView('belege/list', [ 'rows' => $result['rows'], 'total' => $result['total'], 'totalAll' => $totalAll, 'pages' => $result['pages'], 'page' => $page, 'filter' => $filter, ]), 'topbarActions' => 'CSV Export + Beleg hochladen', ]); } public function export(array $params): void { $filter = $_GET; unset($filter['page']); $result = Beleg::all($filter, 1, 99999); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="belegliste-' . date('Ymd') . '.csv"'); $out = fopen('php://output', 'w'); fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel fputcsv($out, [ 'Belegnr.', 'Belegdatum', 'Belegart', 'Lieferant', 'Rechnungsnr.', 'Dateiname', 'Geschäftsjahr', 'Notizen', 'Bewirtungsgrund', 'Teilnehmerliste', 'Brutto (€)', 'Status', ], ';'); foreach ($result['rows'] as $row) { if ($row['locked']) { $status = 'Gesperrt'; } elseif ($row['booked']) { $status = 'Gebucht'; } else { $status = 'Offen'; } fputcsv($out, [ $this->csvSanitize((string)($row['beleg_nr'] ?? '')), $row['beleg_datum'] ?? '', $row['belegtyp'] ?? '', $this->csvSanitize((string)($row['supplier_name'] ?? '')), $this->csvSanitize((string)($row['rechnungs_nr'] ?? '')), $this->csvSanitize((string)($row['filename'] ?? '')), (string)($row['fiscal_year_nr'] ?? ''), $this->csvSanitize((string)($row['notes'] ?? '')), $this->csvSanitize((string)($row['bewirtungsgrund'] ?? '')), $this->csvSanitize((string)($row['teilnehmerliste'] ?? '')), $row['betrag_brutto'] !== null ? number_format((float)$row['betrag_brutto'], 2, ',', '') : '', $status, ], ';'); } fclose($out); } public function uploadForm(array $params): void { $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg hochladen', 'content' => $this->renderView('belege/upload', ['errors' => []]), ]); } public function upload(array $params): void { $errors = []; if (empty($_FILES['beleg_file']) || $_FILES['beleg_file']['error'] !== UPLOAD_ERR_OK) { $errors['file'] = 'Keine gültige Datei übermittelt.'; } else { $file = $_FILES['beleg_file']; $mime = mime_content_type($file['tmp_name']); $mimeKey = strtolower($mime); if (!isset(self::ALLOWED_MIME[$mimeKey])) { $errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.'; } } if ($errors) { if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { http_response_code(422); header('Content-Type: application/json'); echo json_encode(['error' => implode(' ', $errors)]); return; } $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg hochladen', 'content' => $this->renderView('belege/upload', [ 'errors' => $errors, 'old' => $_POST, ]), ]); return; } $file = $_FILES['beleg_file']; $mime = mime_content_type($file['tmp_name']); $ext = self::ALLOWED_MIME[strtolower($mime)]; $belegNr = Beleg::generateBelegNr(); $year = !empty($_POST['beleg_datum']) ? date('Y', strtotime($_POST['beleg_datum'])) : date('Y'); // Store under uploads/{year}/{belegNr}/original.{ext} $relDir = "static/uploads/{$year}/{$belegNr}"; $absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; if (!is_dir($absDir)) mkdir($absDir, 0755, true); $filename = pathinfo($file['name'], PATHINFO_BASENAME); $filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME); $storagePath = "{$relDir}/original.{$ext}"; $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; move_uploaded_file($file['tmp_name'], $absPath); // Weißen Rand bei JPEG/PNG automatisch wegschneiden if (in_array($mime, ['image/jpeg', 'image/png'], true)) { (new ImageCropService())->cropWhitespace($absPath); } // Hash nach potenziellem Crop berechnen (reflektiert den tatsächlich gespeicherten Inhalt) $hash = hash_file('sha256', $absPath); $id = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, 'mime_type' => $mime, 'file_hash' => $hash, 'beleg_datum' => $_POST['beleg_datum'] ?? null, 'rechnungs_nr' => $filenameWithoutExt, 'betrag_brutto' => null, 'belegtyp' => $_POST['belegtyp'] ?? 'Eingangsrechnung', 'notes' => $_POST['notes'] ?? null, ]); if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') { header('Content-Type: application/json'); echo json_encode(['id' => $id, 'beleg_nr' => $belegNr]); return; } View::redirect("/belege/{$id}", "Beleg {$belegNr} erfolgreich hochgeladen."); } public function show(array $params): void { $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } $bookings = Booking::forBeleg((int)$params['id']); $integrity = Beleg::verifyIntegrity((int)$params['id']); $accounts = Account::all(['aktiv' => 1]); $suppliers = Supplier::all(); $assets = Asset::all(); $svc = new KontierungsvorschlagService(); $kiAktiv = $svc->isEnabled(); $kiVorschlag = $beleg['ki_vorschlag'] !== null ? json_decode($beleg['ki_vorschlag'], true) : null; // KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar $kiSektion = $kiAktiv || $kiVorschlag !== null; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg ' . $beleg['beleg_nr'], 'content' => $this->renderView('belege/detail', [ 'beleg' => $beleg, 'suppliers' => $suppliers, 'bookings' => $bookings, 'integrity' => $integrity, 'accounts' => $accounts, 'assets' => $assets, 'kiAktiv' => $kiAktiv, 'kiSektion' => $kiSektion, 'kiVorschlag' => $kiVorschlag, ]), ]); } public function serveFile(array $params): void { $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; if (!file_exists($absPath)) { http_response_code(404); return; } header('Content-Type: ' . $beleg['mime_type']); header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"'); header('Content-Length: ' . filesize($absPath)); readfile($absPath); } public function updateMeta(array $params): void { $isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'; $id = (int)$params['id']; $beleg = Beleg::find($id); if (!$beleg) { if ($isAjax) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Nicht gefunden.']); return; } http_response_code(404); return; } if ($beleg['locked']) { if ($isAjax) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); return; } View::redirect("/belege/{$id}", 'GoBD: Gesperrter Beleg kann nicht geändert werden.', 'error'); return; } // Lieferant: Name → ID (ggf. neu anlegen) $supplierId = null; $supplierName = trim($_POST['supplier_name'] ?? ''); if ($supplierName !== '') { $supplierId = Supplier::findOrCreate($supplierName); } Beleg::updateMeta($id, [ 'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''), 'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'], 'beleg_datum' => $_POST['beleg_datum'] ?: null, 'supplier_id' => $supplierId, ]); if ($isAjax) { header('Content-Type: application/json'); echo json_encode(['ok' => true]); return; } View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); } public function generateVorschlag(array $params): void { header('Content-Type: application/json'); $id = (int)$params['id']; $beleg = Beleg::find($id); if (!$beleg) { echo json_encode(['error' => 'Beleg nicht gefunden']); return; } $svc = new KontierungsvorschlagService(); $vorschlag = $svc->generateForBeleg($beleg); Beleg::saveKiVorschlag($id, $vorschlag); echo json_encode($vorschlag ?? ['error' => 'Kein Vorschlag generiert']); } public function resetVorschlag(array $params): void { header('Content-Type: application/json'); Beleg::saveKiVorschlag((int)$params['id'], null); echo json_encode(['ok' => true]); } public function destroy(array $params): void { $id = (int)$params['id']; if (!Beleg::delete($id)) { View::redirect('/belege', 'Beleg kann nicht gelöscht werden (gesperrt oder enthält Buchungen).', 'error'); return; } View::redirect('/belege', 'Beleg gelöscht.'); } public function cropImage(array $params): void { header('Content-Type: application/json'); $id = (int)$params['id']; $beleg = Beleg::find($id); if (!$beleg) { http_response_code(404); echo json_encode(['error' => 'Beleg nicht gefunden.']); return; } if ($beleg['locked']) { http_response_code(403); echo json_encode(['error' => 'GoBD: Gesperrter Beleg kann nicht verändert werden.']); return; } if (!in_array($beleg['mime_type'], ['image/jpeg', 'image/png'], true)) { http_response_code(422); echo json_encode(['error' => 'Zuschneiden nur für JPEG und PNG möglich.']); return; } $body = json_decode(file_get_contents('php://input'), true) ?? []; $x = (int)($body['x'] ?? 0); $y = (int)($body['y'] ?? 0); $w = (int)($body['w'] ?? 0); $h = (int)($body['h'] ?? 0); if ($w < 10 || $h < 10) { http_response_code(422); echo json_encode(['error' => 'Auswahlbereich zu klein (mindestens 10×10 Pixel).']); return; } $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; if (!file_exists($absPath)) { http_response_code(404); echo json_encode(['error' => 'Datei nicht gefunden.']); return; } $svc = new ImageCropService(); if (!$svc->cropToRect($absPath, $x, $y, $w, $h)) { http_response_code(500); echo json_encode(['error' => 'Zuschnitt fehlgeschlagen.']); return; } Beleg::updateHash($id, hash_file('sha256', $absPath)); echo json_encode(['ok' => true]); } private function csvSanitize(string $value): string { if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { return "'" . $value; } return $value; } private function renderView(string $template, array $data): string { extract($data, EXTR_SKIP); ob_start(); include dirname(__DIR__) . '/Views/' . $template . '.php'; return ob_get_clean(); } }