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.
461 lines
18 KiB
PHP
461 lines
18 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\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;
|
||
}
|
||
}
|