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 )' ); $stmt->execute([ 'agent_id' => $agentId, 'annee' => $year, 'quotite' => $rate, 'quota_reference' => self::REFERENCE_MINUTES, 'quota_cible' => $target, 'commentaire' => 'Quota PTA de référence pour l’année civile', ]); } /** * Retourne le quota de l'année civile contenant la date fournie. * * Exemple : une date de contexte au 15/09/2026 renvoie toujours la période * 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat. * * Les minutes VALIDÉES et les minutes en BROUILLON sont comptées * séparément mais s'additionnent toutes deux dans `minutes_affectees` : * un brouillon consomme déjà le quota du point de vue de la planification, * la distinction ne sert qu'à indiquer ce qui est acquis. * * Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier : * CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES). * * `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en * ALERTE à la validation, jamais bloqué. * * @return array Période de référence enrichie des compteurs. */ public function forDate(int $agentId, string $contextDate): array { $period = $this->referencePeriod($agentId, $contextDate); $quota = $this->quotaProfile($agentId, (int) $period['annee_periode']); $usedStmt = $this->pdo->prepare( "SELECT 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 AND m.compte_dans_quota = TRUE" ); $usedStmt->execute([ 'agent_id' => $agentId, 'date_debut' => $period['date_debut_periode'], 'date_fin' => $period['date_fin_periode'], ]); $used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0]; $validated = (int) $used['minutes_valides']; $draft = (int) $used['minutes_brouillon']; $target = (int) $quota['quota_cible_minutes']; $allocated = $validated + $draft; return array_merge($period, [ 'annee' => (int) $period['annee_periode'], 'quotite_travail' => (float) $quota['quotite_travail'], 'quota_reference_minutes' => (int) $quota['quota_reference_minutes'], 'quota_cible_minutes' => $target, 'quota_source_annee' => (int) $quota['quota_source_annee'], 'minutes_valides' => $validated, 'minutes_brouillon' => $draft, 'minutes_affectees' => $allocated, 'minutes_restantes' => $target - $allocated, 'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0, ]); } /** * Idem forDate(), avec les libellés lisibles ajoutés par enrich(). * C'est cette variante que consomment tous les endpoints JSON. * * @return array */ public function forDateForApi(int $agentId, string $contextDate): array { return $this->enrich($this->forDate($agentId, $contextDate)); } /** * Quota d'une année entière. Le 1er janvier suffit comme point d'entrée, * la période étant de toute façon l'année civile complète. * * @return array */ public function annual(int $agentId, int $year): array { return $this->forDate($agentId, sprintf('%04d-01-01', $year)); } /** * Idem annual(), enrichi des libellés. * * @return array */ public function annualForApi(int $agentId, int $year): array { return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year)); } /** * Période de référence PTA : toujours l'année civile. * La date de contrat est retournée uniquement à titre informatif. * * Les clés `periode_contractuelle`, `periode_civile` et * `date_contrat_manquante` sont des VESTIGES d'une version où la période * pouvait être ancrée sur la date de contrat. Elles sont figées * (false / true / false) et conservées pour ne pas casser les écrans qui les * lisent encore. Ne pas construire de nouvelle logique dessus. * * @return array * * @throws InvalidArgumentException Si la date de contexte est malformée. */ public function referencePeriod(int $agentId, string $contextDate): array { $context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate); if (!$context || $context->format('Y-m-d') !== $contextDate) { throw new InvalidArgumentException('Date de référence du quota invalide.'); } $stmt = $this->pdo->prepare( 'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1' ); $stmt->execute(['agent_id' => $agentId]); $contractStartValue = $stmt->fetchColumn(); $year = (int) $context->format('Y'); $start = new DateTimeImmutable(sprintf('%04d-01-01', $year)); $end = new DateTimeImmutable(sprintf('%04d-12-31', $year)); return [ 'date_reference' => $context->format('Y-m-d'), 'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null, 'date_debut_periode' => $start->format('Y-m-d'), 'date_fin_periode' => $end->format('Y-m-d'), 'annee_periode' => $year, 'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')), 'periode_contractuelle' => false, 'periode_civile' => true, // Conservé pour compatibilité avec les anciens écrans : la date de // contrat n'est plus requise pour calculer la période du quota. 'date_contrat_manquante' => false, ]; } /** * Ajoute les versions lisibles des compteurs à un quota brut. * * Les clés en minutes restent présentes : le JavaScript calcule sur les * minutes et n'affiche que les libellés. * * @param array $quota * * @return array */ public function enrich(array $quota): array { return array_merge($quota, [ 'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']), 'valides' => $this->formatMinutes((int) $quota['minutes_valides']), 'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']), 'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']), 'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']), ]); } /** * Met en forme une durée en minutes sous ses trois représentations. * * Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait * avant la division, sans quoi intdiv() et le modulo produiraient un libellé * incohérent du type « -1 h -30 ». * * @return array{minutes: int, heures_decimales: float, libelle: string} * Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ». */ public function formatMinutes(int $minutes): array { $sign = $minutes < 0 ? -1 : 1; $absolute = abs($minutes); $hours = intdiv($absolute, 60); $mins = $absolute % 60; return [ 'minutes' => $minutes, 'heures_decimales' => round($minutes / 60, 2), 'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT), ]; } /** * Retrouve la ligne de quota la plus pertinente pour une année donnée. * * Le tri du ORDER BY applique une règle de repli en trois temps : * 1. l'année demandée exactement ; * 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus * récente reste la meilleure estimation) ; * 3. sinon l'année POSTÉRIEURE la plus proche. * * Sans aucune ligne — agent créé avant la mise en place des quotas — on * retombe sur un temps plein théorique plutôt que sur un quota nul, qui * afficherait un dépassement à la première heure planifiée. * * `quota_source_annee` indique quelle année a réellement servi, pour que * l'interface puisse signaler un quota emprunté à une autre année. * * @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int} */ private function quotaProfile(int $agentId, int $year): array { $stmt = $this->pdo->prepare( "SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes FROM quota_agent_annuel WHERE id_agent = :agent_id ORDER BY CASE WHEN annee = :year_exact THEN 0 WHEN annee < :year_past THEN 1 ELSE 2 END, CASE WHEN annee <= :year_latest THEN annee END DESC, CASE WHEN annee > :year_future THEN annee END ASC LIMIT 1" ); $stmt->execute([ 'agent_id' => $agentId, 'year_exact' => $year, 'year_past' => $year, 'year_latest' => $year, 'year_future' => $year, ]); $quota = $stmt->fetch(); if (!$quota) { return [ 'quotite_travail' => 100.00, 'quota_reference_minutes' => self::REFERENCE_MINUTES, 'quota_cible_minutes' => self::REFERENCE_MINUTES, 'quota_source_annee' => $year, ]; } return [ 'quotite_travail' => (float) $quota['quotite_travail'], 'quota_reference_minutes' => (int) $quota['quota_reference_minutes'], 'quota_cible_minutes' => (int) $quota['quota_cible_minutes'], 'quota_source_annee' => (int) $quota['annee'], ]; } }