coverageModel = new CoverageModel($pdo); $this->structureModel = new StructureModel($pdo); $this->access = new Access($pdo); } public function alerts(): void { $this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]); $weekValue = trim((string) ($_GET['week'] ?? '')); $week = $this->parseWeek($weekValue); $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); if ($this->access->role() === Access::ROLE_RESPONSABLE) { $structureId = $this->access->structureId(); } if ($week === null) { JsonResponse::send(400, ['error' => 'Semaine invalide.']); } [$year, $weekNumber] = $week; $structures = $this->structureModel->allActive(); if ($structureId) { $structures = array_values(array_filter( $structures, static fn (array $structure): bool => (int) $structure['id_structure'] === (int) $structureId )); } $alerts = []; $validationWarnings = []; $unconfigured = []; $affectedStructures = []; $gapMinutes = 0; foreach ($structures as $structure) { $id = (int) $structure['id_structure']; $report = $this->coverageModel->reportForStructure($id, $year, $weekNumber); if (!$report['configured']) { $unconfigured[] = [ 'id_structure' => $id, 'nom' => $structure['nom'], 'code' => $structure['code'], ]; continue; } foreach ($report['gaps'] as $gap) { $gap['id_structure'] = $id; $gap['structure_nom'] = $structure['nom']; $gap['structure_code'] = $structure['code']; $gap['structure_type_affectation'] = $structure['type_affectation']; $alerts[] = $gap; $affectedStructures[$id] = true; $gapMinutes += $this->durationMinutes($gap['heure_debut'], $gap['heure_fin']); } foreach ($report['validation_warnings'] as $warning) { $warning['id_structure'] = $id; $warning['structure_nom'] = $structure['nom']; $validationWarnings[] = $warning; } } JsonResponse::send(200, [ 'week' => $weekValue, 'alerts' => $alerts, 'validation_warnings' => $validationWarnings, 'unconfigured_structures' => $unconfigured, 'summary' => [ 'gap_count' => count($alerts), 'affected_structure_count' => count($affectedStructures), 'gap_minutes' => $gapMinutes, 'validation_warning_count' => count($validationWarnings), 'unconfigured_structure_count' => count($unconfigured), ], ]); } public function rules(): void { $this->access->requireService(); $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); if (!$structureId) { JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide.']); } $structure = $this->structureModel->findActive((int) $structureId); if ($structure === null) { JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); } JsonResponse::send(200, [ 'structure' => $structure, 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), ]); } public function saveRules(): void { $this->access->requireService(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); $rawRules = $payload['rules'] ?? null; if (!$structureId || !is_array($rawRules)) { JsonResponse::send(422, ['error' => 'Paramètres de couverture invalides.']); } if ($this->structureModel->findActive((int) $structureId) === null) { JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); } $rules = []; foreach ($rawRules as $index => $rawRule) { if (!is_array($rawRule)) { JsonResponse::send(422, ['error' => 'Une règle de couverture est invalide.']); } $day = filter_var($rawRule['jour_semaine'] ?? null, FILTER_VALIDATE_INT); $start = trim((string) ($rawRule['heure_debut'] ?? '')); $end = trim((string) ($rawRule['heure_fin'] ?? '')); $minimum = filter_var($rawRule['minimum_agents'] ?? 1, FILTER_VALIDATE_INT); if (!$day || $day < 1 || $day > 5 || !$this->validQuarterHour($start) || !$this->validQuarterHour($end)) { JsonResponse::send(422, ['error' => sprintf('La règle de couverture n°%d contient un jour ou un horaire invalide.', $index + 1)]); } if ($this->timeToMinutes($end) <= $this->timeToMinutes($start)) { JsonResponse::send(422, ['error' => sprintf('La fin doit être postérieure au début pour la règle n°%d.', $index + 1)]); } if (!$minimum || $minimum < 1 || $minimum > 50) { JsonResponse::send(422, ['error' => 'Le nombre minimum d’agents doit être compris entre 1 et 50.']); } $rules[] = [ 'jour_semaine' => (int) $day, 'heure_debut' => $start, 'heure_fin' => $end, 'minimum_agents' => (int) $minimum, ]; } $this->assertNoOverlap($rules); try { $this->coverageModel->replaceRules((int) $structureId, $rules); } catch (Throwable $e) { JsonResponse::send(500, [ 'error' => 'Impossible d’enregistrer les horaires de couverture.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } JsonResponse::send(200, [ 'message' => 'Les horaires de couverture du lieu ont été enregistrés.', 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), ]); } private function assertNoOverlap(array $rules): void { $byDay = []; foreach ($rules as $rule) { $byDay[(int) $rule['jour_semaine']][] = $rule; } foreach ($byDay as $day => $dayRules) { usort($dayRules, fn (array $a, array $b): int => $this->timeToMinutes($a['heure_debut']) <=> $this->timeToMinutes($b['heure_debut'])); $previousEnd = null; foreach ($dayRules as $rule) { $start = $this->timeToMinutes($rule['heure_debut']); $end = $this->timeToMinutes($rule['heure_fin']); if ($previousEnd !== null && $start < $previousEnd) { JsonResponse::send(422, ['error' => sprintf('Deux plages de couverture se chevauchent pour le jour %d.', $day)]); } $previousEnd = $end; } } } private function validQuarterHour(string $time): bool { if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) { return false; } return true; } private function timeToMinutes(string $time): int { [$hours, $minutes] = array_map('intval', explode(':', $time)); return ($hours * 60) + $minutes; } private function durationMinutes(string $start, string $end): int { return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start)); } private function parseWeek(string $value): ?array { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { return null; } $week = (int) $matches[2]; return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null; } }