Files
pta/app/Services/SchoolHolidayApiService.php
2026-08-20 13:22:49 +02:00

651 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
/**
* Import des vacances scolaires depuis l'API publique data.education.gouv.fr.
*
* Ce service ne fait que PROPOSER : il ne touche jamais la base. L'écriture
* relève de VacationModel, après validation manuelle par un utilisateur. Ce
* découpage est délibéré — les libellés et découpages publiés varient d'une
* académie et d'une année à l'autre, et rien de tout cela n'entre en base sans
* relecture humaine.
*
* TOLÉRANCE AUX PANNES, principe directeur de tout le fichier. Le portail public
* change de version d'API, de graphie d'académie et de format de réponse sans
* préavis. Chaque étape est donc doublée d'un repli, et un échec partiel produit
* un AVERTISSEMENT plutôt qu'une erreur : mieux vaut proposer les dates
* disponibles que rien du tout.
*
* Nécessite un accès réseau SORTANT depuis le conteneur php.
*/
final class SchoolHolidayApiService
{
/** Jeu de données public du calendrier scolaire. */
private const DATASET = 'fr-en-calendrier-scolaire';
/**
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
* la page continue à fonctionner lors d'une évolution du portail.
*/
private const EXPLORE_ENDPOINTS = [
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
];
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
/**
* Zone de vacances (A, B ou C) de chaque académie métropolitaine.
*
* Table locale servant de REPLI quand l'API ne publie pas de ligne propre à
* l'académie mais seulement une ligne commune à sa zone. À mettre à jour si
* le découpage officiel évolue — il a déjà changé par le passé, et les
* académies d'outre-mer n'y figurent pas.
*/
private const ACADEMY_ZONES = [
'dijon' => 'Zone A'
];
/**
* Compatibilité avec le code existant : retourne uniquement les périodes,
* sans les avertissements. Le code actuel appelle plutôt la variante
* détaillée, qui permet d'afficher ce qui n'a pas pu être chargé.
*
* @return array<int, array<string, mixed>>
*/
public function previewCalendarYear(string $academy, int $calendarYear): array
{
return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods'];
}
/**
* Retourne toutes les périodes recouvrant une année civile, accompagnées
* d'avertissements non bloquants. Une année civile chevauche deux années
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
* empêcher l'affichage des dates déjà disponibles.
*
* Une année civile couvre la FIN d'une année scolaire et le DÉBUT de la
* suivante : les deux sont donc interrogées, puis les périodes fusionnées et
* dédoublonnées sur (libellé, début, fin). Ne sont conservées que celles qui
* INTERSECTENT l'année civile demandée.
*
* L'exception n'est levée que si AUCUNE des deux requêtes n'a abouti : un
* seul succès suffit à rendre un résultat exploitable, l'échec de l'autre
* partant en avertissement.
*
* @return array{periods: array, warnings: array, school_years: array}
*
* @throws RuntimeException Année ou académie invalide, ou API totalement
* injoignable.
*/
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
{
if ($calendarYear < 2000 || $calendarYear > 2100) {
throw new RuntimeException('Année civile invalide.');
}
$academy = trim($academy);
if ($academy === '') {
throw new RuntimeException('Académie manquante.');
}
$schoolYears = [
($calendarYear - 1) . '-' . $calendarYear,
$calendarYear . '-' . ($calendarYear + 1),
];
$all = [];
$warnings = [];
$successfulQueries = 0;
foreach ($schoolYears as $schoolYear) {
try {
$periods = $this->previewSchoolYear($academy, $schoolYear);
$successfulQueries++;
if ($periods === []) {
$warnings[] = sprintf(
'Aucune date publiée pour l’académie %s et l’année scolaire %s.',
$academy,
$schoolYear
);
}
foreach ($periods as $period) {
$key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin'];
$all[$key] = $period;
}
} catch (Throwable $e) {
$warnings[] = sprintf(
'Les dates de l’année scolaire %s n’ont pas pu être chargées : %s',
$schoolYear,
$e->getMessage()
);
}
}
if ($successfulQueries === 0) {
throw new RuntimeException(implode(' ', $warnings));
}
$yearStart = sprintf('%04d-01-01', $calendarYear);
$yearEnd = sprintf('%04d-12-31', $calendarYear);
$periods = array_values(array_filter(
$all,
static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart
));
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
return [
'periods' => $periods,
'warnings' => array_values(array_unique($warnings)),
'school_years' => $schoolYears,
];
}
/**
* Compatibilité avec l'ancienne API interne V5.17, qui raisonnait en année
* scolaire et non en année civile.
*
* @return array<int, array<string, mixed>>
*/
public function preview(string $academy, string $schoolYear): array
{
return $this->previewSchoolYear($academy, $schoolYear);
}
/**
* Interroge l'API pour UNE année scolaire, avec trois stratégies en cascade.
*
* On s'arrête au PREMIER résultat non vide ; les erreurs intermédiaires sont
* accumulées et seule la dernière est remontée si tout échoue. Un résultat
* vide sans erreur n'est pas une anomalie : l'académie peut simplement ne
* rien avoir publié.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si toutes les stratégies ont échoué en erreur.
*/
private function previewSchoolYear(string $academy, string $schoolYear): array
{
$errors = [];
// 1. Recherche par académie. On essaie plusieurs graphies afin de ne
// pas rendre la recherche dépendante des accents ou du type de tiret.
foreach ($this->academyVariants($academy) as $academyVariant) {
try {
$records = $this->fetchExploreRecords([
'limit' => 100,
'lang' => 'fr',
'timezone' => 'Europe/Paris',
'order_by' => 'start_date',
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
'refine' => 'location:' . $this->quoteWhereValue($academyVariant),
]);
$periods = $this->normalize($records, $academy, $schoolYear, false);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
// 2. Repli par zone. Il est utile lorsque le portail fournit une
// période commune à toute la zone, sans ligne spécifique à l'académie.
$zone = $this->zoneForAcademy($academy);
if ($zone !== null) {
try {
$records = $this->fetchExploreRecords([
'limit' => 100,
'lang' => 'fr',
'timezone' => 'Europe/Paris',
'order_by' => 'start_date',
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
'refine' => 'zones:' . $this->quoteWhereValue($zone),
]);
$periods = $this->normalize($records, $academy, $schoolYear, true);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
// 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un
// format JSON différent, normalisé dans fetchLegacyRecords().
foreach ($this->academyVariants($academy) as $academyVariant) {
try {
$records = $this->fetchLegacyRecords($academyVariant, $schoolYear);
$periods = $this->normalize($records, $academy, $schoolYear, false);
if ($periods !== []) {
return $periods;
}
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
if ($errors !== []) {
throw new RuntimeException(end($errors) ?: 'Erreur inconnue de l’API du calendrier scolaire.');
}
return [];
}
/**
* Interroge l'API Explore, en essayant successivement chaque version
* d'endpoint jusqu'à obtenir une réponse exploitable.
*
* @param array<string, mixed> $parameters Paramètres de requête Explore.
*
* @return array<int, mixed> Contenu brut de la clé `results`.
*
* @throws RuntimeException Si aucun endpoint n'a répondu correctement.
*/
private function fetchExploreRecords(array $parameters): array
{
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
$lastError = null;
foreach (self::EXPLORE_ENDPOINTS as $endpoint) {
try {
$payload = $this->fetchJson($endpoint . '?' . $query);
$records = $payload['results'] ?? null;
if (!is_array($records)) {
throw new RuntimeException('Réponse inattendue de l’API Explore.');
}
return $records;
} catch (RuntimeException $e) {
$lastError = $e;
}
}
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
}
/**
* Dernier repli : l'ancienne API Opendatasoft 1.0.
*
* Son format diffère — les champs utiles sont imbriqués sous `fields` — et
* sont remis à plat ici pour que normalize() traite les deux sources de la
* même façon.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si la réponse n'a pas la forme attendue.
*/
private function fetchLegacyRecords(string $academy, string $schoolYear): array
{
$query = http_build_query([
'dataset' => self::DATASET,
'rows' => 100,
'sort' => 'start_date',
'refine.location' => $academy,
'refine.annee_scolaire' => $schoolYear,
], '', '&', PHP_QUERY_RFC3986);
$payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query);
$records = $payload['records'] ?? null;
if (!is_array($records)) {
throw new RuntimeException('Réponse inattendue de l’ancienne API du calendrier scolaire.');
}
return array_values(array_filter(array_map(
static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null)
? $record['fields']
: null,
$records
)));
}
/**
* Convertit les enregistrements bruts de l'API en périodes exploitables.
*
* Trois filtres successifs :
* 1. la POPULATION doit concerner les élèves — un enregistrement réservé
* aux enseignants ne fait pas de vacances pour l'accueil de loisirs ;
* 2. hors repli par zone, l'ACADÉMIE doit correspondre à celle demandée,
* comparée après normalisation (accents et tirets neutralisés) ;
* 3. la DESCRIPTION doit ressembler à des vacances (voir
* isVacationDescription()), afin d'écarter rentrées et pré-rentrées.
*
* CONVERSION DE DATE ESSENTIELLE : l'API publie la DATE DE REPRISE des
* cours, alors que PTA raisonne en bornes INCLUSIVES. Le dernier jour de
* vacances est donc la veille de la reprise. Sans cette correction, chaque
* période gagnerait un jour et le premier jour de classe serait qualifié
* d'extrascolaire.
*
* @param array<int, mixed> $records
* @param bool $zoneFallback true quand les données viennent du repli par
* zone : le filtre sur l'académie est alors levé, et l'académie
* demandée est conservée dans le résultat.
*
* @return array<int, array<string, mixed>> Triées par date de début.
*/
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
{
$periods = [];
$requestedAcademy = $this->normalizeAcademy($academy);
foreach ($records as $record) {
if (!is_array($record)) {
continue;
}
// Certaines vacances communes sont publiées avec population "-".
// En filtrant uniquement "Élèves", elles disparaissaient du résultat.
if (!$this->isStudentPopulation($record['population'] ?? '')) {
continue;
}
$recordAcademy = trim($this->stringValue($record['location'] ?? ''));
if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) {
continue;
}
$description = trim($this->stringValue($record['description'] ?? ''));
if (!$this->isVacationDescription($description)) {
continue;
}
$startRaw = $this->stringValue($record['start_date'] ?? '');
$endRaw = $this->stringValue($record['end_date'] ?? '');
$start = $this->dateOnly($startRaw);
$returnDate = $this->dateOnly($endRaw);
if ($start === null || $returnDate === null) {
continue;
}
// L'API donne la date de reprise. PTA utilise des bornes inclusives,
// donc le dernier jour de vacances est la veille de la reprise.
$end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d');
if ($end < $start) {
$end = $start;
}
$zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? '')));
$recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear));
$key = $description . '|' . $start . '|' . $end;
$periods[$key] = [
'libelle' => $description,
'date_debut' => $start,
'date_fin' => $end,
'date_reprise_api' => $returnDate,
'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear,
'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy,
'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''),
'source' => 'DATA_GOUV',
];
}
$periods = array_values($periods);
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
return $periods;
}
/**
* Reconnaît une période de vacances à partir de sa description.
*
* Filtrage par MOTS-CLÉS faute de champ de type dans le jeu de données. Les
* rentrées et pré-rentrées sont exclues explicitement : ce sont des
* événements ponctuels, pas des périodes. « été austral » et « hiver
* austral » couvrent les académies d'outre-mer.
*
* Une description non reconnue est SILENCIEUSEMENT ignorée : un libellé
* inédit peut donc faire disparaître une période, à surveiller si des
* vacances manquent à l'appel.
*/
private function isVacationDescription(string $description): bool
{
$normalized = $this->normalizeText($description);
if ($normalized === '') {
return false;
}
if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) {
return false;
}
foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) {
if (str_contains($normalized, $keyword)) {
return true;
}
}
return false;
}
/**
* Graphies alternatives d'un nom d'académie à essayer successivement.
*
* Le portail n'est pas constant : « Nancy-Metz », « Nancy Metz »,
* « Orléans-Tours » ou « Orleans-Tours » selon les enregistrements, avec
* parfois un tiret cadratin plutôt qu'un trait d'union. On essaie donc
* l'original, les tirets uniformisés, la version sans accents, et les
* variantes à espaces.
*
* @return array<int, string> Sans doublon, dans l'ordre d'essai.
*/
private function academyVariants(string $academy): array
{
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
$variants = [$academy];
$variants[] = str_replace(['–', '—', '‑'], '-', $academy);
$variants[] = $this->stripAccents($academy);
$variants[] = str_replace('-', ' ', $academy);
$variants[] = str_replace('-', ' ', $this->stripAccents($academy));
return array_values(array_unique(array_filter(array_map('trim', $variants))));
}
/**
* Zone de vacances d'une académie, ou null si elle est inconnue de la table
* (outre-mer, ou nom mal orthographié).
*/
private function zoneForAcademy(string $academy): ?string
{
$key = $this->normalizeAcademy($academy);
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
}
/**
* Effectue la requête HTTP et décode le JSON.
*
* Deux implémentations : cURL quand l'extension est présente, sinon
* file_get_contents() avec un contexte de flux. Ce second chemin ne
* fonctionne que si `allow_url_fopen` est actif, d'où le message explicite
* en cas d'échec — c'est la panne la plus fréquente sur un hébergement
* mutualisé.
*
* Délais volontairement courts (8 s de connexion, 20 s au total) : l'appel
* est SYNCHRONE dans une requête web, l'utilisateur attend devant son écran.
*
* `ignore_errors` et CURLOPT_RETURNTRANSFER permettent de lire le corps même
* sur un code d'erreur, mais tout code >= 400 est traité comme un échec.
*
* @return array<mixed> Charge utile décodée.
*
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
*/
private function fetchJson(string $url): array
{
$body = false;
$status = 0;
if (function_exists('curl_init')) {
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Impossible d’initialiser cURL.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'],
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false) {
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
}
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 20,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
}
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
$status = (int) $matches[1];
break;
}
}
}
if ($status >= 400) {
throw new RuntimeException('L’API a répondu avec le code HTTP ' . $status . '.');
}
$data = json_decode((string) $body, true);
if (!is_array($data)) {
throw new RuntimeException('Le contenu reçu depuis l’API n’est pas un JSON valide.');
}
return $data;
}
/**
* Échappe une valeur pour la clause `where` de l'API Explore, dont la
* syntaxe est proche du SQL : les guillemets et les barres obliques
* inverses doivent être protégés pour ne pas altérer la requête distante.
*/
private function quoteWhereValue(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
/**
* Extrait la partie date d'un horodatage ISO.
* L'API renvoie tantôt « 2026-02-21 », tantôt
* « 2026-02-21T23:00:00+00:00 » ; seule la date nous intéresse.
*
* @return string|null null si aucune date n'est reconnaissable.
*/
private function dateOnly(string $value): ?string
{
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
return null;
}
return $matches[1];
}
/**
* Ramène une valeur d'API à une chaîne.
* Certains champs (zones, population) sont tantôt scalaires, tantôt des
* tableaux selon la version de l'API ; les tableaux sont aplatis.
*/
private function stringValue(mixed $value): string
{
if (is_array($value)) {
return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value));
}
return (string) $value;
}
/**
* Vrai si l'enregistrement concerne les élèves.
*
* Acceptent : la valeur vide, « - », « Élèves », « Tous », « Tout public ».
* Les vacances communes sont en effet publiées avec une population « - » ;
* ne retenir que « Élèves » les faisait disparaître du résultat.
*/
private function isStudentPopulation(mixed $value): bool
{
$values = is_array($value) ? $value : [$value];
foreach ($values as $item) {
$population = $this->normalizeText((string) $item);
if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) {
return true;
}
}
return false;
}
/**
* Forme canonique d'un nom d'académie pour comparaison : tirets et
* soulignés deviennent des espaces, accents et casse neutralisés.
* « Nancy-Metz », « nancy metz » et « NANCY_METZ » se rejoignent ainsi.
*/
private function normalizeAcademy(string $value): string
{
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
return $this->normalizeText($value);
}
/**
* Forme canonique d'un texte : sans accent, en minuscules, réduit aux
* lettres, chiffres et tirets, espaces compactés.
*/
private function normalizeText(string $value): string
{
$value = $this->stripAccents($value);
$value = str_replace(['–', '—', '‑', '_'], '-', $value);
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value;
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return trim($value);
}
/**
* Retire les accents par une table de correspondance explicite.
*
* Table écrite à la main plutôt qu'iconv ou intl : ces extensions ne sont
* pas garanties partout, et leur comportement de translittération varie
* selon la locale du système. Une table donne un résultat identique
* quel que soit l'hébergement.
*/
private function stripAccents(string $value): string
{
return strtr($value, [
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
'Ç' => 'C', 'ç' => 'c',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'Ñ' => 'N', 'ñ' => 'n',
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y',
'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae',
]);
}
}