493 lines
19 KiB
PHP
493 lines
19 KiB
PHP
<?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]);
|
||
}
|
||
}
|