coverageModel = new CoverageModel($pdo); $this->structureModel = new StructureModel($pdo); $this->access = new Access($pdo); } /** * GET api/coverage_alerts.php — trous de couverture d'une semaine. * * Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé * est ignoré pour un responsable, écrasé par son propre lieu : un simple 403 * l'empêcherait de consulter son écran avec une URL partagée. * * Trois familles de retours, à ne pas confondre : * - `alerts` : plages réellement sous-couvertes ; * - `validation_warnings` : couverture assurée par des plannings encore en * brouillon, donc pas encore fiable ; * - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas * produire d'alerte, et les taire les ferait passer pour conformes. * * Paramètres GET : week, structure_id (facultatif). */ 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), ], ]); } /** * GET api/structure_coverage_rules.php — règles de couverture d'un lieu. */ 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), ]); } /** * POST api/save_structure_coverage_rules.php — remplace les règles d'un lieu. * * Remplacement INTÉGRAL : la liste reçue devient l'unique jeu de règles du * lieu, et une liste vide supprime toute règle (le lieu redevient « non * configuré »). Il n'y a pas d'ajout ni de suppression unitaire. * * Les règles sont validées ici — jour ouvré, horaires au quart d'heure, fin * après début, minimum dans [1, 50], puis absence de chevauchement — avant * d'être confiées au modèle, qui n'effectue aucun contrôle métier. * * Charge utile : structure_id, rules[], csrf_token. */ 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, ]); } Journal::ecriture( 'COUVERTURE_ENREGISTREE', 'structure_couverture_horaire', (int) $structureId, sprintf('Horaires de couverture du lieu #%d enregistrés : %d plage(s).', (int) $structureId, count($rules)), ['id_structure' => (int) $structureId, 'nombre_plages' => count($rules)] ); JsonResponse::send(200, [ 'message' => 'Les horaires de couverture du lieu ont été enregistrés.', 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), ]); } /** * Refuse deux plages de couverture qui se recouvrent le même jour. * * Les règles sont regroupées par jour puis triées : il suffit alors de * comparer chaque début à la fin précédente, en O(n log n) et non en O(n²). * Des plages jointives sont acceptées (une fin égale à un début). * * @param array $rules */ 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; } } } /** HH:MM au quart d'heure, entre 00:00 et 23:45. */ private function validQuarterHour(string $time): bool { if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) { return false; } return true; } /** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */ private function timeToMinutes(string $time): int { [$hours, $minutes] = array_map('intval', explode(':', $time)); return ($hours * 60) + $minutes; } /** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */ private function durationMinutes(string $start, string $end): int { return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start)); } /** * Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs. * * @return array{0: int, 1: int}|null [année ISO, semaine], ou null. */ 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; } }