Steuerkram/src/Controllers/BelegController.php

493 lines
19 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) {
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'];
$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);
$filename = pathinfo($file['name'], PATHINFO_BASENAME);
$filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME);
$storagePath = "{$relDir}/original.{$ext}";
$absPath = Config::get('upload_dir') . "/{$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;
$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]);
}
}