From 0c8d1e0b5d6ed33f002d55ae6bd372ad52548f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Mon, 6 Oct 2025 19:35:09 +0200 Subject: [PATCH] Fix 500 generation: include account entity in template defaults and merge global whitelist entities during resolution --- .../Admin/DocumentTemplateController.php | 170 ++++++++++++++++++ app/Services/Documents/TokenValueResolver.php | 18 +- 2 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Admin/DocumentTemplateController.php diff --git a/app/Http/Controllers/Admin/DocumentTemplateController.php b/app/Http/Controllers/Admin/DocumentTemplateController.php new file mode 100644 index 0000000..b9dca16 --- /dev/null +++ b/app/Http/Controllers/Admin/DocumentTemplateController.php @@ -0,0 +1,170 @@ +ensurePermission(); + $templates = DocumentTemplate::query()->orderByDesc('updated_at')->get(); + + return Inertia::render('Admin/DocumentTemplates/Index', [ + 'templates' => $templates, + ]); + } + + public function toggleActive(DocumentTemplate $template) + { + $this->ensurePermission(); + $template->active = ! $template->active; + $template->updated_by = Auth::id(); + $template->save(); + + return redirect()->back()->with('success', 'Status predloge posodobljen.'); + } + + public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentTemplate $template) + { + $this->ensurePermission(); + $template->fill($request->only(['output_filename_pattern', 'date_format'])); + if ($request->has('fail_on_unresolved')) { + $template->fail_on_unresolved = (bool) $request->boolean('fail_on_unresolved'); + } + // Build formatting options array from discrete fields if provided + $fmt = $template->formatting_options ?? []; + $dirty = false; + foreach ([ + 'number_decimals', 'decimal_separator', 'thousands_separator', + 'currency_symbol', 'currency_position', + ] as $key) { + if ($request->filled($key)) { + $fmt[$key] = $request->input($key); + $dirty = true; + } elseif ($request->has($key) && $request->input($key) === null) { + unset($fmt[$key]); + $dirty = true; + } + } + if ($request->has('currency_space')) { + $fmt['currency_space'] = (bool) $request->boolean('currency_space'); + $dirty = true; + } + if ($request->filled('default_date_format')) { + $fmt['default_date_format'] = $request->input('default_date_format'); + $dirty = true; + } + if ($request->has('date_formats')) { + $fmt['date_formats'] = array_filter((array) $request->input('date_formats'), fn ($v) => $v !== null && $v !== ''); + $dirty = true; + } + if ($dirty) { + $template->formatting_options = $fmt; + } + $template->updated_by = Auth::id(); + $template->save(); + + return redirect()->back()->with('success', 'Nastavitve predloge shranjene.'); + } + + public function store(StoreDocumentTemplateRequest $request) + { + $this->ensurePermission(); + $file = $request->file('file'); + + // Basic extension guard (defense in depth vs only MIME detection) + if (strtolower($file->getClientOriginalExtension()) !== 'docx') { + return redirect()->back()->withErrors(['file' => 'Datoteka mora biti DOCX.']); + } + + $slug = Str::slug($request->slug); + + // Determine next version if slug exists + $latest = DocumentTemplate::where('slug', $slug)->orderByDesc('version')->first(); + $nextVersion = $latest ? ($latest->version + 1) : 1; + + $hash = hash_file('sha256', $file->getRealPath()); + $path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public'); + + // Scan tokens from uploaded DOCX (best effort) + $tokens = []; + try { + /** @var TokenScanner $scanner */ + $scanner = app(TokenScanner::class); + $zip = new \ZipArchive; + $tmp = tempnam(sys_get_temp_dir(), 'tmpl'); + copy($file->getRealPath(), $tmp); + if ($zip->open($tmp) === true) { + $xml = $zip->getFromName('word/document.xml'); + if ($xml !== false) { + $tokens = $scanner->scan($xml); + } + $zip->close(); + } + } catch (\Throwable $e) { + // swallow scanning errors + } + + // (Future) Could refine allowed columns automatically based on tokens + $entities = ['contract', 'client_case', 'client', 'person', 'account']; + $columns = [ + 'contract' => ['reference', 'start_date', 'end_date', 'description'], + 'client_case' => ['client_ref'], + 'client' => [], + 'person' => ['full_name', 'first_name', 'last_name', 'nu'], + // Add common account attributes; whitelist may further refine + 'account' => ['reference', 'initial_amount', 'balance_amount', 'promise_date'], + ]; + + $payload = [ + 'name' => $request->name, + 'slug' => $slug, + 'custom_name' => $request->custom_name, + 'description' => $request->description, + 'core_entity' => 'contract', + 'entities' => $entities, + 'columns' => $columns, + 'version' => $nextVersion, + 'engine' => 'tokens', + 'file_path' => $path, + 'file_hash' => $hash, + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'active' => true, + 'created_by' => $latest ? $latest->created_by : Auth::id(), // preserve original author for lineage if re-upload + 'updated_by' => Auth::id(), + 'formatting_options' => [ + 'number_decimals' => 2, + 'decimal_separator' => ',', + 'thousands_separator' => '.', + 'currency_symbol' => '€', + 'currency_position' => 'after', + 'currency_space' => true, + ], + ]; + if (Schema::hasColumn('document_templates', 'tokens')) { + $payload['tokens'] = $tokens; + } + $template = DocumentTemplate::create($payload); + + return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id); + } + + private function ensurePermission(): void + { + if (Gate::denies('manage-document-templates') && Gate::denies('manage-settings')) { + abort(403); + } + } +} diff --git a/app/Services/Documents/TokenValueResolver.php b/app/Services/Documents/TokenValueResolver.php index 91a8f8c..9e2e299 100644 --- a/app/Services/Documents/TokenValueResolver.php +++ b/app/Services/Documents/TokenValueResolver.php @@ -19,12 +19,17 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con { $values = []; $unresolved = []; - // Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays) - $settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? []; - $configWhitelist = config('documents.whitelist', []); - // Merge preserving DB additions/overrides - $globalWhitelist = array_replace($configWhitelist, $settingsWhitelist); - $templateEntities = $template->entities ?: array_keys($globalWhitelist); + // Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays) + $settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? []; + $configWhitelist = config('documents.whitelist', []); + // Merge preserving DB additions/overrides + $globalWhitelist = array_replace($configWhitelist, $settingsWhitelist); + // Always treat globally whitelisted entities as available, even if legacy template does not list them + if ($template->entities && is_array($template->entities)) { + $templateEntities = array_values(array_unique(array_merge($template->entities, array_keys($globalWhitelist)))); + } else { + $templateEntities = array_keys($globalWhitelist); + } foreach ($tokens as $token) { [$entity,$attr] = explode('.', $token, 2); if ($entity === 'generation') { @@ -80,6 +85,7 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac return (string) $person->{$attr}; case 'account': $account = optional($contract->account); + return (string) $account->{$attr}; default: return '';