Document gen fixed
This commit is contained in:
parent
e0303ece74
commit
23f2011e33
110
app/Console/Commands/DocScanCommand.php
Normal file
110
app/Console/Commands/DocScanCommand.php
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Services\Documents\DocumentSettings;
|
||||||
|
use App\Services\Documents\TokenScanner;
|
||||||
|
use App\Services\Documents\TokenValueResolver;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DocScanCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'doc:scan {contract : Contract UUID} {xml : Path to Word document.xml}';
|
||||||
|
|
||||||
|
protected $description = 'Scan a Word document.xml for tokens and resolve values against a contract UUID';
|
||||||
|
|
||||||
|
public function handle(TokenScanner $scanner, TokenValueResolver $resolver, DocumentSettings $settings): int
|
||||||
|
{
|
||||||
|
$uuid = (string) $this->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('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Iteratively collapse boundaries between text runs, even if w:rPr is present
|
||||||
|
$patterns = [
|
||||||
|
// </w:t></w:r> [optional proofErr] <w:r ...> [optional rPr] <w:t>
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Console/Commands/TemplateScanCommand.php
Normal file
152
app/Console/Commands/TemplateScanCommand.php
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Services\Documents\TokenScanner;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class TemplateScanCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'template:scan {slug : Template slug} {--tpl-version= : Specific template version number} {--parts : Show per-part tokens}';
|
||||||
|
|
||||||
|
protected $description = 'Scan a stored DOCX template by slug/version and dump detected tokens directly from storage.';
|
||||||
|
|
||||||
|
public function handle(TokenScanner $scanner): int
|
||||||
|
{
|
||||||
|
$slug = (string) $this->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('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Collapse boundaries between runs and inside runs (include tabs/line breaks)
|
||||||
|
$patterns = [
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
|
||||||
|
'#</w:t>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*<w:t[^>]*>#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -133,6 +133,94 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
|
||||||
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
|
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)
|
public function store(StoreDocumentTemplateRequest $request)
|
||||||
{
|
{
|
||||||
$this->ensurePermission();
|
$this->ensurePermission();
|
||||||
|
|
@ -152,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request)
|
||||||
$hash = hash_file('sha256', $file->getRealPath());
|
$hash = hash_file('sha256', $file->getRealPath());
|
||||||
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
|
$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 = [];
|
$tokens = [];
|
||||||
try {
|
try {
|
||||||
/** @var TokenScanner $scanner */
|
/** @var TokenScanner $scanner */
|
||||||
|
|
@ -161,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request)
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
||||||
copy($file->getRealPath(), $tmp);
|
copy($file->getRealPath(), $tmp);
|
||||||
if ($zip->open($tmp) === true) {
|
if ($zip->open($tmp) === true) {
|
||||||
$xml = $zip->getFromName('word/document.xml');
|
// Collect main document and header/footer parts
|
||||||
if ($xml !== false) {
|
$parts = [];
|
||||||
$tokens = $scanner->scan($xml);
|
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();
|
$zip->close();
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
@ -224,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request)
|
||||||
if (Schema::hasColumn('document_templates', 'tokens')) {
|
if (Schema::hasColumn('document_templates', 'tokens')) {
|
||||||
$payload['tokens'] = $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);
|
$template = DocumentTemplate::create($payload);
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
|
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);
|
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('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Iteratively collapse boundaries between text runs, even if w:rPr is present
|
||||||
|
$patterns = [
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
|
||||||
'client_case' => $case,
|
'client_case' => $case,
|
||||||
'contracts' => $contracts,
|
'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_meta' => [
|
||||||
'archive_segment_id' => $archiveSegmentId,
|
'archive_segment_id' => $archiveSegmentId,
|
||||||
'related_tables' => $relatedArchiveTables,
|
'related_tables' => $relatedArchiveTables,
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,24 @@ public function __invoke(Request $request, Contract $contract): Response
|
||||||
}
|
}
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
||||||
|
'template_version' => ['nullable', 'integer'],
|
||||||
'custom' => ['nullable', 'array'],
|
'custom' => ['nullable', 'array'],
|
||||||
'custom.*' => ['nullable'],
|
'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('core_entity', 'contract')
|
||||||
->where('active', true)
|
->where('active', true);
|
||||||
->orderByDesc('version')
|
if ($request->filled('template_version')) {
|
||||||
->firstOrFail();
|
$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
|
// Load related data minimally
|
||||||
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
|
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
|
||||||
|
|
@ -47,6 +56,16 @@ public function __invoke(Request $request, Contract $contract): Response
|
||||||
'tokens' => $e->unresolved ?? [],
|
'tokens' => $e->unresolved ?? [],
|
||||||
], 422);
|
], 422);
|
||||||
} catch (\Throwable $e) {
|
} 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([
|
return response()->json([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Generation failed.',
|
'message' => 'Generation failed.',
|
||||||
|
|
@ -115,6 +134,13 @@ public function __invoke(Request $request, Contract $contract): Response
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'document_uuid' => $doc->uuid,
|
'document_uuid' => $doc->uuid,
|
||||||
'path' => $doc->path,
|
'path' => $doc->path,
|
||||||
|
'stats' => $result['stats'] ?? null,
|
||||||
|
'template' => [
|
||||||
|
'id' => $template->id,
|
||||||
|
'slug' => $template->slug,
|
||||||
|
'version' => $template->version,
|
||||||
|
'file_path' => $template->file_path,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ public function rules(): array
|
||||||
'meta.custom_defaults' => ['nullable', 'array'],
|
'meta.custom_defaults' => ['nullable', 'array'],
|
||||||
'meta.custom_defaults.*' => ['nullable'],
|
'meta.custom_defaults.*' => ['nullable'],
|
||||||
'meta.custom_default_types' => ['nullable', 'array'],
|
'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'],
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
'activity_note_template' => ['nullable', 'string'],
|
'activity_note_template' => ['nullable', 'string'],
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,80 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
||||||
file_put_contents($tmpIn, $templateStream);
|
file_put_contents($tmpIn, $templateStream);
|
||||||
|
|
||||||
$zip = new ZipArchive;
|
$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');
|
$docXml = $zip->getFromName('word/document.xml');
|
||||||
if ($docXml === false) {
|
if ($docXml === false) {
|
||||||
throw new \RuntimeException('Manjkajoča document.xml');
|
throw new \RuntimeException('Manjkajoča document.xml');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tokens = $this->scanner->scan($docXml);
|
// Collect all XML parts we should scan/replace: document + headers/footers + footnotes/endnotes/comments
|
||||||
// Determine effective unresolved policy early (template override -> global -> config)
|
$parts = [];
|
||||||
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
|
$parts['word/document.xml'] = $docXml;
|
||||||
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
// Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings.
|
$name = $zip->getNameIndex($i);
|
||||||
$customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller
|
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;
|
$customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null;
|
||||||
$resolved = $this->resolver->resolve(
|
$resolved = $this->resolver->resolve(
|
||||||
$tokens,
|
$tokens,
|
||||||
|
|
@ -57,7 +119,18 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
||||||
$values = $resolved['values'];
|
$values = $resolved['values'];
|
||||||
$initialUnresolved = $resolved['unresolved'];
|
$initialUnresolved = $resolved['unresolved'];
|
||||||
$customTypes = $resolved['customTypes'] ?? [];
|
$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 ?? [];
|
$fmt = $template->formatting_options ?? [];
|
||||||
$decimals = (int) ($fmt['number_decimals'] ?? 2);
|
$decimals = (int) ($fmt['number_decimals'] ?? 2);
|
||||||
$decSep = $fmt['decimal_separator'] ?? '.';
|
$decSep = $fmt['decimal_separator'] ?? '.';
|
||||||
|
|
@ -65,83 +138,155 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
||||||
$currencySymbol = $fmt['currency_symbol'] ?? null;
|
$currencySymbol = $fmt['currency_symbol'] ?? null;
|
||||||
$currencyPos = $fmt['currency_position'] ?? 'before';
|
$currencyPos = $fmt['currency_position'] ?? 'before';
|
||||||
$currencySpace = (bool) ($fmt['currency_space'] ?? false);
|
$currencySpace = (bool) ($fmt['currency_space'] ?? false);
|
||||||
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
|
$globalDateFormats = $docSettings->date_formats ?? [];
|
||||||
$globalDateFormats = $globalSettings->date_formats ?? [];
|
|
||||||
foreach ($values as $k => $v) {
|
foreach ($values as $k => $v) {
|
||||||
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
|
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
|
||||||
$isTypedNumber = ($customTypes[$k] ?? null) === 'number';
|
$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))) {
|
if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
|
||||||
$dateFmtOverrides = $fmt['date_formats'] ?? [];
|
$dateFmtOverrides = $fmt['date_formats'] ?? [];
|
||||||
$desiredFormat = $dateFmtOverrides[$k]
|
$desiredFormat = $dateFmtOverrides[$k]
|
||||||
?? ($globalDateFormats[$k] ?? null)
|
?? ($globalDateFormats[$k] ?? null)
|
||||||
?? ($fmt['default_date_format'] ?? null)
|
?? ($fmt['default_date_format'] ?? null)
|
||||||
?? ($template->date_format ?: null)
|
?? ($template->date_format ?: null)
|
||||||
?? ($globalSettings->date_format ?? null)
|
?? ($docSettings->date_format ?? null)
|
||||||
?? config('documents.date_format', 'Y-m-d');
|
?? config('documents.date_format', 'Y-m-d');
|
||||||
if ($desiredFormat) {
|
if ($desiredFormat) {
|
||||||
try {
|
try {
|
||||||
$dt = Carbon::parse($v);
|
$dt = Carbon::parse($v);
|
||||||
$values[$k] = $dt->format($desiredFormat);
|
$values[$k] = $dt->format($desiredFormat);
|
||||||
|
|
||||||
continue; // skip numeric detection below
|
continue;
|
||||||
} catch (\Throwable $e) {
|
} 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);
|
$isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k);
|
||||||
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
|
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
|
||||||
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
|
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
|
||||||
if ($currencySymbol && $isFinanceField) {
|
if ($currencySymbol && $isFinanceField) {
|
||||||
$space = $currencySpace ? ' ' : '';
|
$space = $currencySpace ? ' ' : '';
|
||||||
if ($currencyPos === 'after') {
|
$num = $currencyPos === 'after' ? ($num.$space.$currencySymbol) : ($currencySymbol.$space.$num);
|
||||||
$num = $num.$space.$currencySymbol;
|
|
||||||
} else {
|
|
||||||
$num = $currencySymbol.$space.$num;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$values[$k] = $num;
|
$values[$k] = $num;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Replace tokens
|
|
||||||
foreach ($values as $token => $val) {
|
// Add unresolved tokens found in document but not produced in values
|
||||||
$docXml = str_replace('{{'.$token.'}}', htmlspecialchars($val), $docXml);
|
$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 (! empty($initialUnresolved)) {
|
||||||
if ($effectivePolicy === 'blank') {
|
if ($effectivePolicy === 'blank') {
|
||||||
foreach ($initialUnresolved as $r) {
|
foreach (array_values(array_unique($initialUnresolved)) as $r) {
|
||||||
$docXml = str_replace('{{'.$r.'}}', '', $docXml);
|
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') {
|
} elseif ($effectivePolicy === 'keep') {
|
||||||
// keep unresolved markers
|
// leave as-is
|
||||||
} else { // fail
|
} else {
|
||||||
throw new UnresolvedTokensException($initialUnresolved, 'Neuspešna zamenjava tokenov');
|
throw new UnresolvedTokensException($initialUnresolved, 'Neuspešna zamenjava tokenov');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip->addFromString('word/document.xml', $docXml);
|
// Ensure each XML part is well-formed, then write back to zip (fallback to original if needed)
|
||||||
$zip->close();
|
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);
|
$output = file_get_contents($tmpIn);
|
||||||
|
if ($output === false) {
|
||||||
|
throw new \RuntimeException('Bralni izhod iz začasne DOCX datoteke je spodletel.');
|
||||||
|
}
|
||||||
$checksum = hash('sha256', $output);
|
$checksum = hash('sha256', $output);
|
||||||
$size = strlen($output);
|
$size = strlen($output);
|
||||||
|
|
||||||
// Filename pattern & date format precedence: template override -> global settings -> config fallback
|
// Filename & date format
|
||||||
$globalSettings = $globalSettings ?? app(\App\Services\Documents\DocumentSettings::class)->get();
|
|
||||||
$pattern = $template->output_filename_pattern
|
$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
|
$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 = [
|
$replacements = [
|
||||||
'{slug}' => $template->slug,
|
'{slug}' => $template->slug,
|
||||||
'{version}' => 'v'.$template->version,
|
'{version}' => 'v'.$template->version,
|
||||||
'{generation.date}' => now()->format($dateFormat),
|
'{generation.date}' => now()->format($dateFormat),
|
||||||
'{generation.timestamp}' => (string) now()->timestamp,
|
'{generation.timestamp}' => (string) now()->timestamp,
|
||||||
];
|
];
|
||||||
// Also allow any token ({{x.y}}) style replaced pattern variants: convert {contract.reference}
|
|
||||||
foreach ($values as $token => $val) {
|
foreach ($values as $token => $val) {
|
||||||
$replacements['{'.$token.'}'] = Str::slug((string) $val) ?: 'value';
|
$replacements['{'.$token.'}'] = Str::slug((string) $val) ?: 'value';
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +303,114 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
||||||
'relativePath' => $relativePath,
|
'relativePath' => $relativePath,
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'checksum' => $checksum,
|
'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 <w:r><w:t> 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('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); // zero-width space, soft hyphen
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If normalization produced sequences like "<w:t> <w:t>" or "</w:t></w:t>", fix them.
|
||||||
|
*/
|
||||||
|
private function fixNestedTextTags(string $xml): string
|
||||||
|
{
|
||||||
|
// No-op: we no longer restructure <w:t> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,25 @@
|
||||||
|
|
||||||
class TokenScanner
|
class TokenScanner
|
||||||
{
|
{
|
||||||
// Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id)
|
// Allow nested tokens like client.person.full_name or custom.order-id
|
||||||
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/';
|
// 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<int,string>
|
* @return array<int,string>
|
||||||
*/
|
*/
|
||||||
public function scan(string $content): array
|
public function scan(string $content): array
|
||||||
{
|
{
|
||||||
preg_match_all(self::REGEX, $content, $m);
|
$out = [];
|
||||||
if (empty($m[1])) {
|
if (preg_match_all(self::REGEX_DOUBLE, $content, $m1) && ! empty($m1[1])) {
|
||||||
return [];
|
$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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public function resolve(
|
||||||
$customTypes = [];
|
$customTypes = [];
|
||||||
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
|
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
|
||||||
foreach ($template->meta['custom_default_types'] as $k => $t) {
|
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;
|
$customTypes[(string) $k] = $t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +57,17 @@ public function resolve(
|
||||||
} else {
|
} else {
|
||||||
$templateEntities = array_keys($globalWhitelist);
|
$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) {
|
foreach ($tokens as $token) {
|
||||||
[$entity,$attr] = explode('.', $token, 2);
|
[$entity,$attr] = explode('.', $token, 2);
|
||||||
if ($entity === 'generation') {
|
if ($entity === 'generation') {
|
||||||
|
|
@ -93,20 +104,43 @@ public function resolve(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! in_array($entity, $templateEntities, true)) {
|
if (! in_array($entity, $templateEntities, true)) {
|
||||||
if ($policy === 'fail') {
|
// If the token is explicitly listed on the template's tokens, allow it
|
||||||
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
if (! $templateTokens || ! in_array($token, $templateTokens, true)) {
|
||||||
}
|
if ($policy === 'fail') {
|
||||||
$unresolved[] = $token;
|
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
||||||
|
}
|
||||||
|
$unresolved[] = $token;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
|
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
|
||||||
// Rationale: old templates may not list newly allowed attributes (like nested paths),
|
// Support nested dotted attributes (e.g. person.person_address.city). We allow if either the full
|
||||||
// so we honor both sources instead of preferring one exclusively.
|
// dotted path is listed or if the base prefix is listed (e.g. person.person_address) and the resolver
|
||||||
$allowedFromTemplate = $template->columns[$entity] ?? [];
|
// can handle it.
|
||||||
|
// Safely read template-declared columns
|
||||||
|
$columns = is_array($template->columns ?? null) ? $template->columns : [];
|
||||||
|
$allowedFromTemplate = $columns[$entity] ?? [];
|
||||||
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
|
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
|
||||||
$allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal)));
|
$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') {
|
if ($policy === 'fail') {
|
||||||
throw new \RuntimeException("Nedovoljen stolpec token: $token");
|
throw new \RuntimeException("Nedovoljen stolpec token: $token");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|
|
||||||
|
|
@ -228,16 +228,27 @@
|
||||||
class="input input-bordered input-sm w-full col-span-4"
|
class="input input-bordered input-sm w-full col-span-4"
|
||||||
placeholder="custom ključ (npr. order_id)"
|
placeholder="custom ključ (npr. order_id)"
|
||||||
/>
|
/>
|
||||||
<input
|
<template v-if="row.type === 'text'">
|
||||||
v-model="row.value"
|
<textarea
|
||||||
type="text"
|
v-model="row.value"
|
||||||
class="input input-bordered input-sm w-full col-span-5"
|
rows="3"
|
||||||
placeholder="privzeta vrednost"
|
class="textarea textarea-bordered w-full text-xs col-span-5"
|
||||||
/>
|
placeholder="privzeta vrednost"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="row.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full col-span-5"
|
||||||
|
placeholder="privzeta vrednost"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<select v-model="row.type" class="select select-bordered select-sm w-full col-span-2">
|
<select v-model="row.type" class="select select-bordered select-sm w-full col-span-2">
|
||||||
<option value="string">string</option>
|
<option value="string">string</option>
|
||||||
<option value="number">number</option>
|
<option value="number">number</option>
|
||||||
<option value="date">date</option>
|
<option value="date">date</option>
|
||||||
|
<option value="text">text</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-ghost btn-xs col-span-1" @click="removeCustomDefault(idx)">✕</button>
|
<button type="button" class="btn btn-ghost btn-xs col-span-1" @click="removeCustomDefault(idx)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -393,10 +404,21 @@ function toggleActive() {
|
||||||
// Custom defaults rows state
|
// Custom defaults rows state
|
||||||
const baseDefaults = (props.template.meta && props.template.meta.custom_defaults) || {};
|
const baseDefaults = (props.template.meta && props.template.meta.custom_defaults) || {};
|
||||||
const baseTypes = (props.template.meta && props.template.meta.custom_default_types) || {};
|
const baseTypes = (props.template.meta && props.template.meta.custom_default_types) || {};
|
||||||
|
// Gather detected custom tokens from template.tokens
|
||||||
|
const detectedCustoms = Array.isArray(props.template.tokens)
|
||||||
|
? props.template.tokens.filter((t) => typeof t === 'string' && t.startsWith('custom.')).map((t) => t.replace(/^custom\./, ''))
|
||||||
|
: [];
|
||||||
|
// Build a union of keys from defaults, types, and detected tokens
|
||||||
|
const allKeysSet = new Set([
|
||||||
|
...Object.keys(baseDefaults || {}),
|
||||||
|
...Object.keys(baseTypes || {}),
|
||||||
|
...detectedCustoms,
|
||||||
|
]);
|
||||||
|
const allKeys = Array.from(allKeysSet);
|
||||||
const customRows = reactive(
|
const customRows = reactive(
|
||||||
Object.keys(baseDefaults).length
|
allKeys.length
|
||||||
? Object.entries(baseDefaults).map(([k, v]) => ({ key: k, value: v, type: baseTypes[k] || 'string' }))
|
? allKeys.map((k) => ({ key: k, value: baseDefaults[k] ?? '', type: baseTypes[k] || 'string' }))
|
||||||
: [{ key: "", value: "", type: 'string' }]
|
: [{ key: '', value: '', type: 'string' }]
|
||||||
);
|
);
|
||||||
|
|
||||||
function addCustomDefault() {
|
function addCustomDefault() {
|
||||||
|
|
@ -405,6 +427,6 @@ function addCustomDefault() {
|
||||||
|
|
||||||
function removeCustomDefault(idx) {
|
function removeCustomDefault(idx) {
|
||||||
customRows.splice(idx, 1);
|
customRows.splice(idx, 1);
|
||||||
if (!customRows.length) customRows.push({ key: "", value: "" });
|
if (!customRows.length) customRows.push({ key: "", value: "", type: 'string' });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,12 @@
|
||||||
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
|
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form @submit.prevent="rescan">
|
||||||
|
<button type="submit" :class="[btnBase, btnOutline]" :disabled="rescanForm.processing">
|
||||||
|
<span v-if="rescanForm.processing">Pregledujem…</span>
|
||||||
|
<span v-else>Ponovno preglej tokene</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<Link
|
<Link
|
||||||
:href="route('admin.document-templates.index')"
|
:href="route('admin.document-templates.index')"
|
||||||
:class="[btnBase, btnOutline]"
|
:class="[btnBase, btnOutline]"
|
||||||
|
|
@ -212,9 +218,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Link } from "@inertiajs/vue3";
|
import { Link, useForm } from "@inertiajs/vue3";
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { useForm } from "@inertiajs/vue3";
|
|
||||||
|
|
||||||
// Button style utility classes
|
// Button style utility classes
|
||||||
const btnBase =
|
const btnBase =
|
||||||
|
|
@ -228,10 +233,18 @@ const props = defineProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleForm = useForm({});
|
const toggleForm = useForm({});
|
||||||
|
const rescanForm = useForm({});
|
||||||
|
|
||||||
function toggleActive() {
|
function toggleActive() {
|
||||||
toggleForm.post(route("admin.document-templates.toggle", template.id), {
|
toggleForm.post(route("admin.document-templates.toggle", props.template.id), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rescan() {
|
||||||
|
rescanForm.post(route("admin.document-templates.rescan", props.template.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
only: ["template"],
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,13 @@ import {
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
client: { type: Object, default: null },
|
||||||
client_case: Object,
|
client_case: Object,
|
||||||
contract_types: Array,
|
contract_types: Array,
|
||||||
contracts: { type: Array, default: () => [] },
|
contracts: { type: Array, default: () => [] },
|
||||||
segments: { type: Array, default: () => [] },
|
segments: { type: Array, default: () => [] },
|
||||||
all_segments: { type: Array, default: () => [] },
|
all_segments: { type: Array, default: () => [] },
|
||||||
|
templates: { type: Array, default: () => [] }, // active document templates (latest per slug)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug: log incoming contract balances (remove after fix)
|
// Debug: log incoming contract balances (remove after fix)
|
||||||
|
|
@ -153,28 +155,108 @@ const onAddActivity = (c) => emit("add-activity", c);
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { router, useForm } from "@inertiajs/vue3";
|
import { router, useForm } from "@inertiajs/vue3";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
// Document generation state
|
// Document generation state/dialog
|
||||||
const generating = ref({}); // contract_uuid => boolean
|
const generating = ref({}); // contract_uuid => boolean
|
||||||
const generatedDocs = ref({}); // contract_uuid => { uuid, path }
|
const generatedDocs = ref({}); // contract_uuid => { uuid, path }
|
||||||
const generationError = ref({}); // contract_uuid => message
|
const generationError = ref({}); // contract_uuid => message
|
||||||
|
const showGenerateDialog = ref(false);
|
||||||
|
const generateFor = ref(null); // selected contract
|
||||||
|
const selectedTemplateSlug = ref(null);
|
||||||
|
const templateTokens = ref([]);
|
||||||
|
const templateCustomDefaults = ref({});
|
||||||
|
const templateCustomTypes = ref({});
|
||||||
|
const customInputs = ref({}); // { key: value } for custom.* tokens
|
||||||
|
// Separate selectors for address overrides
|
||||||
|
const clientAddressSource = ref("client"); // for client.person.person_address.*
|
||||||
|
const personAddressSource = ref("case_person"); // for person.person_address.*
|
||||||
|
|
||||||
// Hard-coded slug for now; could be made a prop or dynamic select later
|
const clientAddress = computed(() => {
|
||||||
const templateSlug = "contract-summary";
|
const addr = props.client?.person?.addresses?.[0] || null;
|
||||||
|
return addr
|
||||||
|
? { address: addr.address || "", post_code: addr.post_code || "", city: addr.city || "" }
|
||||||
|
: { address: "", post_code: "", city: "" };
|
||||||
|
});
|
||||||
|
const casePersonAddress = computed(() => {
|
||||||
|
const addr = props.client_case?.person?.addresses?.[0] || null;
|
||||||
|
return addr
|
||||||
|
? { address: addr.address || "", post_code: addr.post_code || "", city: addr.city || "" }
|
||||||
|
: { address: "", post_code: "", city: "" };
|
||||||
|
});
|
||||||
|
|
||||||
async function generateDocument(c) {
|
const customTokenList = computed(() => (templateTokens.value || []).filter((t) => t.startsWith("custom.")));
|
||||||
|
|
||||||
|
function openGenerateDialog(c) {
|
||||||
|
generateFor.value = c;
|
||||||
|
// Prefer a template that actually has tokens; fallback to the first available
|
||||||
|
const first = (props.templates || []).find(t => Array.isArray(t?.tokens) && t.tokens.length > 0) || (props.templates || [])[0] || null;
|
||||||
|
selectedTemplateSlug.value = first?.slug || null;
|
||||||
|
templateTokens.value = Array.isArray(first?.tokens) ? first.tokens : [];
|
||||||
|
templateCustomDefaults.value = (first?.meta && first.meta.custom_defaults) || {};
|
||||||
|
templateCustomTypes.value = (first?.meta && first.meta.custom_default_types) || {};
|
||||||
|
// Prefill customs with defaults
|
||||||
|
customInputs.value = {};
|
||||||
|
for (const t of customTokenList.value) {
|
||||||
|
const key = t.replace(/^custom\./, "");
|
||||||
|
customInputs.value[key] = templateCustomDefaults.value?.[key] ?? "";
|
||||||
|
}
|
||||||
|
clientAddressSource.value = "client";
|
||||||
|
personAddressSource.value = "case_person";
|
||||||
|
showGenerateDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTemplateChange() {
|
||||||
|
const tpl = (props.templates || []).find((t) => t.slug === selectedTemplateSlug.value);
|
||||||
|
templateTokens.value = Array.isArray(tpl?.tokens) ? tpl.tokens : [];
|
||||||
|
templateCustomDefaults.value = (tpl?.meta && tpl.meta.custom_defaults) || {};
|
||||||
|
templateCustomTypes.value = (tpl?.meta && tpl.meta.custom_default_types) || {};
|
||||||
|
// reset customs
|
||||||
|
customInputs.value = {};
|
||||||
|
for (const t of customTokenList.value) {
|
||||||
|
const key = t.replace(/^custom\./, "");
|
||||||
|
customInputs.value[key] = templateCustomDefaults.value?.[key] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGenerate() {
|
||||||
|
const c = generateFor.value;
|
||||||
if (!c?.uuid || generating.value[c.uuid]) return;
|
if (!c?.uuid || generating.value[c.uuid]) return;
|
||||||
|
const tpl = (props.templates || []).find((t) => t.slug === selectedTemplateSlug.value);
|
||||||
|
if (!tpl) return;
|
||||||
generating.value[c.uuid] = true;
|
generating.value[c.uuid] = true;
|
||||||
generationError.value[c.uuid] = null;
|
generationError.value[c.uuid] = null;
|
||||||
try {
|
try {
|
||||||
|
const clientAddr = clientAddressSource.value === "case_person" ? casePersonAddress.value : clientAddress.value;
|
||||||
|
const personAddr = personAddressSource.value === "case_person" ? casePersonAddress.value : clientAddress.value;
|
||||||
|
const token_overrides = {
|
||||||
|
"client.person.person_address.address": clientAddr.address,
|
||||||
|
"client.person.person_address.post_code": clientAddr.post_code,
|
||||||
|
"client.person.person_address.city": clientAddr.city,
|
||||||
|
"person.person_address.address": personAddr.address,
|
||||||
|
"person.person_address.post_code": personAddr.post_code,
|
||||||
|
"person.person_address.city": personAddr.city,
|
||||||
|
};
|
||||||
|
const payload = {
|
||||||
|
template_slug: tpl.slug,
|
||||||
|
template_version: tpl.version,
|
||||||
|
custom: { ...customInputs.value },
|
||||||
|
token_overrides,
|
||||||
|
unresolved_policy: 'fail',
|
||||||
|
};
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
route("contracts.generate-document", { contract: c.uuid }),
|
route("contracts.generate-document", { contract: c.uuid }),
|
||||||
{
|
payload
|
||||||
template_slug: templateSlug,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (data.status === "ok") {
|
if (data.status === "ok") {
|
||||||
generatedDocs.value[c.uuid] = { uuid: data.document_uuid, path: data.path };
|
generatedDocs.value[c.uuid] = { uuid: data.document_uuid, path: data.path };
|
||||||
// optimistic: reload documents list (if parent provides it) – partial reload optional
|
// if no tokens were found/replaced, surface a gentle warning inline
|
||||||
|
const stats = data.stats || null;
|
||||||
|
// Show warning only when zero tokens were found in the template (most common real issue)
|
||||||
|
if (stats && stats.tokensFound === 0) {
|
||||||
|
generationError.value[c.uuid] = "Opozorilo: V predlogi niso bili najdeni tokeni.";
|
||||||
|
} else {
|
||||||
|
generationError.value[c.uuid] = null;
|
||||||
|
}
|
||||||
|
showGenerateDialog.value = false;
|
||||||
router.reload({ only: ["documents"] });
|
router.reload({ only: ["documents"] });
|
||||||
} else {
|
} else {
|
||||||
generationError.value[c.uuid] = data.message || "Napaka pri generiranju.";
|
generationError.value[c.uuid] = data.message || "Napaka pri generiranju.";
|
||||||
|
|
@ -189,6 +271,10 @@ async function generateDocument(c) {
|
||||||
generating.value[c.uuid] = false;
|
generating.value[c.uuid] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function closeGenerateDialog() {
|
||||||
|
showGenerateDialog.value = false;
|
||||||
|
generateFor.value = null;
|
||||||
|
}
|
||||||
const showObjectDialog = ref(false);
|
const showObjectDialog = ref(false);
|
||||||
const showObjectsList = ref(false);
|
const showObjectsList = ref(false);
|
||||||
const selectedContract = ref(null);
|
const selectedContract = ref(null);
|
||||||
|
|
@ -653,17 +739,15 @@ const closePaymentsDialog = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||||
:disabled="generating[c.uuid]"
|
:disabled="generating[c.uuid] || !templates || templates.length===0"
|
||||||
@click="generateDocument(c)"
|
@click="openGenerateDialog(c)"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
:icon="generating[c.uuid] ? faSpinner : faFileWord"
|
:icon="generating[c.uuid] ? faSpinner : faFileWord"
|
||||||
class="h-4 w-4 text-gray-600"
|
class="h-4 w-4 text-gray-600"
|
||||||
:class="generating[c.uuid] ? 'animate-spin' : ''"
|
:class="generating[c.uuid] ? 'animate-spin' : ''"
|
||||||
/>
|
/>
|
||||||
<span>{{
|
<span>{{ generating[c.uuid] ? 'Generiranje...' : (templates && templates.length ? 'Generiraj dokument' : 'Ni predlog') }}</span>
|
||||||
generating[c.uuid] ? "Generiranje..." : "Generiraj povzetek"
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
v-if="generatedDocs[c.uuid]?.path"
|
v-if="generatedDocs[c.uuid]?.path"
|
||||||
|
|
@ -855,4 +939,110 @@ const closePaymentsDialog = () => {
|
||||||
:contract="selectedContract"
|
:contract="selectedContract"
|
||||||
@close="closePaymentsDialog"
|
@close="closePaymentsDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Generate document dialog -->
|
||||||
|
<div v-if="showGenerateDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-lg">
|
||||||
|
<div class="text-base font-medium text-gray-900 mb-2">Generiraj dokument</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
||||||
|
<select v-model="selectedTemplateSlug" @change="onTemplateChange" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
<option v-if="!templates || templates.length===0" :value="null" disabled>Ni aktivnih predlog</option>
|
||||||
|
<option v-for="t in templates" :key="t.slug" :value="t.slug">{{ t.name }} ({{ t.version }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Naslovi</label>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">client.person.person_address.*</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" value="client" v-model="clientAddressSource" />
|
||||||
|
<span>Stranka</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" value="case_person" v-model="clientAddressSource" />
|
||||||
|
<span>Oseba primera</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Naslov</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).address || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Pošta</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).post_code || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Kraj</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).city || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">person.person_address.*</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" value="client" v-model="personAddressSource" />
|
||||||
|
<span>Stranka</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" value="case_person" v-model="personAddressSource" />
|
||||||
|
<span>Oseba primera</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Naslov</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).address || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Pošta</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).post_code || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500">Kraj</div>
|
||||||
|
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).city || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="customTokenList.length" class="pt-2">
|
||||||
|
<div class="text-sm font-medium text-gray-700 mb-1">Dodatna polja</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="tok in customTokenList" :key="tok" class="grid grid-cols-3 gap-2 items-start">
|
||||||
|
<div class="col-span-1 text-sm text-gray-600">{{ tok }}</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<template v-if="templateCustomTypes[tok.replace(/^custom\./,'')] === 'text'">
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
||||||
|
rows="3"
|
||||||
|
v-model="customInputs[tok.replace(/^custom\./,'')]"
|
||||||
|
:placeholder="templateCustomDefaults[tok.replace(/^custom\./,'')] ?? 'privzeta vrednost'"
|
||||||
|
></textarea>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
v-model="customInputs[tok.replace(/^custom\./,'')]"
|
||||||
|
:placeholder="templateCustomDefaults[tok.replace(/^custom\./,'')] ?? 'privzeta vrednost'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="closeGenerateDialog">Prekliči</button>
|
||||||
|
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="!selectedTemplateSlug || generating[generateFor?.uuid]" @click="submitGenerate">Generiraj</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="generationError[generateFor?.uuid]" class="mt-2 text-sm text-rose-600">{{ generationError[generateFor?.uuid] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ const props = defineProps({
|
||||||
segments: { type: Array, default: () => [] },
|
segments: { type: Array, default: () => [] },
|
||||||
all_segments: { type: Array, default: () => [] },
|
all_segments: { type: Array, default: () => [] },
|
||||||
current_segment: { type: Object, default: null },
|
current_segment: { type: Object, default: null },
|
||||||
|
contract_doc_templates: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const showUpload = ref(false);
|
const showUpload = ref(false);
|
||||||
|
|
@ -287,10 +288,12 @@ const submitAttachSegment = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ContractTable
|
<ContractTable
|
||||||
|
:client="client"
|
||||||
:client_case="client_case"
|
:client_case="client_case"
|
||||||
:contracts="contracts"
|
:contracts="contracts"
|
||||||
:contract_types="contract_types"
|
:contract_types="contract_types"
|
||||||
:segments="segments"
|
:segments="segments"
|
||||||
|
:templates="contract_doc_templates"
|
||||||
@edit="openDrawerEditContract"
|
@edit="openDrawerEditContract"
|
||||||
@delete="requestDeleteContract"
|
@delete="requestDeleteContract"
|
||||||
@add-activity="openDrawerAddActivity"
|
@add-activity="openDrawerAddActivity"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
// Document templates & global document settings
|
// Document templates & global document settings
|
||||||
Route::get('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'index'])->name('document-templates.index');
|
Route::get('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'index'])->name('document-templates.index');
|
||||||
Route::post('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'store'])->name('document-templates.store');
|
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::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::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');
|
Route::get('document-templates/{template}', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'show'])->name('document-templates.show');
|
||||||
|
|
|
||||||
45
tests/Feature/DocumentTemplateCustomTokensTest.php
Normal file
45
tests/Feature/DocumentTemplateCustomTokensTest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
use function Pest\Laravel\actingAs;
|
||||||
|
use function Pest\Laravel\post;
|
||||||
|
|
||||||
|
// Intentionally call Pest helpers with fully-qualified names in-body to satisfy static analyzers
|
||||||
|
|
||||||
|
it('auto-detects custom tokens and sets default types on upload', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$admin = User::factory()->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', '<w:document>{{custom.km_driven}}</w:document>');
|
||||||
|
$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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user