Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -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, [