'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi', ]; /** * Règles de couverture d'un lieu, triées par jour puis par horaire. * * @return array> */ public function rulesForStructure(int $structureId): array { $stmt = $this->pdo->prepare( "SELECT id_couverture, id_structure, jour_semaine, TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut, TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin, minimum_agents FROM structure_couverture_horaire WHERE id_structure = :structure_id ORDER BY jour_semaine, heure_debut, heure_fin" ); $stmt->execute(['structure_id' => $structureId]); return $stmt->fetchAll(); } /** * Remplace INTÉGRALEMENT les règles d'un lieu, en une transaction. * * Une liste vide supprime toute règle : le lieu redevient « non configuré » * et cesse de produire des alertes. Il n'y a pas d'ajout ni de suppression * unitaire, la validation métier (chevauchements compris) étant faite en * amont par CoverageController::saveRules(). * * @param array> $rules Règles déjà validées. */ public function replaceRules(int $structureId, array $rules): void { try { $this->pdo->beginTransaction(); $delete = $this->pdo->prepare( 'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id' ); $delete->execute(['structure_id' => $structureId]); if ($rules !== []) { $insert = $this->pdo->prepare( 'INSERT INTO structure_couverture_horaire (id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents) VALUES (:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)' ); foreach ($rules as $rule) { $insert->execute([ 'structure_id' => $structureId, 'jour_semaine' => (int) $rule['jour_semaine'], 'heure_debut' => $rule['heure_debut'], 'heure_fin' => $rule['heure_fin'], 'minimum_agents' => (int) $rule['minimum_agents'], ]); } } $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } /** * Rapport de couverture d'un lieu sur une semaine. * * Un lieu sans règle renvoie `configured: false` et AUCUNE alerte : il n'y a * pas d'attendu, donc pas de manque. L'appelant doit le signaler à part — * taire ces lieux les ferait passer pour conformes. * * L'analyse s'arrête au VENDREDI (weekStart + 4 jours), conformément au * modèle métier. * * @return array{configured: bool, rules: array>, gaps: array>, validation_warnings: array>, summary: array{gap_count: int, gap_minutes: int, validation_warning_count: int}} */ public function reportForStructure(int $structureId, int $year, int $weekNumber): array { $rules = $this->rulesForStructure($structureId); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekEnd = $weekStart->modify('+4 days'); if ($rules === []) { return [ 'configured' => false, 'rules' => [], 'gaps' => [], 'validation_warnings' => [], 'summary' => [ 'gap_count' => 0, 'gap_minutes' => 0, 'validation_warning_count' => 0, ], ]; } $entries = $this->workEntries( $structureId, $weekStart->format('Y-m-d'), $weekEnd->format('Y-m-d') ); $entriesByDate = []; foreach ($entries as $entry) { $entriesByDate[$entry['date_jour']][] = $entry; } $gaps = []; $validationWarnings = []; foreach ($rules as $rule) { $dayNumber = (int) $rule['jour_semaine']; $date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d'); $start = $this->timeToMinutes((string) $rule['heure_debut']); $end = $this->timeToMinutes((string) $rule['heure_fin']); $minimum = (int) $rule['minimum_agents']; for ($slotStart = $start; $slotStart < $end; $slotStart += 15) { $slotEnd = min($slotStart + 15, $end); $plannedAgents = []; $validatedAgents = []; foreach ($entriesByDate[$date] ?? [] as $entry) { $entryStart = $this->timeToMinutes((string) $entry['heure_debut']); $entryEnd = $this->timeToMinutes((string) $entry['heure_fin']); if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) { continue; } $agentId = (int) $entry['id_agent']; $plannedAgents[$agentId] = true; if ($entry['statut'] === 'VALIDE') { $validatedAgents[$agentId] = true; } } $plannedCount = count($plannedAgents); $validatedCount = count($validatedAgents); $base = [ 'date' => $date, 'jour_semaine' => $dayNumber, 'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour', 'heure_debut' => $this->minutesToTime($slotStart), 'heure_fin' => $this->minutesToTime($slotEnd), 'minimum_agents' => $minimum, 'agents_planifies' => $plannedCount, 'agents_valides' => $validatedCount, ]; if ($plannedCount < $minimum) { $base['agents_manquants'] = $minimum - $plannedCount; $this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']); } elseif ($validatedCount < $minimum) { $base['validations_manquantes'] = $minimum - $validatedCount; $this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']); } } } $gapMinutes = 0; foreach ($gaps as $gap) { $gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']); } return [ 'configured' => true, 'rules' => $rules, 'gaps' => $gaps, 'validation_warnings' => $validationWarnings, 'summary' => [ 'gap_count' => count($gaps), 'gap_minutes' => $gapMinutes, 'validation_warning_count' => count($validationWarnings), ], ]; } /** * Créneaux comptant pour la couverture d'un lieu. * * ATTENTION : le filtre porte sur le seul motif `TRAVAIL`. Les motifs de * travail plus fins (ALP_MATIN, RESTAURATION, ALP_SOIR, EXTRASCOLAIRE…), * pourtant reconnus comme du travail effectif par * App\Core\MotifCategories::WORK_CODES, ne sont PAS comptés ici. Un lieu * planifié exclusivement avec ces motifs apparaîtra donc entièrement * découvert. À revoir si les alertes remontent des manques inexpliqués. * * Les agents désactivés sont exclus (jointure sur `a.actif = TRUE`) : ils ne * couvrent plus rien. * * @return array> */ private function workEntries(int $structureId, string $dateStart, string $dateEnd): array { $stmt = $this->pdo->prepare( "SELECT p.id_agent, p.statut, c.date_jour, TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin FROM planning p INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning INNER JOIN motif_planning m ON m.id_motif = c.id_motif WHERE p.id_structure = :structure_id AND c.date_jour BETWEEN :date_start AND :date_end AND m.code = 'TRAVAIL' ORDER BY c.date_jour, c.heure_debut, c.heure_fin" ); $stmt->execute([ 'structure_id' => $structureId, 'date_start' => $dateStart, 'date_end' => $dateEnd, ]); return $stmt->fetchAll(); } /** * Ajoute un créneau de 15 minutes, en le FUSIONNANT avec le précédent quand * c'est le même jour, qu'il enchaîne exactement (fin = début) et que tous * les compteurs comparés sont identiques. * * Sans cette fusion, une matinée découverte produirait seize alertes de * quinze minutes au lieu d'une seule ligne de 8h00 à 12h00. * * Ne regarde que le DERNIER élément : les créneaux sont produits dans * l'ordre chronologique, une fusion plus lointaine n'a pas lieu d'être. * * @param array> $target Liste enrichie sur place. * @param array $slot Créneau candidat. * @param array $comparisonKeys Compteurs devant coïncider. */ private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void { $lastIndex = count($target) - 1; if ($lastIndex >= 0) { $last = $target[$lastIndex]; $same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut']; foreach ($comparisonKeys as $key) { $same = $same && (int) $last[$key] === (int) $slot[$key]; } if ($same) { $target[$lastIndex]['heure_fin'] = $slot['heure_fin']; return; } } $target[] = $slot; } /** « 09:30 » → 570. Tronque à HH:MM, les horaires arrivant parfois en HH:MM:SS. */ private function timeToMinutes(string $time): int { [$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5))); return ($hours * 60) + $minutes; } /** 570 → « 09:30 ». Opération inverse de timeToMinutes(). */ private function minutesToTime(int $minutes): string { return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60); } }