pour prod
This commit is contained in:
484
app/Services/SchoolHolidayApiService.php
Normal file
484
app/Services/SchoolHolidayApiService.php
Normal 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 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user