Files
pta/app/Controllers/VacationController.php
2026-08-20 17:00:06 +02:00

248 lines
9.6 KiB
PHP
Raw 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\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\VacationModel;
use App\Services\SchoolHolidayApiService;
use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Périodes de vacances scolaires.
*
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
* journée, qui conditionne à son tour les motifs pertinents et l'application
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
* simple calendrier d'affichage.
*
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
* humain l'ait relue — les libellés et découpages de l'API varient d'une
* académie à l'autre.
*
* Réservé au Service Enfance.
*/
final class VacationController
{
private VacationModel $vacationModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->vacationModel = new VacationModel($pdo);
$this->access = new Access($pdo);
}
/**
* GET api/vacation_periods.php — périodes enregistrées.
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
*/
public function index(): void
{
$this->access->requireService();
$schoolYear = trim((string) ($_GET['school_year'] ?? ''));
if ($schoolYear !== '' && !$this->isSchoolYear($schoolYear)) {
JsonResponse::send(422, ['error' => 'Année scolaire invalide. Format attendu : 2026-2027.']);
}
JsonResponse::send(200, [
'periods' => $this->vacationModel->all($schoolYear !== '' ? $schoolYear : null),
]);
}
/**
* GET api/preview_school_holidays.php — propositions de l'API officielle,
* SANS aucune écriture en base.
*
* Le 502 (et non 500) en cas d'échec dit ce qui s'est passé : c'est le
* service distant qui n'a pas répondu, pas PTA qui a échoué. Le conteneur
* php a besoin d'un accès réseau sortant pour cet appel.
*
* Les `warnings` signalent les périodes que le service n'a pas su
* interpréter ; elles sont montrées à l'utilisateur plutôt que tues.
*
* Paramètres GET : academy, year (année civile).
*/
public function preview(): void
{
$this->access->requireService();
$academy = trim((string) ($_GET['academy'] ?? ''));
$calendarYearRaw = trim((string) ($_GET['year'] ?? ''));
if ($academy === '' || (function_exists('mb_strlen') ? mb_strlen($academy, 'UTF-8') : strlen($academy)) > 100) {
JsonResponse::send(422, ['error' => 'Renseignez une académie valide.']);
}
if (!preg_match('/^\d{4}$/', $calendarYearRaw)) {
JsonResponse::send(422, ['error' => 'Année civile invalide. Format attendu : 2026.']);
}
$calendarYear = (int) $calendarYearRaw;
if ($calendarYear < 2000 || $calendarYear > 2100) {
JsonResponse::send(422, ['error' => 'Année civile invalide.']);
}
try {
$result = (new SchoolHolidayApiService())->previewCalendarYearDetailed($academy, $calendarYear);
$periods = $result['periods'];
$warnings = $result['warnings'];
} catch (Throwable $e) {
JsonResponse::send(502, [
'error' => 'Impossible de charger les propositions depuis l’API officielle.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'academy' => $academy,
'year' => $calendarYear,
'periods' => $periods,
'warnings' => $warnings ?? [],
'message' => $periods === []
? 'Aucune période n’a été trouvée pour l’année ' . $calendarYear . '.'
: sprintf('%d période%s proposée%s pour toute l’année %d. Aucune donnée n’a été enregistrée en base.', count($periods), count($periods) > 1 ? 's' : '', count($periods) > 1 ? 's' : '', $calendarYear),
]);
}
/**
* POST api/save_vacation_periods.php — enregistre les périodes retenues.
*
* Le plafond de 50 périodes correspond à un large excédent d'une année
* scolaire complète ; il borne le coût d'un envoi malformé ou répété.
*
* Les libellés, académies et zones sont tronqués avec les fonctions mb_* aux
* longueurs des colonnes : une troncature en octets couperait un caractère
* accentué en deux et produirait de l'UTF-8 invalide en base.
*
* `source` distingue MANUEL de DATA_GOUV pour tracer l'origine ; une valeur
* inconnue retombe sur MANUEL au lieu de faire échouer l'enregistrement.
*
* Charge utile : periods[], csrf_token.
*/
public function save(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$rawPeriods = $payload['periods'] ?? [];
if (!is_array($rawPeriods) || $rawPeriods === []) {
JsonResponse::send(422, ['error' => 'Sélectionnez au moins une période à enregistrer.']);
}
if (count($rawPeriods) > 50) {
JsonResponse::send(422, ['error' => 'Trop de périodes ont été envoyées en une seule fois.']);
}
$periods = [];
foreach ($rawPeriods as $index => $raw) {
if (!is_array($raw)) {
JsonResponse::send(422, ['error' => 'Période invalide à la ligne ' . ($index + 1) . '.']);
}
$label = trim((string) ($raw['libelle'] ?? ''));
$start = trim((string) ($raw['date_debut'] ?? ''));
$end = trim((string) ($raw['date_fin'] ?? ''));
$schoolYear = trim((string) ($raw['annee_scolaire'] ?? ''));
$academy = trim((string) ($raw['academie'] ?? ''));
$zone = trim((string) ($raw['zone'] ?? ''));
$source = strtoupper(trim((string) ($raw['source'] ?? 'MANUEL')));
if ($label === '' || (function_exists('mb_strlen') ? mb_strlen($label, 'UTF-8') : strlen($label)) > 150 || !$this->isDate($start) || !$this->isDate($end) || $end < $start) {
JsonResponse::send(422, ['error' => 'Dates ou libellé invalides à la ligne ' . ($index + 1) . '.']);
}
if (!$this->isSchoolYear($schoolYear)) {
JsonResponse::send(422, ['error' => 'Année scolaire invalide à la ligne ' . ($index + 1) . '.']);
}
if (!in_array($source, ['MANUEL', 'DATA_GOUV'], true)) {
$source = 'MANUEL';
}
$periods[] = [
'libelle' => $label,
'date_debut' => $start,
'date_fin' => $end,
'annee_scolaire' => $schoolYear,
'academie' => function_exists('mb_substr') ? mb_substr($academy, 0, 100, 'UTF-8') : substr($academy, 0, 100),
'zone' => function_exists('mb_substr') ? mb_substr($zone, 0, 50, 'UTF-8') : substr($zone, 0, 50),
'source' => $source,
];
}
$count = $this->vacationModel->saveMany($periods);
Journal::ecriture(
'VACANCES_ENREGISTREES',
'periode_vacance',
null,
sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count),
['nombre' => $count]
);
JsonResponse::send(200, [
'success' => true,
'count' => $count,
'message' => sprintf('%d période%s de vacances enregistrée%s après validation manuelle.', $count, $count > 1 ? 's' : '', $count > 1 ? 's' : ''),
]);
}
/**
* POST api/delete_vacation_period.php — supprime une période.
*
* Aucun contrôle d'usage : supprimer une période change le contexte
* scolaire/extrascolaire des journées concernées, mais ne touche aucun
* créneau déjà planifié.
*
* Charge utile : period_id, csrf_token.
*/
public function delete(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$id = filter_var($payload['period_id'] ?? null, FILTER_VALIDATE_INT);
if (!$id) {
JsonResponse::send(422, ['error' => 'Période invalide.']);
}
if (!$this->vacationModel->delete((int) $id)) {
JsonResponse::send(404, ['error' => 'Période introuvable.']);
}
Journal::ecriture(
'VACANCES_SUPPRIMEES',
'periode_vacance',
(int) $id,
sprintf('Période de vacances #%d supprimée.', (int) $id)
);
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
}
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function isDate(string $value): bool
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value;
}
/**
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
* immédiatement la première : « 2026-2028 » est refusé.
*/
private function isSchoolYear(string $value): bool
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
return false;
}
return (int) $matches[2] === (int) $matches[1] + 1;
}
}