Steuerkram/src/Controllers/BelegController.php
fds2610-bot f9b6a5d303 Feature: Buchungsvorlagen im Buchungsdialog
Neues Vorlagen-Badge zwischen Bank-Badge und Buchungsformular in
/belege/{id}. Vorlage auswählen öffnet modalen Dialog mit Feldgruppen
(Metadaten, USt-Zeilen mit Live-Berechnung, Zahlungskonto). Buchungen
werden per VorlageBuchungsService aus JSON-Regeln in der DB erstellt.
Pflege-UI unter /einstellungen/buchungsvorlagen (CRUD inkl. Felder und
Regeln). Initiale Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" via
Migration 080 eingespielt. Test 42 prüft CRUD und Buchungsausführung.
2026-05-28 21:57:04 +02:00

461 lines
18 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\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,
]),
'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 = "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;
$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(dirname(__DIR__, 2) . '/public/static/uploads');
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
$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; }
header('Content-Type: ' . $beleg['mime_type']);
header('Content-Disposition: inline; 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 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;
}
}