Steuerkram/src/Controllers/BelegController.php

381 lines
14 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\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\Services\KontierungsvorschlagService;
class BelegController
{
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,
]),
'topbarActions' => '<a href="/belege/export?' . http_build_query($filter) . '" class="btn btn-secondary">CSV Export</a> <a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</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'];
$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();
}
}