Teren-app/app/Services/Documents/DocxTemplateRenderer.php
Simon Pocrnjič e0303ece74 documents
2025-10-12 12:24:17 +02:00

164 lines
7.2 KiB
PHP

<?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'));
// Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings.
$customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller
$customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null;
$resolved = $this->resolver->resolve(
$tokens,
$template,
$contract,
$user,
$effectivePolicy,
is_array($customOverrides) ? $customOverrides : [],
$customDefaults,
'empty'
);
$values = $resolved['values'];
$initialUnresolved = $resolved['unresolved'];
$customTypes = $resolved['customTypes'] ?? [];
// 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) {
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
$isTypedNumber = ($customTypes[$k] ?? null) === 'number';
// Date formatting (typed or heuristic based on key ending with _date or .date)
if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
$dateFmtOverrides = $fmt['date_formats'] ?? [];
$desiredFormat = $dateFmtOverrides[$k]
?? ($globalDateFormats[$k] ?? null)
?? ($fmt['default_date_format'] ?? null)
?? ($template->date_format ?: null)
?? ($globalSettings->date_format ?? null)
?? 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
}
}
}
// Number formatting: only for explicitly typed numbers or common monetary fields
$isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k);
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
if ($currencySymbol && $isFinanceField) {
$space = $currencySpace ? ' ' : '';
if ($currencyPos === 'after') {
$num = $num.$space.$currencySymbol;
} else {
$num = $currencySymbol.$space.$num;
}
}
$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,
];
}
}