From 38562fbabe0a120f5d1c1e9c38ff8622a645b57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Mon, 6 Oct 2025 19:11:46 +0200 Subject: [PATCH] Refactor token resolution to honor unresolved policy (blank|keep) without exceptions; update renderer and policy test --- .../Documents/DocxTemplateRenderer.php | 146 ++++++++++++++++++ app/Services/Documents/TokenValueResolver.php | 81 ++++++++++ .../Feature/DocumentSettingsPoliciesTest.php | 115 ++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 app/Services/Documents/DocxTemplateRenderer.php create mode 100644 app/Services/Documents/TokenValueResolver.php create mode 100644 tests/Feature/DocumentSettingsPoliciesTest.php diff --git a/app/Services/Documents/DocxTemplateRenderer.php b/app/Services/Documents/DocxTemplateRenderer.php new file mode 100644 index 0000000..9ca68c8 --- /dev/null +++ b/app/Services/Documents/DocxTemplateRenderer.php @@ -0,0 +1,146 @@ +get($template->file_path); + + // Work in temp file + $tmpIn = tempnam(sys_get_temp_dir(), 'tmpl'); + file_put_contents($tmpIn, $templateStream); + + $zip = new ZipArchive; + $zip->open($tmpIn); + $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')); + $resolved = $this->resolver->resolve($tokens, $template, $contract, $user, $effectivePolicy); + $values = $resolved['values']; + $initialUnresolved = $resolved['unresolved']; + // Formatting options + $fmt = $template->formatting_options ?? []; + $decimals = (int) ($fmt['number_decimals'] ?? 2); + $decSep = $fmt['decimal_separator'] ?? '.'; + $thouSep = $fmt['thousands_separator'] ?? ','; + $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 ?? []; + foreach ($values as $k => $v) { + // Date formatting (heuristic based on key ending with _date or .date) + if (is_string($v) && ($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) + ?? config('documents.date_format', 'Y-m-d'); + if ($desiredFormat) { + try { + $dt = Carbon::parse($v); + $values[$k] = $dt->format($desiredFormat); + + continue; // skip numeric detection below + } catch (\Throwable $e) { + // swallow + } + } + } + if (is_numeric($v)) { + $num = number_format((float) $v, $decimals, $decSep, $thouSep); + if ($currencySymbol && preg_match('/(amount|balance|total|price|cost)/i', $k)) { + $space = $currencySpace ? ' ' : ''; + if ($currencyPos === 'after') { + $num = $num.$space.$currencySymbol; + } else { + $num = $currencySymbol.$space.$num; + } + } + $values[$k] = $num; + } + } + // Replace tokens + foreach ($values as $token => $val) { + $docXml = str_replace('{{'.$token.'}}', htmlspecialchars($val), $docXml); + } + // After replacement: check unresolved patterns + if (! empty($initialUnresolved)) { + if ($effectivePolicy === 'blank') { + foreach ($initialUnresolved as $r) { + $docXml = str_replace('{{'.$r.'}}', '', $docXml); + } + } elseif ($effectivePolicy === 'keep') { + // keep unresolved markers + } else { // fail + throw new UnresolvedTokensException($initialUnresolved, 'Neuspešna zamenjava tokenov'); + } + } + + $zip->addFromString('word/document.xml', $docXml); + $zip->close(); + + $output = file_get_contents($tmpIn); + $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(); + $pattern = $template->output_filename_pattern + ?: ($globalSettings->file_name_pattern ?? config('documents.file_name_pattern')); + $dateFormat = $template->date_format + ?: ($globalSettings->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'; + } + $fileName = strtr($pattern, $replacements); + if (! str_ends_with(strtolower($fileName), '.docx')) { + $fileName .= '.docx'; + } + $relativeDir = 'contracts/'.$contract->uuid.'/generated/'.now()->toDateString(); + $relativePath = $relativeDir.'/'.$fileName; + Storage::disk($disk)->put($relativePath, $output); + + return [ + 'fileName' => $fileName, + 'relativePath' => $relativePath, + 'size' => $size, + 'checksum' => $checksum, + ]; + } +} diff --git a/app/Services/Documents/TokenValueResolver.php b/app/Services/Documents/TokenValueResolver.php new file mode 100644 index 0000000..81121e0 --- /dev/null +++ b/app/Services/Documents/TokenValueResolver.php @@ -0,0 +1,81 @@ +value) and unresolved (list of tokens not resolved / not allowed) + * Policy determines whether invalid tokens throw (fail) or are collected (blank|keep). + * + * @return array{values:array,unresolved:array} + */ + public function resolve(array $tokens, DocumentTemplate $template, Contract $contract, User $user, string $policy = 'fail'): array + { + $values = []; + $unresolved = []; + $globalWhitelist = config('documents.whitelist', []); + $templateEntities = $template->entities ?: array_keys($globalWhitelist); + foreach ($tokens as $token) { + [$entity,$attr] = explode('.', $token, 2); + if ($entity === 'generation') { + $values[$token] = $this->generationAttribute($attr, $user); + + continue; + } + if (! in_array($entity, $templateEntities, true)) { + if ($policy === 'fail') { + throw new \RuntimeException("Nedovoljen entiteta token: $entity"); + } + $unresolved[] = $token; + + continue; + } + $allowed = ($template->columns[$entity] ?? []) ?: ($globalWhitelist[$entity] ?? []); + if (! in_array($attr, $allowed, true)) { + if ($policy === 'fail') { + throw new \RuntimeException("Nedovoljen stolpec token: $token"); + } + $unresolved[] = $token; + + continue; + } + $values[$token] = $this->entityAttribute($entity, $attr, $contract) ?? ''; + } + + return ['values' => $values, 'unresolved' => array_values(array_unique($unresolved))]; + } + + private function generationAttribute(string $attr, User $user): string + { + return match ($attr) { + 'timestamp' => (string) now()->timestamp, + 'date' => now()->toDateString(), // raw ISO; formatting applied later + 'user_name' => $user->name ?? 'Uporabnik', + default => '' + }; + } + + private function entityAttribute(string $entity, string $attr, Contract $contract): ?string + { + switch ($entity) { + case 'contract': + return (string) ($contract->{$attr} ?? ''); + case 'client_case': + return (string) optional($contract->clientCase)->{$attr}; + case 'client': + return (string) optional(optional($contract->clientCase)->client)->{$attr}; + case 'person': + $person = optional(optional($contract->clientCase)->person); + + return (string) $person->{$attr}; + default: + return ''; + } + } +} diff --git a/tests/Feature/DocumentSettingsPoliciesTest.php b/tests/Feature/DocumentSettingsPoliciesTest.php new file mode 100644 index 0000000..b3265dc --- /dev/null +++ b/tests/Feature/DocumentSettingsPoliciesTest.php @@ -0,0 +1,115 @@ +open($tmp, \ZipArchive::OVERWRITE); + $zip->addFromString('[Content_Types].xml', ''); + $zip->addFromString('word/document.xml', $xml); + $zip->close(); + $contents = file_get_contents($tmp); + \Storage::disk('public')->put('templates/policy-template.docx', $contents); + $tmpl = new \App\Models\DocumentTemplate; + $userId = auth()->id() ?? \App\Models\User::factory()->create()->id; + $tmpl->fill([ + 'name' => 'PolTest', + 'slug' => 'policy-template', + 'core_entity' => 'contract', + 'version' => 1, + 'engine' => 'docx', + 'file_path' => 'templates/policy-template.docx', + 'file_hash' => sha1($contents), + 'file_size' => strlen($contents), + 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'active' => true, + 'output_filename_pattern' => null, + 'fail_on_unresolved' => false, + 'entities' => [], + 'columns' => [], + 'tokens' => [], + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + $tmpl->save(); + } + + public function test_unresolved_policy_blank_and_keep(): void + { + $user = User::factory()->create(); + $role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']); + $user->roles()->sync([$role->id]); + $this->actingAs($user); + + $this->baseTemplateUpload('{{contract.reference}} {{contract.unknown_field}}'); + $contract = Contract::factory()->create(['reference' => 'ABC123']); + + $settings = DocumentSetting::instance(); + $settings->unresolved_policy = 'blank'; + $settings->save(); + app(\App\Services\Documents\DocumentSettings::class)->refresh(); + + $resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [ + 'template_slug' => 'policy-template', + ]); + $resp->assertOk(); + + // Switch to keep and regenerate + $settings->unresolved_policy = 'keep'; + $settings->save(); + app(\App\Services\Documents\DocumentSettings::class)->refresh(); + $resp2 = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [ + 'template_slug' => 'policy-template', + ]); + $resp2->assertOk(); + } + + public function test_global_date_format_override_applies(): void + { + $user = User::factory()->create(); + $role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']); + $user->roles()->sync([$role->id]); + $this->actingAs($user); + $this->baseTemplateUpload('{{contract.start_date}}'); + $contract = Contract::factory()->create(['start_date' => now()->toDateString()]); + $settings = DocumentSetting::instance(); + $settings->date_formats = ['contract.start_date' => 'd.m.Y']; + $settings->save(); + + $resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [ + 'template_slug' => 'policy-template', + ]); + $resp->assertOk(); + } + + public function test_settings_update_dispatches_event(): void + { + Event::fake(); + $user = User::factory()->create(); + $role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']); + $user->roles()->sync([$role->id]); + $this->actingAs($user); + $settings = DocumentSetting::instance(); + $this->put(route('admin.document-settings.update'), [ + 'file_name_pattern' => $settings->file_name_pattern, + 'date_format' => $settings->date_format, + 'unresolved_policy' => $settings->unresolved_policy, + 'preview_enabled' => $settings->preview_enabled, + 'whitelist' => $settings->whitelist, + 'date_formats' => $settings->date_formats, + ])->assertRedirect(); + Event::assertDispatched(DocumentSettingsUpdated::class); + } +}