'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, 'suppliers' => Supplier::all(), ]), 'topbarActions' => 'CSV Export ' . '' . '' . '' . '' . '', ]); } 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']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $mimeKey = strtolower((string)$mime); if (!isset(self::ALLOWED_MIME[$mimeKey])) { $errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.'; } } if ($errors) { $this->renderUploadErrors($errors); return; } $file = $_FILES['beleg_file']; $finfo2 = finfo_open(FILEINFO_MIME_TYPE); $mime = (string)finfo_file($finfo2, $file['tmp_name']); finfo_close($finfo2); $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 = "{$year}/{$belegNr}"; $absDir = Config::get('upload_dir') . "/{$relDir}"; if (!is_dir($absDir)) @mkdir($absDir, 0755, true); // Ein bereits vorhandenes, aber nicht beschreibbares Zielverzeichnis (etwa // ein Rückstand aus einem Testlauf mit anderem Nutzer) ließ move_uploaded_file() // sonst mit einer Ausnahme und HTTP 500 auflaufen. if (!is_dir($absDir) || !is_writable($absDir)) { Beleg::releaseBelegNr($belegNr); $this->renderUploadErrors([ 'file' => "Das Zielverzeichnis {$relDir} ist nicht beschreibbar. " . 'Bitte die Schreibrechte im Belegverzeichnis prüfen.', ]); return; } $filename = pathinfo($file['name'], PATHINFO_BASENAME); $filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME); $storagePath = "{$relDir}/original.{$ext}"; $absPath = Config::get('upload_dir') . "/{$storagePath}"; if (!move_uploaded_file($file['tmp_name'], $absPath)) { Beleg::releaseBelegNr($belegNr); $this->renderUploadErrors([ 'file' => 'Die hochgeladene Datei konnte nicht gespeichert werden. ' . 'Bitte die Schreibrechte im Belegverzeichnis prüfen.', ]); return; } // 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."); } /** * Zeigt das Upload-Formular erneut mit Fehlermeldungen an – als JSON, wenn * der Upload per AJAX kam. * * @param array $errors */ private function renderUploadErrors(array $errors): void { 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, ]), ]); } 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; $kiSektion = $kiAktiv || $kiVorschlag !== null; // Bank-Kontext: Vorausfüllen aus Bankbuchung $bankTx = null; $bankTxSupplier = ''; $bankTxBrutto = 0.0; $bankFormData = []; if (!empty($_GET['bank_tx'])) { $bankTx = BankTransaction::find((int)$_GET['bank_tx']); } if ($bankTx) { // Auftraggeber extrahieren: "Auftraggeber: X Buchungstext" → X if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) { $bankTxSupplier = trim($m[1]); } // alloc_betrag aus URL hat Vorrang (Teilbetrag bei Aufteilung), sonst voller Betrag $allocBetrag = isset($_GET['alloc_betrag']) ? round((float)$_GET['alloc_betrag'], 2) : 0.0; $bankTxBrutto = $allocBetrag > 0 ? $allocBetrag : abs((float)$bankTx['wert']); $netto = round($bankTxBrutto / 1.19, 2); $bankFormData = [ 'buchungs_datum' => $bankTx['wertstellung'], 'betrag_netto' => number_format($netto, 2, '.', ''), 'alloc_betrag' => number_format($bankTxBrutto, 2, '.', ''), 'zahlungs_kontonummer'=> '1200', ]; // Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') { $supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier); \Steuerkram\Database::execute( 'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?', [$supplierId, (int)$params['id']] ); $beleg['supplier_name'] = $bankTxSupplier; } } $linkedBankTx = BankTransaction::findByBelegId((int)$beleg['id']); $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, 'bankTx' => $bankTx, 'bankTxSupplier' => $bankTxSupplier, 'bankTxBrutto' => $bankTxBrutto, 'formData' => $bankFormData, 'linkedBankTx' => $linkedBankTx, 'vorlagen' => Buchungsvorlage::findAll(), ]), ]); } public function serveFile(array $params): void { $beleg = Beleg::find((int)$params['id']); if (!$beleg) { http_response_code(404); return; } // Beleg ohne physische Datei → PDF der verknüpften Rechnung inline ausliefern if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) { $invoice = \Steuerkram\Models\Invoice::find((int)$beleg['invoice_id']); if (!$invoice) { http_response_code(404); return; } if (!empty($invoice['storno_of_id'])) { $original = \Steuerkram\Models\Invoice::find((int)$invoice['storno_of_id']); if ($original) { $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; } } $lines = \Steuerkram\Models\Invoice::lines((int)$invoice['id']); $settings = \Steuerkram\Models\Setting::get(); $svc = new \Steuerkram\Services\PdfInvoiceService(); $pdf = $svc->renderToString($invoice, $lines, $settings); header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"'); header('Content-Length: ' . strlen($pdf)); echo $pdf; exit; } $basePath = (string)realpath(Config::get('upload_dir')); $absPath = Beleg::absolutePath($beleg); $realPath = realpath($absPath); if ($realPath === false || !str_starts_with($realPath, $basePath)) { http_response_code(403); return; } if (!file_exists($realPath)) { http_response_code(404); return; } // XML kann eingebettete XSLT-Stylesheets enthalten, die bei Inline-Anzeige // im Browser (v.a. Firefox) als Stored-XSS ausgeführt würden -> Download erzwingen. $isXml = in_array($beleg['mime_type'], ['application/xml', 'text/xml'], true); $disposition = $isXml ? 'attachment' : 'inline'; header('Content-Type: ' . $beleg['mime_type']); header('Content-Disposition: ' . $disposition . '; filename="' . $beleg['filename'] . '"'); header('Content-Length: ' . filesize($realPath)); readfile($realPath); } 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 updateSupplier(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' => 'Nicht gefunden.']); return; } if ($beleg['locked']) { http_response_code(403); echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); return; } $supplierName = trim($_POST['supplier_name'] ?? ''); $supplierId = $supplierName !== '' ? Supplier::findOrCreate($supplierName) : null; Beleg::updateSupplier($id, $supplierId); echo json_encode(['ok' => true, 'supplier_name' => $supplierName !== '' ? $supplierName : null]); } 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); // [] = "verarbeitet, kein Ergebnis"; null würde endlose Auto-Retry-Schleife auslösen Beleg::saveKiVorschlag($id, $vorschlag ?? []); if ($vorschlag === null) { $llm = new \Steuerkram\Services\LlmService(); echo json_encode($llm->isReachable() ? ['error' => 'Kein Vorschlag generiert'] : ['connection_error' => true] ); return; } echo json_encode($vorschlag); } 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 = Beleg::absolutePath($beleg); 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]); } }