From 23f2011e335be8b7f3a892b0008480a79c7c8b7b Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 12 Oct 2025 17:52:17 +0200 Subject: [PATCH] Document gen fixed --- app/Console/Commands/DocScanCommand.php | 110 ++++++ app/Console/Commands/TemplateScanCommand.php | 152 +++++++++ .../Admin/DocumentTemplateController.php | 172 +++++++++- app/Http/Controllers/ClientCaseContoller.php | 9 + .../ContractDocumentGenerationController.php | 34 +- .../UpdateDocumentTemplateRequest.php | 2 +- .../Documents/DocxTemplateRenderer.php | 323 ++++++++++++++++-- app/Services/Documents/TokenScanner.php | 18 +- app/Services/Documents/TokenValueResolver.php | 54 ++- phpunit.xml | 4 +- .../js/Pages/Admin/DocumentTemplates/Edit.vue | 42 ++- .../js/Pages/Admin/DocumentTemplates/Show.vue | 19 +- .../js/Pages/Cases/Partials/ContractTable.vue | 216 +++++++++++- resources/js/Pages/Cases/Show.vue | 3 + routes/web.php | 1 + .../DocumentTemplateCustomTokensTest.php | 45 +++ 16 files changed, 1116 insertions(+), 88 deletions(-) create mode 100644 app/Console/Commands/DocScanCommand.php create mode 100644 app/Console/Commands/TemplateScanCommand.php create mode 100644 tests/Feature/DocumentTemplateCustomTokensTest.php diff --git a/app/Console/Commands/DocScanCommand.php b/app/Console/Commands/DocScanCommand.php new file mode 100644 index 0000000..1d547af --- /dev/null +++ b/app/Console/Commands/DocScanCommand.php @@ -0,0 +1,110 @@ +argument('contract'); + $xmlPath = (string) $this->argument('xml'); + + if (! is_file($xmlPath)) { + $this->error("XML file not found: {$xmlPath}"); + + return self::FAILURE; + } + $xml = file_get_contents($xmlPath); + if ($xml === false) { + $this->error('Unable to read XML file.'); + + return self::FAILURE; + } + + $contract = Contract::where('uuid', $uuid)->first(); + if (! $contract) { + $this->error("Contract not found for UUID: {$uuid}"); + + return self::FAILURE; + } + + // Normalize common Word run boundaries so tokens appear contiguous + $norm = $this->normalizeRunsForTokens($xml); + + $tokens = $scanner->scan($norm); + $this->info('Detected tokens:'); + foreach ($tokens as $t) { + $this->line(" - {$t}"); + } + if (empty($tokens)) { + $this->warn('No tokens detected.'); + } + + // Build a minimal in-memory template using global whitelist so we can resolve values + $whitelist = $settings->get()->whitelist ?? []; + if (! is_array($whitelist)) { + $whitelist = []; + } + $entities = array_keys($whitelist); + $template = new DocumentTemplate([ + 'entities' => $entities, + 'columns' => $whitelist, + 'fail_on_unresolved' => false, + 'formatting_options' => [], + 'meta' => [], + ]); + + // Resolve values using a relaxed policy to avoid exceptions on unknowns + $user = auth()->user() ?? (\App\Models\User::query()->first() ?: new \App\Models\User(['name' => 'System'])); + $resolved = $resolver->resolve($tokens, $template, $contract, $user, policy: 'blank'); + $values = $resolved['values'] ?? []; + $unresolved = $resolved['unresolved'] ?? []; + + $this->info('Resolved values:'); + foreach ($values as $k => $v) { + $short = strlen((string) $v) > 120 ? substr((string) $v, 0, 117).'...' : (string) $v; + $this->line(" - {$k} => {$short}"); + } + if (! empty($unresolved)) { + $this->warn('Unresolved tokens:'); + foreach ($unresolved as $u) { + $this->line(" - {$u}"); + } + } + + return self::SUCCESS; + } + + private function normalizeRunsForTokens(string $xml): string + { + // Remove proofing error spans that may split content + $xml = preg_replace('#]*/>#i', '', $xml) ?? $xml; + // Iteratively collapse boundaries between text runs, even if w:rPr is present + $patterns = [ + // [optional proofErr] [optional rPr] + '#\s*\s*(?:]*/>\s*)*(?:]*>\s*(?:.*?\s*)*)?]*>#is', + ]; + $prev = null; + while ($prev !== $xml) { + $prev = $xml; + foreach ($patterns as $pat) { + $xml = preg_replace($pat, '', $xml) ?? $xml; + } + } + // Remove zero-width and soft hyphen characters + $xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); + + return $xml; + } +} diff --git a/app/Console/Commands/TemplateScanCommand.php b/app/Console/Commands/TemplateScanCommand.php new file mode 100644 index 0000000..19805ae --- /dev/null +++ b/app/Console/Commands/TemplateScanCommand.php @@ -0,0 +1,152 @@ +argument('slug'); + $version = $this->option('tpl-version'); + + /** @var DocumentTemplate|null $template */ + $query = DocumentTemplate::query()->where('slug', $slug); + if (! empty($version)) { + $query->where('version', (int) $version); + } else { + $query->orderByDesc('version'); + } + $template = $query->first(); + if (! $template) { + $this->error("Template not found for slug '{$slug}'".($version ? " v{$version}" : '')); + + return self::FAILURE; + } + + $disk = 'public'; + $path = $template->file_path; + if (! $path || ! Storage::disk($disk)->exists($path)) { + $this->error('Template file not found on disk: '.$path); + + return self::FAILURE; + } + + $bytes = Storage::disk($disk)->get($path); + $tmp = tempnam(sys_get_temp_dir(), 'tmpl'); + file_put_contents($tmp, $bytes); + + $zip = new ZipArchive; + if ($zip->open($tmp) !== true) { + $this->error('Unable to open DOCX (zip).'); + + return self::FAILURE; + } + + // Collect parts: main + headers/footers + notes/comments + $parts = []; + $doc = $zip->getFromName('word/document.xml'); + if ($doc !== false) { + $parts['word/document.xml'] = $doc; + } + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (! is_string($name)) { + continue; + } + if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) { + $xml = $zip->getFromName($name); + if ($xml !== false) { + $parts[$name] = $xml; + } + } + } + + // Normalize and scan + $all = []; + $perPart = []; + foreach ($parts as $name => $xml) { + $norm = $this->normalizeRunsForTokens($xml); + $found = $scanner->scan($norm); + $perPart[$name] = $found; + if ($found) { + $all = array_merge($all, $found); + } + } + $union = array_values(array_unique($all)); + + $this->info("Template: {$template->name} (slug={$template->slug}, v{$template->version})"); + $this->line('File: '.$path); + $this->line('Tokens found (union): '.count($union)); + foreach ($union as $t) { + $this->line(' - '.$t); + } + + if ($this->option('parts')) { + $this->line(''); + $this->info('Per-part details:'); + foreach ($perPart as $n => $list) { + $this->line("[{$n}] (".count($list).')'); + foreach ($list as $t) { + $this->line(' - '.$t); + } + } + } + + $zip->close(); + @unlink($tmp); + + return self::SUCCESS; + } + + private function normalizeRunsForTokens(string $xml): string + { + // Remove proofing error markers + $xml = preg_replace('#]*/>#i', '', $xml) ?? $xml; + // Collapse boundaries between runs and inside runs (include tabs/line breaks) + $patterns = [ + '#\s*\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:]*>\s*(?:.*?\s*)*)?]*>#is', + '#\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*]*>#is', + ]; + $prev = null; + while ($prev !== $xml) { + $prev = $xml; + foreach ($patterns as $pat) { + $xml = preg_replace($pat, '', $xml) ?? $xml; + } + } + // Clean inside {{ ... }} + $xml = preg_replace_callback('/\{\{.*?\}\}/s', function (array $m) { + $inner = substr($m[0], 2, -2); + $inner = preg_replace('/<[^>]+>/', '', $inner) ?? $inner; + $inner = preg_replace('/\s+/', '', $inner) ?? $inner; + + return '{{'.$inner.'}}'; + }, $xml) ?? $xml; + // Clean inside { ... } if it looks like a token + $xml = preg_replace_callback('/\{[^{}]*\}/s', function (array $m) { + $raw = $m[0]; + $inner = substr($raw, 1, -1); + $clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner; + $clean = preg_replace('/\s+/', '', $clean) ?? $clean; + if (preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) { + return '{'.$clean.'}'; + } + + return $raw; + }, $xml) ?? $xml; + // Remove zero-width and soft hyphen + $xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); + + return $xml; + } +} diff --git a/app/Http/Controllers/Admin/DocumentTemplateController.php b/app/Http/Controllers/Admin/DocumentTemplateController.php index 1308bbc..497ec5a 100644 --- a/app/Http/Controllers/Admin/DocumentTemplateController.php +++ b/app/Http/Controllers/Admin/DocumentTemplateController.php @@ -133,6 +133,94 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT return redirect()->back()->with('success', 'Nastavitve predloge shranjene.'); } + public function rescanTokens(DocumentTemplate $template) + { + $this->ensurePermission(); + + // Best-effort: read stored DOCX from disk and re-scan tokens + $tokens = []; + try { + /** @var TokenScanner $scanner */ + $scanner = app(TokenScanner::class); + $zip = new \ZipArchive; + $tmp = tempnam(sys_get_temp_dir(), 'tmpl'); + // Copy file from storage to a temp path + $disk = 'public'; + $stream = \Storage::disk($disk)->get($template->file_path); + file_put_contents($tmp, $stream); + if ($zip->open($tmp) === true) { + // Collect main document and header/footer parts + $parts = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = $stat['name'] ?? ''; + if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) { + $parts[] = $name; + } + } + if (empty($parts)) { + $parts = ['word/document.xml']; + } + $found = []; + foreach ($parts as $name) { + $xml = $zip->getFromName($name); + if ($xml === false) { + continue; + } + $norm = self::normalizeDocxXmlTokens($xml); + $det = $scanner->scan($norm); + if (! empty($det)) { + $found = array_merge($found, $det); + } + } + $tokens = array_values(array_unique($found)); + $zip->close(); + } + } catch (\Throwable $e) { + // swallow scanning errors, keep $tokens as empty + } + + if (\Schema::hasColumn('document_templates', 'tokens')) { + $template->tokens = $tokens; + } + // Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults + try { + $meta = is_array($template->meta) ? $template->meta : []; + $types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : []; + $defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : []; + foreach (($tokens ?? []) as $tok) { + if (is_string($tok) && str_starts_with($tok, 'custom.')) { + $key = substr($tok, 7); + if ($key !== '') { + if (! array_key_exists($key, $types)) { + $types[$key] = 'string'; + } + if (! array_key_exists($key, $defaults)) { + $defaults[$key] = ''; + } + } + } + } + if (! empty($types)) { + $meta['custom_default_types'] = $types; + } + if (! empty($defaults)) { + $meta['custom_defaults'] = $defaults; + } + if ($meta !== ($template->meta ?? [])) { + $template->meta = $meta; + } + } catch (\Throwable $e) { + // ignore meta typing/defaults failures + } + $template->updated_by = Auth::id(); + $template->save(); + + $count = is_array($tokens) ? count($tokens) : 0; + + return back()->with('success', "Tokens posodobljeni ({$count} najdenih)."); + } + public function store(StoreDocumentTemplateRequest $request) { $this->ensurePermission(); @@ -152,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request) $hash = hash_file('sha256', $file->getRealPath()); $path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public'); - // Scan tokens from uploaded DOCX (best effort) + // Scan tokens from uploaded DOCX (best effort) – normalize XML to collapse Word run boundaries $tokens = []; try { /** @var TokenScanner $scanner */ @@ -161,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request) $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); + // Collect main document and header/footer parts + $parts = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = $stat['name'] ?? ''; + if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) { + $parts[] = $name; + } } + if (empty($parts)) { + $parts = ['word/document.xml']; + } + $found = []; + foreach ($parts as $name) { + $xml = $zip->getFromName($name); + if ($xml === false) { + continue; + } + $norm = self::normalizeDocxXmlTokens($xml); + $det = $scanner->scan($norm); + if (! empty($det)) { + $found = array_merge($found, $det); + } + } + $tokens = array_values(array_unique($found)); $zip->close(); } } catch (\Throwable $e) { @@ -224,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request) if (Schema::hasColumn('document_templates', 'tokens')) { $payload['tokens'] = $tokens; } + // Auto-add default string types for any detected custom.* tokens + try { + $meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : []; + $types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : []; + $defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : []; + foreach (($tokens ?? []) as $tok) { + if (is_string($tok) && str_starts_with($tok, 'custom.')) { + $key = substr($tok, 7); + if ($key !== '') { + if (! array_key_exists($key, $types)) { + $types[$key] = 'string'; + } + if (! array_key_exists($key, $defaults)) { + $defaults[$key] = ''; + } + } + } + } + if (! empty($types)) { + $meta['custom_default_types'] = $types; + } + if (! empty($defaults)) { + $meta['custom_defaults'] = $defaults; + } + if ($meta !== ($payload['meta'] ?? [])) { + $payload['meta'] = $meta; + } + } catch (\Throwable $e) { + // ignore meta typing/defaults failures + } $template = DocumentTemplate::create($payload); return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id); @@ -235,4 +374,29 @@ private function ensurePermission(): void abort(403); } } + + /** + * Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}} + * appear contiguous in XML for scanning. + */ + private static function normalizeDocxXmlTokens(string $xml): string + { + // Remove proofing error markers + $xml = preg_replace('#]*/>#i', '', $xml) ?? $xml; + // Iteratively collapse boundaries between text runs, even if w:rPr is present + $patterns = [ + '#\s*\s*(?:]*/>\s*)*(?:]*>\s*(?:.*?\s*)*)?]*>#is', + ]; + $prev = null; + while ($prev !== $xml) { + $prev = $xml; + foreach ($patterns as $pat) { + $xml = preg_replace($pat, '', $xml) ?? $xml; + } + } + // Remove zero-width and soft hyphen characters + $xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); + + return $xml; + } } diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 7591391..cf027c0 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -1195,6 +1195,15 @@ public function show(ClientCase $clientCase) 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, + // Active document templates for contracts (latest version per slug) + 'contract_doc_templates' => \App\Models\DocumentTemplate::query() + ->where('active', true) + ->where('core_entity', 'contract') + ->orderBy('slug') + ->get(['id', 'name', 'slug', 'version', 'tokens', 'meta']) + ->groupBy('slug') + ->map(fn ($g) => $g->sortByDesc('version')->first()) + ->values(), 'archive_meta' => [ 'archive_segment_id' => $archiveSegmentId, 'related_tables' => $relatedArchiveTables, diff --git a/app/Http/Controllers/ContractDocumentGenerationController.php b/app/Http/Controllers/ContractDocumentGenerationController.php index fd8003a..04b8633 100644 --- a/app/Http/Controllers/ContractDocumentGenerationController.php +++ b/app/Http/Controllers/ContractDocumentGenerationController.php @@ -23,15 +23,24 @@ public function __invoke(Request $request, Contract $contract): Response } $request->validate([ 'template_slug' => ['required', 'string', 'exists:document_templates,slug'], + 'template_version' => ['nullable', 'integer'], 'custom' => ['nullable', 'array'], 'custom.*' => ['nullable'], ]); - $template = DocumentTemplate::where('slug', $request->template_slug) + // Prefer explicitly requested version if provided and active; otherwise use latest active + $baseQuery = DocumentTemplate::query() + ->where('slug', $request->template_slug) ->where('core_entity', 'contract') - ->where('active', true) - ->orderByDesc('version') - ->firstOrFail(); + ->where('active', true); + if ($request->filled('template_version')) { + $template = (clone $baseQuery)->where('version', (int) $request->integer('template_version'))->first(); + if (! $template) { + $template = (clone $baseQuery)->orderByDesc('version')->firstOrFail(); + } + } else { + $template = $baseQuery->orderByDesc('version')->firstOrFail(); + } // Load related data minimally $contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']); @@ -47,6 +56,16 @@ public function __invoke(Request $request, Contract $contract): Response 'tokens' => $e->unresolved ?? [], ], 422); } catch (\Throwable $e) { + try { + logger()->error('ContractDocumentGenerationController generation failed', [ + 'template_id' => $template->id ?? null, + 'template_slug' => $template->slug ?? null, + 'template_version' => $template->version ?? null, + 'error' => $e->getMessage(), + ]); + } catch (\Throwable $logEx) { + } + return response()->json([ 'status' => 'error', 'message' => 'Generation failed.', @@ -115,6 +134,13 @@ public function __invoke(Request $request, Contract $contract): Response 'status' => 'ok', 'document_uuid' => $doc->uuid, 'path' => $doc->path, + 'stats' => $result['stats'] ?? null, + 'template' => [ + 'id' => $template->id, + 'slug' => $template->slug, + 'version' => $template->version, + 'file_path' => $template->file_path, + ], ]); } } diff --git a/app/Http/Requests/UpdateDocumentTemplateRequest.php b/app/Http/Requests/UpdateDocumentTemplateRequest.php index 7352e49..a6da7e5 100644 --- a/app/Http/Requests/UpdateDocumentTemplateRequest.php +++ b/app/Http/Requests/UpdateDocumentTemplateRequest.php @@ -33,7 +33,7 @@ public function rules(): array 'meta.custom_defaults' => ['nullable', 'array'], 'meta.custom_defaults.*' => ['nullable'], 'meta.custom_default_types' => ['nullable', 'array'], - 'meta.custom_default_types.*' => ['nullable', 'in:string,number,date'], + 'meta.custom_default_types.*' => ['nullable', 'in:string,number,date,text'], 'action_id' => ['nullable', 'integer', 'exists:actions,id'], 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], 'activity_note_template' => ['nullable', 'string'], diff --git a/app/Services/Documents/DocxTemplateRenderer.php b/app/Services/Documents/DocxTemplateRenderer.php index c0ffdc5..3eaa4d5 100644 --- a/app/Services/Documents/DocxTemplateRenderer.php +++ b/app/Services/Documents/DocxTemplateRenderer.php @@ -31,18 +31,80 @@ public function render(DocumentTemplate $template, Contract $contract, User $use file_put_contents($tmpIn, $templateStream); $zip = new ZipArchive; - $zip->open($tmpIn); + $openResult = $zip->open($tmpIn); + if ($openResult !== true) { + throw new \RuntimeException('Ne morem odpreti DOCX arhiva: code '.$openResult); + } $docXml = $zip->getFromName('word/document.xml'); if ($docXml === false) { throw new \RuntimeException('Manjkajoča document.xml'); } - $tokens = $this->scanner->scan($docXml); - // Determine effective unresolved policy early (template override -> global -> config) - $globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get(); - $effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail')); - // Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings. - $customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller + // Collect all XML parts we should scan/replace: document + headers/footers + footnotes/endnotes/comments + $parts = []; + $parts['word/document.xml'] = $docXml; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (! is_string($name)) { + continue; + } + if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) { + $xml = $zip->getFromName($name); + if ($xml !== false) { + $parts[$name] = $xml; + } + } + } + + // Keep originals for safe fallback on write if normalization yields invalid XML + $originalParts = $parts; + // Normalize each part for scanning and replacement + $scanParts = []; + foreach ($parts as $name => $xml) { + $normalized = $this->normalizeRunsForTokens($xml); + $scanParts[$name] = $normalized; // used for scanning tokens + $parts[$name] = $normalized; // used for replacement/write-back + } + + // Scan tokens across all parts (merge default scanner + brace-aware split-run scanner + text-only scanner) + $tokens = []; + foreach ($scanParts as $xml) { + $found = $this->scanner->scan($xml); + if ($found) { + $tokens = array_merge($tokens, $found); + } + $foundSplit = $this->scanBraceTokens($xml); + if ($foundSplit) { + $tokens = array_merge($tokens, $foundSplit); + } + $foundText = $this->scanTextOnlyTokens($xml); + if ($foundText) { + $tokens = array_merge($tokens, $foundText); + } + } + $tokens = array_values(array_unique($tokens)); + try { + logger()->info('DocxTemplateRenderer scan', [ + 'template_id' => $template->id, + 'template_slug' => $template->slug, + 'template_version' => $template->version, + 'file_path' => $template->file_path, + 'tokens_found' => count($tokens), + ]); + } catch (\Throwable $e) { + // swallow logging errors + } + + // Policy: template flag -> global settings -> config; allow per-request override + $docSettings = app(\App\Services\Documents\DocumentSettings::class)->get(); + $effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($docSettings->unresolved_policy ?? config('documents.unresolved_policy', 'fail')); + $reqPolicy = request()->input('unresolved_policy'); + if (in_array($reqPolicy, ['fail', 'keep', 'blank'], true)) { + $effectivePolicy = $reqPolicy; + } + + // Resolve values + $customOverrides = request()->input('custom', []); $customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null; $resolved = $this->resolver->resolve( $tokens, @@ -57,7 +119,18 @@ public function render(DocumentTemplate $template, Contract $contract, User $use $values = $resolved['values']; $initialUnresolved = $resolved['unresolved']; $customTypes = $resolved['customTypes'] ?? []; - // Formatting options + + // Explicit per-token overrides (e.g., address choices) + $tokenOverrides = request()->input('token_overrides', []); + if (is_array($tokenOverrides) && ! empty($tokenOverrides)) { + foreach ($tokenOverrides as $tok => $val) { + if ($tok && (is_scalar($val) || $val === null)) { + $values[(string) $tok] = (string) ($val ?? ''); + } + } + } + + // Formatting $fmt = $template->formatting_options ?? []; $decimals = (int) ($fmt['number_decimals'] ?? 2); $decSep = $fmt['decimal_separator'] ?? '.'; @@ -65,83 +138,155 @@ public function render(DocumentTemplate $template, Contract $contract, User $use $currencySymbol = $fmt['currency_symbol'] ?? null; $currencyPos = $fmt['currency_position'] ?? 'before'; $currencySpace = (bool) ($fmt['currency_space'] ?? false); - $globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get(); - $globalDateFormats = $globalSettings->date_formats ?? []; + $globalDateFormats = $docSettings->date_formats ?? []; foreach ($values as $k => $v) { $isTypedDate = ($customTypes[$k] ?? null) === 'date'; $isTypedNumber = ($customTypes[$k] ?? null) === 'number'; - // Date formatting (typed or heuristic based on key ending with _date or .date) if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) { $dateFmtOverrides = $fmt['date_formats'] ?? []; $desiredFormat = $dateFmtOverrides[$k] ?? ($globalDateFormats[$k] ?? null) ?? ($fmt['default_date_format'] ?? null) ?? ($template->date_format ?: null) - ?? ($globalSettings->date_format ?? null) + ?? ($docSettings->date_format ?? null) ?? config('documents.date_format', 'Y-m-d'); if ($desiredFormat) { try { $dt = Carbon::parse($v); $values[$k] = $dt->format($desiredFormat); - continue; // skip numeric detection below + continue; } catch (\Throwable $e) { - // swallow + // ignore } } } - // Number formatting: only for explicitly typed numbers or common monetary fields $isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k); if (($isTypedNumber || $isFinanceField) && is_numeric($v)) { $num = number_format((float) $v, $decimals, $decSep, $thouSep); if ($currencySymbol && $isFinanceField) { $space = $currencySpace ? ' ' : ''; - if ($currencyPos === 'after') { - $num = $num.$space.$currencySymbol; - } else { - $num = $currencySymbol.$space.$num; - } + $num = $currencyPos === 'after' ? ($num.$space.$currencySymbol) : ($currencySymbol.$space.$num); } $values[$k] = $num; } } - // Replace tokens - foreach ($values as $token => $val) { - $docXml = str_replace('{{'.$token.'}}', htmlspecialchars($val), $docXml); + + // Add unresolved tokens found in document but not produced in values + $resolvedTokens = array_keys($values); + $unresolvedFromDoc = array_values(array_diff($tokens, $resolvedTokens)); + if (! empty($unresolvedFromDoc)) { + $initialUnresolved = array_values(array_unique(array_merge($initialUnresolved, $unresolvedFromDoc))); } - // After replacement: check unresolved patterns + + // Replace tokens in each part: support {{token}} and {token}, allow surrounding whitespace + foreach ($parts as $name => $xml) { + // Fast path for contiguous tokens + foreach ($values as $token => $val) { + $replacement = $this->sanitizeXmlText((string) $val); + $xml = str_replace('{{'.$token.'}}', $replacement, $xml); + $xml = str_replace('{'.$token.'}', $replacement, $xml); + $escapedToken = preg_quote(str_replace('.', '\\.', $token), '#'); + $boundaryPatterns = [ + '#\\{\\{\s*'.$escapedToken.'\s*\\}\\}#', + '#\\{\s*'.$escapedToken.'\s*\\}#', + ]; + foreach ($boundaryPatterns as $pat) { + $xml = preg_replace($pat, $replacement, $xml) ?? $xml; + } + } + + // Slow path: single pass across brace chunks; if flattened token matches any key, replace with its value + if (! empty($values)) { + $xml = preg_replace_callback('#\\{\\{.*?\\}\\}|\\{[^{}]*\\}#s', function (array $m) use ($values) { + $chunk = $m[0]; + $flat = preg_replace('/<[^>]+>/', '', $chunk) ?? $chunk; + $flat = preg_replace('/\\s+/', '', $flat) ?? $flat; + foreach ($values as $t => $v) { + if ($flat === '{{'.$t.'}}' || $flat === '{'.$t.'}') { + return $this->sanitizeXmlText((string) $v); + } + } + + return $chunk; + }, $xml) ?? $xml; + } + + $parts[$name] = $xml; + } + + // Handle unresolved according to policy if (! empty($initialUnresolved)) { if ($effectivePolicy === 'blank') { - foreach ($initialUnresolved as $r) { - $docXml = str_replace('{{'.$r.'}}', '', $docXml); + foreach (array_values(array_unique($initialUnresolved)) as $r) { + foreach ($parts as $name => $xml) { + $xml = str_replace('{{'.$r.'}}', '', $xml); + $xml = str_replace('{'.$r.'}', '', $xml); + $escaped = preg_quote(str_replace('.', '\\.', $r), '#'); + $xml = preg_replace('#\\{\\{\s*'.$escaped.'\s*\\}\\}#', '', $xml) ?? $xml; + $xml = preg_replace('#\\{\s*'.$escaped.'\s*\\}#', '', $xml) ?? $xml; + $parts[$name] = $xml; + } } } elseif ($effectivePolicy === 'keep') { - // keep unresolved markers - } else { // fail + // leave as-is + } else { throw new UnresolvedTokensException($initialUnresolved, 'Neuspešna zamenjava tokenov'); } } - $zip->addFromString('word/document.xml', $docXml); - $zip->close(); + // Ensure each XML part is well-formed, then write back to zip (fallback to original if needed) + foreach ($parts as $name => $xml) { + if (! $this->isWellFormedXml($xml)) { + // Fallback to original part to avoid producing a broken DOCX (these parts typically had no tokens) + $fallback = $originalParts[$name] ?? null; + if (! is_string($fallback) || ! $this->isWellFormedXml($fallback)) { + try { + logger()->error('DocxTemplateRenderer invalid XML with no safe fallback', [ + 'part' => $name, + 'template_id' => $template->id, + 'template_version' => $template->version, + ]); + } catch (\Throwable $e) { + } + throw new \RuntimeException("Končni XML del '{$name}' ni veljaven in ni varnega nadomestnega originala."); + } + try { + logger()->warning('DocxTemplateRenderer fallback to original part', [ + 'part' => $name, + 'template_id' => $template->id, + 'template_version' => $template->version, + ]); + } catch (\Throwable $e) { + } + $zip->addFromString($name, $fallback); + } else { + $zip->addFromString($name, $xml); + } + } + $closeOk = $zip->close(); + if ($closeOk !== true) { + throw new \RuntimeException('Zapiranje DOCX arhiva ni uspelo.'); + } $output = file_get_contents($tmpIn); + if ($output === false) { + throw new \RuntimeException('Bralni izhod iz začasne DOCX datoteke je spodletel.'); + } $checksum = hash('sha256', $output); $size = strlen($output); - // Filename pattern & date format precedence: template override -> global settings -> config fallback - $globalSettings = $globalSettings ?? app(\App\Services\Documents\DocumentSettings::class)->get(); + // Filename & date format $pattern = $template->output_filename_pattern - ?: ($globalSettings->file_name_pattern ?? config('documents.file_name_pattern')); + ?: ($docSettings->file_name_pattern ?? config('documents.file_name_pattern')); $dateFormat = $template->date_format - ?: ($globalSettings->date_format ?? config('documents.date_format', 'Y-m-d')); + ?: ($docSettings->date_format ?? config('documents.date_format', 'Y-m-d')); $replacements = [ '{slug}' => $template->slug, '{version}' => 'v'.$template->version, '{generation.date}' => now()->format($dateFormat), '{generation.timestamp}' => (string) now()->timestamp, ]; - // Also allow any token ({{x.y}}) style replaced pattern variants: convert {contract.reference} foreach ($values as $token => $val) { $replacements['{'.$token.'}'] = Str::slug((string) $val) ?: 'value'; } @@ -158,6 +303,114 @@ public function render(DocumentTemplate $template, Contract $contract, User $use 'relativePath' => $relativePath, 'size' => $size, 'checksum' => $checksum, + 'stats' => [ + 'tokensFound' => count($tokens), + 'resolvedCount' => count(array_intersect(array_keys($values), $tokens)), + 'unresolved' => array_values(array_unique($initialUnresolved)), + ], ]; } + + /** + * Word may split tokens like {{client.person.full_name}} across multiple runs. + * This method removes common run/element boundaries that appear between token braces so + * the scanner can find contiguous token strings. + */ + private function normalizeRunsForTokens(string $xml): string + { + // Non-destructive normalization: remove proofing markers and invisible characters only + $xml = preg_replace('#]*/>#i', '', $xml) ?? $xml; + $xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); // zero-width space, soft hyphen + + return $xml; + } + + /** + * If normalization produced sequences like " " or "", fix them. + */ + private function fixNestedTextTags(string $xml): string + { + // No-op: we no longer restructure tags in normalization + return $xml; + } + + /** + * Simple well-formedness check using DOMDocument. + */ + private function isWellFormedXml(string $xml): bool + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = true; + $dom->formatOutput = false; + + return @($dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING)) !== false; + } + + /** + * Prepare text for safe inclusion in Word XML content. + */ + private function sanitizeXmlText(string $text): string + { + // Remove characters not allowed in XML 1.0 + $text = preg_replace('/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}]/u', '', $text) ?? $text; + + return htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } + + /** + * Aggressive text-only token scan: strips all tags and searches for braces pairs in the raw text. + * Useful when tokens are heavily split across runs. + * + * @return string[] + */ + private function scanTextOnlyTokens(string $xml): array + { + $text = preg_replace('/<[^>]+>/', '', $xml) ?? $xml; + $found = []; + if (preg_match_all('/\{\{([^}]+)\}\}/s', $text, $m1)) { + foreach ($m1[1] as $inner) { + $tok = preg_replace('/\s+/', '', $inner) ?? $inner; + if ($tok !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $tok)) { + $found[] = $tok; + } + } + } + if (preg_match_all('/\{([^{}]+)\}/s', $text, $m2)) { + foreach ($m2[1] as $inner) { + $tok = preg_replace('/\s+/', '', $inner) ?? $inner; + if ($tok !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $tok)) { + $found[] = $tok; + } + } + } + + return array_values(array_unique($found)); + } + + /** + * Finds tokens inside brace pairs even when Word has split them across runs. + * Strips XML tags from within braces and collapses whitespace to detect valid token patterns. + * + * @return string[] + */ + private function scanBraceTokens(string $xml): array + { + $tokens = []; + if (! preg_match_all('/\{\{.*?\}\}|\{[^{}]*\}/s', $xml, $matches)) { + return $tokens; + } + foreach ($matches[0] as $chunk) { + $isDouble = str_starts_with($chunk, '{{'); + $inner = substr($chunk, $isDouble ? 2 : 1, $isDouble ? -2 : -1); + // Remove XML tags and whitespace inside braces + $clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner; + $clean = preg_replace('/\s+/', '', $clean) ?? $clean; + // Accept nested dotted tokens, allow dash in final segment + if ($clean !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) { + $tokens[] = $clean; + } + } + + return array_values(array_unique($tokens)); + } } diff --git a/app/Services/Documents/TokenScanner.php b/app/Services/Documents/TokenScanner.php index 96db770..77a30a5 100644 --- a/app/Services/Documents/TokenScanner.php +++ b/app/Services/Documents/TokenScanner.php @@ -4,19 +4,25 @@ class TokenScanner { - // Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id) - private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/'; + // Allow nested tokens like client.person.full_name or custom.order-id + // Pattern: entity(.[subentity])* . attribute + private const REGEX_DOUBLE = '/{{\s*([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+)\s*}}/'; + + private const REGEX_SINGLE = '/\{\s*([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+)\s*\}/'; /** * @return array */ public function scan(string $content): array { - preg_match_all(self::REGEX, $content, $m); - if (empty($m[1])) { - return []; + $out = []; + if (preg_match_all(self::REGEX_DOUBLE, $content, $m1) && ! empty($m1[1])) { + $out = array_merge($out, $m1[1]); + } + if (preg_match_all(self::REGEX_SINGLE, $content, $m2) && ! empty($m2[1])) { + $out = array_merge($out, $m2[1]); } - return array_values(array_unique($m[1])); + return array_values(array_unique($out)); } } diff --git a/app/Services/Documents/TokenValueResolver.php b/app/Services/Documents/TokenValueResolver.php index 767538a..9c60af7 100644 --- a/app/Services/Documents/TokenValueResolver.php +++ b/app/Services/Documents/TokenValueResolver.php @@ -42,7 +42,7 @@ public function resolve( $customTypes = []; if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) { foreach ($template->meta['custom_default_types'] as $k => $t) { - $t = in_array($t, ['string', 'number', 'date'], true) ? $t : 'string'; + $t = in_array($t, ['string', 'number', 'date', 'text'], true) ? $t : 'string'; $customTypes[(string) $k] = $t; } } @@ -57,6 +57,17 @@ public function resolve( } else { $templateEntities = array_keys($globalWhitelist); } + // Normalize template tokens list (used as an allow-list if columns / global whitelist are not exhaustive) + $templateTokens = []; + $rawTemplateTokens = $template->tokens ?? null; + if (is_array($rawTemplateTokens)) { + $templateTokens = array_values(array_filter(array_map('strval', $rawTemplateTokens))); + } elseif (is_string($rawTemplateTokens)) { + $decoded = json_decode($rawTemplateTokens, true); + if (is_array($decoded)) { + $templateTokens = array_values(array_filter(array_map('strval', $decoded))); + } + } foreach ($tokens as $token) { [$entity,$attr] = explode('.', $token, 2); if ($entity === 'generation') { @@ -93,20 +104,43 @@ public function resolve( continue; } if (! in_array($entity, $templateEntities, true)) { - if ($policy === 'fail') { - throw new \RuntimeException("Nedovoljen entiteta token: $entity"); - } - $unresolved[] = $token; + // If the token is explicitly listed on the template's tokens, allow it + if (! $templateTokens || ! in_array($token, $templateTokens, true)) { + if ($policy === 'fail') { + throw new \RuntimeException("Nedovoljen entiteta token: $entity"); + } + $unresolved[] = $token; - continue; + continue; + } } // Allowed attributes: merge template-declared columns with global whitelist (config + DB settings) - // Rationale: old templates may not list newly allowed attributes (like nested paths), - // so we honor both sources instead of preferring one exclusively. - $allowedFromTemplate = $template->columns[$entity] ?? []; + // Support nested dotted attributes (e.g. person.person_address.city). We allow if either the full + // dotted path is listed or if the base prefix is listed (e.g. person.person_address) and the resolver + // can handle it. + // Safely read template-declared columns + $columns = is_array($template->columns ?? null) ? $template->columns : []; + $allowedFromTemplate = $columns[$entity] ?? []; $allowedFromGlobal = $globalWhitelist[$entity] ?? []; $allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal))); - if (! in_array($attr, $allowed, true)) { + $isAllowed = in_array($attr, $allowed, true); + if (! $isAllowed && str_contains($attr, '.')) { + // Check progressive prefixes: a.b.c -> a.b + $parts = explode('.', $attr); + while (count($parts) > 1 && ! $isAllowed) { + array_pop($parts); + $prefix = implode('.', $parts); + if (in_array($prefix, $allowed, true)) { + $isAllowed = true; + break; + } + } + } + // If still not allowed, permit tokens explicitly scanned/stored on the template + if (! $isAllowed && $templateTokens) { + $isAllowed = in_array($token, $templateTokens, true); + } + if (! $isAllowed) { if ($policy === 'fail') { throw new \RuntimeException("Nedovoljen stolpec token: $token"); } diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..17ab44b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,8 +22,8 @@ - - + + diff --git a/resources/js/Pages/Admin/DocumentTemplates/Edit.vue b/resources/js/Pages/Admin/DocumentTemplates/Edit.vue index 5f5211d..2c497c6 100644 --- a/resources/js/Pages/Admin/DocumentTemplates/Edit.vue +++ b/resources/js/Pages/Admin/DocumentTemplates/Edit.vue @@ -228,16 +228,27 @@ class="input input-bordered input-sm w-full col-span-4" placeholder="custom ključ (npr. order_id)" /> - + + + + + + + +
+ + +
+
{{ generationError[generateFor?.uuid] }}
+ + diff --git a/resources/js/Pages/Cases/Show.vue b/resources/js/Pages/Cases/Show.vue index a99777b..10b2336 100644 --- a/resources/js/Pages/Cases/Show.vue +++ b/resources/js/Pages/Cases/Show.vue @@ -31,6 +31,7 @@ const props = defineProps({ segments: { type: Array, default: () => [] }, all_segments: { type: Array, default: () => [] }, current_segment: { type: Object, default: null }, + contract_doc_templates: { type: Array, default: () => [] }, }); const showUpload = ref(false); @@ -287,10 +288,12 @@ const submitAttachSegment = () => { name('document-templates.index'); Route::post('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'store'])->name('document-templates.store'); + Route::post('document-templates/{template}/rescan', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'rescanTokens'])->name('document-templates.rescan'); Route::post('document-templates/{template}/toggle', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'toggleActive'])->name('document-templates.toggle'); Route::put('document-templates/{template}/settings', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'updateSettings'])->name('document-templates.settings.update'); Route::get('document-templates/{template}', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'show'])->name('document-templates.show'); diff --git a/tests/Feature/DocumentTemplateCustomTokensTest.php b/tests/Feature/DocumentTemplateCustomTokensTest.php new file mode 100644 index 0000000..b8cd0cd --- /dev/null +++ b/tests/Feature/DocumentTemplateCustomTokensTest.php @@ -0,0 +1,45 @@ +create(); + $perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']); + $role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']); + $role->permissions()->syncWithoutDetaching([$perm->id]); + $admin->roles()->syncWithoutDetaching([$role->id]); + actingAs($admin); + + // Build minimal DOCX with a custom token {{custom.km_driven}} + $tmp = tempnam(sys_get_temp_dir(), 'docx'); + $zip = new ZipArchive; + $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('word/document.xml', '{{custom.km_driven}}'); + $zip->close(); + $file = new UploadedFile($tmp, 'custom.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true); + + post('/admin/document-templates', [ + 'name' => 'Custom Test', + 'slug' => 'custom-test', + 'file' => $file, + ])->assertRedirect(); + + $tpl = DocumentTemplate::where('slug', 'custom-test')->latest('version')->first(); + expect($tpl)->not->toBeNull(); + expect($tpl->tokens)->toBeArray()->and($tpl->tokens)->toContain('custom.km_driven'); + $types = $tpl->meta['custom_default_types'] ?? []; + expect($types)->toBeArray(); + expect($types)->toHaveKey('km_driven'); + expect($types['km_driven'])->toBe('string'); +});