208 lines
8.2 KiB
PHP
208 lines
8.2 KiB
PHP
<?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>,customTypes?:array<string,string>}
|
||
*/
|
||
public function resolve(
|
||
array $tokens,
|
||
DocumentTemplate $template,
|
||
Contract $contract,
|
||
User $user,
|
||
string $policy = 'fail',
|
||
array $customOverrides = [],
|
||
?array $customDefaults = null,
|
||
string $onMissingCustom = 'empty'
|
||
): array {
|
||
$values = [];
|
||
$unresolved = [];
|
||
$customTypesOut = [];
|
||
// Custom namespace: merge defaults from settings/template meta and overrides
|
||
$settings = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||
$defaults = $customDefaults ?? ($template->meta['custom_defaults'] ?? null) ?? ($settings->custom_defaults ?? []);
|
||
if (! is_array($defaults)) {
|
||
$defaults = [];
|
||
}
|
||
if (! is_array($customOverrides)) {
|
||
$customOverrides = [];
|
||
}
|
||
$custom = array_replace($defaults, $customOverrides);
|
||
// Collect custom types from template meta (optional)
|
||
$customTypes = [];
|
||
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
|
||
foreach ($template->meta['custom_default_types'] as $k => $t) {
|
||
$t = in_array($t, ['string', 'number', 'date'], true) ? $t : 'string';
|
||
$customTypes[(string) $k] = $t;
|
||
}
|
||
}
|
||
// Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays)
|
||
$settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? [];
|
||
$configWhitelist = config('documents.whitelist', []);
|
||
// Merge preserving DB additions/overrides
|
||
$globalWhitelist = array_replace($configWhitelist, $settingsWhitelist);
|
||
// Always treat globally whitelisted entities as available, even if legacy template does not list them
|
||
if ($template->entities && is_array($template->entities)) {
|
||
$templateEntities = array_values(array_unique(array_merge($template->entities, array_keys($globalWhitelist))));
|
||
} else {
|
||
$templateEntities = array_keys($globalWhitelist);
|
||
}
|
||
foreach ($tokens as $token) {
|
||
[$entity,$attr] = explode('.', $token, 2);
|
||
if ($entity === 'generation') {
|
||
$values[$token] = $this->generationAttribute($attr, $user);
|
||
|
||
continue;
|
||
}
|
||
if ($entity === 'custom') {
|
||
// Track type info if present
|
||
if (isset($customTypes[$attr])) {
|
||
$customTypesOut[$token] = $customTypes[$attr];
|
||
}
|
||
if (array_key_exists($attr, $custom)) {
|
||
$v = $custom[$attr];
|
||
if (is_scalar($v) || (is_object($v) && method_exists($v, '__toString'))) {
|
||
$values[$token] = (string) $v;
|
||
} else {
|
||
$values[$token] = '';
|
||
}
|
||
} else {
|
||
// Missing custom – apply onMissingCustom policy locally (empty|leave|error)
|
||
if ($onMissingCustom === 'error') {
|
||
if ($policy === 'fail') {
|
||
throw new \RuntimeException("Manjkajoč custom token: {$token}");
|
||
}
|
||
$unresolved[] = $token;
|
||
} elseif ($onMissingCustom === 'leave') {
|
||
$unresolved[] = $token;
|
||
} else { // empty
|
||
$values[$token] = '';
|
||
}
|
||
}
|
||
|
||
continue;
|
||
}
|
||
if (! in_array($entity, $templateEntities, true)) {
|
||
if ($policy === 'fail') {
|
||
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
||
}
|
||
$unresolved[] = $token;
|
||
|
||
continue;
|
||
}
|
||
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
|
||
// Rationale: old templates may not list newly allowed attributes (like nested paths),
|
||
// so we honor both sources instead of preferring one exclusively.
|
||
$allowedFromTemplate = $template->columns[$entity] ?? [];
|
||
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
|
||
$allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal)));
|
||
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)),
|
||
'customTypes' => $customTypesOut,
|
||
];
|
||
}
|
||
|
||
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':
|
||
$client = optional($contract->clientCase)->client;
|
||
if (! $client) {
|
||
return '';
|
||
}
|
||
if (str_contains($attr, '.')) {
|
||
return $this->resolveNestedFromModel($client, $attr);
|
||
}
|
||
|
||
return (string) ($client->{$attr} ?? '');
|
||
case 'person':
|
||
$person = optional($contract->clientCase)->person;
|
||
if (! $person) {
|
||
return '';
|
||
}
|
||
if (str_contains($attr, '.')) {
|
||
return $this->resolveNestedFromModel($person, $attr);
|
||
}
|
||
|
||
return (string) ($person->{$attr} ?? '');
|
||
case 'account':
|
||
$account = optional($contract->account);
|
||
|
||
return (string) $account->{$attr};
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve nested dotted paths from a base model for supported relations/aliases.
|
||
* Supports:
|
||
* - Client: person.*
|
||
* - Person: person_address.* (uses first active address)
|
||
*/
|
||
private function resolveNestedFromModel(object $model, string $path): string
|
||
{
|
||
$segments = explode('.', $path);
|
||
$current = $model;
|
||
foreach ($segments as $seg) {
|
||
if (! $current) {
|
||
return '';
|
||
}
|
||
if ($current instanceof \App\Models\Client && $seg === 'person') {
|
||
$current = $current->person;
|
||
|
||
continue;
|
||
}
|
||
if ($current instanceof \App\Models\Person\Person && $seg === 'person_address') {
|
||
$current = $current->addresses()->first();
|
||
|
||
continue;
|
||
}
|
||
// Default attribute access
|
||
try {
|
||
$current = is_array($current) ? ($current[$seg] ?? null) : ($current->{$seg} ?? null);
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
return $current !== null ? (string) $current : '';
|
||
}
|
||
}
|