Teilnahmerechnungen erstellen
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\DocumentAsset;
|
||||
use App\Models\DocumentTemplate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Setzt ein Dokument aus den in der Datenbank gepflegten Vorlagenblöcken zusammen.
|
||||
*
|
||||
* Ablauf: `layout` bildet das Seitengerüst und enthält `{block:...}`-Platzhalter; darin werden die
|
||||
* einzelnen Blöcke eingesetzt, anschließend Bilder und Werte. Das Ergebnis wandert in ein dünnes
|
||||
* Blade-Gerüst, das selbst kein Layout enthält.
|
||||
*
|
||||
* WICHTIG: Vorlageninhalt kommt aus der Datenbank und wird von einem Admin-Formular befüllt. Er wird
|
||||
* ausschließlich per `strtr()` bzw. `preg_replace_callback()` ersetzt und NIEMALS durch `Blade::render()`
|
||||
* oder `eval` geschickt -- sonst wäre das Formular ein Weg zur Codeausführung auf dem Server.
|
||||
*/
|
||||
class DocumentTemplateRenderProvider
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private array $blocks;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $overrides Blockinhalte, die den gespeicherten Stand ersetzen -- für die
|
||||
* Vorschau in der Vorlagen-Verwaltung, damit ungespeicherte
|
||||
* Änderungen sichtbar werden.
|
||||
*/
|
||||
public function __construct(private readonly string $documentType, array $overrides = [])
|
||||
{
|
||||
$stored = DocumentTemplate::forType($this->documentType)
|
||||
->map(fn(DocumentTemplate $block): string => (string) $block->content)
|
||||
->all();
|
||||
|
||||
$this->blocks = array_merge($stored, array_map(strval(...), $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tokens Platzhalter ohne geschweifte Klammern, z.B. ['invoice_number' => '...']
|
||||
*/
|
||||
public function render(array $tokens): string
|
||||
{
|
||||
$html = $this->blockContent(DocumentTemplate::BLOCK_LAYOUT);
|
||||
|
||||
$html = $this->insertBlocks($html);
|
||||
$html = $this->insertAssets($html);
|
||||
$html = $this->applyConditionals($html, $tokens);
|
||||
$html = $this->insertTokens($html, $tokens);
|
||||
|
||||
$style = $this->stripBareDataUris($this->insertTokens(
|
||||
$this->insertAssets($this->blockContent(DocumentTemplate::BLOCK_STYLE)),
|
||||
$tokens
|
||||
));
|
||||
|
||||
return view('pdfs.document', [
|
||||
'title' => $tokens['document_title'] ?? '',
|
||||
'style' => $style,
|
||||
'content' => $html,
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Data-URIs, die nicht in `url(...)` bzw. Anführungszeichen stehen.
|
||||
*
|
||||
* dompdf lagert Data-URIs im CSS nur dann in interne Blobs aus, wenn ihnen `(`, `"` oder `'`
|
||||
* vorausgeht (siehe Stylesheet::_parse_css). Ein bar im CSS stehendes Data-URI bleibt als Rohtext
|
||||
* liegen, und die anschließende Ruleset-Regex `[^{]*{[^}]*}` scannt für jede Startposition durch
|
||||
* das gesamte Base64 -- quadratische Laufzeit. Bei einem eingebetteten Logo sind das Minuten, und
|
||||
* unter PHP-FPM stirbt der Worker am Zeitlimit: die Anfrage endet in einem 502.
|
||||
*
|
||||
* Gültiges CSS ist so ein Data-URI ohnehin nicht. Es wird deshalb entfernt und weitergerendert,
|
||||
* statt das Dokument scheitern zu lassen -- wer eine Rechnung braucht, hat die Vorlage nicht
|
||||
* kaputt gemacht. Die Warnung im Log sorgt dafür, dass der Zustand trotzdem auffällt.
|
||||
*/
|
||||
private function stripBareDataUris(string $css): string
|
||||
{
|
||||
// Das Semikolon gehört zum Data-URI selbst ("data:image/png;base64,..."), darf hier also nicht
|
||||
// begrenzen -- sonst bliebe der Base64-Rumpf stehen und der Parser bremst weiter.
|
||||
$cleaned = preg_replace('/(?<![("\'])data:[^\s}\)\'"]+/', '', $css);
|
||||
|
||||
if ($cleaned !== $css) {
|
||||
Log::warning('Dokumentvorlage: Data-URI ausserhalb von url("...") im CSS entfernt.', [
|
||||
'document_type' => $this->documentType,
|
||||
'block' => DocumentTemplate::BLOCK_STYLE,
|
||||
]);
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
|
||||
/** Ersetzt `{block:name}` durch den jeweiligen Blockinhalt. Leere oder fehlende Blöcke fallen weg. */
|
||||
private function insertBlocks(string $html): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\{block:([a-z0-9_-]+)}/i',
|
||||
fn(array $match): string => $this->blockContent($match[1]),
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/** Ersetzt `{asset:name}` durch die Data-URI des Bildes. Unbekannte Namen werden zu einem Leerstring. */
|
||||
private function insertAssets(string $html): string
|
||||
{
|
||||
if (!str_contains($html, '{asset:')) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$assets = DocumentAsset::all()->keyBy('name');
|
||||
|
||||
return preg_replace_callback(
|
||||
'/\{asset:([a-z0-9_-]+)}/i',
|
||||
static fn(array $match): string => $assets->get($match[1])?->toDataUri() ?? '',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet `{if:token}...{/if:token}` aus: der Abschnitt bleibt nur stehen, wenn der Platzhalter einen
|
||||
* Wert hat. Ohne das stünden auf der Rechnung leere Zeilen, hängende Trenner und Beschriftungen ohne
|
||||
* Wert ("Steuernummer: "), sobald ein optionales Feld beim Tenant nicht gepflegt ist.
|
||||
*
|
||||
* Verschachtelte Bedingungen werden nicht unterstützt -- für die Vorlagen hier reicht eine Ebene.
|
||||
*
|
||||
* @param array<string, string> $tokens
|
||||
*/
|
||||
private function applyConditionals(string $html, array $tokens): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\{if:([a-z0-9_]+)}(.*?)\{\/if:\1}/is',
|
||||
static fn(array $match): string => trim((string) ($tokens[$match[1]] ?? '')) === '' ? '' : $match[2],
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die Wert-Platzhalter. Unbekannte Platzhalter bleiben unangetastet stehen -- das macht beim
|
||||
* Pflegen der Vorlage sofort sichtbar, dass ein Name nicht stimmt.
|
||||
*
|
||||
* @param array<string, string> $tokens
|
||||
*/
|
||||
private function insertTokens(string $html, array $tokens): string
|
||||
{
|
||||
$replacements = [];
|
||||
foreach ($tokens as $name => $value) {
|
||||
$replacements['{' . $name . '}'] = (string) $value;
|
||||
}
|
||||
|
||||
return strtr($html, $replacements);
|
||||
}
|
||||
|
||||
private function blockContent(string $block): string
|
||||
{
|
||||
return $this->blocks[$block] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,14 @@ class GlobalDataProvider {
|
||||
$this->user = auth()->user();
|
||||
|
||||
$canAccessAdmin = false;
|
||||
$isMainAdmin = false;
|
||||
if (null !== $this->user) {
|
||||
$authCheck = new AuthCheckProvider();
|
||||
$effectiveRole = $authCheck->getUserRole();
|
||||
$canAccessAdmin = in_array($effectiveRole, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true);
|
||||
// Steuert nur die Sichtbarkeit app-weiter Einstellungen im Menü; abgesichert wird über
|
||||
// MainAdminRoleMiddleware.
|
||||
$isMainAdmin = $authCheck->isMainAdministrator($this->user);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -37,6 +41,7 @@ class GlobalDataProvider {
|
||||
'version' => config('app.version'),
|
||||
'currentEvent' => $this->getCurrentEventData(),
|
||||
'canAccessAdmin' => $canAccessAdmin,
|
||||
'isMainAdmin' => $isMainAdmin,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user