| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 48.63% covered (danger) | 48.63% | 249 / 512 | 33.33% covered (danger) | 33.33% | 9 / 27 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
AttendanceService | 48.63% covered (danger) | 48.63% | 249 / 512 | 33.33% covered (danger) | 33.33% | 9 / 27 | 2038.34 | 0.00% covered (danger) | 0.00% | 0 / 1
__construct | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
calculateRate | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
calculateRateFromBreakdown | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getIndexData | 0.00% covered (danger) | 0.00% | 0 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
getFormData | 0.00% covered (danger) | 0.00% | 0 / 35 | 0.00% covered (danger) | 0.00% | 0 / 1 | 42 |
saveSession | 83.72% covered (warning) | 83.72% | 36 / 43 | 0.00% covered (danger) | 0.00% | 0 / 1 | 15.97 |
getSessionData | 100.00% covered (success) | 100.00% | 15 / 15 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
saveCheckIn | 83.33% covered (warning) | 83.33% | 40 / 48 | 0.00% covered (danger) | 0.00% | 0 / 1 | 16.04 |
buildFilters | 0.00% covered (danger) | 0.00% | 0 / 39 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
resolveDates | 0.00% covered (danger) | 0.00% | 0 / 12 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
getSessionRows | 0.00% covered (danger) | 0.00% | 0 / 36 | 0.00% covered (danger) | 0.00% | 0 / 1 | 110 |
buildIndexSummary | 0.00% covered (danger) | 0.00% | 0 / 21 | 0.00% covered (danger) | 0.00% | 0 / 1 | 56 |
validateSessionPayload | 60.00% covered (warning) | 60.00% | 6 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 16.40 |
normalizeSessionPayload | 100.00% covered (success) | 100.00% | 11 / 11 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
groupOptions | 0.00% covered (danger) | 0.00% | 0 / 11 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
coachOptions | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
recoverySessionOptions | 0.00% covered (danger) | 0.00% | 0 / 13 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
sessionMetricMap | 0.00% covered (danger) | 0.00% | 0 / 20 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
groupRosterCountMap | 0.00% covered (danger) | 0.00% | 0 / 14 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
getSessionRow | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getSessionRoster | 100.00% covered (success) | 100.00% | 14 / 14 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
buildRosterBreakdown | 90.00% covered (success) | 90.00% | 9 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.02 |
recordRecoveryJourneyEvents | 0.00% covered (danger) | 0.00% | 0 / 21 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
createAttendanceIssueIfNeeded | 96.00% covered (success) | 96.00% | 48 / 50 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
createAttendanceNotifications | 97.67% covered (success) | 97.67% | 42 / 43 | 0.00% covered (danger) | 0.00% | 0 / 1 | 7 |
settingsMap | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
db | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |

