Steuerkram/src/Controllers/BelegController.php

524 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Config;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Asset;
use Steuerkram\Models\Supplier;
use Steuerkram\View;
use Steuerkram\Services\ImageCropService;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Models\Buchungsvorlage;
use Steuerkram\Services\KontierungsvorschlagService;
class BelegController extends BaseController
{
private const ALLOWED_MIME = [
'application/pdf' => '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' => '<a href="/belege/export?' . View::buildQuery($filter) . '" class="btn btn-secondary">CSV Export</a> '
. '<a href="/belege/upload" class="btn btn-primary" title="Beleg hochladen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">'
. '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'
. '</svg>'
. '</a>',
]);
}
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<string,string> $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]);
}
}