documents

This commit is contained in:
Simon Pocrnjič
2025-10-12 12:24:17 +02:00
parent 3ab1c05fcc
commit e0303ece74
22 changed files with 898 additions and 88 deletions
@@ -25,4 +25,16 @@ public function fresh(): DocumentSetting
{
return $this->refresh();
}
/**
* Convenience accessor for custom defaults.
*
* @return array<string,mixed>
*/
public function customDefaults(): array
{
$settings = $this->get();
return is_array($settings->custom_defaults ?? null) ? $settings->custom_defaults : [];
}
}
@@ -41,9 +41,22 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
// Determine effective unresolved policy early (template override -> global -> config)
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
$resolved = $this->resolver->resolve($tokens, $template, $contract, $user, $effectivePolicy);
// 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);
@@ -55,8 +68,10 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
$globalDateFormats = $globalSettings->date_formats ?? [];
foreach ($values as $k => $v) {
// Date formatting (heuristic based on key ending with _date or .date)
if (is_string($v) && ($k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
$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)
@@ -75,9 +90,11 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
}
}
}
if (is_numeric($v)) {
// 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 && preg_match('/(amount|balance|total|price|cost)/i', $k)) {
if ($currencySymbol && $isFinanceField) {
$space = $currencySpace ? ' ' : '';
if ($currencyPos === 'after') {
$num = $num.$space.$currencySymbol;
+2 -1
View File
@@ -4,7 +4,8 @@
class TokenScanner
{
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*}}/';
// Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id)
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/';
/**
* @return array<int,string>
+122 -9
View File
@@ -13,12 +13,39 @@ class TokenValueResolver
* 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>}
* @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
{
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', []);
@@ -37,6 +64,34 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
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");
@@ -45,7 +100,12 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
continue;
}
$allowed = ($template->columns[$entity] ?? []) ?: ($globalWhitelist[$entity] ?? []);
// 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");
@@ -57,7 +117,11 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
$values[$token] = $this->entityAttribute($entity, $attr, $contract) ?? '';
}
return ['values' => $values, 'unresolved' => array_values(array_unique($unresolved))];
return [
'values' => $values,
'unresolved' => array_values(array_unique($unresolved)),
'customTypes' => $customTypesOut,
];
}
private function generationAttribute(string $attr, User $user): string
@@ -78,11 +142,25 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
case 'client_case':
return (string) optional($contract->clientCase)->{$attr};
case 'client':
return (string) optional(optional($contract->clientCase)->client)->{$attr};
case 'person':
$person = optional(optional($contract->clientCase)->person);
$client = optional($contract->clientCase)->client;
if (! $client) {
return '';
}
if (str_contains($attr, '.')) {
return $this->resolveNestedFromModel($client, $attr);
}
return (string) $person->{$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);
@@ -91,4 +169,39 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
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 : '';
}
}
+5 -3
View File
@@ -2365,13 +2365,15 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
if (! $field) {
continue;
}
$val = $addrData[$field] ?? null;
// Allow alias 'postal_code' in CSV mappings but persist as 'post_code' in DB
$targetField = $field === 'postal_code' ? 'post_code' : $field;
$val = $addrData[$field] ?? $addrData[$targetField] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $val;
$applyInsert[$targetField] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $val;
$applyUpdate[$targetField] = $val;
}
}
if ($existing) {