$agent Fiche agent, telle que renvoyée par * findActive() ou allActive(). * * @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ. */ public static function contractRange(array $agent): array { $debut = trim((string) ($agent['date_debut_contrat'] ?? '')); $fin = trim((string) ($agent['date_fin_contrat'] ?? '')); return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null]; } /** * Vrai si la date tombe dans les bornes du contrat. * * Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare * lexicographiquement dans le même ordre que chronologiquement, ce qui évite * de construire deux objets DateTime par créneau contrôlé. */ public static function contractCoversDate(array $agent, string $date): bool { [$debut, $fin] = self::contractRange($agent); if ($debut !== null && $date < $debut) { return false; } return !($fin !== null && $date > $fin); } /** * Filtre les dates qui tombent hors contrat, dédoublonnées et triées. * * @param array $dates * * @return array */ public static function datesOutsideContract(array $agent, array $dates): array { $hors = []; foreach ($dates as $date) { $date = trim((string) $date); if ($date !== '' && !self::contractCoversDate($agent, $date)) { $hors[$date] = true; } } $hors = array_keys($hors); sort($hors); return $hors; } /** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */ public static function contractPeriodLabel(array $agent): string { [$debut, $fin] = self::contractRange($agent); $lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date))); if ($debut !== null && $fin !== null) { return sprintf('du %s au %s', $lisible($debut), $lisible($fin)); } if ($debut !== null) { return sprintf('à partir du %s', $lisible($debut)); } if ($fin !== null) { return sprintf('jusqu’au %s', $lisible($fin)); } return 'sans date de contrat renseignée'; } /** * Message de refus commun à tous les points d'écriture, pour que * l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit. * * @param array $datesHorsContrat */ public static function contractViolationMessage(array $agent, array $datesHorsContrat): string { $lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date))); $exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3)); if (count($datesHorsContrat) > 3) { $exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3); } return sprintf( '%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.', (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? ''), self::contractPeriodLabel($agent), implode(', ', $exemples) ); } /** * Créneaux déjà planifiés en dehors d'une fenêtre de contrat. * * Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit * pas laisser derrière lui des affectations orphelines. Interroge donc les * créneaux, pas les plannings — un planning peut être à cheval sur la borne. * * @return array{nombre: int, premiere: string|null, derniere: string|null} */ public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array { $stmt = $this->pdo->prepare( 'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere FROM creneau_horaire c JOIN planning p ON p.id_planning = c.id_planning WHERE p.id_agent = :agent_id AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare) OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))' ); // Chaque paramètre nommé est répété sous deux noms : PDO tourne sans // émulation, un même nom ne peut pas apparaître deux fois. $stmt->execute([ 'agent_id' => $agentId, 'debut' => $debut, 'debut_compare' => $debut, 'fin' => $fin, 'fin_compare' => $fin, ]); $ligne = $stmt->fetch() ?: []; return [ 'nombre' => (int) ($ligne['nombre'] ?? 0), 'premiere' => $ligne['premiere'] ?? null, 'derniere' => $ligne['derniere'] ?? null, ]; } /** * Projection commune à toutes les lectures d'agent, complétée par la clause * WHERE de chaque appelant. * * Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de * rattachement, et doit rester listé dans ce cas. * * `structure_nom` et `structure_principale_nom` (idem pour le type) sont le * MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus * récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et * les modules JS. */ private const AGENT_SELECT = <<<'SQL' SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.date_debut_contrat, a.date_fin_contrat, a.type_contrat, a.droit_enveloppe_formation, a.commentaire_pta, a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle, po.famille AS poste_famille, po.heures_mercredi_minutes, po.heures_extrascolaire_minutes, po.autorise_preparation, po.peut_assurer_animation, po.preparation_lundi_si_100, a.id_structure, s.nom AS structure_nom, s.nom AS structure_principale_nom, s.type_affectation AS structure_type_affectation, s.type_affectation AS structure_principale_type_affectation FROM agent a LEFT JOIN poste_agent po ON po.id_poste = a.id_poste LEFT JOIN structure s ON s.id_structure = a.id_structure SQL; /** * Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est * désactivé (V7.29). * * Trois précisions, toutes portées par le `NOT EXISTS` : * * - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu * son jeton, ce qui est l'état normal avant distribution : le filtrer * viderait les listes d'une installation neuve ; * - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui * est déjà exigé par ailleurs. Ce sont deux décisions distinctes : * fermer une fiche d'agent et couper un accès ; * - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de * consultation (planning par agent, vue annuelle, PTA détaillé) et le * référentiel « Agents » continuent de montrer l'agent — sans quoi on ne * pourrait plus ni lire son année passée, ni lui rétablir son accès. * * La table compte_acces est garantie présente : SchemaModel::assertCurrent() * refuse de démarrer si les vues de comptes manquent. */ private const SANS_COMPTE_COUPE = <<<'SQL' AND NOT EXISTS ( SELECT 1 FROM compte_acces ca WHERE ca.id_agent = a.id_agent AND ca.actif = 0 ) SQL; /** * Tous les agents actifs, y compris ceux dont l'accès est coupé. * * Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte, * voir allSelectable(). * * @return array> */ public function allActive(): array { return $this->pdo->query( self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom" )->fetchAll(); } /** * Agents proposables dans une liste d'affectation : actifs ET dont le compte * d'accès n'a pas été coupé. * * @return array> */ public function allSelectable(): array { return $this->pdo->query( self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY a.nom, a.prenom" )->fetchAll(); } /** * Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui * y ont un planning, quel que soit leur rattachement. * * C'est le périmètre d'un responsable de structure, aligné sur la vue * v_acces_agent_par_structure utilisée par Access::requireAgentView(). * * @return array> */ public function allVisibleForStructure(int $structureId): array { $stmt = $this->pdo->prepare( self::AGENT_SELECT . " WHERE a.actif = TRUE AND ( a.id_structure = :structure_id_default OR EXISTS ( SELECT 1 FROM planning p WHERE p.id_agent = a.id_agent AND p.id_structure = :structure_id_planning ) ) ORDER BY a.nom, a.prenom" ); $stmt->execute([ 'structure_id_default' => $structureId, 'structure_id_planning' => $structureId, ]); return $stmt->fetchAll(); } /** * Agents éligibles au PTA annuel : permanents et pourvus d'un poste. * * Les contrats temporaires en sont exclus, le PTA étant un engagement de * répartition sur l'année civile entière. * * @return array> */ public function allEligibleForPta(): array { return $this->pdo->query( self::AGENT_SELECT . " WHERE a.actif = TRUE AND a.type_contrat = 'PERMANENT' AND a.id_poste IS NOT NULL ORDER BY a.nom, a.prenom" )->fetchAll(); } /** * Référentiel des postes. * * Le poste porte les volumes forfaitaires de l'agent (heures des mercredis, * de l'extrascolaire) et ses autorisations (préparation, animation), qui * alimentent les calculs de PtaModel. * * @return array> */ public function allPosts(): array { return $this->pdo->query( "SELECT id_poste, code, libelle, famille, heures_mercredi_minutes, heures_extrascolaire_minutes, autorise_preparation, peut_assurer_animation, preparation_lundi_si_100 FROM poste_agent WHERE actif = TRUE ORDER BY ordre_affichage, libelle" )->fetchAll(); } /** * Agents du sélecteur de l'éditeur de planning. * * Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être * planifié ailleurs que sur son rattachement. Le tri fait simplement * REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les * autres. `lieu_principal_selectionne` permet à l'interface de les distinguer * visuellement. * * @param int $selectedLocation 0 pour ne privilégier aucun lieu. * * @return array> */ public function listForLocation(int $selectedLocation = 0): array { $stmt = $this->pdo->prepare( "SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure, a.id_poste, po.libelle AS poste_libelle, s.nom AS structure_nom, s.type_affectation AS structure_type_affectation, CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne FROM agent a LEFT JOIN poste_agent po ON po.id_poste = a.id_poste LEFT JOIN structure s ON s.id_structure = a.id_structure WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom" ); $stmt->execute(['selected_location' => $selectedLocation]); return $stmt->fetchAll(); } /** * Fiche complète d'un agent actif. * * @return array|null null si inexistant OU désactivé. */ public function findActive(int $agentId): ?array { $stmt = $this->pdo->prepare( self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1" ); $stmt->execute(['id' => $agentId]); return $stmt->fetch() ?: null; } /** * Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de * rapprochement avec les outils RH, elle reste unique en base. */ public function matriculeExists(string $matricule): bool { $stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1'); $stmt->execute(['matricule' => $matricule]); return $stmt->fetchColumn() !== false; } /** * Crée un agent et TOUT ce qui doit l'accompagner, en une transaction : * 1. la fiche `agent` ; * 2. sa première ligne d'historique `agent_structure` ; * 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ; * 4. son PTA annuel `pta_annuel`, en BROUILLON. * * L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT, * mais la période court du 1er janvier au 31 décembre : le PTA suit l'année * civile, jamais la date anniversaire du contrat. * * ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du * PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été * capturé plus haut dans $agentId. * * @param array $data Champs déjà validés par le contrôleur. * * @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int} */ public function createWithDefaults(array $data): array { $contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']); $contractEnd = !empty($data['date_fin_contrat']) ? new DateTimeImmutable((string) $data['date_fin_contrat']) : null; $year = (int) $contractStart->format('Y'); // Le quota PTA est suivi sur l'année civile, indépendamment de la date // anniversaire du contrat. La date de contrat reste une donnée RH. $periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year)); $periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year)); $quotaReference = QuotaModel::REFERENCE_MINUTES; $quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100)); $trainingEnvelope = self::trainingEnvelopeMinutes([ 'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0, ]); try { $this->pdo->beginTransaction(); $insertAgent = $this->pdo->prepare( 'INSERT INTO agent ( matricule, nom, prenom, email, telephone, adresse, est_diplome, diplome_libelle, date_debut_contrat, id_poste, type_contrat, date_fin_contrat, droit_enveloppe_formation, commentaire_pta, id_structure, actif ) VALUES ( :matricule, :nom, :prenom, :email, :telephone, :adresse, :est_diplome, :diplome_libelle, :date_debut_contrat, :id_poste, :type_contrat, :date_fin_contrat, :droit_enveloppe_formation, :commentaire_pta, :structure_id, TRUE )' ); $insertAgent->execute([ 'matricule' => $data['matricule'], 'nom' => $data['nom'], 'prenom' => $data['prenom'], 'email' => $data['email'], 'telephone' => $data['telephone'], 'adresse' => $data['adresse'], 'est_diplome' => $data['est_diplome'] ? 1 : 0, 'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null, 'date_debut_contrat' => $contractStart->format('Y-m-d'), 'id_poste' => $data['id_poste'], 'type_contrat' => $data['type_contrat'], 'date_fin_contrat' => $contractEnd?->format('Y-m-d'), 'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0, 'commentaire_pta' => $data['commentaire_pta'], 'structure_id' => $data['structure_id'], ]); $agentId = (int) $this->pdo->lastInsertId(); $history = $this->pdo->prepare( 'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)' ); $history->execute([ 'agent_id' => $agentId, 'structure_id' => $data['structure_id'], 'date_debut' => $contractStart->format('Y-m-d'), ]); $quota = $this->pdo->prepare( 'INSERT INTO quota_agent_annuel ( id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire ) VALUES ( :agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire )' ); $quota->execute([ 'agent_id' => $agentId, 'annee' => $year, 'quotite' => $data['quotite_travail'], 'quota_reference' => $quotaReference, 'quota_cible' => $quotaTarget, 'commentaire' => 'Quota PTA de référence - année civile', ]); $pta = $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\' )' ); $pta->execute([ 'agent_id' => $agentId, 'date_debut' => $periodStart->format('Y-m-d'), 'date_fin' => $periodEnd->format('Y-m-d'), 'quotite' => $data['quotite_travail'], 'quota_reference' => $quotaReference, 'quota_cible' => $quotaTarget, 'formation' => $trainingEnvelope, ]); $this->pdo->commit(); return [ 'id_agent' => $agentId, 'annee_quota' => $year, 'date_debut_contrat' => $contractStart->format('Y-m-d'), 'date_fin_contrat' => $contractEnd?->format('Y-m-d'), 'quota_cible_minutes' => $quotaTarget, 'id_pta' => (int) $this->pdo->lastInsertId(), ]; } catch (Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } /** * Met à jour la fiche d'un agent et, si son rattachement change, son * historique `agent_structure`. * * L'historique n'est touché QUE si le lieu change réellement, sans quoi * chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne, * rendant l'historique illisible. * * La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont * la date de début est dans le FUTUR : on referme alors sur cette date de * début, pour ne pas produire une période qui finit avant de commencer. * * Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le * passé : la ligne existante est réactivée plutôt que dupliquée. * * bindValue() explicite ici (et non un tableau passé à execute()) car * `structure_id` peut être NULL et exige PDO::PARAM_NULL. * * Le nom et le prénom font partie des champs modifiables depuis la V7.13 ; * le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec * les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent. * * @param array $data Champs déjà validés par le contrôleur. * * @throws \RuntimeException Si l'agent n'existe pas. */ public function updateProfile(int $agentId, array $data): void { try { $this->pdo->beginTransaction(); $currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE'); $currentStmt->execute(['agent_id' => $agentId]); $currentStructure = $currentStmt->fetchColumn(); if ($currentStructure === false) { throw new \RuntimeException('Agent introuvable.'); } $currentStructureId = $currentStructure !== null ? (int) $currentStructure : null; $newStructureId = $data['structure_id']; $update = $this->pdo->prepare( 'UPDATE agent SET nom = :nom, prenom = :prenom, email = :email, telephone = :telephone, adresse = :adresse, est_diplome = :est_diplome, diplome_libelle = :diplome_libelle, date_debut_contrat = :date_debut_contrat, id_poste = :id_poste, type_contrat = :type_contrat, date_fin_contrat = :date_fin_contrat, droit_enveloppe_formation = :droit_enveloppe_formation, commentaire_pta = :commentaire_pta, id_structure = :structure_id WHERE id_agent = :agent_id' ); $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); $update->bindValue(':nom', $data['nom']); $update->bindValue(':prenom', $data['prenom']); $update->bindValue(':email', $data['email']); $update->bindValue(':telephone', $data['telephone']); $update->bindValue(':adresse', $data['adresse']); $update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT); $update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null); $update->bindValue(':date_debut_contrat', $data['date_debut_contrat']); $update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT); $update->bindValue(':type_contrat', $data['type_contrat']); $update->bindValue(':date_fin_contrat', $data['date_fin_contrat']); $update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT); $update->bindValue(':commentaire_pta', $data['commentaire_pta']); if ($newStructureId === null) { $update->bindValue(':structure_id', null, PDO::PARAM_NULL); } else { $update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT); } $update->execute(); if ($currentStructureId !== $newStructureId) { $close = $this->pdo->prepare( "UPDATE agent_structure SET actif = FALSE, date_fin = CASE WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut ELSE CURRENT_DATE END WHERE id_agent = :agent_id AND actif = TRUE" ); $close->execute(['agent_id' => $agentId]); if ($newStructureId !== null) { $history = $this->pdo->prepare( "INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE) ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE" ); $history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]); } } $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } /** * Change le seul rattachement principal, sans toucher au reste de la fiche. * * Même gestion d'historique qu'updateProfile(), à ceci près que l'historique * est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas. * * @param int|null $structureId null pour détacher l'agent de tout lieu par * défaut — il reste planifiable ponctuellement. */ public function updateAssignment(int $agentId, ?int $structureId): void { try { $this->pdo->beginTransaction(); $update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id'); $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); if ($structureId === null) { $update->bindValue(':structure_id', null, PDO::PARAM_NULL); } else { $update->bindValue(':structure_id', $structureId, PDO::PARAM_INT); } $update->execute(); $close = $this->pdo->prepare( "UPDATE agent_structure SET actif = FALSE, date_fin = CASE WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut ELSE CURRENT_DATE END WHERE id_agent = :agent_id AND actif = TRUE" ); $close->execute(['agent_id' => $agentId]); if ($structureId !== null) { $history = $this->pdo->prepare( "INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif) VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE) ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE" ); $history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]); } $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } /** * Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus. * * Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine » * telle que l'utilisateur la conçoit. * * @return array> */ public function entriesForWeek(int $agentId, int $year, int $week): array { $stmt = $this->pdo->prepare( "SELECT p.id_planning, p.statut, s2.id_structure, s2.nom AS structure_nom, s2.type_affectation AS structure_type_affectation, c.id_creneau, c.id_motif, c.date_jour, TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota FROM planning p INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine INNER JOIN structure s2 ON s2.id_structure = p.id_structure 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_agent = :agent_id AND sem.annee = :annee AND sem.numero_semaine = :semaine ORDER BY c.date_jour, c.heure_debut, s2.nom" ); $stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]); return $stmt->fetchAll(); } /** * Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus. * * Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable * pour les grilles mensuelles et les PDF multi-semaines, qui débordent des * bornes de semaine. * * @return array> */ public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array { $stmt = $this->pdo->prepare( "SELECT p.id_planning, p.statut, s2.id_structure, s2.nom AS structure_nom, s2.type_affectation AS structure_type_affectation, c.id_creneau, c.id_motif, c.date_jour, TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut, TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin, m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota FROM planning p INNER JOIN structure s2 ON s2.id_structure = p.id_structure 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_agent = :agent_id AND c.date_jour BETWEEN :date_debut AND :date_fin ORDER BY c.date_jour, c.heure_debut, s2.nom" ); $stmt->execute([ 'agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate, ]); return $stmt->fetchAll(); } /** * Cumul des minutes par motif sur une période — base du bilan d'heures PDF. * * Trois totaux par motif : global, validé, brouillon. Le regroupement porte * sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas * un motif en deux lignes. * * Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota * est ce que le lecteur cherche en premier. * * @return array> */ public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array { $stmt = $this->pdo->prepare( "SELECT UPPER(m.code) AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota, COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total, COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides, COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon FROM planning p 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_agent = :agent_id AND c.date_jour BETWEEN :date_debut AND :date_fin GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota ORDER BY m.compte_dans_quota DESC, m.libelle" ); $stmt->execute([ 'agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate, ]); return $stmt->fetchAll(); } /** * Cumul par motif sur une année civile complète. * * @return array> */ public function timeSummaryForYear(int $agentId, int $year): array { return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)); } }