1 | <?php
2 |
3 | namespace App\Services;
4 |
5 | use App\Models\AttendanceRecordModel;
6 | use App\Models\AttendanceSessionModel;
7 | use App\Models\NotificationModel;
8 | use CodeIgniter\Shield\Entities\User;
9 | use DateTimeImmutable;
10 |
11 | class AttendanceService
12 | {
13 | private AuthorizationService $authorizationService;
14 | private JourneyService $journeyService;
15 |
16 | public function __construct()
17 | {
18 | helper(['academy', 'url']);
19 |
20 | $this->authorizationService = service('authorization');
21 | $this->journeyService = service('journey');
22 | }
23 |
24 | public function calculateRate(int $attendedSessions, int $totalSessions): float
25 | {
26 | if ($totalSessions === 0) {
27 | return 0.0;
28 | }
29 |
30 | return round(($attendedSessions / $totalSessions) * 100, 1);
31 | }
32 |
33 | /**
34 | * @param array<string, int> $totals
35 | */
36 | public function calculateRateFromBreakdown(array $totals): float
37 | {
38 | $attended = (int) ($totals['present'] ?? 0) + (int) ($totals['recovery'] ?? 0);
39 | $total = array_sum($totals);
40 |
41 | return $this->calculateRate($attended, $total);
42 | }
43 |
44 | /**
45 | * @param array<string, mixed> $input
46 | * @return array<string, mixed>
47 | */
48 | public function getIndexData(array $input, User $user): array
49 | {
50 | $filters = $this->buildFilters($input, (int) $user->id);
51 | $sessions = $this->getSessionRows($filters);
52 |
53 | return [
54 | 'pageTitle' => 'Prezenta',
55 | 'pageDescription' => 'Planificare sesiuni, check-in rapid pe grupe si control asupra absentelor sau recovery sessions.',
56 | 'filters' => $filters,
57 | 'summary' => $this->buildIndexSummary($sessions),
58 | 'sessions' => $sessions,
59 | 'canCreate' => $filters['accessible_group_ids'] !== [],
60 | ];
61 | }
62 |
63 | /**
64 | * @param array<string, mixed> $input
65 | * @param array<string, string> $errors
66 | * @return array<string, mixed>
67 | */
68 | public function getFormData(User $user, array $input = [], array $errors = []): array
69 | {
70 | $userId = (int) $user->id;
71 | $primaryRole = $this->authorizationService->getPrimaryRole($userId);
72 | $lockedTrainerId = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
73 | $groupOptions = $this->groupOptions($userId, $lockedTrainerId);
74 | $coachOptions = $this->coachOptions($lockedTrainerId);
75 | $recoveryOptions = $this->recoverySessionOptions($userId);
76 |
77 | $values = [
78 | 'academy_group_id' => (string) ($groupOptions[0]['id'] ?? ''),
79 | 'coach_id' => (string) ($lockedTrainerId ?? ''),
80 | 'session_date' => date('Y-m-d'),
81 | 'start_time' => '17:00',
82 | 'end_time' => '18:30',
83 | 'status' => 'scheduled',
84 | 'cancelled_reason' => '',
85 | 'recovery_for_session_id' => '',
86 | 'notes' => '',
87 | ];
88 |
89 | foreach ($input as $key => $value) {
90 | if (array_key_exists($key, $values) && is_scalar($value)) {
91 | $values[$key] = trim((string) $value);
92 | }
93 | }
94 |
95 | if ($lockedTrainerId !== null) {
96 | $values['coach_id'] = (string) $lockedTrainerId;
97 | }
98 |
99 | return [
100 | 'pageTitle' => 'Sesiune noua',
101 | 'pageDescription' => 'Configureaza o sedinta noua, inclusiv scenariile de anulare sau recovery session.',
102 | 'values' => $values,
103 | 'errors' => $errors,
104 | 'groupOptions' => $groupOptions,
105 | 'coachOptions' => $coachOptions,
106 | 'recoveryOptions' => $recoveryOptions,
107 | 'statusOptions' => session_status_options(),
108 | 'coachLocked' => $lockedTrainerId !== null,
109 | 'submitUrl' => url_to('attendance.create'),
110 | 'cancelUrl' => url_to('attendance.index'),
111 | ];
112 | }
113 |
114 | /**
115 | * @param array<string, mixed> $input
116 | * @return array{success: bool, session_id?: int, errors?: array<string, string>, forbidden?: bool}
117 | */
118 | public function saveSession(array $input, User $user): array
119 | {
120 | $validation = service('validation');
121 | $validation->setRules(config('Validation')->attendanceSession);
122 |
123 | $payload = $this->normalizeSessionPayload($input);
124 |
125 | if (! $validation->run($payload)) {
126 | return [
127 | 'success' => false,
128 | 'errors' => $validation->getErrors(),
129 | ];
130 | }
131 |
132 | $userId = (int) $user->id;
133 | $primaryRole = $this->authorizationService->getPrimaryRole($userId);
134 | $lockedTrainer = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
135 | $groupId = (int) $payload['academy_group_id'];
136 |
137 | if (! $this->authorizationService->userCanAccessGroup($userId, $groupId)) {
138 | return ['success' => false, 'forbidden' => true];
139 | }
140 |
141 | if ($lockedTrainer !== null) {
142 | $payload['coach_id'] = (string) $lockedTrainer;
143 | }
144 |
145 | $extraErrors = $this->validateSessionPayload($payload, $userId);
146 | if ($extraErrors !== []) {
147 | return ['success' => false, 'errors' => $extraErrors];
148 | }
149 |
150 | $now = date('Y-m-d H:i:s');
151 | $status = $payload['status'];
152 | $isCancelled = $status === 'cancelled' ? 1 : 0;
153 |
154 | model(AttendanceSessionModel::class)->insert([
155 | 'academy_group_id' => $groupId,
156 | 'coach_id' => $payload['coach_id'] !== '' ? (int) $payload['coach_id'] : null,
157 | 'session_date' => $payload['session_date'],
158 | 'start_time' => $payload['start_time'] !== '' ? $payload['start_time'] . ':00' : null,
159 | 'end_time' => $payload['end_time'] !== '' ? $payload['end_time'] . ':00' : null,
160 | 'status' => $status,
161 | 'is_cancelled' => $isCancelled,
162 | 'cancelled_reason' => $payload['cancelled_reason'] !== '' ? $payload['cancelled_reason'] : null,
163 | 'recovery_for_session_id' => $payload['recovery_for_session_id'] !== '' ? (int) $payload['recovery_for_session_id'] : null,
164 | 'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
165 | 'created_at' => $now,
166 | 'updated_at' => $now,
167 | ]);
168 |
169 | $sessionId = (int) model(AttendanceSessionModel::class)->getInsertID();
170 |
171 | if ($status === 'recovery') {
172 | $this->recordRecoveryJourneyEvents($sessionId, $groupId, $userId, $payload['session_date'], $payload['recovery_for_session_id'] !== '' ? (int) $payload['recovery_for_session_id'] : null);
173 | }
174 |
175 | return [
176 | 'success' => true,
177 | 'session_id' => $sessionId,
178 | ];
179 | }
180 |
181 | /**
182 | * @return array<string, mixed>|null
183 | */
184 | public function getSessionData(int $sessionId, User $user): ?array
185 | {
186 | $session = $this->getSessionRow($sessionId);
187 | if ($session === null || ! $this->authorizationService->userCanAccessSession((int) $user->id, $sessionId)) {
188 | return null;
189 | }
190 |
191 | $roster = $this->getSessionRoster($sessionId, (int) $session['academy_group_id']);
192 | $breakdown = $this->buildRosterBreakdown($roster);
193 |
194 | return [
195 | 'pageTitle' => 'Check-in sesiune',
196 | 'pageDescription' => 'Operare prezenta pe grupa, inclusiv absente motivate, nemotivate si recuperari.',
197 | 'session' => $session,
198 | 'roster' => $roster,
199 | 'breakdown' => $breakdown,
200 | 'recordOptions' => attendance_record_options(),
201 | 'saveUrl' => url_to('attendance.checkIn', $sessionId),
202 | 'backUrl' => url_to('attendance.index'),
203 | ];
204 | }
205 |
206 | /**
207 | * @param array<string, mixed> $input
208 | * @return array{success: bool, errors?: array<string, string>, forbidden?: bool}
209 | */
210 | public function saveCheckIn(int $sessionId, array $input, User $user): array
211 | {
212 | $session = $this->getSessionRow($sessionId);
213 | if ($session === null) {
214 | return ['success' => false, 'errors' => ['general' => 'Sesiunea nu exista.']];
215 | }
216 |
217 | $userId = (int) $user->id;
218 | if (! $this->authorizationService->userCanAccessSession($userId, $sessionId)) {
219 | return ['success' => false, 'forbidden' => true];
220 | }
221 |
222 | if ((int) $session['is_cancelled'] === 1) {
223 | return ['success' => false, 'errors' => ['general' => 'Sesiunea este anulata si nu poate primi check-in.']];
224 | }
225 |
226 | $records = $input['records'] ?? null;
227 | if (! is_array($records)) {
228 | return ['success' => false, 'errors' => ['general' => 'Nu au fost transmise inregistrari de prezenta.']];
229 | }
230 |
231 | $roster = $this->getSessionRoster($sessionId, (int) $session['academy_group_id']);
232 | $allowed = array_keys(attendance_record_options());
233 | $now = date('Y-m-d H:i:s');
234 | $db = $this->db();
235 |
236 | $db->transStart();
237 |
238 | foreach ($roster as $row) {
239 | $childId = (int) $row['id'];
240 | $recordInput = $records[$childId] ?? [];
241 | $status = trim((string) ($recordInput['status'] ?? ($row['attendance_status'] ?? 'present')));
242 | $notes = trim((string) ($recordInput['notes'] ?? ''));
243 | $recordId = $row['attendance_record_id'] !== null ? (int) $row['attendance_record_id'] : null;
244 |
245 | if (! in_array($status, $allowed, true)) {
246 | $db->transRollback();
247 |
248 | return ['success' => false, 'errors' => ['general' => 'Unul dintre statusurile de prezenta nu este valid.']];
249 | }
250 |
251 | $payload = [
252 | 'attendance_session_id' => $sessionId,
253 | 'child_id' => $childId,
254 | 'status' => $status,
255 | 'checked_in_at' => in_array($status, ['present', 'recovery'], true)
256 | ? $session['session_date'] . ' ' . ($session['start_time'] ?: '17:00:00')
257 | : null,
258 | 'notes' => $notes !== '' ? $notes : null,
259 | 'updated_at' => $now,
260 | ];
261 |
262 | if ($recordId === null) {
263 | $payload['created_at'] = $now;
264 | model(AttendanceRecordModel::class)->insert($payload);
265 | } else {
266 | model(AttendanceRecordModel::class)->update($recordId, $payload);
267 | }
268 |
269 | if (in_array($status, ['absent_excused', 'absent_unexcused'], true)) {
270 | $this->createAttendanceIssueIfNeeded($childId, $userId, $session, $status);
271 | }
272 | }
273 |
274 | model(AttendanceSessionModel::class)->update($sessionId, [
275 | 'status' => $session['status'] === 'scheduled' ? 'completed' : $session['status'],
276 | 'updated_at' => $now,
277 | ]);
278 |
279 | $db->transComplete();
280 |
281 | if (! $db->transStatus()) {
282 | return ['success' => false, 'errors' => ['general' => 'Check-in-ul nu a putut fi salvat.']];
283 | }
284 |
285 | return ['success' => true];
286 | }
287 |
288 | /**
289 | * @param array<string, mixed> $input
290 | * @return array<string, mixed>
291 | */
292 | private function buildFilters(array $input, int $userId): array
293 | {
294 | $settings = $this->settingsMap();
295 | $primaryRole = $this->authorizationService->getPrimaryRole($userId);
296 | $lockedTrainerId = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
297 | $selectedPeriod = (string) ($input['period'] ?? ($settings['default_dashboard_period'] ?? '30'));
298 | $today = new DateTimeImmutable('today');
299 | [$dateStart, $dateEnd] = $this->resolveDates($selectedPeriod, $today);
300 |
301 | $groupOptions = $this->groupOptions($userId, $lockedTrainerId);
302 | $coachOptions = $this->coachOptions($lockedTrainerId);
303 | $selectedGroup = (int) ($input['group_id'] ?? 0);
304 | $selectedTrainer = $lockedTrainerId ?: (int) ($input['coach_id'] ?? 0);
305 | $selectedStatus = (string) ($input['status'] ?? 'all');
306 |
307 | if (! in_array($selectedStatus, ['all', 'scheduled', 'completed', 'cancelled', 'recovery'], true)) {
308 | $selectedStatus = 'all';
309 | }
310 |
311 | return [
312 | 'current_role' => $primaryRole,
313 | 'coach_locked' => $lockedTrainerId !== null,
314 | 'selected_period' => $selectedPeriod,
315 | 'selected_group_id' => $selectedGroup,
316 | 'selected_coach_id' => $selectedTrainer,
317 | 'selected_status' => $selectedStatus,
318 | 'date_start' => $dateStart,
319 | 'date_end' => $dateEnd,
320 | 'period_options' => [
321 | '7' => 'Ultimele 7 zile',
322 | '30' => 'Ultimele 30 zile',
323 | '90' => 'Ultimele 90 zile',
324 | 'month' => 'Luna curenta',
325 | ],
326 | 'status_options' => [
327 | 'all' => 'Toate statusurile',
328 | 'scheduled' => 'Scheduled',
329 | 'completed' => 'Completed',
330 | 'cancelled' => 'Cancelled',
331 | 'recovery' => 'Recovery',
332 | ],
333 | 'group_options' => $groupOptions,
334 | 'coach_options' => $coachOptions,
335 | 'accessible_group_ids' => array_values(array_map(static fn (array $row): int => (int) $row['id'], $groupOptions)),
336 | ];
337 | }
338 |
339 | /**
340 | * @return array{0: string, 1: string}
341 | */
342 | private function resolveDates(string $selectedPeriod, DateTimeImmutable $today): array
343 | {
344 | if ($selectedPeriod === 'month') {
345 | return [
346 | $today->modify('first day of this month')->format('Y-m-d'),
347 | $today->format('Y-m-d'),
348 | ];
349 | }
350 |
351 | $days = (int) $selectedPeriod;
352 | if (! in_array($days, [7, 30, 90], true)) {
353 | $days = 30;
354 | }
355 |
356 | return [
357 | $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d'),
358 | $today->format('Y-m-d'),
359 | ];
360 | }
361 |
362 | /**
363 | * @param array<string, mixed> $filters
364 | * @return list<array<string, mixed>>
365 | */
366 | private function getSessionRows(array $filters): array
367 | {
368 | if ($filters['coach_locked'] && $filters['accessible_group_ids'] === []) {
369 | return [];
370 | }
371 |
372 | $builder = $this->db()
373 | ->table('attendance_sessions s')
374 | ->select('s.*, groups.name as group_name, groups.code as group_code, coaches.full_name as coach_name')
375 | ->join('academy_groups groups', 'groups.id = s.academy_group_id')
376 | ->join('coaches', 'coaches.id = s.coach_id', 'left')
377 | ->where('s.session_date >=', $filters['date_start'])
378 | ->where('s.session_date <=', $filters['date_end'])
379 | ->orderBy('s.session_date', 'DESC')
380 | ->orderBy('s.id', 'DESC');
381 |
382 | if ($filters['coach_locked']) {
383 | $builder->whereIn('s.academy_group_id', $filters['accessible_group_ids']);
384 | }
385 |
386 | if ($filters['selected_group_id'] > 0) {
387 | $builder->where('s.academy_group_id', $filters['selected_group_id']);
388 | }
389 |
390 | if ($filters['selected_coach_id'] > 0) {
391 | $builder->where('s.coach_id', $filters['selected_coach_id']);
392 | }
393 |
394 | if ($filters['selected_status'] !== 'all') {
395 | $builder->where('s.status', $filters['selected_status']);
396 | }
397 |
398 | $sessions = $builder->get()->getResultArray();
399 | if ($sessions === []) {
400 | return [];
401 | }
402 |
403 | $sessionIds = array_values(array_map(static fn (array $row): int => (int) $row['id'], $sessions));
404 | $groupIds = array_values(array_unique(array_map(static fn (array $row): int => (int) $row['academy_group_id'], $sessions)));
405 | $metrics = $this->sessionMetricMap($sessionIds);
406 | $rosters = $this->groupRosterCountMap($groupIds);
407 |
408 | foreach ($sessions as &$session) {
409 | $sessionId = (int) $session['id'];
410 | $groupId = (int) $session['academy_group_id'];
411 | $stats = $metrics[$sessionId] ?? ['attended' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0, 'total' => 0];
412 | $session['participants'] = $stats['total'] > 0 ? $stats['total'] : ($rosters[$groupId] ?? 0);
413 | $session['attendance_rate'] = $this->calculateRate((int) $stats['attended'], (int) $stats['total']);
414 | $session['attendance_label'] = format_percentage((float) $session['attendance_rate'], 1);
415 | $session['breakdown'] = $stats;
416 | }
417 | unset($session);
418 |
419 | return $sessions;
420 | }
421 |
422 | /**
423 | * @param list<array<string, mixed>> $sessions
424 | * @return list<array<string, mixed>>
425 | */
426 | private function buildIndexSummary(array $sessions): array
427 | {
428 | $attendanceRates = [];
429 | $cancelled = 0;
430 | $completed = 0;
431 | $recovery = 0;
432 |
433 | foreach ($sessions as $session) {
434 | if ($session['status'] === 'cancelled') {
435 | $cancelled++;
436 | }
437 |
438 | if ($session['status'] === 'completed') {
439 | $completed++;
440 | }
441 |
442 | if ($session['status'] === 'recovery') {
443 | $recovery++;
444 | }
445 |
446 | if ((int) ($session['breakdown']['total'] ?? 0) > 0) {
447 | $attendanceRates[] = (float) $session['attendance_rate'];
448 | }
449 | }
450 |
451 | $avgAttendance = $attendanceRates === [] ? 0.0 : round(array_sum($attendanceRates) / count($attendanceRates), 1);
452 |
453 | return [
454 | ['label' => 'Sesiuni in selectie', 'value' => count($sessions), 'meta' => 'Toate sedintele filtrate'],
455 | ['label' => 'Finalizate', 'value' => $completed, 'meta' => 'Sedinte complet operate'],
456 | ['label' => 'Recovery', 'value' => $recovery, 'meta' => 'Sedinte de recuperare'],
457 | ['label' => 'Anulate', 'value' => $cancelled, 'meta' => 'Sedinte anulate'],
458 | ['label' => 'Prezenta medie', 'value' => format_percentage($avgAttendance, 1), 'meta' => 'Doar sesiuni cu check-in salvat'],
459 | ];
460 | }
461 |
462 | /**
463 | * @param array<string, string> $payload
464 | * @return array<string, string>
465 | */
466 | private function validateSessionPayload(array $payload, int $userId): array
467 | {
468 | $errors = [];
469 |
470 | if ($payload['start_time'] !== '' && $payload['end_time'] !== '' && strtotime($payload['start_time']) >= strtotime($payload['end_time'])) {
471 | $errors['end_time'] = 'Ora de final trebuie sa fie dupa ora de start.';
472 | }
473 |
474 | if ($payload['status'] === 'cancelled' && $payload['cancelled_reason'] === '') {
475 | $errors['cancelled_reason'] = 'Explica motivul anularii sedintei.';
476 | }
477 |
478 | if ($payload['status'] === 'recovery' && $payload['recovery_for_session_id'] === '') {
479 | $errors['recovery_for_session_id'] = 'Selecteaza sedinta anulata pe care o recuperezi.';
480 | }
481 |
482 | if ($payload['recovery_for_session_id'] !== '' && ! $this->authorizationService->userCanAccessSession($userId, (int) $payload['recovery_for_session_id'])) {
483 | $errors['recovery_for_session_id'] = 'Nu ai acces la sesiunea selectata pentru recovery.';
484 | }
485 |
486 | return $errors;
487 | }
488 |
489 | /**
490 | * @param array<string, mixed> $input
491 | * @return array<string, string>
492 | */
493 | private function normalizeSessionPayload(array $input): array
494 | {
495 | return [
496 | 'academy_group_id' => trim((string) ($input['academy_group_id'] ?? '')),
497 | 'coach_id' => trim((string) ($input['coach_id'] ?? '')),
498 | 'session_date' => trim((string) ($input['session_date'] ?? '')),
499 | 'start_time' => trim((string) ($input['start_time'] ?? '')),
500 | 'end_time' => trim((string) ($input['end_time'] ?? '')),
501 | 'status' => trim((string) ($input['status'] ?? 'scheduled')),
502 | 'cancelled_reason' => trim((string) ($input['cancelled_reason'] ?? '')),
503 | 'recovery_for_session_id' => trim((string) ($input['recovery_for_session_id'] ?? '')),
504 | 'notes' => trim((string) ($input['notes'] ?? '')),
505 | ];
506 | }
507 |
508 | /**
509 | * @return list<array<string, mixed>>
510 | */
511 | private function groupOptions(int $userId, ?int $lockedTrainerId = null): array
512 | {
513 | $builder = $this->db()
514 | ->table('academy_groups groups')
515 | ->select('groups.id, groups.name, groups.code, groups.primary_coach_id, coaches.full_name as coach_name')
516 | ->join('coaches', 'coaches.id = groups.primary_coach_id', 'left')
517 | ->where('groups.status', 'active')
518 | ->orderBy('groups.name', 'ASC');
519 |
520 | if ($lockedTrainerId !== null) {
521 | $builder->where('groups.primary_coach_id', $lockedTrainerId);
522 | } elseif (! $this->authorizationService->userHasRole($userId, 'admin')) {
523 | return [];
524 | }
525 |
526 | return $builder->get()->getResultArray();
527 | }
528 |
529 | /**
530 | * @return list<array<string, mixed>>
531 | */
532 | private function coachOptions(?int $lockedTrainerId = null): array
533 | {
534 | $builder = $this->db()
535 | ->table('coaches')
536 | ->select('id, full_name')
537 | ->where('is_active', 1)
538 | ->orderBy('full_name', 'ASC');
539 |
540 | if ($lockedTrainerId !== null) {
541 | $builder->where('id', $lockedTrainerId);
542 | }
543 |
544 | return $builder->get()->getResultArray();
545 | }
546 |
547 | /**
548 | * @return list<array<string, mixed>>
549 | */
550 | private function recoverySessionOptions(int $userId): array
551 | {
552 | $builder = $this->db()
553 | ->table('attendance_sessions s')
554 | ->select('s.id, s.session_date, groups.name as group_name')
555 | ->join('academy_groups groups', 'groups.id = s.academy_group_id')
556 | ->where('s.is_cancelled', 1)
557 | ->orderBy('s.session_date', 'DESC')
558 | ->limit(12);
559 |
560 | if ($this->authorizationService->userHasRole($userId, 'coach') && ! $this->authorizationService->userHasRole($userId, 'admin')) {
561 | $groupIds = array_values(array_map(static fn (array $row): int => (int) $row['id'], $this->groupOptions($userId, $this->authorizationService->getCoachIdForUser($userId))));
562 | if ($groupIds === []) {
563 | return [];
564 | }
565 | $builder->whereIn('s.academy_group_id', $groupIds);
566 | }
567 |
568 | return $builder->get()->getResultArray();
569 | }
570 |
571 | /**
572 | * @param list<int> $sessionIds
573 | * @return array<int, array<string, int>>
574 | */
575 | private function sessionMetricMap(array $sessionIds): array
576 | {
577 | if ($sessionIds === []) {
578 | return [];
579 | }
580 |
581 | $rows = $this->db()
582 | ->table('attendance_records')
583 | ->select("
584 | attendance_session_id,
585 | SUM(CASE WHEN status IN ('present', 'recovery') THEN 1 ELSE 0 END) as attended,
586 | SUM(CASE WHEN status = 'recovery' THEN 1 ELSE 0 END) as recovery,
587 | SUM(CASE WHEN status = 'absent_excused' THEN 1 ELSE 0 END) as absent_excused,
588 | SUM(CASE WHEN status = 'absent_unexcused' THEN 1 ELSE 0 END) as absent_unexcused,
589 | COUNT(*) as total
590 | ", false)
591 | ->whereIn('attendance_session_id', $sessionIds)
592 | ->groupBy('attendance_session_id')
593 | ->get()
594 | ->getResultArray();
595 |
596 | $map = [];
597 | foreach ($rows as $row) {
598 | $map[(int) $row['attendance_session_id']] = [
599 | 'attended' => (int) $row['attended'],
600 | 'recovery' => (int) $row['recovery'],
601 | 'absent_excused' => (int) $row['absent_excused'],
602 | 'absent_unexcused' => (int) $row['absent_unexcused'],
603 | 'total' => (int) $row['total'],
604 | ];
605 | }
606 |
607 | return $map;
608 | }
609 |
610 | /**
611 | * @param list<int> $groupIds
612 | * @return array<int, int>
613 | */
614 | private function groupRosterCountMap(array $groupIds): array
615 | {
616 | if ($groupIds === []) {
617 | return [];
618 | }
619 |
620 | $rows = $this->db()
621 | ->table('children')
622 | ->select('academy_group_id, COUNT(*) as total')
623 | ->whereIn('academy_group_id', $groupIds)
624 | ->where('status !=', 'left')
625 | ->groupBy('academy_group_id')
626 | ->get()
627 | ->getResultArray();
628 |
629 | $map = [];
630 | foreach ($rows as $row) {
631 | $map[(int) $row['academy_group_id']] = (int) $row['total'];
632 | }
633 |
634 | return $map;
635 | }
636 |
637 | /**
638 | * @return array<string, mixed>|null
639 | */
640 | private function getSessionRow(int $sessionId): ?array
641 | {
642 | return $this->db()
643 | ->table('attendance_sessions s')
644 | ->select('s.*, groups.name as group_name, groups.code as group_code, groups.primary_coach_id as group_primary_coach_id, coaches.full_name as coach_name, recovery.session_date as recovery_source_date')
645 | ->join('academy_groups groups', 'groups.id = s.academy_group_id')
646 | ->join('coaches', 'coaches.id = s.coach_id', 'left')
647 | ->join('attendance_sessions recovery', 'recovery.id = s.recovery_for_session_id', 'left')
648 | ->where('s.id', $sessionId)
649 | ->get()
650 | ->getRowArray();
651 | }
652 |
653 | /**
654 | * @return list<array<string, mixed>>
655 | */
656 | private function getSessionRoster(int $sessionId, int $groupId): array
657 | {
658 | $rows = $this->db()
659 | ->table('children c')
660 | ->select('c.id, c.full_name, c.status as child_status, levels.name as level_name, ar.id as attendance_record_id, ar.status as attendance_status, ar.notes as attendance_notes, ar.checked_in_at')
661 | ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
662 | ->join('attendance_records ar', 'ar.child_id = c.id AND ar.attendance_session_id = ' . $sessionId, 'left')
663 | ->where('c.academy_group_id', $groupId)
664 | ->where('c.status !=', 'left')
665 | ->orderBy('c.full_name', 'ASC')
666 | ->get()
667 | ->getResultArray();
668 |
669 | foreach ($rows as &$row) {
670 | $row['selected_status'] = $row['attendance_status'] ?? 'present';
671 | }
672 | unset($row);
673 |
674 | return $rows;
675 | }
676 |
677 | /**
678 | * @param list<array<string, mixed>> $roster
679 | * @return array<string, int|float>
680 | */
681 | private function buildRosterBreakdown(array $roster): array
682 | {
683 | $saved = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
684 |
685 | foreach ($roster as $row) {
686 | $status = $row['attendance_status'];
687 | if ($status === null || ! isset($saved[$status])) {
688 | continue;
689 | }
690 |
691 | $saved[$status]++;
692 | }
693 |
694 | $saved['participants'] = count($roster);
695 | $saved['saved_records'] = array_sum(array_intersect_key($saved, array_flip(['present', 'recovery', 'absent_excused', 'absent_unexcused'])));
696 | $saved['attendance_rate'] = $this->calculateRateFromBreakdown($saved);
697 |
698 | return $saved;
699 | }
700 |
701 | private function recordRecoveryJourneyEvents(int $sessionId, int $groupId, int $userId, string $sessionDate, ?int $recoverySourceId): void
702 | {
703 | $children = $this->db()
704 | ->table('children')
705 | ->select('id')
706 | ->where('academy_group_id', $groupId)
707 | ->where('status !=', 'left')
708 | ->get()
709 | ->getResultArray();
710 |
711 | foreach ($children as $child) {
712 | $this->journeyService->recordEvent([
713 | 'child_id' => (int) $child['id'],
714 | 'event_type' => 'recovery_session_added',
715 | 'title' => 'Sesiune de recuperare programata',
716 | 'description' => 'A fost planificata o sesiune de recuperare pentru grupa copilului.',
717 | 'metadata' => json_encode([
718 | 'attendance_session_id' => $sessionId,
719 | 'recovery_for_session_id' => $recoverySourceId,
720 | 'session_date' => $sessionDate,
721 | ], JSON_UNESCAPED_UNICODE),
722 | 'created_by' => $userId,
723 | 'created_at' => $sessionDate . ' 08:00:00',
724 | ]);
725 | }
726 | }
727 |
728 | /**
729 | * @param array<string, mixed> $session
730 | */
731 | private function createAttendanceIssueIfNeeded(int $childId, int $userId, array $session, string $status): void
732 | {
733 | $settings = $this->settingsMap();
734 | $windowDays = max(7, (int) ($settings['default_dashboard_period'] ?? 30));
735 | $threshold = (float) ($settings['absence_alert_threshold'] ?? 25);
736 | $sessionDate = (string) $session['session_date'];
737 | $windowStart = (new DateTimeImmutable($sessionDate))->modify('-' . ($windowDays - 1) . ' days')->format('Y-m-d');
738 | $attendanceRows = $this->db()
739 | ->table('attendance_records ar')
740 | ->select('ar.status')
741 | ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
742 | ->where('ar.child_id', $childId)
743 | ->where('sessions.is_cancelled', 0)
744 | ->where('sessions.session_date >=', $windowStart)
745 | ->where('sessions.session_date <=', $sessionDate)
746 | ->get()
747 | ->getResultArray();
748 |
749 | $breakdown = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
750 | foreach ($attendanceRows as $row) {
751 | $rowStatus = (string) $row['status'];
752 | if (isset($breakdown[$rowStatus])) {
753 | $breakdown[$rowStatus]++;
754 | }
755 | }
756 |
757 | if (array_sum($breakdown) === 0) {
758 | return;
759 | }
760 |
761 | $absenceRate = 100 - $this->calculateRateFromBreakdown($breakdown);
762 | if ($absenceRate < $threshold) {
763 | return;
764 | }
765 |
766 | $exists = $this->db()
767 | ->table('journey_events')
768 | ->select('id')
769 | ->where('child_id', $childId)
770 | ->where('event_type', 'attendance_issue')
771 | ->where('created_at >=', $sessionDate . ' 00:00:00')
772 | ->where('created_at <=', $sessionDate . ' 23:59:59')
773 | ->get()
774 | ->getRowArray();
775 |
776 | if ($exists === null) {
777 | $this->journeyService->recordEvent([
778 | 'child_id' => $childId,
779 | 'event_type' => 'attendance_issue',
780 | 'title' => 'Avertizare de prezenta',
781 | 'description' => 'Absenta a depasit pragul configurat pentru perioada operationala curenta.',
782 | 'metadata' => json_encode([
783 | 'absence_rate' => round($absenceRate, 1),
784 | 'window_start' => $windowStart,
785 | 'window_end' => $sessionDate,
786 | 'last_status' => $status,
787 | ], JSON_UNESCAPED_UNICODE),
788 | 'created_by' => $userId,
789 | 'created_at' => $sessionDate . ' 20:30:00',
790 | ]);
791 | }
792 |
793 | $this->createAttendanceNotifications($childId, $session, $absenceRate);
794 | }
795 |
796 | /**
797 | * @param array<string, mixed> $session
798 | */
799 | private function createAttendanceNotifications(int $childId, array $session, float $absenceRate): void
800 | {
801 | $targetUserIds = [];
802 |
803 | $adminRows = $this->db()
804 | ->table('user_roles')
805 | ->select('user_roles.user_id')
806 | ->join('roles', 'roles.id = user_roles.role_id')
807 | ->where('roles.slug', 'admin')
808 | ->get()
809 | ->getResultArray();
810 |
811 | foreach ($adminRows as $row) {
812 | $targetUserIds[] = (int) $row['user_id'];
813 | }
814 |
815 | if (! empty($session['coach_id'])) {
816 | $coachUserId = $this->db()
817 | ->table('coaches')
818 | ->select('user_id')
819 | ->where('id', (int) $session['coach_id'])
820 | ->get()
821 | ->getRowArray();
822 |
823 | if ($coachUserId !== null && ! empty($coachUserId['user_id'])) {
824 | $targetUserIds[] = (int) $coachUserId['user_id'];
825 | }
826 | }
827 |
828 | $targetUserIds = array_values(array_unique($targetUserIds));
829 |
830 | foreach ($targetUserIds as $targetUserId) {
831 | $existing = $this->db()
832 | ->table('notifications')
833 | ->select('id')
834 | ->where('user_id', $targetUserId)
835 | ->where('child_id', $childId)
836 | ->where('type', 'attendance_alert')
837 | ->where('status', 'unread')
838 | ->get()
839 | ->getRowArray();
840 |
841 | if ($existing !== null) {
842 | continue;
843 | }
844 |
845 | model(NotificationModel::class)->insert([
846 | 'user_id' => $targetUserId,
847 | 'child_id' => $childId,
848 | 'type' => 'attendance_alert',
849 | 'title' => 'Absente peste prag',
850 | 'message' => 'Copilul a depasit pragul de absenta cu ' . format_percentage($absenceRate, 1) . ' in perioada recenta.',
851 | 'status' => 'unread',
852 | 'action_url' => url_to('children.show', $childId),
853 | 'created_at' => date('Y-m-d H:i:s'),
854 | 'read_at' => null,
855 | ]);
856 | }
857 | }
858 |
859 | /**
860 | * @return array<string, string>
861 | */
862 | private function settingsMap(): array
863 | {
864 | $rows = $this->db()
865 | ->table('settings')
866 | ->select('`key`, value')
867 | ->get()
868 | ->getResultArray();
869 |
870 | $settings = [];
871 | foreach ($rows as $row) {
872 | $settings[$row['key']] = $row['value'];
873 | }
874 |
875 | return $settings;
876 | }
877 |
878 | private function db()
879 | {
880 | return db_connect();
881 | }
882 | }