Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.95% covered (warning)
78.95%
90 / 114
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecordCreationPrefillResolver
79.65% covered (warning)
79.65%
90 / 113
40.00% covered (danger)
40.00%
4 / 10
106.99
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
 resolveDuplicateInitialRecord
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
12.09
 formatDuplicatedName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 resolvePolymorphicPrefills
51.61% covered (warning)
51.61%
16 / 31
0.00% covered (danger)
0.00%
0 / 1
49.74
 cascadeTaskPrefill
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 cascadeTicketPrefill
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 cascadeProjectOrContractPrefill
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 cascadePartyPrefill
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 resolveSourceRelationKey
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
15.31
 populateQueryParamsIntoInitialRecord
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
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\Engine\Presentation\Web\Support;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\FieldMetadata;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use App\Core\Engine\Presentation\Api\CentralEngineApiController;
14
15/**
16 * Record Creation Prefill Resolver.
17 *
18 * Resolves initial record payload during creation, duplication, and cascading polymorphic relationship prefills.
19 *
20 * @package App\Core\Engine\Presentation\Web\Support
21 */
22final readonly class RecordCreationPrefillResolver
23{
24    /**
25     * RecordCreationPrefillResolver constructor.
26     *
27     * @param CentralEngineApiController $engineApi Central Engine API Controller instance.
28     */
29    public function __construct(
30        private CentralEngineApiController $engineApi,
31    ) {
32    }
33
34    /**
35     * Resolves initial record payload when duplicating from an existing record.
36     *
37     * @param string            $moduleName      Module machine name.
38     * @param int|null          $duplicateFromId Source record ID.
39     * @param PermissionContext $context         Security context.
40     * @return array<string, mixed> Initialized record fields.
41     */
42    public function resolveDuplicateInitialRecord(
43        string            $moduleName,
44        ?int              $duplicateFromId,
45        PermissionContext $context,
46    ): array {
47        if ($duplicateFromId === null || $duplicateFromId <= 0) {
48            return [];
49        }
50
51        try {
52            $source = $this->engineApi->fetchRecord($moduleName, $duplicateFromId, $context);
53            unset(
54                $source['id'],
55                $source['created_at'],
56                $source['updated_at'],
57                $source['created_by']
58            );
59
60            if (isset($source['code']) && is_string($source['code'])) {
61                $source['code'] .= '_copy';
62            }
63            if (isset($source['name']) && is_string($source['name'])) {
64                $source['name'] = $this->formatDuplicatedName($source['name']);
65            }
66            if (isset($source['label']) && is_string($source['label'])) {
67                $source['label'] = $this->formatDuplicatedName($source['label']);
68            }
69            if (isset($source['is_system'])) {
70                $source['is_system'] = 0;
71            }
72            if (isset($source['is_protected'])) {
73                $source['is_protected'] = 0;
74            }
75            $source['owner'] = $context->actorUserId;
76
77            return $source;
78        } catch (\Throwable) {
79            return [];
80        }
81    }
82
83    /**
84     * Formats duplicated record name or label by appending copy suffix.
85     *
86     * @param string $name Original name or label.
87     * @return string Cloned name.
88     */
89    private function formatDuplicatedName(string $name): string
90    {
91        if (preg_match('/^[a-z0-9_]+$/', $name)) {
92            return $name . '_copy';
93        }
94        return $name . ' (Kopia)';
95    }
96
97    /**
98     * Resolves cascading polymorphic relation references and labels for calendar and work time.
99     *
100     * @param string               $moduleName    Target module name.
101     * @param string|null          $sourceModule  Source module name if provided.
102     * @param int|null             $sourceId      Source record ID if provided.
103     * @param array<string, mixed> $initialRecord Preloaded record data map (by ref).
104     * @param array<string, mixed> $queryParams   Request query parameters.
105     * @param PermissionContext    $context       Security context.
106     */
107    public function resolvePolymorphicPrefills(
108        string            $moduleName,
109        ?string           $sourceModule,
110        ?int              $sourceId,
111        array             &$initialRecord,
112        array             $queryParams,
113        PermissionContext $context,
114    ): void {
115        if ($moduleName !== 'work_time' && $moduleName !== 'calendar' && $moduleName !== 'documents') {
116            return;
117        }
118
119        $effMod = $sourceModule;
120        $effId = $sourceId ?? 0;
121        if ($effMod === null || $effId <= 0) {
122            foreach (['subprocess_ref', 'process_ref', 'related_party_ref'] as $refKey) {
123                $refVal = (string) ($initialRecord[$refKey] ?? $queryParams[$refKey] ?? '');
124                if ($refVal !== '' && str_contains($refVal, ':')) {
125                    [$rMod, $rIdStr] = explode(':', $refVal, 2);
126                    $effMod = $rMod;
127                    $effId = (int) $rIdStr;
128                    break;
129                }
130            }
131        }
132
133        if ($effMod === null || $effId <= 0) {
134            return;
135        }
136
137        try {
138            match ($effMod) {
139                'project_tasks'                     => $this->cascadeTaskPrefill($effId, $initialRecord, $context),
140                'tickets'                           => $this->cascadeTicketPrefill($effId, $initialRecord, $context),
141                'projects', 'contracts'             => $this->cascadeProjectOrContractPrefill(
142                    $effMod,
143                    $effId,
144                    $initialRecord,
145                    $context
146                ),
147                'companies', 'partners', 'contacts' => $this->cascadePartyPrefill(
148                    $effMod,
149                    $effId,
150                    $initialRecord,
151                    $context
152                ),
153                default                             => null,
154            };
155        } catch (\Throwable) {
156            // Gracefully ignore lookup issues
157        }
158    }
159
160    /**
161     * Cascades Task upwards: Task -> Project -> Company.
162     *
163     * @param int                  $taskId        Task record ID.
164     * @param array<string, mixed> $initialRecord Record payload to populate.
165     * @param PermissionContext    $context       Security context.
166     */
167    public function cascadeTaskPrefill(int $taskId, array &$initialRecord, PermissionContext $context): void
168    {
169        $task = $this->engineApi->fetchRecord('project_tasks', $taskId, $context);
170        $initialRecord['subprocess_ref'] = 'project_tasks:' . $taskId;
171        $initialRecord['subprocess_ref_label'] = (string) ($task['task_name'] ?? ('#' . $taskId));
172
173        $projectId = (int) ($task['project_id'] ?? 0);
174        if ($projectId > 0) {
175            $this->cascadeProjectOrContractPrefill('projects', $projectId, $initialRecord, $context);
176        }
177    }
178
179    /**
180     * Cascades Ticket upwards: Ticket -> Company / Contact.
181     *
182     * @param int                  $ticketId      Ticket record ID.
183     * @param array<string, mixed> $initialRecord Record payload to populate.
184     * @param PermissionContext    $context       Security context.
185     */
186    public function cascadeTicketPrefill(int $ticketId, array &$initialRecord, PermissionContext $context): void
187    {
188        $ticket = $this->engineApi->fetchRecord('tickets', $ticketId, $context);
189        $initialRecord['subprocess_ref'] = 'tickets:' . $ticketId;
190        $label = (string) ($ticket['subject'] ?? $ticket['ticket_subject'] ?? ('#' . $ticketId));
191        $initialRecord['subprocess_ref_label'] = $label;
192
193        $companyId = (int) ($ticket['company_id'] ?? 0);
194        $contactId = (int) ($ticket['contact_id'] ?? 0);
195        if ($companyId > 0) {
196            $this->cascadePartyPrefill('companies', $companyId, $initialRecord, $context);
197        } elseif ($contactId > 0) {
198            $this->cascadePartyPrefill('contacts', $contactId, $initialRecord, $context);
199        }
200    }
201
202    /**
203     * Cascades Project or Contract upwards to Company.
204     *
205     * @param string               $procMod       Process module (projects/contracts).
206     * @param int                  $procId        Process record ID.
207     * @param array<string, mixed> $initialRecord Record payload to populate.
208     * @param PermissionContext    $context       Security context.
209     */
210    public function cascadeProjectOrContractPrefill(
211        string            $procMod,
212        int               $procId,
213        array             &$initialRecord,
214        PermissionContext $context,
215    ): void {
216        $record = $this->engineApi->fetchRecord($procMod, $procId, $context);
217        $nameKey = $procMod === 'projects' ? 'project_name' : 'contract_name';
218        $initialRecord['process_ref'] = $procMod . ':' . $procId;
219        $initialRecord['process_ref_label'] = (string) ($record[$nameKey] ?? ('#' . $procId));
220
221        $companyId = (int) ($record['company_id'] ?? 0);
222        if ($companyId > 0) {
223            $this->cascadePartyPrefill('companies', $companyId, $initialRecord, $context);
224        }
225    }
226
227    /**
228     * Cascades Company / Partner / Contact reference and label.
229     *
230     * @param string               $partyMod      Party module.
231     * @param int                  $partyId       Party record ID.
232     * @param array<string, mixed> $initialRecord Record payload to populate.
233     * @param PermissionContext    $context       Security context.
234     */
235    public function cascadePartyPrefill(
236        string            $partyMod,
237        int               $partyId,
238        array             &$initialRecord,
239        PermissionContext $context,
240    ): void {
241        $party = $this->engineApi->fetchRecord($partyMod, $partyId, $context);
242        $initialRecord['related_party_ref'] = $partyMod . ':' . $partyId;
243
244        $label = match ($partyMod) {
245            'companies' => (string) ($party['name'] ?? $party['company_name'] ?? ''),
246            'partners'  => (string) ($party['name'] ?? $party['partner_name'] ?? ''),
247            'contacts'  => trim(($party['first_name'] ?? '') . ' ' . ($party['last_name'] ?? '')),
248            default     => (string) ($party['name'] ?? $party['subject'] ?? ''),
249        };
250        $initialRecord['related_party_ref_label'] = $label !== '' ? $label : ('#' . $partyId);
251    }
252
253    /**
254     * Populates initial source relation foreign keys if matching fields exist.
255     *
256     * @param string|null                                    $sourceModule  Source module.
257     * @param int|null                                       $sourceId      Source record ID.
258     * @param array<int, FieldMetadata>|array<string, mixed> $fields        Module fields metadata.
259     * @param array<string, mixed>                           $initialRecord Initial record payload.
260     * @param-out array<string, mixed>                       $initialRecord
261     */
262    public function resolveSourceRelationKey(
263        ?string $sourceModule,
264        ?int    $sourceId,
265        array   $fields,
266        array   &$initialRecord,
267    ): void {
268        if ($sourceModule === null || $sourceId === null || $sourceId <= 0) {
269            return;
270        }
271
272        $singular = match ($sourceModule) {
273            'companies' => 'company_id',
274            'contacts'  => 'contact_id',
275            'partners'  => 'partner_id',
276            'tickets'   => 'ticket_id',
277            default     => rtrim($sourceModule, 's') . '_id',
278        };
279
280        $availableKeys = [];
281        foreach ($fields as $k => $f) {
282            if ($f instanceof FieldMetadata) {
283                $availableKeys[$f->fieldKey] = true;
284            } elseif (is_string($k)) {
285                $availableKeys[$k] = true;
286            }
287        }
288
289        foreach ([$singular, $sourceModule . '_id'] as $cKey) {
290            if (isset($availableKeys[$cKey]) && !isset($initialRecord[$cKey])) {
291                $initialRecord[$cKey] = (string) $sourceId;
292                break;
293            }
294        }
295    }
296
297    /**
298     * Copies non-empty query parameters into initial record fields.
299     *
300     * @param array<int, FieldMetadata>|array<string, mixed> $fields        Module fields metadata.
301     * @param array<string, mixed>                           $queryParams   Query parameters from request.
302     * @param array<string, mixed>                           $initialRecord Initial record payload.
303     * @param-out array<string, mixed>                       $initialRecord
304     */
305    public function populateQueryParamsIntoInitialRecord(
306        array $fields,
307        array $queryParams,
308        array &$initialRecord,
309    ): void {
310        foreach ($fields as $field) {
311            $fKey = $field instanceof FieldMetadata ? $field->fieldKey : (string) ($field['field_key'] ?? '');
312            if ($fKey !== '' && isset($queryParams[$fKey]) && !isset($initialRecord[$fKey])
313                && (string) $queryParams[$fKey] !== '') {
314                $initialRecord[$fKey] = (string) $queryParams[$fKey];
315            }
316        }
317    }
318}