Refactor token resolution to honor unresolved policy (blank|keep) without exceptions; update renderer and policy test
This commit is contained in:
parent
020c8ce61b
commit
38562fbabe
146
app/Services/Documents/DocxTemplateRenderer.php
Normal file
146
app/Services/Documents/DocxTemplateRenderer.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Documents;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Documents\Exceptions\UnresolvedTokensException;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class DocxTemplateRenderer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TokenScanner $scanner = new TokenScanner,
|
||||||
|
private TokenValueResolver $resolver = new TokenValueResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{fileName:string,relativePath:string,size:int,checksum:string}
|
||||||
|
*/
|
||||||
|
public function render(DocumentTemplate $template, Contract $contract, User $user): array
|
||||||
|
{
|
||||||
|
$disk = 'public';
|
||||||
|
$templateStream = Storage::disk($disk)->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Services/Documents/TokenValueResolver.php
Normal file
81
app/Services/Documents/TokenValueResolver.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Documents;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class TokenValueResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve tokens to values.
|
||||||
|
* Returns array with keys: values (resolved token=>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<string,string>,unresolved:array<int,string>}
|
||||||
|
*/
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
tests/Feature/DocumentSettingsPoliciesTest.php
Normal file
115
tests/Feature/DocumentSettingsPoliciesTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Events\DocumentSettingsUpdated;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\DocumentSetting;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DocumentSettingsPoliciesTest extends TestCase
|
||||||
|
{
|
||||||
|
private function baseTemplateUpload(string $xml): void
|
||||||
|
{
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'doc');
|
||||||
|
$zip = new \ZipArchive;
|
||||||
|
$zip->open($tmp, \ZipArchive::OVERWRITE);
|
||||||
|
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
|
||||||
|
$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('<w:document><w:body>{{contract.reference}} {{contract.unknown_field}}</w:body></w:document>');
|
||||||
|
$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('<w:document><w:body>{{contract.start_date}}</w:body></w:document>');
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user