485 lines
18 KiB
PHP
485 lines
18 KiB
PHP
<?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 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.
|
||
*/
|
||
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 l’API 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 l’API 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 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
|
||
)));
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
|
||
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',
|
||
]);
|
||
}
|
||
}
|