| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 0.00% covered (danger) | 0.00% | 0 / 429 | 0.00% covered (danger) | 0.00% | 0 / 19 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
AcademyCatalogService | 0.00% covered (danger) | 0.00% | 0 / 429 | 0.00% covered (danger) | 0.00% | 0 / 19 | 14042 | 0.00% covered (danger) | 0.00% | 0 / 1
__construct | 0.00% covered (danger) | 0.00% | 0 / 2 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
getGroupsIndexData | 0.00% covered (danger) | 0.00% | 0 / 30 | 0.00% covered (danger) | 0.00% | 0 / 1 | 30 |
getLevelsIndexData | 0.00% covered (danger) | 0.00% | 0 / 23 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
getGroupFormData | 0.00% covered (danger) | 0.00% | 0 / 36 | 0.00% covered (danger) | 0.00% | 0 / 1 | 110 |
getLevelFormData | 0.00% covered (danger) | 0.00% | 0 / 40 | 0.00% covered (danger) | 0.00% | 0 / 1 | 110 |
saveGroup | 0.00% covered (danger) | 0.00% | 0 / 33 | 0.00% covered (danger) | 0.00% | 0 / 1 | 72 |
saveLevel | 0.00% covered (danger) | 0.00% | 0 / 46 | 0.00% covered (danger) | 0.00% | 0 / 1 | 156 |
groupChildCounts | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
levelStats | 0.00% covered (danger) | 0.00% | 0 / 9 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
levelOptions | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
coachOptions | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
evaluationTypeOptions | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
criteriaCatalogRows | 0.00% covered (danger) | 0.00% | 0 / 37 | 0.00% covered (danger) | 0.00% | 0 / 1 | 42 |
normalizeCriteriaInput | 0.00% covered (danger) | 0.00% | 0 / 24 | 0.00% covered (danger) | 0.00% | 0 / 1 | 156 |
emptyCriteriaRow | 0.00% covered (danger) | 0.00% | 0 / 14 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
saveCriteriaCatalog | 0.00% covered (danger) | 0.00% | 0 / 87 | 0.00% covered (danger) | 0.00% | 0 / 1 | 1122 |
ensureLevelCriteriaInitialized | 0.00% covered (danger) | 0.00% | 0 / 26 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
slugify | 0.00% covered (danger) | 0.00% | 0 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
db | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |

