pdo); $agent = $agentModel->findActive($agentId); if ($agent === null) { throw new RuntimeException('Agent introuvable.'); } $quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate); $pta = $this->ensurePta($agent, $quota); $entries = $agentModel->entriesForDateRange( $agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'] ); $vacations = (new VacationModel($this->pdo))->periodsBetween( (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'] ); $analysis = $this->analyseEntries($agent, $entries, $vacations, $quota); $constraints = $this->constraintsForAgent( $agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'] ); $wishes = $this->wishesForAgent($agentId); $controls = array_merge( $this->eligibilityControls($agent), $analysis['controls'], $this->constraintControls($analysis, $constraints), $this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode']) ); usort($controls, static function (array $a, array $b): int { $order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2]; return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9); }); return [ 'agent' => $agent, 'pta' => $pta, 'quota' => $quota, 'categories' => $analysis['categories'], 'counters' => $analysis['counters'], 'controls' => $controls, 'constraints' => $constraints, 'wishes' => $wishes, 'vacations' => $vacations, ]; } public function saveConstraint(array $data): int { $stmt = $this->pdo->prepare( "INSERT INTO agent_contrainte ( id_agent, type_contrainte, date_debut, date_fin, quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine, interdit_matin, interdit_midi, interdit_soir, commentaire, actif ) VALUES ( :id_agent, :type_contrainte, :date_debut, :date_fin, :quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine, :interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE )" ); $stmt->execute($data); return (int) $this->pdo->lastInsertId(); } public function deleteConstraint(int $constraintId, int $agentId): bool { $stmt = $this->pdo->prepare( 'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id' ); $stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]); return $stmt->rowCount() > 0; } public function saveWish(array $data): int { $stmt = $this->pdo->prepare( "INSERT INTO agent_structure_souhait ( id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif ) VALUES ( :id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE ) ON DUPLICATE KEY UPDATE type_souhait = VALUES(type_souhait), priorite = VALUES(priorite), distance_km = VALUES(distance_km), commentaire = VALUES(commentaire), actif = TRUE" ); $stmt->execute($data); return (int) ($this->pdo->lastInsertId() ?: 0); } public function deleteWish(int $wishId, int $agentId): bool { $stmt = $this->pdo->prepare( 'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id' ); $stmt->execute(['id' => $wishId, 'agent_id' => $agentId]); return $stmt->rowCount() > 0; } private function ensurePta(array $agent, array $quota): array { $stmt = $this->pdo->prepare( 'SELECT * FROM pta_annuel WHERE id_agent = :agent_id AND date_debut = :date_debut AND date_fin = :date_fin LIMIT 1' ); $stmt->execute([ 'agent_id' => $agent['id_agent'], 'date_debut' => $quota['date_debut_periode'], 'date_fin' => $quota['date_fin_periode'], ]); $pta = $stmt->fetch(); if ($pta) { return $pta; } $training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; $insert = $this->pdo->prepare( "INSERT INTO pta_annuel ( id_agent, date_debut, date_fin, quotite_travail, quota_reference_minutes, quota_cible_minutes, enveloppe_formation_minutes, statut ) VALUES ( :agent_id, :date_debut, :date_fin, :quotite, :quota_reference, :quota_cible, :formation, 'BROUILLON' )" ); $insert->execute([ 'agent_id' => $agent['id_agent'], 'date_debut' => $quota['date_debut_periode'], 'date_fin' => $quota['date_fin_periode'], 'quotite' => $quota['quotite_travail'], 'quota_reference' => $quota['quota_reference_minutes'], 'quota_cible' => $quota['quota_cible_minutes'], 'formation' => $training, ]); $stmt->execute([ 'agent_id' => $agent['id_agent'], 'date_debut' => $quota['date_debut_periode'], 'date_fin' => $quota['date_fin_periode'], ]); return $stmt->fetch() ?: []; } private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array { $categories = [ 'PERISCOLAIRE' => 0, 'MERCREDI_PERISCOLAIRE' => 0, 'EXTRASCOLAIRE' => 0, 'FORMATION' => 0, 'CONGE' => 0, 'JOUR_FRACTIONNEMENT' => 0, 'PREPARATION' => 0, 'MENAGE_FOND' => 0, 'ABSENCE' => 0, 'NON_DECOMPTE' => 0, ]; $daysByCategory = []; $daily = []; $weekly = []; $controls = []; foreach ($entries as $entry) { $date = (string) $entry['date_jour']; $start = (string) $entry['heure_debut']; $end = (string) $entry['heure_fin']; $minutes = $this->minutesBetween($start, $end); $code = strtoupper((string) $entry['motif_code']); $isVacation = $this->vacationForDate($date, $vacations) !== null; $weekday = (int) (new DateTimeImmutable($date))->format('N'); $category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']); $categories[$category] = ($categories[$category] ?? 0) + $minutes; $daysByCategory[$category][$date] = true; $daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []]; $daily[$date]['minutes'] += $minutes; $daily[$date]['starts'][] = $start; $daily[$date]['ends'][] = $end; $daily[$date]['entries'][] = [ 'start' => $start, 'end' => $end, 'minutes' => $minutes, 'code' => $code, 'category' => $category, ]; $monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d'); $weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes; } foreach ($daily as $date => &$day) { sort($day['starts']); sort($day['ends']); usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start'])); $first = min($day['starts']); $last = max($day['ends']); $day['amplitude_minutes'] = $this->minutesBetween($first, $last); $day['first'] = $first; $day['last'] = $last; } unset($day); foreach ($daily as $date => $day) { if ($day['minutes'] > 600) { $controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf( '%s : %s planifiées, au-delà de 10 h de travail quotidien.', $this->formatDate($date), $this->formatMinutes($day['minutes']) )); } if ($day['amplitude_minutes'] > 720) { $controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf( '%s : amplitude de %s, au-delà de 12 h.', $this->formatDate($date), $this->formatMinutes($day['amplitude_minutes']) )); } $continuous = 0; $previousEnd = null; foreach ($day['entries'] as $entry) { if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) { $continuous = $entry['minutes']; } else { $continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end'])); } $previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd; if ($continuous > 360) { $controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf( '%s : plus de 6 h consécutives sans coupure d’au moins 20 minutes.', $this->formatDate($date) )); break; } } } $dates = array_keys($daily); sort($dates); for ($i = 1, $count = count($dates); $i < $count; $i++) { $previousDate = $dates[$i - 1]; $currentDate = $dates[$i]; $previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']); $currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']); $restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60); if ($restMinutes < 660) { $controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf( 'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.', $this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes) )); } } foreach ($weekly as $monday => $minutes) { if ($minutes > 2880) { $controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf( 'Semaine du %s : %s planifiées, au-delà de 48 h.', $this->formatDate($monday), $this->formatMinutes($minutes) )); } } // Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives. $periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']); $periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']); $firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days'); $lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days'); $weekSeries = []; for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) { $key = $monday->format('Y-m-d'); $weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)]; } for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) { $window = array_slice($weekSeries, $index, 12); $average = (int) round(array_sum(array_column($window, 'minutes')) / 12); if ($average > 2640) { $controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf( 'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.', $this->formatDate($window[0]['date']), $this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')), $this->formatMinutes($average) )); break; } } $expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0); $expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0); $vacationWeeks = []; foreach ($daily as $date => $day) { $dateObject = new DateTimeImmutable($date); $weekday = (int) $dateObject->format('N'); $vacation = $this->vacationForDate($date, $vacations); $workingMinutes = 0; foreach ($day['entries'] as $entry) { if (in_array($entry['code'], self::WORK_CODES, true)) { $workingMinutes += $entry['minutes']; } } if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) { $controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf( '%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.', $this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday) )); } if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) { $controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf( '%s : %s de travail extrascolaire, contre %s attendues pour le poste.', $this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra) )); } if ($vacation !== null && $workingMinutes > 0) { $weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d'); $vacationWeeks[$weekKey][$date] = true; } } if (($agent['poste_famille'] ?? null) === 'ANIMATION') { foreach ($vacationWeeks as $weekStart => $workedDays) { $count = count($workedDays); if ($count > 0 && $count < 5) { $controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf( 'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.', $this->formatDate($weekStart), $count )); } } } if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') { foreach (self::PREPARATION_CODES as $code) { $hasPreparation = false; foreach ($entries as $entry) { if (strtoupper((string) $entry['motif_code']) === $code) { $hasPreparation = true; break; } } if ($hasPreparation) { $controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE', 'Un agent de restauration collective et d’entretien ne doit pas recevoir de temps de préparation.'); break; } } } $formationMinutes = $categories['FORMATION']; $formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; if ($formationTarget > 0 && $formationMinutes > $formationTarget) { $controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf( 'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.', $this->formatMinutes($formationMinutes) )); } if ($formationTarget > 0 && $formationMinutes < $formationTarget) { $controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf( 'Il reste %s dans l’enveloppe de formation CNFPT de 14 h.', $this->formatMinutes($formationTarget - $formationMinutes) )); } $caDays = count($daysByCategory['CONGE'] ?? []); $jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []); if ($caDays !== 25) { $controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf( '%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.', $caDays )); } if ($jfDays !== 2) { $controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf( '%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.', $jfDays )); } if ($controls === []) { $controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.'); } $formattedCategories = []; foreach ($categories as $code => $minutes) { $formattedCategories[] = [ 'code' => $code, 'minutes' => $minutes, 'libelle' => $this->categoryLabel($code), 'duree' => $this->formatMinutes($minutes), 'jours' => count($daysByCategory[$code] ?? []), ]; } return [ 'categories' => $formattedCategories, 'counters' => [ 'conges_annuels_jours' => $caDays, 'conges_annuels_cible' => 25, 'jours_fractionnement' => $jfDays, 'jours_fractionnement_cible' => 2, 'formation_minutes' => $formationMinutes, 'formation_cible_minutes' => $formationTarget, 'formation_duree' => $this->formatMinutes($formationMinutes), 'formation_cible_duree' => $this->formatMinutes($formationTarget), 'quota_cible' => $quota['quota_cible'], 'heures_affectees' => $quota['affectees'], 'heures_restantes' => $quota['restantes'], ], 'controls' => $controls, 'daily' => $daily, 'weekly' => $weekly, ]; } private function eligibilityControls(array $agent): array { $controls = []; if (($agent['type_contrat'] ?? '') !== 'PERMANENT') { $controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT', 'Le dispositif PTA est réservé ici aux agents en contrat permanent.'); } if (empty($agent['id_poste'])) { $controls[] = $this->control('ERREUR', 'POSTE_MANQUANT', 'Le poste de l’agent doit être renseigné pour appliquer les règles du PTA.'); } return $controls; } private function constraintControls(array $analysis, array $constraints): array { $controls = []; foreach ($constraints as $constraint) { $label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE' ? 'Temps partiel thérapeutique' : 'Préconisation médicale'; $controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf( '%s du %s au %s : %s', $label, $this->formatDate((string) $constraint['date_debut']), $constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin', (string) $constraint['commentaire'] )); foreach ($analysis['daily'] as $date => $day) { if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) { continue; } if ($constraint['maximum_minutes_jour'] !== null && $day['minutes'] > (int) $constraint['maximum_minutes_jour']) { $controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf( '%s : %s planifiées alors que la contrainte limite la journée à %s.', $this->formatDate($date), $this->formatMinutes($day['minutes']), $this->formatMinutes((int) $constraint['maximum_minutes_jour']) )); } } } return $controls; } private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array { $stmt = $this->pdo->prepare( "SELECT c.date_jour, p.id_structure, s.nom AS structure_nom, COUNT(DISTINCT p.id_agent) AS agents, SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent FROM creneau_horaire c JOIN planning p ON p.id_planning = c.id_planning JOIN motif_planning m ON m.id_motif = c.id_motif JOIN structure s ON s.id_structure = p.id_structure WHERE m.code = 'MENAGE_FOND' AND c.date_jour BETWEEN :date_debut AND :date_fin AND EXISTS ( SELECT 1 FROM planning px JOIN creneau_horaire cx ON cx.id_planning = px.id_planning JOIN motif_planning mx ON mx.id_motif = cx.id_motif WHERE px.id_agent = :agent_exists AND px.id_structure = p.id_structure AND cx.date_jour = c.date_jour AND mx.code = 'MENAGE_FOND' ) GROUP BY c.date_jour, p.id_structure, s.nom" ); $stmt->execute([ 'agent_id' => $agentId, 'agent_exists' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate, ]); $controls = []; foreach ($stmt->fetchAll() as $row) { if ((int) $row['agents'] < 2) { $controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf( '%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.', $this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents'] )); } if ((int) $row['minutes_agent'] !== 420) { $controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf( '%s — %s : %s de ménage de fond pour l’agent, au lieu de 7 h.', $this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], $this->formatMinutes((int) $row['minutes_agent']) )); } } return $controls; } private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array { $stmt = $this->pdo->prepare( "SELECT id_contrainte, type_contrainte, date_debut, date_fin, quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine, interdit_matin, interdit_midi, interdit_soir, commentaire FROM agent_contrainte WHERE id_agent = :agent_id AND actif = TRUE AND date_debut <= :date_fin AND COALESCE(date_fin, '9999-12-31') >= :date_debut ORDER BY date_debut DESC" ); $stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]); return $stmt->fetchAll(); } private function wishesForAgent(int $agentId): array { $stmt = $this->pdo->prepare( "SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom, sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire FROM agent_structure_souhait sw JOIN structure s ON s.id_structure = sw.id_structure WHERE sw.id_agent = :agent_id AND sw.actif = TRUE ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom" ); $stmt->execute(['agent_id' => $agentId]); return $stmt->fetchAll(); } private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string { return match ($code) { 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE', 'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE' => 'EXTRASCOLAIRE', 'FORMATION' => 'FORMATION', 'CONGE' => 'CONGE', 'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT', 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION', 'MENAGE_FOND' => 'MENAGE_FOND', 'ABSENCE' => 'ABSENCE', 'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE', 'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'), default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE', }; } private function categoryLabel(string $code): string { return match ($code) { 'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi', 'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires', 'EXTRASCOLAIRE' => 'Temps extrascolaire', 'FORMATION' => 'Formation', 'CONGE' => 'Congés annuels non décomptés', 'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement', 'PREPARATION' => 'Temps de préparation', 'MENAGE_FOND' => 'Ménages de fond', 'ABSENCE' => 'Absences décomptées', 'NON_DECOMPTE' => 'JNT et absences non décomptées', default => $code, }; } private function vacationForDate(string $date, array $vacations): ?array { foreach ($vacations as $vacation) { if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) { return $vacation; } } return null; } private function minutesBetween(string $start, string $end): int { [$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5))); [$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5))); return ($eh * 60 + $em) - ($sh * 60 + $sm); } private function formatMinutes(int $minutes): string { $sign = $minutes < 0 ? '-' : ''; $minutes = abs($minutes); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); } private function formatDate(string $date): string { return (new DateTimeImmutable($date))->format('d/m/Y'); } private function control(string $level, string $code, string $message): array { return ['niveau' => $level, 'code' => $code, 'message' => $message]; } }