Files
pta/app/Services/SchoolHolidayApiService.php
Loic Masi 5fbf76868f pour prod
2026-08-07 16:13:43 +02:00

485 lines
18 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;
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',
]);
}
}