Ajout des commentaires dans le code
This commit is contained in:
@@ -8,8 +8,26 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -24,35 +42,24 @@ final class SchoolHolidayApiService
|
||||
|
||||
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 = [
|
||||
'aix-marseille' => 'Zone B',
|
||||
'amiens' => 'Zone B',
|
||||
'besancon' => 'Zone A',
|
||||
'bordeaux' => 'Zone A',
|
||||
'clermont-ferrand' => 'Zone A',
|
||||
'creteil' => 'Zone C',
|
||||
'dijon' => 'Zone A',
|
||||
'grenoble' => 'Zone A',
|
||||
'lille' => 'Zone B',
|
||||
'limoges' => 'Zone A',
|
||||
'lyon' => 'Zone A',
|
||||
'montpellier' => 'Zone C',
|
||||
'nancy-metz' => 'Zone B',
|
||||
'nantes' => 'Zone B',
|
||||
'nice' => 'Zone B',
|
||||
'normandie' => 'Zone B',
|
||||
'orleans-tours' => 'Zone B',
|
||||
'paris' => 'Zone C',
|
||||
'poitiers' => 'Zone A',
|
||||
'reims' => 'Zone B',
|
||||
'rennes' => 'Zone B',
|
||||
'strasbourg' => 'Zone B',
|
||||
'toulouse' => 'Zone C',
|
||||
'versailles' => 'Zone C',
|
||||
'dijon' => 'Zone A'
|
||||
];
|
||||
|
||||
/**
|
||||
* Compatibilité avec le code existant : retourne uniquement les périodes.
|
||||
* 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
|
||||
{
|
||||
@@ -65,7 +72,19 @@ final class SchoolHolidayApiService
|
||||
* 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
|
||||
{
|
||||
@@ -135,13 +154,28 @@ final class SchoolHolidayApiService
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancienne API interne V5.17.
|
||||
* 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 = [];
|
||||
@@ -212,6 +246,16 @@ final class SchoolHolidayApiService
|
||||
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);
|
||||
@@ -233,6 +277,17 @@ final class SchoolHolidayApiService
|
||||
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([
|
||||
@@ -257,6 +312,30 @@ final class SchoolHolidayApiService
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
@@ -319,6 +398,18 @@ final class SchoolHolidayApiService
|
||||
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);
|
||||
@@ -337,6 +428,17 @@ final class SchoolHolidayApiService
|
||||
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);
|
||||
@@ -349,12 +451,35 @@ final class SchoolHolidayApiService
|
||||
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;
|
||||
@@ -412,11 +537,23 @@ final class SchoolHolidayApiService
|
||||
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)) {
|
||||
@@ -425,6 +562,11 @@ final class SchoolHolidayApiService
|
||||
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)) {
|
||||
@@ -434,6 +576,13 @@ final class SchoolHolidayApiService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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];
|
||||
@@ -446,12 +595,21 @@ final class SchoolHolidayApiService
|
||||
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);
|
||||
@@ -462,6 +620,14 @@ final class SchoolHolidayApiService
|
||||
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, [
|
||||
|
||||
Reference in New Issue
Block a user