Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.29% covered (warning)
52.29%
171 / 327
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
EvaluationsService
52.29% covered (warning)
52.29%
171 / 327
28.57% covered (danger)
28.57%
4 / 14
570.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getIndexData
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getFormData
4.65% covered (danger)
4.65%
2 / 43
0.00% covered (danger)
0.00%
0 / 1
159.50
 save
89.90% covered (warning)
89.90%
89 / 99
0.00% covered (danger)
0.00%
0 / 1
22.50
 getDetailData
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
5
 buildIndexFilters
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
56
 getEvaluationRows
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 buildStats
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getChildRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getEvaluationType
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCriteriaForType
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
3
 getLatestEvaluationForChildAndType
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 coachOptions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 db
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services;
4
5use CodeIgniter\Shield\Entities\User;
6use DateTimeImmutable;
7
8class EvaluationsService
9{
10    private AuthorizationService $authorizationService;
11    private EvaluationEngineService $evaluationEngine;
12    private JourneyService $journeyService;
13
14    public function __construct()
15    {
16        helper('academy');
17
18        $this->authorizationService = service('authorization');
19        $this->evaluationEngine     = service('evaluationEngine');
20        $this->journeyService       = service('journey');
21    }
22
23    /**
24     * @param array<string, mixed> $input
25     * @return array<string, mixed>|null
26     */
27    public function getIndexData(array $input, User $user, string $typeSlug): ?array
28    {
29        $type = $this->getEvaluationType($typeSlug);
30        if ($type === null) {
31            return null;
32        }
33
34        $filters     = $this->buildIndexFilters($input, (int) $user->id, $typeSlug);
35        $evaluations = $this->getEvaluationRows((int) $type['id'], $filters);
36        $stats       = $this->buildStats($evaluations);
37
38        return [
39            'pageTitle' => $typeSlug === 'quick-check' ? 'Quick Check' : 'Evaluari',
40            'pageDescription' => $typeSlug === 'quick-check'
41                ? 'Monitorizare rapida a progresului, separata de evaluarile complete care pot sustine promovarea.'
42                : 'Evaluari complete cu scorare ponderata, status calculat automat si semnal de promovare.',
43            'scopeType' => $type,
44            'filters' => $filters,
45            'stats' => $stats,
46            'evaluations' => $evaluations,
47        ];
48    }
49
50    /**
51     * @param array<string, mixed> $input
52     * @param array<string, string> $errors
53     * @return array<string, mixed>|null
54     */
55    public function getFormData(int $childId, string $typeSlug, User $user, array $input = [], array $errors = []): ?array
56    {
57        if (! $this->authorizationService->userCanAccessChild((int) $user->id, $childId)) {
58            return null;
59        }
60
61        $child = $this->getChildRow($childId);
62        $type  = $this->getEvaluationType($typeSlug);
63        if ($child === null || $type === null) {
64            return null;
65        }
66
67        $criteria = $this->getCriteriaForType((int) $type['id'], $child['academy_level_id'] !== null ? (int) $child['academy_level_id'] : null);
68        $rules    = $this->evaluationEngine->getActiveRuleSet();
69        $coachId  = $this->authorizationService->getCoachIdForUser((int) $user->id);
70        $defaultEvaluatorTrainerId = $this->authorizationService->userHasRole((int) $user->id, 'coach')
71            ? $coachId
72            : ((int) ($child['primary_coach_id'] ?? 0) > 0 ? (int) $child['primary_coach_id'] : null);
73
74        $values = [
75            'evaluation_date' => date('Y-m-d'),
76            'evaluator_coach_id' => $defaultEvaluatorTrainerId !== null ? (string) $defaultEvaluatorTrainerId : '',
77            'notes' => '',
78            'scores' => [],
79        ];
80
81        foreach ($criteria as $criterion) {
82            $values['scores'][(int) $criterion['id']] = '';
83        }
84
85        if ($input !== []) {
86            $values['evaluation_date'] = trim((string) ($input['evaluation_date'] ?? $values['evaluation_date']));
87            $values['evaluator_coach_id'] = trim((string) ($input['evaluator_coach_id'] ?? $values['evaluator_coach_id']));
88            $values['notes'] = trim((string) ($input['notes'] ?? ''));
89
90            foreach ((array) ($input['scores'] ?? []) as $criterionId => $score) {
91                $criterionId = (int) $criterionId;
92                if (array_key_exists($criterionId, $values['scores'])) {
93                    $values['scores'][$criterionId] = trim((string) $score);
94                }
95            }
96        }
97
98        return [
99            'pageTitle' => $type['name'] . ' nou',
100            'pageDescription' => 'Evaluarea foloseste criteriile active asociate tipului selectat si calculeaza scorul final in backend.',
101            'child' => $child,
102            'evaluationType' => $type,
103            'criteria' => $criteria,
104            'rules' => $rules,
105            'values' => $values,
106            'errors' => $errors,
107            'canChooseEvaluator' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
108            'evaluatorOptions' => $this->coachOptions(),
109            'latestEvaluation' => $this->getLatestEvaluationForChildAndType($childId, $typeSlug),
110            'submitUrl' => url_to('evaluations.create', $childId, $typeSlug),
111            'cancelUrl' => url_to('children.show', $childId) . '?tab=' . ($typeSlug === 'quick-check' ? 'quick-checks' : 'evaluations'),
112        ];
113    }
114
115    /**
116     * @param array<string, mixed> $input
117     * @return array{success: bool, evaluation_id?: int, errors?: array<string, string>, forbidden?: bool}
118     */
119    public function save(int $childId, string $typeSlug, array $input, User $user): array
120    {
121        if (! $this->authorizationService->userCanAccessChild((int) $user->id, $childId)) {
122            return ['success' => false, 'forbidden' => true];
123        }
124
125        $child = $this->getChildRow($childId);
126        $type  = $this->getEvaluationType($typeSlug);
127        if ($child === null || $type === null) {
128            return ['success' => false, 'forbidden' => true];
129        }
130
131        $criteria = $this->getCriteriaForType((int) $type['id'], $child['academy_level_id'] !== null ? (int) $child['academy_level_id'] : null);
132        $payload  = [
133            'evaluation_date' => trim((string) ($input['evaluation_date'] ?? '')),
134            'evaluator_coach_id' => trim((string) ($input['evaluator_coach_id'] ?? '')),
135            'notes' => trim((string) ($input['notes'] ?? '')),
136            'scores' => (array) ($input['scores'] ?? []),
137        ];
138
139        $validation = service('validation');
140        $validation->setRules(config('Validation')->evaluation);
141
142        $errors = [];
143
144        if (! $validation->run($payload)) {
145            $errors = $validation->getErrors();
146        }
147
148        $criteriaRows = [];
149        foreach ($criteria as $criterion) {
150            $criterionId = (int) $criterion['id'];
151            $value       = trim((string) ($payload['scores'][$criterionId] ?? ''));
152
153            if ($value === '') {
154                $errors['score_' . $criterionId] = 'Scorul este obligatoriu.';
155                continue;
156            }
157
158            if (! is_numeric($value)) {
159                $errors['score_' . $criterionId] = 'Scorul trebuie sa fie numeric.';
160                continue;
161            }
162
163            $score = (float) $value;
164            $min   = (float) $criterion['min_score'];
165            $max   = (float) $criterion['max_score'];
166
167            if ($score < $min || $score > $max) {
168                $errors['score_' . $criterionId] = 'Scorul trebuie sa fie intre ' . format_score($min, 0) . ' si ' . format_score($max, 0) . '.';
169                continue;
170            }
171
172            $criteriaRows[] = [
173                'evaluation_criteria_id' => $criterionId,
174                'weight' => (float) $criterion['weight'],
175                'score' => $score,
176                'is_critical' => (bool) $criterion['is_critical'],
177                'notes' => null,
178            ];
179        }
180
181        if ($errors !== []) {
182            return ['success' => false, 'errors' => $errors];
183        }
184
185        $currentTrainerId = $this->authorizationService->getCoachIdForUser((int) $user->id);
186        $evaluatorTrainerId = $this->authorizationService->userHasRole((int) $user->id, 'coach')
187            ? $currentTrainerId
188            : ($payload['evaluator_coach_id'] !== '' ? (int) $payload['evaluator_coach_id'] : null);
189
190        $rules     = $this->evaluationEngine->getActiveRuleSet();
191        $result    = $this->evaluationEngine->evaluate($criteriaRows, $rules);
192        $signal    = (int) ($type['affects_promotion'] ? $result['promotion_signal'] : false);
193        $timestamp = date('Y-m-d H:i:s');
194
195        $db = $this->db();
196        $db->transStart();
197
198        $db->table('evaluations')->insert([
199            'child_id' => $childId,
200            'evaluation_type_id' => (int) $type['id'],
201            'evaluation_date' => $payload['evaluation_date'],
202            'evaluator_user_id' => (int) $user->id,
203            'evaluator_coach_id' => $evaluatorTrainerId,
204            'level_at_time_id' => $child['academy_level_id'] !== null ? (int) $child['academy_level_id'] : null,
205            'final_score' => $result['final_score'],
206            'final_status' => $result['final_status'],
207            'promotion_signal' => $signal,
208            'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
209            'created_at' => $timestamp,
210            'updated_at' => $timestamp,
211        ]);
212
213        $evaluationId = (int) $db->insertID();
214
215        foreach ($criteriaRows as $criteriaRow) {
216            $db->table('evaluation_scores')->insert(array_merge($criteriaRow, [
217                'evaluation_id' => $evaluationId,
218            ]));
219        }
220
221        $db->table('evaluation_status_history')->insert([
222            'evaluation_id' => $evaluationId,
223            'previous_status' => null,
224            'new_status' => $result['final_status'],
225            'reason' => 'Status initial calculat automat la creare.',
226            'changed_by' => (int) $user->id,
227            'created_at' => $timestamp,
228        ]);
229
230        $this->journeyService->recordEvent([
231            'child_id' => $childId,
232            'event_type' => $typeSlug === 'quick-check' ? 'quick_check_added' : 'evaluation_added',
233            'title' => $typeSlug === 'quick-check' ? 'Quick Check adaugat' : 'Full Evaluation adaugata',
234            'description' => $payload['notes'] !== '' ? $payload['notes'] : 'Evaluare noua adaugata din aplicatie.',
235            'metadata' => json_encode([
236                'evaluation_id' => $evaluationId,
237                'type' => $typeSlug,
238                'score' => $result['final_score'],
239                'status' => $result['final_status'],
240                'promotion_signal' => $signal,
241            ], JSON_UNESCAPED_UNICODE),
242            'created_by' => (int) $user->id,
243            'created_at' => $timestamp,
244        ]);
245
246        $db->transComplete();
247
248        if (! $db->transStatus()) {
249            return ['success' => false, 'errors' => ['general' => 'Nu am putut salva evaluarea.']];
250        }
251
252        return ['success' => true, 'evaluation_id' => $evaluationId];
253    }
254
255    /**
256     * @return array<string, mixed>|null
257     */
258    public function getDetailData(int $evaluationId, User $user): ?array
259    {
260        $evaluation = $this->db()
261            ->table('evaluations e')
262            ->select('e.*, children.full_name as child_name, children.id as child_id, children.status as child_status, children.primary_coach_id as child_primary_coach_id, types.name as type_name, types.slug as type_slug, levels.name as level_name, coaches.full_name as evaluator_coach_name, users.username as evaluator_username')
263            ->join('children', 'children.id = e.child_id')
264            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
265            ->join('academy_levels levels', 'levels.id = e.level_at_time_id', 'left')
266            ->join('coaches', 'coaches.id = e.evaluator_coach_id', 'left')
267            ->join('users', 'users.id = e.evaluator_user_id', 'left')
268            ->where('e.id', $evaluationId)
269            ->get()
270            ->getRowArray();
271
272        if ($evaluation === null || ! $this->authorizationService->userCanAccessChild((int) $user->id, (int) $evaluation['child_id'])) {
273            return null;
274        }
275
276        $criteriaScores = $this->db()
277            ->table('evaluation_scores scores')
278            ->select('scores.score, scores.weight, scores.is_critical, scores.notes, criteria.name, criteria.min_score, criteria.max_score')
279            ->join('evaluation_criteria criteria', 'criteria.id = scores.evaluation_criteria_id')
280            ->where('scores.evaluation_id', $evaluationId)
281            ->orderBy('criteria.sort_order', 'ASC')
282            ->get()
283            ->getResultArray();
284
285        $statusHistory = $this->db()
286            ->table('evaluation_status_history history')
287            ->select('history.*, users.username')
288            ->join('users', 'users.id = history.changed_by', 'left')
289            ->where('history.evaluation_id', $evaluationId)
290            ->orderBy('history.created_at', 'DESC')
291            ->get()
292            ->getResultArray();
293
294        return [
295            'pageTitle' => $evaluation['type_name'],
296            'pageDescription' => 'Detaliul complet al evaluarii, inclusiv criteriile punctate si istoricul statusului rezultat.',
297            'evaluation' => $evaluation,
298            'criteriaScores' => $criteriaScores,
299            'statusHistory' => $statusHistory,
300            'rules' => $this->evaluationEngine->getActiveRuleSet(),
301            'childProfileUrl' => url_to('children.show', $evaluation['child_id']) . '?tab=' . ($evaluation['type_slug'] === 'quick-check' ? 'quick-checks' : 'evaluations'),
302            'moduleUrl' => $evaluation['type_slug'] === 'quick-check' ? url_to('quickChecks.index') : url_to('evaluations.index'),
303        ];
304    }
305
306    /**
307     * @param array<string, mixed> $input
308     * @return array<string, mixed>
309     */
310    private function buildIndexFilters(array $input, int $userId, string $typeSlug): array
311    {
312        $primaryRole = $this->authorizationService->getPrimaryRole($userId);
313        $coachId     = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
314        $selectedPeriod = (string) ($input['period'] ?? '30');
315        $today       = new DateTimeImmutable('today');
316        $dateStart   = null;
317        $dateEnd     = null;
318
319        if ($selectedPeriod === '30' || $selectedPeriod === '90' || $selectedPeriod === '7') {
320            $days      = (int) $selectedPeriod;
321            $dateStart = $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d');
322            $dateEnd   = $today->format('Y-m-d');
323        } else {
324            $selectedPeriod = 'all';
325        }
326
327        $selectedTrainer = $coachId ?: (int) ($input['coach_id'] ?? 0);
328        $selectedStatus = (string) ($input['status'] ?? 'all');
329
330        return [
331            'type_slug' => $typeSlug,
332            'current_role' => $primaryRole,
333            'coach_locked' => $primaryRole === 'coach',
334            'selected_coach_id' => $selectedTrainer,
335            'selected_status' => in_array($selectedStatus, ['all', 'READY', 'ALMOST READY', 'HOLD'], true) ? $selectedStatus : 'all',
336            'query' => trim((string) ($input['q'] ?? '')),
337            'selected_period' => $selectedPeriod,
338            'date_start' => $dateStart,
339            'date_end' => $dateEnd,
340            'period_options' => [
341                '7' => 'Ultimele 7 zile',
342                '30' => 'Ultimele 30 zile',
343                '90' => 'Ultimele 90 zile',
344                'all' => 'Toata perioada',
345            ],
346            'status_options' => [
347                'all' => 'Toate statusurile',
348                'READY' => 'Ready',
349                'ALMOST READY' => 'Almost Ready',
350                'HOLD' => 'Hold',
351            ],
352            'coach_options' => $this->coachOptions(),
353        ];
354    }
355
356    /**
357     * @param array<string, mixed> $filters
358     * @return list<array<string, mixed>>
359     */
360    private function getEvaluationRows(int $typeId, array $filters): array
361    {
362        $builder = $this->db()
363            ->table('evaluations e')
364            ->select('e.id, e.evaluation_date, e.final_score, e.final_status, e.promotion_signal, e.notes, children.id as child_id, children.full_name as child_name, children.status as child_status, coaches.full_name as evaluator_coach_name')
365            ->join('children', 'children.id = e.child_id')
366            ->join('coaches', 'coaches.id = e.evaluator_coach_id', 'left')
367            ->where('e.evaluation_type_id', $typeId)
368            ->orderBy('e.evaluation_date', 'DESC')
369            ->orderBy('e.id', 'DESC');
370
371        if ($filters['selected_coach_id'] > 0) {
372            $builder->where('children.primary_coach_id', $filters['selected_coach_id']);
373        }
374
375        if ($filters['selected_status'] !== 'all') {
376            $builder->where('e.final_status', $filters['selected_status']);
377        }
378
379        if ($filters['query'] !== '') {
380            $builder->groupStart()
381                ->like('children.full_name', $filters['query'])
382                ->orLike('coaches.full_name', $filters['query'])
383                ->groupEnd();
384        }
385
386        if ($filters['date_start'] !== null && $filters['date_end'] !== null) {
387            $builder->where('e.evaluation_date >=', $filters['date_start'])
388                ->where('e.evaluation_date <=', $filters['date_end']);
389        }
390
391        return $builder->get()->getResultArray();
392    }
393
394    /**
395     * @param list<array<string, mixed>> $evaluations
396     * @return list<array<string, mixed>>
397     */
398    private function buildStats(array $evaluations): array
399    {
400        $ready = count(array_filter($evaluations, static fn (array $row): bool => $row['final_status'] === 'READY'));
401        $almost = count(array_filter($evaluations, static fn (array $row): bool => $row['final_status'] === 'ALMOST READY'));
402        $avgScore = $evaluations === []
403            ? null
404            : round(array_sum(array_map(static fn (array $row): float => (float) $row['final_score'], $evaluations)) / count($evaluations), 2);
405
406        return [
407            ['label' => 'Total evaluari', 'value' => count($evaluations), 'meta' => 'Rezultate in filtrul curent', 'icon' => 'eval'],
408            ['label' => 'Ready', 'value' => $ready, 'meta' => 'Status READY calculat automat', 'icon' => 'rdy'],
409            ['label' => 'Almost Ready', 'value' => $almost, 'meta' => 'Sub pragul final, peste pragul de alerta', 'icon' => 'alm'],
410            ['label' => 'Scor mediu', 'value' => format_score($avgScore), 'meta' => 'Media scorurilor finale in selectie', 'icon' => 'avg'],
411        ];
412    }
413
414    /**
415     * @return array<string, mixed>|null
416     */
417    private function getChildRow(int $childId): ?array
418    {
419        return $this->db()
420            ->table('children c')
421            ->select('c.id, c.full_name, c.status, c.academy_level_id, c.primary_coach_id, levels.name as level_name, groups.name as group_name, coaches.full_name as coach_name')
422            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
423            ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
424            ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
425            ->where('c.id', $childId)
426            ->get()
427            ->getRowArray();
428    }
429
430    /**
431     * @return array<string, mixed>|null
432     */
433    private function getEvaluationType(string $typeSlug): ?array
434    {
435        return $this->db()
436            ->table('evaluation_types')
437            ->where('slug', $typeSlug)
438            ->where('status', 'active')
439            ->get()
440            ->getRowArray();
441    }
442
443    /**
444     * @return list<array<string, mixed>>
445     */
446    private function getCriteriaForType(int $typeId, ?int $levelId = null): array
447    {
448        if ($levelId !== null) {
449            $levelCriteria = $this->db()
450                ->table('academy_level_criteria level_criteria')
451                ->select('criteria.id, criteria.name, criteria.description, criteria.min_score, criteria.max_score, criteria.is_critical, level_criteria.weight, level_criteria.is_required')
452                ->join('evaluation_criteria criteria', 'criteria.id = level_criteria.evaluation_criteria_id')
453                ->where('level_criteria.academy_level_id', $levelId)
454                ->where('level_criteria.evaluation_type_id', $typeId)
455                ->where('criteria.is_active', 1)
456                ->orderBy('level_criteria.sort_order', 'ASC')
457                ->orderBy('criteria.id', 'ASC')
458                ->get()
459                ->getResultArray();
460
461            if ($levelCriteria !== []) {
462                return $levelCriteria;
463            }
464        }
465
466        return $this->db()
467            ->table('evaluation_type_criteria pivot')
468            ->select('criteria.id, criteria.name, criteria.description, criteria.min_score, criteria.max_score, criteria.is_critical, pivot.weight, pivot.is_required')
469            ->join('evaluation_criteria criteria', 'criteria.id = pivot.evaluation_criteria_id')
470            ->where('pivot.evaluation_type_id', $typeId)
471            ->where('criteria.is_active', 1)
472            ->orderBy('pivot.sort_order', 'ASC')
473            ->get()
474            ->getResultArray();
475    }
476
477    /**
478     * @return array<string, mixed>|null
479     */
480    private function getLatestEvaluationForChildAndType(int $childId, string $typeSlug): ?array
481    {
482        return $this->db()
483            ->table('evaluations e')
484            ->select('e.evaluation_date, e.final_score, e.final_status, e.notes, coaches.full_name as evaluator_name')
485            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
486            ->join('coaches', 'coaches.id = e.evaluator_coach_id', 'left')
487            ->where('e.child_id', $childId)
488            ->where('types.slug', $typeSlug)
489            ->orderBy('e.evaluation_date', 'DESC')
490            ->orderBy('e.id', 'DESC')
491            ->get()
492            ->getRowArray();
493    }
494
495    /**
496     * @return list<array<string, mixed>>
497     */
498    private function coachOptions(): array
499    {
500        return $this->db()
501            ->table('coaches')
502            ->select('id, full_name')
503            ->where('is_active', 1)
504            ->orderBy('full_name', 'ASC')
505            ->get()
506            ->getResultArray();
507    }
508
509    private function db()
510    {
511        return db_connect();
512    }
513}