pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class SchoolHolidayApiService
{
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/';
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',
];
/**
* Compatibilité avec le code existant : retourne uniquement les périodes.
*/
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.
*
* @return array{periods: array, warnings: array, school_years: array}
*/
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 lacadémie %s et lanné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 lannée scolaire %s nont 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.
*/
public function preview(string $academy, string $schoolYear): array
{
return $this->previewSchoolYear($academy, $schoolYear);
}
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 lAPI du calendrier scolaire.');
}
return [];
}
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 lAPI Explore.');
}
return $records;
} catch (RuntimeException $e) {
$lastError = $e;
}
}
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
}
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 lancienne 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
)));
}
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;
}
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;
}
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))));
}
private function zoneForAcademy(string $academy): ?string
{
$key = $this->normalizeAcademy($academy);
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
}
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 dinitialiser 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 dURL 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('LAPI 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 lAPI nest pas un JSON valide.');
}
return $data;
}
private function quoteWhereValue(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
private function dateOnly(string $value): ?string
{
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
return null;
}
return $matches[1];
}
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;
}
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;
}
private function normalizeAcademy(string $value): string
{
$value = str_replace(['', '—', '', '_', '-'], ' ', $value);
return $this->normalizeText($value);
}
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);
}
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',
]);
}
}