1 | <?php
2 |
3 | namespace App\Services;
4 |
5 | use App\Models\AcademyGroupModel;
6 | use App\Models\AcademyLevelModel;
7 | use CodeIgniter\Shield\Entities\User;
8 |
9 | class AcademyCatalogService
10 | {
11 | private AuthorizationService $authorizationService;
12 |
13 | public function __construct()
14 | {
15 | helper('academy');
16 |
17 | $this->authorizationService = service('authorization');
18 | }
19 |
20 | /**
21 | * @param array<string, mixed> $input
22 | * @return array<string, mixed>
23 | */
24 | public function getGroupsIndexData(array $input, User $user): array
25 | {
26 | $role = $this->authorizationService->getPrimaryRole((int) $user->id);
27 | $lockedTrainerId = $role === 'coach' ? $this->authorizationService->getCoachIdForUser((int) $user->id) : null;
28 | $query = trim((string) ($input['q'] ?? ''));
29 |
30 | $builder = $this->db()
31 | ->table('academy_groups g')
32 | ->select('g.*, levels.name as level_name, levels.color_hex, coaches.full_name as coach_name')
33 | ->join('academy_levels levels', 'levels.id = g.academy_level_id', 'left')
34 | ->join('coaches', 'coaches.id = g.primary_coach_id', 'left')
35 | ->orderBy('g.name', 'ASC');
36 |
37 | if ($lockedTrainerId !== null) {
38 | $builder->where('g.primary_coach_id', $lockedTrainerId);
39 | }
40 |
41 | if ($query !== '') {
42 | $builder
43 | ->groupStart()
44 | ->like('g.name', $query)
45 | ->orLike('g.code', $query)
46 | ->orLike('coaches.full_name', $query)
47 | ->groupEnd();
48 | }
49 |
50 | $groups = $builder->get()->getResultArray();
51 | $counts = $this->groupChildCounts(array_map(static fn (array $row): int => (int) $row['id'], $groups));
52 |
53 | foreach ($groups as &$group) {
54 | $group['active_children'] = $counts[(int) $group['id']] ?? 0;
55 | }
56 | unset($group);
57 |
58 | return [
59 | 'pageTitle' => 'Grupe',
60 | 'pageDescription' => 'Structura operationala a grupelor, trainerii principali si incarcare curenta.',
61 | 'groups' => $groups,
62 | 'query' => $query,
63 | 'canManage' => $role === 'admin',
64 | ];
65 | }
66 |
67 | /**
68 | * @param array<string, mixed> $input
69 | * @return array<string, mixed>
70 | */
71 | public function getLevelsIndexData(array $input, User $user): array
72 | {
73 | $query = trim((string) ($input['q'] ?? ''));
74 |
75 | $builder = $this->db()
76 | ->table('academy_levels')
77 | ->orderBy('sort_order', 'ASC');
78 |
79 | if ($query !== '') {
80 | $builder
81 | ->groupStart()
82 | ->like('name', $query)
83 | ->orLike('slug', $query)
84 | ->groupEnd();
85 | }
86 |
87 | $levels = $builder->get()->getResultArray();
88 | $counts = $this->levelStats(array_map(static fn (array $row): int => (int) $row['id'], $levels));
89 |
90 | foreach ($levels as &$level) {
91 | $level['children_count'] = $counts['children'][(int) $level['id']] ?? 0;
92 | $level['groups_count'] = $counts['groups'][(int) $level['id']] ?? 0;
93 | }
94 | unset($level);
95 |
96 | return [
97 | 'pageTitle' => 'Nivele academie',
98 | 'pageDescription' => 'Benchmarks, descrieri si distributia curenta a copiilor pe nivele.',
99 | 'levels' => $levels,
100 | 'query' => $query,
101 | 'canManage' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
102 | ];
103 | }
104 |
105 | /**
106 | * @param array<string, mixed> $input
107 | * @param array<string, string> $errors
108 | * @return array<string, mixed>|null
109 | */
110 | public function getGroupFormData(?int $groupId = null, array $input = [], array $errors = []): ?array
111 | {
112 | $group = $groupId !== null ? model(AcademyGroupModel::class)->find($groupId) : null;
113 | if ($groupId !== null && $group === null) {
114 | return null;
115 | }
116 |
117 | $values = [
118 | 'name' => '',
119 | 'code' => '',
120 | 'academy_level_id' => '',
121 | 'primary_coach_id' => '',
122 | 'schedule_summary' => '',
123 | 'status' => 'active',
124 | 'capacity' => '12',
125 | ];
126 |
127 | if ($group !== null) {
128 | $values = [
129 | 'name' => $group['name'],
130 | 'code' => $group['code'],
131 | 'academy_level_id' => (string) ($group['academy_level_id'] ?? ''),
132 | 'primary_coach_id' => (string) ($group['primary_coach_id'] ?? ''),
133 | 'schedule_summary' => $group['schedule_summary'],
134 | 'status' => $group['status'],
135 | 'capacity' => (string) $group['capacity'],
136 | ];
137 | }
138 |
139 | foreach ($input as $key => $value) {
140 | if (array_key_exists($key, $values) && is_string($value)) {
141 | $values[$key] = trim($value);
142 | }
143 | }
144 |
145 | return [
146 | 'pageTitle' => $groupId === null ? 'Grupa noua' : 'Editare grupa',
147 | 'pageDescription' => 'Configureaza grupa, nivelul tinta si trainerul principal.',
148 | 'group' => $group,
149 | 'values' => $values,
150 | 'errors' => $errors,
151 | 'levelOptions' => $this->levelOptions(),
152 | 'coachOptions' => $this->coachOptions(),
153 | 'submitUrl' => $groupId === null ? url_to('groups.create') : url_to('groups.update', $groupId),
154 | 'cancelUrl' => url_to('groups.index'),
155 | ];
156 | }
157 |
158 | /**
159 | * @param array<string, mixed> $input
160 | * @param array<string, string> $errors
161 | * @return array<string, mixed>|null
162 | */
163 | public function getLevelFormData(?int $levelId = null, array $input = [], array $errors = []): ?array
164 | {
165 | $level = $levelId !== null ? model(AcademyLevelModel::class)->find($levelId) : null;
166 | if ($levelId !== null && $level === null) {
167 | return null;
168 | }
169 |
170 | $values = [
171 | 'name' => '',
172 | 'slug' => '',
173 | 'sort_order' => '1',
174 | 'color_hex' => '#4f46e5',
175 | 'internal_description' => '',
176 | 'parent_description' => '',
177 | 'benchmark_minimum' => '3.0',
178 | 'benchmark_target' => '4.0',
179 | 'promotion_rules' => '',
180 | ];
181 |
182 | if ($level !== null) {
183 | $values = [
184 | 'name' => $level['name'],
185 | 'slug' => $level['slug'],
186 | 'sort_order' => (string) $level['sort_order'],
187 | 'color_hex' => $level['color_hex'],
188 | 'internal_description' => $level['internal_description'],
189 | 'parent_description' => $level['parent_description'],
190 | 'benchmark_minimum' => (string) $level['benchmark_minimum'],
191 | 'benchmark_target' => (string) $level['benchmark_target'],
192 | 'promotion_rules' => $level['promotion_rules'],
193 | ];
194 | }
195 |
196 | foreach ($input as $key => $value) {
197 | if (array_key_exists($key, $values) && is_string($value)) {
198 | $values[$key] = trim($value);
199 | }
200 | }
201 |
202 | return [
203 | 'pageTitle' => $levelId === null ? 'Nivel nou' : 'Editare nivel',
204 | 'pageDescription' => 'Ajusteaza benchmark-urile, textele si criteriile folosite in evaluari.',
205 | 'level' => $level,
206 | 'values' => $values,
207 | 'errors' => $errors,
208 | 'evaluationTypes' => $this->evaluationTypeOptions(),
209 | 'criteriaRows' => $this->criteriaCatalogRows($levelId, $input),
210 | 'submitUrl' => $levelId === null ? url_to('levels.create') : url_to('levels.update', $levelId),
211 | 'cancelUrl' => url_to('levels.index'),
212 | ];
213 | }
214 |
215 | /**
216 | * @param array<string, mixed> $input
217 | * @return array{success: bool, group_id?: int, errors?: array<string, string>}
218 | */
219 | public function saveGroup(array $input, ?int $groupId = null): array
220 | {
221 | $validation = service('validation');
222 | $validation->setRules(config('Validation')->academyGroup);
223 |
224 | $payload = [
225 | 'name' => trim((string) ($input['name'] ?? '')),
226 | 'code' => trim((string) ($input['code'] ?? '')),
227 | 'academy_level_id' => trim((string) ($input['academy_level_id'] ?? '')),
228 | 'primary_coach_id' => trim((string) ($input['primary_coach_id'] ?? '')),
229 | 'schedule_summary' => trim((string) ($input['schedule_summary'] ?? '')),
230 | 'status' => trim((string) ($input['status'] ?? 'active')),
231 | 'capacity' => trim((string) ($input['capacity'] ?? '12')),
232 | ];
233 |
234 | if (! $validation->run($payload)) {
235 | return ['success' => false, 'errors' => $validation->getErrors()];
236 | }
237 |
238 | $duplicate = $this->db()->table('academy_groups')->select('id')->where('LOWER(code)', strtolower($payload['code']))->get()->getRowArray();
239 | if ($duplicate !== null && (int) $duplicate['id'] !== (int) $groupId) {
240 | return ['success' => false, 'errors' => ['code' => 'Codul grupei este deja folosit.']];
241 | }
242 |
243 | $data = [
244 | 'name' => $payload['name'],
245 | 'code' => $payload['code'],
246 | 'academy_level_id' => $payload['academy_level_id'] !== '' ? (int) $payload['academy_level_id'] : null,
247 | 'primary_coach_id' => $payload['primary_coach_id'] !== '' ? (int) $payload['primary_coach_id'] : null,
248 | 'schedule_summary' => $payload['schedule_summary'] !== '' ? $payload['schedule_summary'] : null,
249 | 'status' => $payload['status'],
250 | 'capacity' => (int) $payload['capacity'],
251 | 'updated_at' => date('Y-m-d H:i:s'),
252 | ];
253 |
254 | $model = model(AcademyGroupModel::class);
255 |
256 | if ($groupId === null) {
257 | $data['created_at'] = $data['updated_at'];
258 | $model->insert($data);
259 | $groupId = (int) $model->getInsertID();
260 | } else {
261 | $model->update($groupId, $data);
262 | }
263 |
264 | return ['success' => true, 'group_id' => $groupId];
265 | }
266 |
267 | /**
268 | * @param array<string, mixed> $input
269 | * @return array{success: bool, level_id?: int, errors?: array<string, string>}
270 | */
271 | public function saveLevel(array $input, ?int $levelId = null): array
272 | {
273 | $validation = service('validation');
274 | $validation->setRules(config('Validation')->academyLevel);
275 |
276 | $payload = [
277 | 'name' => trim((string) ($input['name'] ?? '')),
278 | 'slug' => trim((string) ($input['slug'] ?? '')),
279 | 'sort_order' => trim((string) ($input['sort_order'] ?? '1')),
280 | 'color_hex' => trim((string) ($input['color_hex'] ?? '#4f46e5')),
281 | 'internal_description' => trim((string) ($input['internal_description'] ?? '')),
282 | 'parent_description' => trim((string) ($input['parent_description'] ?? '')),
283 | 'benchmark_minimum' => trim((string) ($input['benchmark_minimum'] ?? '3.0')),
284 | 'benchmark_target' => trim((string) ($input['benchmark_target'] ?? '4.0')),
285 | 'promotion_rules' => trim((string) ($input['promotion_rules'] ?? '')),
286 | ];
287 |
288 | if (! $validation->run($payload)) {
289 | return ['success' => false, 'errors' => $validation->getErrors()];
290 | }
291 |
292 | $duplicate = $this->db()->table('academy_levels')->select('id')->where('LOWER(slug)', strtolower($payload['slug']))->get()->getRowArray();
293 | if ($duplicate !== null && (int) $duplicate['id'] !== (int) $levelId) {
294 | return ['success' => false, 'errors' => ['slug' => 'Slug-ul nivelului este deja folosit.']];
295 | }
296 |
297 | $data = [
298 | 'name' => $payload['name'],
299 | 'slug' => $payload['slug'],
300 | 'sort_order' => (int) $payload['sort_order'],
301 | 'color_hex' => $payload['color_hex'] !== '' ? $payload['color_hex'] : null,
302 | 'internal_description' => $payload['internal_description'] !== '' ? $payload['internal_description'] : null,
303 | 'parent_description' => $payload['parent_description'] !== '' ? $payload['parent_description'] : null,
304 | 'benchmark_minimum' => $payload['benchmark_minimum'] !== '' ? $payload['benchmark_minimum'] : null,
305 | 'benchmark_target' => $payload['benchmark_target'] !== '' ? $payload['benchmark_target'] : null,
306 | 'promotion_rules' => $payload['promotion_rules'] !== '' ? $payload['promotion_rules'] : null,
307 | 'updated_at' => date('Y-m-d H:i:s'),
308 | ];
309 |
310 | $model = model(AcademyLevelModel::class);
311 |
312 | $this->db()->transBegin();
313 |
314 | if ($levelId === null) {
315 | $data['created_at'] = $data['updated_at'];
316 | $model->insert($data);
317 | $levelId = (int) $model->getInsertID();
318 | } else {
319 | $model->update($levelId, $data);
320 | }
321 |
322 | $criteriaResult = $this->saveCriteriaCatalog($levelId, (array) ($input['criteria'] ?? []));
323 | if (! $criteriaResult['success']) {
324 | $this->db()->transRollback();
325 |
326 | return [
327 | 'success' => false,
328 | 'errors' => $criteriaResult['errors'],
329 | ];
330 | }
331 |
332 | $this->db()->transCommit();
333 |
334 | return ['success' => true, 'level_id' => $levelId];
335 | }
336 |
337 | /**
338 | * @param list<int> $groupIds
339 | * @return array<int, int>
340 | */
341 | private function groupChildCounts(array $groupIds): array
342 | {
343 | $counts = [];
344 | if ($groupIds === []) {
345 | return $counts;
346 | }
347 |
348 | foreach ($this->db()->table('children')->select('academy_group_id, COUNT(*) as total')->where('status', 'active')->whereIn('academy_group_id', $groupIds)->groupBy('academy_group_id')->get()->getResultArray() as $row) {
349 | $counts[(int) $row['academy_group_id']] = (int) $row['total'];
350 | }
351 |
352 | return $counts;
353 | }
354 |
355 | /**
356 | * @param list<int> $levelIds
357 | * @return array<string, array<int, int>>
358 | */
359 | private function levelStats(array $levelIds): array
360 | {
361 | $children = [];
362 | $groups = [];
363 |
364 | if ($levelIds === []) {
365 | return compact('children', 'groups');
366 | }
367 |
368 | foreach ($this->db()->table('children')->select('academy_level_id, COUNT(*) as total')->where('status !=', 'left')->whereIn('academy_level_id', $levelIds)->groupBy('academy_level_id')->get()->getResultArray() as $row) {
369 | $children[(int) $row['academy_level_id']] = (int) $row['total'];
370 | }
371 |
372 | foreach ($this->db()->table('academy_groups')->select('academy_level_id, COUNT(*) as total')->whereIn('academy_level_id', $levelIds)->groupBy('academy_level_id')->get()->getResultArray() as $row) {
373 | $groups[(int) $row['academy_level_id']] = (int) $row['total'];
374 | }
375 |
376 | return compact('children', 'groups');
377 | }
378 |
379 | /**
380 | * @return list<array<string, mixed>>
381 | */
382 | private function levelOptions(): array
383 | {
384 | return $this->db()->table('academy_levels')->select('id, name')->orderBy('sort_order', 'ASC')->get()->getResultArray();
385 | }
386 |
387 | /**
388 | * @return list<array<string, mixed>>
389 | */
390 | private function coachOptions(): array
391 | {
392 | return $this->db()->table('coaches')->select('id, full_name')->where('is_active', 1)->orderBy('full_name', 'ASC')->get()->getResultArray();
393 | }
394 |
395 | /**
396 | * @return list<array<string, mixed>>
397 | */
398 | private function evaluationTypeOptions(): array
399 | {
400 | return $this->db()
401 | ->table('evaluation_types')
402 | ->select('id, name, slug')
403 | ->where('status', 'active')
404 | ->orderBy('is_quick_check', 'DESC')
405 | ->orderBy('id', 'ASC')
406 | ->get()
407 | ->getResultArray();
408 | }
409 |
410 | /**
411 | * @param array<string, mixed> $input
412 | * @return list<array<string, mixed>>
413 | */
414 | private function criteriaCatalogRows(?int $levelId, array $input = []): array
415 | {
416 | if (isset($input['criteria']) && is_array($input['criteria'])) {
417 | return $this->normalizeCriteriaInput($input['criteria'], true);
418 | }
419 |
420 | if ($levelId === null) {
421 | return [$this->emptyCriteriaRow()];
422 | }
423 |
424 | $this->ensureLevelCriteriaInitialized($levelId);
425 |
426 | $criteria = $this->db()
427 | ->table('academy_level_criteria level_criteria')
428 | ->select('criteria.*, level_criteria.weight as level_weight, level_criteria.sort_order as level_sort_order, types.slug as type_slug')
429 | ->join('evaluation_criteria criteria', 'criteria.id = level_criteria.evaluation_criteria_id')
430 | ->join('evaluation_types types', 'types.id = level_criteria.evaluation_type_id')
431 | ->where('level_criteria.academy_level_id', $levelId)
432 | ->orderBy('level_criteria.sort_order', 'ASC')
433 | ->orderBy('criteria.id', 'ASC')
434 | ->get()
435 | ->getResultArray();
436 |
437 | $rowsByCriterion = [];
438 | foreach ($criteria as $row) {
439 | $criterionId = (int) $row['id'];
440 | if (! isset($rowsByCriterion[$criterionId])) {
441 | $rowsByCriterion[$criterionId] = [
442 | 'id' => (string) $criterionId,
443 | 'name' => (string) $row['name'],
444 | 'slug' => (string) $row['slug'],
445 | 'description' => (string) ($row['description'] ?? ''),
446 | 'default_weight' => (string) $row['level_weight'],
447 | 'sort_order' => (string) $row['level_sort_order'],
448 | 'min_score' => (string) $row['min_score'],
449 | 'max_score' => (string) $row['max_score'],
450 | 'is_active' => (string) ((int) $row['is_active']),
451 | 'is_critical' => (string) ((int) $row['is_critical']),
452 | 'delete' => '0',
453 | 'types' => [],
454 | ];
455 | }
456 |
457 | $rowsByCriterion[$criterionId]['types'][] = (string) $row['type_slug'];
458 | }
459 |
460 | $rows = array_values($rowsByCriterion);
461 | $rows[] = $this->emptyCriteriaRow();
462 |
463 | return $rows;
464 | }
465 |
466 | /**
467 | * @param array<mixed> $input
468 | * @return list<array<string, mixed>>
469 | */
470 | private function normalizeCriteriaInput(array $input, bool $includeBlank = false): array
471 | {
472 | $rows = [];
473 |
474 | foreach ($input as $row) {
475 | if (! is_array($row)) {
476 | continue;
477 | }
478 |
479 | $normalized = [
480 | 'id' => trim((string) ($row['id'] ?? '')),
481 | 'name' => trim((string) ($row['name'] ?? '')),
482 | 'slug' => trim((string) ($row['slug'] ?? '')),
483 | 'description' => trim((string) ($row['description'] ?? '')),
484 | 'default_weight' => trim((string) ($row['default_weight'] ?? '1.0')),
485 | 'sort_order' => trim((string) ($row['sort_order'] ?? '1')),
486 | 'min_score' => trim((string) ($row['min_score'] ?? '1')),
487 | 'max_score' => trim((string) ($row['max_score'] ?? '5')),
488 | 'is_active' => isset($row['is_active']) ? '1' : '0',
489 | 'is_critical' => isset($row['is_critical']) ? '1' : '0',
490 | 'delete' => isset($row['delete']) ? '1' : '0',
491 | 'types' => array_values(array_filter((array) ($row['types'] ?? []), static fn ($type): bool => is_string($type) && $type !== '')),
492 | ];
493 |
494 | if ($normalized['id'] === '' && $normalized['name'] === '' && $normalized['slug'] === '' && ! $includeBlank) {
495 | continue;
496 | }
497 |
498 | $rows[] = $normalized;
499 | }
500 |
501 | if ($includeBlank) {
502 | $rows[] = $this->emptyCriteriaRow();
503 | }
504 |
505 | return $rows;
506 | }
507 |
508 | /**
509 | * @return array<string, mixed>
510 | */
511 | private function emptyCriteriaRow(): array
512 | {
513 | return [
514 | 'id' => '',
515 | 'name' => '',
516 | 'slug' => '',
517 | 'description' => '',
518 | 'default_weight' => '1.0',
519 | 'sort_order' => '10',
520 | 'min_score' => '1',
521 | 'max_score' => '5',
522 | 'is_active' => '1',
523 | 'is_critical' => '0',
524 | 'delete' => '0',
525 | 'types' => ['quick-check', 'full-evaluation'],
526 | ];
527 | }
528 |
529 | /**
530 | * @param array<mixed> $input
531 | * @return array{success: bool, errors?: array<string, string>}
532 | */
533 | private function saveCriteriaCatalog(int $levelId, array $input): array
534 | {
535 | if ($input === []) {
536 | return ['success' => true];
537 | }
538 |
539 | $rows = $this->normalizeCriteriaInput($input);
540 | $errors = [];
541 | $now = date('Y-m-d H:i:s');
542 | $typeRows = $this->evaluationTypeOptions();
543 | $typeIdsBySlug = [];
544 | foreach ($typeRows as $type) {
545 | $typeIdsBySlug[(string) $type['slug']] = (int) $type['id'];
546 | }
547 |
548 | foreach ($rows as $index => $row) {
549 | $fieldPrefix = 'criteria_' . $index . '_';
550 | $criterionId = $row['id'] !== '' ? (int) $row['id'] : null;
551 |
552 | if ($row['delete'] === '1' && $criterionId !== null) {
553 | $this->db()
554 | ->table('academy_level_criteria')
555 | ->where('academy_level_id', $levelId)
556 | ->where('evaluation_criteria_id', $criterionId)
557 | ->delete();
558 | continue;
559 | }
560 |
561 | if ($criterionId === null && $row['name'] === '' && $row['slug'] === '') {
562 | continue;
563 | }
564 |
565 | if ($row['name'] === '') {
566 | $errors[$fieldPrefix . 'name'] = 'Numele intrebarii este obligatoriu.';
567 | }
568 |
569 | $slug = $row['slug'] !== '' ? strtolower($row['slug']) : $this->slugify($row['name']);
570 | if ($slug === '') {
571 | $errors[$fieldPrefix . 'slug'] = 'Slug-ul este obligatoriu.';
572 | }
573 |
574 | if (! preg_match('/^[a-z0-9-]+$/', $slug)) {
575 | $errors[$fieldPrefix . 'slug'] = 'Slug-ul poate contine doar litere mici, cifre si cratime.';
576 | }
577 |
578 | $sortOrder = filter_var($row['sort_order'], FILTER_VALIDATE_INT);
579 | if ($sortOrder === false || $sortOrder < 1) {
580 | $errors[$fieldPrefix . 'sort_order'] = 'Ordinea trebuie sa fie un numar pozitiv.';
581 | }
582 |
583 | $weight = filter_var($row['default_weight'], FILTER_VALIDATE_FLOAT);
584 | if ($weight === false || $weight <= 0) {
585 | $errors[$fieldPrefix . 'default_weight'] = 'Ponderea trebuie sa fie mai mare decat 0.';
586 | }
587 |
588 | $minScore = filter_var($row['min_score'], FILTER_VALIDATE_FLOAT);
589 | $maxScore = filter_var($row['max_score'], FILTER_VALIDATE_FLOAT);
590 | if ($minScore === false || $maxScore === false || $minScore < 1 || $maxScore > 5 || $minScore >= $maxScore) {
591 | $errors[$fieldPrefix . 'score_range'] = 'Scara trebuie sa fie intre 1 si 5, cu minimul mai mic decat maximul.';
592 | }
593 |
594 | if ($row['types'] === []) {
595 | $errors[$fieldPrefix . 'types'] = 'Alege cel putin un tip de evaluare.';
596 | }
597 |
598 | foreach ($row['types'] as $typeSlug) {
599 | if (! isset($typeIdsBySlug[$typeSlug])) {
600 | $errors[$fieldPrefix . 'types'] = 'Tip de evaluare invalid.';
601 | }
602 | }
603 |
604 | $duplicate = $this->db()
605 | ->table('evaluation_criteria')
606 | ->select('id')
607 | ->where('LOWER(slug)', strtolower($slug))
608 | ->get()
609 | ->getRowArray();
610 |
611 | if ($duplicate !== null && (int) $duplicate['id'] !== (int) ($criterionId ?? 0)) {
612 | $errors[$fieldPrefix . 'slug'] = 'Slug-ul criteriului este deja folosit.';
613 | }
614 |
615 | if ($errors !== []) {
616 | continue;
617 | }
618 |
619 | $data = [
620 | 'name' => $row['name'],
621 | 'slug' => $slug,
622 | 'description' => $row['description'] !== '' ? $row['description'] : null,
623 | 'default_weight' => (float) $weight,
624 | 'is_critical' => (int) $row['is_critical'],
625 | 'sort_order' => (int) $sortOrder,
626 | 'min_score' => (float) $minScore,
627 | 'max_score' => (float) $maxScore,
628 | 'is_active' => (int) $row['is_active'],
629 | 'updated_at' => $now,
630 | ];
631 |
632 | if ($criterionId === null) {
633 | $data['created_at'] = $now;
634 | $this->db()->table('evaluation_criteria')->insert($data);
635 | $criterionId = (int) $this->db()->insertID();
636 | } else {
637 | $this->db()->table('evaluation_criteria')->where('id', $criterionId)->update($data);
638 | }
639 |
640 | $this->db()
641 | ->table('academy_level_criteria')
642 | ->where('academy_level_id', $levelId)
643 | ->where('evaluation_criteria_id', $criterionId)
644 | ->delete();
645 | foreach ($row['types'] as $typeSlug) {
646 | $this->db()->table('academy_level_criteria')->insert([
647 | 'academy_level_id' => $levelId,
648 | 'evaluation_type_id' => $typeIdsBySlug[$typeSlug],
649 | 'evaluation_criteria_id' => $criterionId,
650 | 'weight' => (float) $weight,
651 | 'is_required' => 1,
652 | 'sort_order' => (int) $sortOrder,
653 | ]);
654 | }
655 | }
656 |
657 | if ($errors !== []) {
658 | return ['success' => false, 'errors' => $errors];
659 | }
660 |
661 | return ['success' => true];
662 | }
663 |
664 | private function ensureLevelCriteriaInitialized(int $levelId): void
665 | {
666 | $existing = $this->db()
667 | ->table('academy_level_criteria')
668 | ->select('id')
669 | ->where('academy_level_id', $levelId)
670 | ->limit(1)
671 | ->get()
672 | ->getRowArray();
673 |
674 | if ($existing !== null) {
675 | return;
676 | }
677 |
678 | $globalRows = $this->db()
679 | ->table('evaluation_type_criteria')
680 | ->get()
681 | ->getResultArray();
682 |
683 | if ($globalRows === []) {
684 | return;
685 | }
686 |
687 | $rows = [];
688 | foreach ($globalRows as $row) {
689 | $rows[] = [
690 | 'academy_level_id' => $levelId,
691 | 'evaluation_type_id' => (int) $row['evaluation_type_id'],
692 | 'evaluation_criteria_id' => (int) $row['evaluation_criteria_id'],
693 | 'weight' => $row['weight'],
694 | 'is_required' => (int) $row['is_required'],
695 | 'sort_order' => (int) $row['sort_order'],
696 | ];
697 | }
698 |
699 | $this->db()->table('academy_level_criteria')->insertBatch($rows);
700 | }
701 |
702 | private function slugify(string $value): string
703 | {
704 | $value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
705 | $value = strtolower($value);
706 | $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
707 | $value = trim($value, '-');
708 |
709 | return $value;
710 | }
711 |
712 | private function db()
713 | {
714 | return db_connect();
715 | }
716 | }