Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.56% covered (success)
95.56%
258 / 270
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateVariableResolver
95.54% covered (success)
95.54%
257 / 269
77.78% covered (warning)
77.78%
14 / 18
60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveContext
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 resolveListContext
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableVariables
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
3
 detectRecipientLocale
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 getModuleTableAndColumns
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 loadRecordData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 fetchRawRecordRow
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 enrichRecordInventory
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 resolveInventoryTable
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 fetchInventoryItems
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 aggregateInventoryTotals
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
10
 enrichRecordCounterparty
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 loadRecordsList
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getModuleTableName
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 fetchModuleFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 buildUserContext
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildSystemContext
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Template\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use PDO;
13
14/**
15 * Metadata-Driven Context and Variable Resolver for Templates.
16 *
17 * Resolves record fields, related 1:M entities, active user session data,
18 * multi-language recipient preferences, and system settings for template variable replacement.
19 *
20 * @package App\Core\Template\Application\Service
21 */
22final class TemplateVariableResolver implements TemplateVariableResolverInterface
23{
24    private const string DEFAULT_APP_NAME = 'Ammonly';
25    private const string DEFAULT_COMPANY_NAME = 'Ammonly Enterprise Suite';
26    private const string DEFAULT_LOCALE = 'pl';
27
28    /**
29     * TemplateVariableResolver constructor.
30     *
31     * @param PDO|null $pdo Database PDO connection instance.
32     */
33    public function __construct(
34        private ?PDO $pdo = null
35    ) {
36        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
37    }
38
39    /**
40     * Resolves complete template context variables for a specific record.
41     *
42     * @param string                    $moduleName Target module name (e.g. tickets, companies).
43     * @param int|null                  $recordId   Optional primary record identifier.
44     * @param array<string, mixed>      $overrides  Explicit variable overrides.
45     * @param array<string, mixed>|null $user       Optional current user identity data.
46     * @return array<string, mixed> Hierarchical context dictionary for Twig rendering.
47     */
48    public function resolveContext(
49        string $moduleName,
50        ?int $recordId = null,
51        array $overrides = [],
52        ?array $user = null
53    ): array {
54        $recordData = $this->loadRecordData($moduleName, $recordId);
55        $recordData = array_merge($recordData, $overrides);
56
57        $detectedLocale = $this->detectRecipientLocale($recordData);
58
59        return [
60            'record'  => $recordData,
61            'user'    => $this->buildUserContext($user),
62            'system'  => $this->buildSystemContext(),
63            'context' => [
64                'today'      => date('Y-m-d'),
65                'now'        => date('Y-m-d H:i:s'),
66                'date'       => date('Y-m-d'),
67                'module'     => $moduleName,
68                'record_id'  => $recordId,
69                'locale'     => $detectedLocale,
70            ],
71        ];
72    }
73
74    /**
75     * Resolves template context variables for a collection of records (list scope).
76     *
77     * @param string                    $moduleName Target module name.
78     * @param array<int>                $recordIds  Optional explicit list of record IDs.
79     * @param array<string, mixed>|null $user       Optional current user identity data.
80     * @return array<string, mixed> Hierarchical context dictionary for Twig rendering.
81     */
82    public function resolveListContext(
83        string $moduleName,
84        array $recordIds = [],
85        ?array $user = null
86    ): array {
87        $records = $this->loadRecordsList($moduleName, $recordIds);
88
89        return [
90            'records'     => $records,
91            'total_count' => count($records),
92            'user'        => $this->buildUserContext($user),
93            'system'      => $this->buildSystemContext(),
94            'context'     => [
95                'today'  => date('Y-m-d'),
96                'now'    => date('Y-m-d H:i:s'),
97                'date'   => date('Y-m-d'),
98                'module' => $moduleName,
99                'locale' => self::DEFAULT_LOCALE,
100            ],
101        ];
102    }
103
104    /**
105     * Returns categorized catalog of available variables for the visual Variable Picker UI.
106     *
107     * @param string $moduleName Target module machine name.
108     * @return array<string, mixed> Categorized variable tree.
109     */
110    public function getAvailableVariables(string $moduleName): array
111    {
112        $fields = $this->fetchModuleFields($moduleName);
113
114        $recordVariables = [];
115        foreach ($fields as $field) {
116            $key = (string)($field['field_key'] ?? '');
117            $label = (string)($field['label'] ?? $key);
118            if ($key !== '') {
119                $recordVariables[] = [
120                    'token' => 'record.' . $key,
121                    'label' => $label,
122                    'type'  => (string)($field['category'] ?? 'textual'),
123                ];
124            }
125        }
126
127        return [
128            'module'  => $moduleName,
129            'groups'  => [
130                [
131                    'name'      => 'Record fields (' . $moduleName . ')',
132                    'variables' => $recordVariables,
133                ],
134                [
135                    'name'      => 'Logged in user (Author)',
136                    'variables' => [
137                        ['token' => 'user.full_name', 'label' => 'Author full name'],
138                        ['token' => 'user.email', 'label' => 'Author email address'],
139                        ['token' => 'user.phone', 'label' => 'Author phone number'],
140                        ['token' => 'user.job_title', 'label' => 'Author job title'],
141                        ['token' => 'user.signature_html', 'label' => 'Author HTML signature'],
142                    ],
143                ],
144                [
145                    'name'      => 'System and company variables',
146                    'variables' => [
147                        ['token' => 'system.company_name', 'label' => 'Company name'],
148                        ['token' => 'system.app_name', 'label' => 'Application name'],
149                        ['token' => 'system.app_url', 'label' => 'Base application URL'],
150                        ['token' => 'context.today', 'label' => 'Current date (YYYY-MM-DD)'],
151                        ['token' => 'context.date', 'label' => 'Current date (DD.MM.YYYY)'],
152                        ['token' => 'context.locale', 'label' => 'Template language (pl, en, de)'],
153                    ],
154                ],
155            ],
156        ];
157    }
158
159    /**
160     * Detects recipient preferred language code from record attributes.
161     *
162     * @param array<string, mixed> $recordData Record field dictionary.
163     * @return string Two-letter ISO language code.
164     */
165    private function detectRecipientLocale(array $recordData): string
166    {
167        $candidateKeys = ['preferred_language', 'language', 'lang', 'locale'];
168        foreach ($candidateKeys as $k) {
169            if (isset($recordData[$k]) && is_string($recordData[$k]) && trim($recordData[$k]) !== '') {
170                $clean = strtolower(trim($recordData[$k]));
171                if (in_array($clean, ['pl', 'en', 'de', 'fr', 'es', 'it'], true)) {
172                    return $clean;
173                }
174            }
175        }
176
177        return self::DEFAULT_LOCALE;
178    }
179
180    /**
181     * Loads raw database row for given module and primary key ID.
182     *
183     * @param string   $moduleName Module machine name.
184     * @param int|null $recordId   Primary ID.
185     * @return array<string, mixed> Record associative array or empty array.
186     */
187    /**
188     * Resolves physical table name and column names for a module.
189     *
190     * @param string $moduleName Target module name.
191     * @return array{0: string, 1: list<string>}|null Tuple of table name and columns, or null.
192     */
193    private function getModuleTableAndColumns(string $moduleName): ?array
194    {
195        if ($this->pdo === null) {
196            return null;
197        }
198
199        $table = $this->getModuleTableName($moduleName);
200        if ($table === null) {
201            return null;
202        }
203
204        $colsStmt = $this->pdo->prepare("SHOW COLUMNS FROM `{$table}`");
205        $colsStmt->execute();
206        $columns = $colsStmt->fetchAll(PDO::FETCH_COLUMN);
207
208        return is_array($columns) && $columns !== [] ? [$table, $columns] : null;
209    }
210
211    /**
212     * Loads raw database row for given module and primary key ID.
213     *
214     * @param string   $moduleName Module machine name.
215     * @param int|null $recordId   Primary ID.
216     * @return array<string, mixed> Record associative array or empty array.
217     */
218    private function loadRecordData(string $moduleName, ?int $recordId): array
219    {
220        $info = ($recordId !== null && $recordId > 0)
221            ? $this->getModuleTableAndColumns($moduleName)
222            : null;
223        if ($info === null) {
224            return [];
225        }
226
227        [$table, $columns] = $info;
228        $row = $this->fetchRawRecordRow($table, $columns, $recordId);
229        if ($row === null) {
230            return [];
231        }
232
233        $row = $this->enrichRecordInventory($table, $recordId, $row);
234
235        return $this->enrichRecordCounterparty($row);
236    }
237
238    /**
239     * @param list<string> $columns
240     * @return array<string, mixed>|null
241     */
242    private function fetchRawRecordRow(string $table, array $columns, int $recordId): ?array
243    {
244        $escapedCols = implode(', ', array_map(static fn(string $c): string => "`{$c}`", $columns));
245        $stmt = $this->pdo->prepare("SELECT {$escapedCols} FROM `{$table}` WHERE `id` = :id LIMIT 1");
246        $stmt->execute([':id' => $recordId]);
247        $row = $stmt->fetch(PDO::FETCH_ASSOC);
248
249        return is_array($row) ? $row : null;
250    }
251
252    /**
253     * Enriches record data with inventory line items and calculated totals.
254     *
255     * @param string               $baseTable  Module physical table name.
256     * @param int                  $recordId   Primary record ID.
257     * @param array<string, mixed> $recordData Record array.
258     * @return array<string, mixed> Enriched record array.
259     */
260    private function enrichRecordInventory(string $baseTable, int $recordId, array $recordData): array
261    {
262        $invTable = $this->resolveInventoryTable($baseTable);
263        if ($invTable === null) {
264            return $recordData;
265        }
266
267        $items = $this->fetchInventoryItems($invTable, $recordId);
268        $recordData['items'] = $items;
269        $recordData['items_count'] = count($items);
270
271        return $this->aggregateInventoryTotals($recordData, $items);
272    }
273
274    private function resolveInventoryTable(string $baseTable): ?string
275    {
276        if ($this->pdo === null) {
277            return null;
278        }
279
280        $invTable = str_replace('_records', '_inventory', $baseTable);
281        if ($invTable === $baseTable) {
282            return null;
283        }
284
285        $checkStmt = $this->pdo->prepare(
286            'SELECT table_name FROM information_schema.tables '
287            . 'WHERE table_schema = DATABASE() AND table_name = :table'
288        );
289        $checkStmt->execute([':table' => $invTable]);
290
291        return $checkStmt->fetchColumn() !== false ? $invTable : null;
292    }
293
294    /**
295     * @return array<int, array<string, mixed>>
296     */
297    private function fetchInventoryItems(string $invTable, int $recordId): array
298    {
299        $cols = [
300            'id', 'record_id', 'group_name', 'item_type', 'item_name', 'unit',
301            'quantity', 'unit_price', 'net_amount', 'discount_percent', 'discount_amount',
302            'price_after_discount', 'purchase_cost', 'margin_percent', 'margin_amount',
303            'tax_percent', 'tax_amount', 'gross_amount', 'comment', 'sort_order',
304        ];
305        $escCols = implode(', ', array_map(static fn(string $c): string => "`{$c}`", $cols));
306        $itemsStmt = $this->pdo->prepare(
307            "SELECT {$escCols} FROM `{$invTable}` WHERE `record_id` = :rid ORDER BY `sort_order` ASC, `id` ASC"
308        );
309        $itemsStmt->execute([':rid' => $recordId]);
310
311        return (array) $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
312    }
313
314    /**
315     * Aggregates financial totals, tax breakdowns, and group items.
316     *
317     * @param array<string, mixed>                     $recordData Record dictionary.
318     * @param array<int, array<string, mixed>>         $items      Inventory line items.
319     * @return array<string, mixed> Enriched record array.
320     */
321    private function aggregateInventoryTotals(array $recordData, array $items): array
322    {
323        $subtotal = 0.0;
324        $taxTotal = 0.0;
325        $grandTotal = 0.0;
326        $groups = [];
327        $taxBreakdown = [];
328
329        foreach ($items as $item) {
330            $net = (float)($item['net_amount'] ?? 0);
331            $tax = (float)($item['tax_amount'] ?? 0);
332            $gross = (float)($item['gross_amount'] ?? 0);
333            $taxRate = (string)($item['tax_percent'] ?? '23.00');
334
335            $subtotal += $net;
336            $taxTotal += $tax;
337            $grandTotal += $gross;
338
339            $groupName = trim((string)($item['group_name'] ?? ''));
340            if (!isset($groups[$groupName])) {
341                $groups[$groupName] = [
342                    'group_name'  => $groupName,
343                    'items'       => [],
344                    'subtotal'    => 0.0,
345                    'grand_total' => 0.0,
346                ];
347            }
348            $groups[$groupName]['items'][] = $item;
349            $groups[$groupName]['subtotal'] += $net;
350            $groups[$groupName]['grand_total'] += $gross;
351
352            if (!isset($taxBreakdown[$taxRate])) {
353                $taxBreakdown[$taxRate] = [
354                    'tax_percent'  => (float)$taxRate,
355                    'tax_rate_lbl' => $taxRate . '%',
356                    'net_amount'   => 0.0,
357                    'tax_amount'   => 0.0,
358                    'gross_amount' => 0.0,
359                ];
360            }
361            $taxBreakdown[$taxRate]['net_amount'] += $net;
362            $taxBreakdown[$taxRate]['tax_amount'] += $tax;
363            $taxBreakdown[$taxRate]['gross_amount'] += $gross;
364        }
365
366        $recordData['items_grouped'] = array_values($groups);
367        $recordData['tax_breakdown'] = array_values($taxBreakdown);
368        $recordData['subtotal_calculated'] = $subtotal;
369        $recordData['tax_total_calculated'] = $taxTotal;
370        $recordData['grand_total_calculated'] = $grandTotal;
371
372        if (empty($recordData['subtotal']) || (float)$recordData['subtotal'] === 0.0) {
373            $recordData['subtotal'] = $subtotal;
374        }
375        if (empty($recordData['tax_total']) || (float)$recordData['tax_total'] === 0.0) {
376            $recordData['tax_total'] = $taxTotal;
377        }
378        if (empty($recordData['grand_total']) || (float)$recordData['grand_total'] === 0.0) {
379            $recordData['grand_total'] = $grandTotal;
380        }
381
382        $recordData['amount_net'] = $recordData['subtotal'];
383        $recordData['amount_vat'] = $recordData['tax_total'];
384        $recordData['amount_gross'] = $recordData['grand_total'];
385
386        return $recordData;
387    }
388
389    /**
390     * Enriches record with associated company and contact information.
391     *
392     * @param array<string, mixed> $recordData Record array.
393     * @return array<string, mixed> Enriched record array.
394     */
395    private function enrichRecordCounterparty(array $recordData): array
396    {
397        if ($this->pdo === null) {
398            return $recordData;
399        }
400
401        $companyId = (int)($recordData['company_id'] ?? ($recordData['account_id'] ?? 0));
402        if ($companyId > 0) {
403            $cStmt = $this->pdo->prepare(
404                'SELECT `name`, `tax_identifier`, `email`, `phone`, `address_street`, '
405                . '`address_building_number`, `address_apartment_number`, `address_postal_code`, '
406                . '`address_city`, `address_country` FROM `c_mod_companies_records` WHERE `id` = :cid LIMIT 1'
407            );
408            $cStmt->execute([':cid' => $companyId]);
409            $comp = $cStmt->fetch(PDO::FETCH_ASSOC);
410            if (is_array($comp)) {
411                $street = (string)($comp['address_street'] ?? '');
412                $bld = (string)($comp['address_building_number'] ?? '');
413                $post = (string)($comp['address_postal_code'] ?? '');
414                $city = (string)($comp['address_city'] ?? '');
415                $fullAddr = trim($street . ' ' . $bld . ', ' . $post . ' ' . $city);
416
417                $recordData['company_name'] = (string)($comp['name'] ?? '');
418                $recordData['company_tax_id'] = (string)($comp['tax_identifier'] ?? '');
419                $recordData['company_nip'] = (string)($comp['tax_identifier'] ?? '');
420                $recordData['company_address'] = $fullAddr;
421                $recordData['company_street'] = $street;
422                $recordData['company_building'] = $bld;
423                $recordData['company_postal_code'] = $post;
424                $recordData['company_city'] = $city;
425                $recordData['company_email'] = (string)($comp['email'] ?? '');
426                $recordData['company_phone'] = (string)($comp['phone'] ?? '');
427            }
428        }
429
430        $contactId = (int)($recordData['contact_id'] ?? 0);
431        if ($contactId > 0) {
432            $kStmt = $this->pdo->prepare(
433                'SELECT `formatted_name`, `email`, `phone`, `job_title` '
434                . 'FROM `c_mod_contacts_records` WHERE `id` = :kid LIMIT 1'
435            );
436            $kStmt->execute([':kid' => $contactId]);
437            $cont = $kStmt->fetch(PDO::FETCH_ASSOC);
438            if (is_array($cont)) {
439                $recordData['contact_name'] = (string)($cont['formatted_name'] ?? '');
440                $recordData['contact_email'] = (string)($cont['email'] ?? '');
441                $recordData['contact_phone'] = (string)($cont['phone'] ?? '');
442                $recordData['contact_job_title'] = (string)($cont['job_title'] ?? '');
443            }
444        }
445
446        return $recordData;
447    }
448
449    /**
450     * Loads list of records from module table.
451     *
452     * @param string     $moduleName Target module name.
453     * @param array<int> $recordIds  Optional filter by IDs.
454     * @return array<int, array<string, mixed>> List of record rows.
455     */
456    private function loadRecordsList(string $moduleName, array $recordIds = []): array
457    {
458        $info = $this->getModuleTableAndColumns($moduleName);
459        if ($info === null) {
460            return [];
461        }
462
463        [$table, $columns] = $info;
464        $escapedCols = implode(', ', array_map(static fn(string $c): string => "`{$c}`", $columns));
465        $sql = "SELECT {$escapedCols} FROM `{$table}` WHERE 1=1";
466
467        if ($recordIds !== []) {
468            $inClause = implode(',', array_map('intval', $recordIds));
469            $sql .= " AND `id` IN ({$inClause})";
470        }
471
472        $sql .= ' ORDER BY `id` DESC LIMIT 500';
473        $stmt = $this->pdo->query($sql);
474
475        return $stmt !== false ? (array)$stmt->fetchAll(PDO::FETCH_ASSOC) : [];
476    }
477
478    /**
479     * Resolves target physical database table for module name.
480     *
481     * @param string $moduleName Machine module name.
482     * @return string|null Physical table name or null.
483     */
484    private function getModuleTableName(string $moduleName): ?string
485    {
486        if ($this->pdo === null) {
487            return null;
488        }
489
490        $stmt = $this->pdo->prepare(
491            'SELECT `table_name` FROM `a_core_module_records` WHERE `name` = :name LIMIT 1'
492        );
493        $stmt->execute([':name' => $moduleName]);
494        $result = $stmt->fetchColumn();
495
496        return is_string($result) && $result !== '' ? $result : null;
497    }
498
499    /**
500     * Fetches field metadata for the given module.
501     *
502     * @param string $moduleName Machine module name.
503     * @return array<int, array<string, mixed>> List of field metadata definitions.
504     */
505    private function fetchModuleFields(string $moduleName): array
506    {
507        if ($this->pdo === null) {
508            return [];
509        }
510
511        $stmt = $this->pdo->prepare(
512            'SELECT f.`field_key`, f.`label`, u.`category`
513             FROM `a_core_field_records` f
514             JOIN `a_core_module_records` m ON m.`id` = f.`module_id`
515             LEFT JOIN `a_core_uitype_records` u ON u.`id` = f.`uitype_id`
516             WHERE m.`name` = :module_name AND m.`is_active` = 1 AND f.`special_access` = 1
517             ORDER BY f.`sort_order` ASC'
518        );
519        $stmt->execute([':module_name' => $moduleName]);
520        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
521
522        return is_array($rows) ? $rows : [];
523    }
524
525    /**
526     * Builds standardized user identity variables.
527     *
528     * @param array<string, mixed>|null $user Optional user data.
529     * @return array<string, mixed> Normalized user context.
530     */
531    private function buildUserContext(?array $user): array
532    {
533        return [
534            'id'             => (int)($user['id'] ?? 1),
535            'full_name'      => (string)($user['full_name'] ?? ($user['username'] ?? 'Administrator')),
536            'email'          => (string)($user['email'] ?? 'admin@ammonly.com'),
537            'phone'          => (string)($user['phone'] ?? ''),
538            'job_title'      => (string)($user['job_title'] ?? 'Administrator'),
539            'signature_html' => (string)($user['signature_html'] ?? ''),
540        ];
541    }
542
543    /**
544     * Builds standardized company and system settings variables.
545     *
546     * @return array<string, string> System context values.
547     */
548    private function buildSystemContext(): array
549    {
550        return [
551            'app_name'        => self::DEFAULT_APP_NAME,
552            'company_name'    => self::DEFAULT_COMPANY_NAME,
553            'company_nip'     => 'PL1234567890',
554            'company_address' => 'ul. Biznesowa 10, 00-001 Warszawa',
555            'app_url'         => 'https://app-admin.ammonly.com',
556            'support_email'   => 'support@ammonly.com',
557        ];
558    }
559}