381 lines
14 KiB
PHP
381 lines
14 KiB
PHP
<?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();
|
||
}
|
||
}
|