feat: Configurer les jours fériés et vacances du calendrier scolaire

Les administrateurs d'établissement avaient besoin de gérer le calendrier
scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement
les jours non travaillés. Sans cette configuration centralisée, chaque
module devait gérer indépendamment les contraintes de dates.

Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer
les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés
français (dont les fêtes mobiles liées à Pâques). Les enseignants sont
notifiés par email lors de l'ajout d'une journée pédagogique. Un query
IsSchoolDay et une validation des dates d'échéance de devoirs permettent
aux autres modules de s'intégrer sans couplage direct.
This commit is contained in:
2026-02-18 10:16:28 +01:00
parent 0951322d71
commit e06fd5424d
60 changed files with 7698 additions and 1 deletions

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AddPedagogicalDay;
/**
* Command pour ajouter une journée pédagogique au calendrier scolaire.
*/
final readonly class AddPedagogicalDayCommand
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $date,
public string $label,
public ?string $description = null,
) {
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AddPedagogicalDay;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Ajoute une journée pédagogique au calendrier et déclenche la notification enseignants.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AddPedagogicalDayHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private Clock $clock,
) {
}
public function __invoke(AddPedagogicalDayCommand $command): SchoolCalendar
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $command->date) !== 1) {
throw new InvalidArgumentException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $command->date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new InvalidArgumentException('La date n\'existe pas dans le calendrier.');
}
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$date = new DateTimeImmutable($command->date);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId)
?? SchoolCalendar::initialiser($tenantId, $academicYearId);
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: $date,
endDate: $date,
label: $command->label,
description: $command->description,
);
$calendar->ajouterJourneePedagogique($entry, $this->clock->now());
$this->calendarRepository->save($calendar);
return $calendar;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureCalendar;
/**
* Command pour configurer le calendrier scolaire avec une zone et les entrées officielles.
*/
final readonly class ConfigureCalendarCommand
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $zone,
public string $academicYear,
) {
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureCalendar;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Configure le calendrier scolaire en important les données officielles pour une zone.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ConfigureCalendarHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private OfficialCalendarProvider $calendarProvider,
private Clock $clock,
) {
}
public function __invoke(ConfigureCalendarCommand $command): SchoolCalendar
{
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$zone = SchoolZone::from($command->zone);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId)
?? SchoolCalendar::initialiser($tenantId, $academicYearId);
$entries = $this->calendarProvider->toutesEntreesOfficielles($zone, $command->academicYear);
$calendar->configurer($zone, $entries, $this->clock->now());
$this->calendarRepository->save($calendar);
return $calendar;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
/**
* Port pour fournir les données officielles du calendrier scolaire.
*/
interface OfficialCalendarProvider
{
/**
* Retourne les jours fériés officiels pour une année académique.
*
* @return CalendarEntry[]
*/
public function joursFeries(string $academicYear): array;
/**
* Retourne les vacances scolaires pour une zone et une année académique.
*
* @return CalendarEntry[]
*/
public function vacancesParZone(SchoolZone $zone, string $academicYear): array;
/**
* Retourne toutes les entrées officielles (fériés + vacances) pour une zone.
*
* @return CalendarEntry[]
*/
public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\IsSchoolDay;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class IsSchoolDayHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
) {
}
public function __invoke(IsSchoolDayQuery $query): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $query->date) !== 1) {
throw new InvalidArgumentException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $query->date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new InvalidArgumentException('La date n\'existe pas dans le calendrier.');
}
$date = new DateTimeImmutable($query->date);
// Weekend = pas un jour d'école
$dayOfWeek = (int) $date->format('N');
if ($dayOfWeek >= 6) {
return false;
}
$tenantId = TenantId::fromString($query->tenantId);
$academicYearId = AcademicYearId::fromString($query->academicYearId);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
// Pas de calendrier configuré : on considère que c'est un jour ouvré (lundi-vendredi)
return true;
}
return $calendar->estJourOuvre($date);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\IsSchoolDay;
final readonly class IsSchoolDayQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $date,
) {
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
final readonly class DueDateValidationResult
{
/**
* @param string[] $warnings
*/
public function __construct(
public bool $valid,
public ?string $reason = null,
public array $warnings = [],
) {
}
public static function ok(string ...$warnings): self
{
return new self(valid: true, warnings: $warnings);
}
public static function invalide(string $reason): self
{
return new self(valid: false, reason: $reason);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class ValidateHomeworkDueDateHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
) {
}
public function __invoke(ValidateHomeworkDueDateQuery $query): DueDateValidationResult
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $query->dueDate) !== 1) {
return DueDateValidationResult::invalide('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $query->dueDate);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
return DueDateValidationResult::invalide('La date n\'existe pas dans le calendrier.');
}
$dueDate = new DateTimeImmutable($query->dueDate);
// Weekend
$dayOfWeek = (int) $dueDate->format('N');
if ($dayOfWeek >= 6) {
return DueDateValidationResult::invalide(
"L'échéance ne peut pas être un weekend.",
);
}
$tenantId = TenantId::fromString($query->tenantId);
$academicYearId = AcademicYearId::fromString($query->academicYearId);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
return DueDateValidationResult::ok();
}
if (!$calendar->estJourOuvre($dueDate)) {
return DueDateValidationResult::invalide(
"L'échéance ne peut pas être un jour férié ou pendant les vacances.",
);
}
if ($calendar->estJourRetourVacances($dueDate)) {
return DueDateValidationResult::ok(
'Attention : cette date est le jour du retour de vacances.',
);
}
return DueDateValidationResult::ok();
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
final readonly class ValidateHomeworkDueDateQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $dueDate,
) {
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use function sprintf;
/**
* Événement émis lorsque le calendrier scolaire est configuré
* (zone sélectionnée, vacances importées).
*/
final readonly class CalendrierConfigure implements DomainEvent
{
public function __construct(
public TenantId $tenantId,
public AcademicYearId $academicYearId,
public SchoolZone $zone,
public int $nombreEntrees,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId),
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use function sprintf;
/**
* Événement émis lorsqu'une journée pédagogique est ajoutée au calendrier.
*
* Déclenche la notification aux enseignants (AC5).
*/
final readonly class JourneePedagogiqueAjoutee implements DomainEvent
{
public function __construct(
public CalendarEntryId $entryId,
public TenantId $tenantId,
public AcademicYearId $academicYearId,
public DateTimeImmutable $date,
public string $label,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId),
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DateTimeImmutable;
use DomainException;
use function sprintf;
final class CalendrierDatesInvalidesException extends DomainException
{
public static function finAvantDebut(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
{
return new self(sprintf(
'La date de fin (%s) ne peut pas être antérieure à la date de début (%s).',
$endDate->format('Y-m-d'),
$startDate->format('Y-m-d'),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use DomainException;
use function sprintf;
final class CalendrierEntreeNonTrouveeException extends DomainException
{
public static function avecId(CalendarEntryId $entryId): self
{
return new self(sprintf(
'L\'entrée de calendrier avec l\'ID "%s" n\'a pas été trouvée.',
(string) $entryId,
));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function mb_substr;
use function preg_replace;
use function sprintf;
final class CalendrierLabelInvalideException extends DomainException
{
public static function pourLongueur(string $label, int $min, int $max): self
{
$sanitized = preg_replace('/[\x00-\x1f\x7f]/u', '', mb_substr($label, 0, 50));
return new self(sprintf(
'Le libellé "%s" doit contenir entre %d et %d caractères.',
$sanitized,
$min,
$max,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use function sprintf;
final class CalendrierNonTrouveException extends DomainException
{
public static function pourTenantEtAnnee(TenantId $tenantId, AcademicYearId $academicYearId): self
{
return new self(sprintf(
'Aucun calendrier scolaire trouvé pour le tenant "%s" et l\'année académique "%s".',
(string) $tenantId,
(string) $academicYearId,
));
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use function assert;
use DateTimeImmutable;
use function mb_strlen;
use function trim;
/**
* Value Object représentant une entrée dans le calendrier scolaire.
*
* Chaque entrée couvre une période (jour unique ou plage) et indique
* un type (férié, vacances, journée pédagogique, etc.).
*/
final readonly class CalendarEntry
{
private const int MIN_LABEL_LENGTH = 2;
private const int MAX_LABEL_LENGTH = 100;
/** @var non-empty-string */
public string $label;
public function __construct(
public CalendarEntryId $id,
public CalendarEntryType $type,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
string $label,
public ?string $description = null,
) {
if ($this->endDate < $this->startDate) {
throw CalendrierDatesInvalidesException::finAvantDebut($this->startDate, $this->endDate);
}
$trimmed = trim($label);
$length = mb_strlen($trimmed);
if ($length < self::MIN_LABEL_LENGTH || $length > self::MAX_LABEL_LENGTH) {
throw CalendrierLabelInvalideException::pourLongueur($label, self::MIN_LABEL_LENGTH, self::MAX_LABEL_LENGTH);
}
assert($trimmed !== '');
$this->label = $trimmed;
}
/**
* Vérifie si cette entrée couvre une date donnée.
*/
public function couvre(DateTimeImmutable $date): bool
{
$dateStr = $date->format('Y-m-d');
return $dateStr >= $this->startDate->format('Y-m-d')
&& $dateStr <= $this->endDate->format('Y-m-d');
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Shared\Domain\EntityId;
final readonly class CalendarEntryId extends EntityId
{
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
/**
* Type d'entrée dans le calendrier scolaire.
*
* Chaque type a un impact sur la disponibilité des cours et devoirs.
*/
enum CalendarEntryType: string
{
case HOLIDAY = 'holiday';
case VACATION = 'vacation';
case PEDAGOGICAL_DAY = 'pedagogical';
case BRIDGE = 'bridge';
case EXCEPTIONAL_CLOSURE = 'closure';
public function label(): string
{
return match ($this) {
self::HOLIDAY => 'Jour férié',
self::VACATION => 'Vacances scolaires',
self::PEDAGOGICAL_DAY => 'Journée pédagogique',
self::BRIDGE => 'Pont',
self::EXCEPTIONAL_CLOSURE => 'Fermeture exceptionnelle',
};
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Event\CalendrierConfigure;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Exception\CalendrierEntreeNonTrouveeException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Aggregate Root représentant le calendrier scolaire d'un établissement.
*
* Un calendrier est identifié par (tenantId, academicYearId).
* Il contient les jours fériés, vacances et journées pédagogiques.
*
* @see FR80: Configurer jours fériés et vacances scolaires
*/
final class SchoolCalendar extends AggregateRoot
{
/** @var array<string, CalendarEntry> Indexé par CalendarEntryId */
private array $entries = [];
private function __construct(
public private(set) TenantId $tenantId,
public private(set) AcademicYearId $academicYearId,
public private(set) ?SchoolZone $zone,
) {
}
/**
* Initialise un nouveau calendrier scolaire pour un tenant et une année académique.
*/
public static function initialiser(
TenantId $tenantId,
AcademicYearId $academicYearId,
): self {
return new self(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: null,
);
}
/**
* Configure la zone scolaire et importe les entrées officielles.
*
* @param CalendarEntry[] $entries
*/
public function configurer(SchoolZone $zone, array $entries, DateTimeImmutable $at): void
{
$this->zone = $zone;
$this->entries = array_filter(
$this->entries,
static fn (CalendarEntry $e): bool => $e->type === CalendarEntryType::PEDAGOGICAL_DAY,
);
foreach ($entries as $entry) {
$this->entries[(string) $entry->id] = $entry;
}
$this->recordEvent(new CalendrierConfigure(
tenantId: $this->tenantId,
academicYearId: $this->academicYearId,
zone: $zone,
nombreEntrees: count($this->entries),
occurredOn: $at,
));
}
/**
* Configure uniquement la zone scolaire du calendrier.
*/
public function configurerZone(SchoolZone $zone): void
{
$this->zone = $zone;
}
/**
* Ajoute une entrée au calendrier.
*/
public function ajouterEntree(CalendarEntry $entry): void
{
$this->entries[(string) $entry->id] = $entry;
}
/**
* Ajoute une journée pédagogique et émet l'événement pour notifier les enseignants.
*/
public function ajouterJourneePedagogique(CalendarEntry $entry, DateTimeImmutable $at): void
{
if ($entry->type !== CalendarEntryType::PEDAGOGICAL_DAY) {
throw new InvalidArgumentException('L\'entrée doit être de type journée pédagogique.');
}
$this->entries[(string) $entry->id] = $entry;
$this->recordEvent(new JourneePedagogiqueAjoutee(
entryId: $entry->id,
tenantId: $this->tenantId,
academicYearId: $this->academicYearId,
date: $entry->startDate,
label: $entry->label,
occurredOn: $at,
));
}
/**
* Supprime une entrée du calendrier.
*
* @throws CalendrierEntreeNonTrouveeException
*/
public function supprimerEntree(CalendarEntryId $entryId): void
{
$key = (string) $entryId;
if (!isset($this->entries[$key])) {
throw CalendrierEntreeNonTrouveeException::avecId($entryId);
}
unset($this->entries[$key]);
}
/**
* Vide toutes les entrées du calendrier.
*/
public function viderEntrees(): void
{
$this->entries = [];
}
/**
* Vérifie si une date est un jour ouvré (pas un weekend, férié, ou vacances).
*/
public function estJourOuvre(DateTimeImmutable $date): bool
{
$dayOfWeek = (int) $date->format('N');
if ($dayOfWeek >= 6) {
return false;
}
foreach ($this->entries as $entry) {
if ($entry->couvre($date)) {
return false;
}
}
return true;
}
/**
* Trouve l'entrée calendrier couvrant une date donnée.
*/
public function trouverEntreePourDate(DateTimeImmutable $date): ?CalendarEntry
{
foreach ($this->entries as $entry) {
if ($entry->couvre($date)) {
return $entry;
}
}
return null;
}
/**
* Vérifie si une date tombe pendant les vacances scolaires.
*/
public function estEnVacances(DateTimeImmutable $date): bool
{
foreach ($this->entries as $entry) {
if ($entry->couvre($date) && $entry->type === CalendarEntryType::VACATION) {
return true;
}
}
return false;
}
/**
* Vérifie si une date est un jour de retour de vacances.
*/
public function estJourRetourVacances(DateTimeImmutable $date): bool
{
$veille = $date->modify('-1 day');
foreach ($this->entries as $entry) {
if ($entry->type === CalendarEntryType::VACATION
&& $veille->format('Y-m-d') === $entry->endDate->format('Y-m-d')
) {
return true;
}
}
return false;
}
/**
* @return CalendarEntry[]
*/
public function entries(): array
{
return array_values($this->entries);
}
/**
* Reconstitue un SchoolCalendar depuis le stockage.
*
* @param CalendarEntry[] $entries
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
TenantId $tenantId,
AcademicYearId $academicYearId,
?SchoolZone $zone,
array $entries,
): self {
$calendar = new self(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $zone,
);
foreach ($entries as $entry) {
$calendar->entries[(string) $entry->id] = $entry;
}
return $calendar;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
interface SchoolCalendarRepository
{
public function save(SchoolCalendar $calendar): void;
public function findByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): ?SchoolCalendar;
/**
* @throws CalendrierNonTrouveException
*/
public function getByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): SchoolCalendar;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
/**
* Zone scolaire française pour le calendrier des vacances.
*/
enum SchoolZone: string
{
case A = 'A';
case B = 'B';
case C = 'C';
/**
* @return non-empty-list<string>
*/
public function academies(): array
{
return match ($this) {
self::A => ['Besançon', 'Bordeaux', 'Clermont-Ferrand', 'Dijon', 'Grenoble', 'Limoges', 'Lyon', 'Poitiers'],
self::B => ['Aix-Marseille', 'Amiens', 'Lille', 'Nancy-Metz', 'Nantes', 'Nice', 'Orléans-Tours', 'Reims', 'Rennes', 'Rouen', 'Strasbourg'],
self::C => ['Créteil', 'Montpellier', 'Paris', 'Toulouse', 'Versailles'],
};
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayCommand;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayHandler;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<CalendarResource, CalendarResource>
*/
final readonly class AddPedagogicalDayProcessor implements ProcessorInterface
{
public function __construct(
private AddPedagogicalDayHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private MessageBusInterface $eventBus,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à ajouter une journée pédagogique.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
if ($data->date === null || $data->label === null) {
throw new BadRequestHttpException('La date et le libellé sont requis.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$command = new AddPedagogicalDayCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
date: $data->date,
label: $data->label,
description: $data->description,
);
$calendar = ($this->handler)($command);
foreach ($calendar->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
} catch (InvalidArgumentException|CalendrierLabelInvalideException|CalendrierDatesInvalidesException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarCommand;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarHandler;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use ValueError;
/**
* @implements ProcessorInterface<CalendarResource, CalendarResource>
*/
final readonly class ConfigureCalendarProcessor implements ProcessorInterface
{
public function __construct(
private ConfigureCalendarHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private MessageBusInterface $eventBus,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$startYear = $this->academicYearResolver->resolveStartYear($rawAcademicYearId);
if ($startYear === null) {
throw new BadRequestHttpException(
'Impossible de déterminer l\'année scolaire pour l\'identifiant fourni. '
. 'Utilisez "previous", "current" ou "next".',
);
}
$academicYear = $startYear . '-' . ($startYear + 1);
try {
$command = new ConfigureCalendarCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $data->zone ?? $data->importZone ?? throw new BadRequestHttpException('La zone scolaire est requise.'),
academicYear: $academicYear,
);
$calendar = ($this->handler)($command);
foreach ($calendar->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
} catch (InvalidArgumentException|ValueError $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<CalendarResource>
*/
final readonly class CalendarProvider implements ProviderInterface
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$tenantId = $this->tenantContext->getCurrentTenantId();
$calendar = $this->calendarRepository->findByTenantAndYear(
$tenantId,
AcademicYearId::fromString($academicYearId),
);
if ($calendar === null) {
return null;
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayHandler;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayQuery;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use function preg_match;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<CalendarResource>
*/
final readonly class IsSchoolDayProvider implements ProviderInterface
{
public function __construct(
private IsSchoolDayHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
/** @var string $date */
$date = $uriVariables['date'];
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
throw new BadRequestHttpException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new BadRequestHttpException('La date n\'existe pas dans le calendrier.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$isSchoolDay = ($this->handler)(new IsSchoolDayQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
date: $date,
));
$resource = new CalendarResource();
$resource->academicYearId = $academicYearId;
$resource->isSchoolDay = $isSchoolDay;
$resource->date = $date;
return $resource;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use Symfony\Component\Validator\Constraints as Assert;
final class CalendarEntryItem
{
public ?string $id = null;
#[Assert\NotBlank(message: "Le type d'entrée est requis.")]
#[Assert\Choice(choices: ['holiday', 'vacation', 'pedagogical', 'bridge', 'closure'], message: 'Type invalide.')]
public ?string $type = null;
#[Assert\NotBlank(message: 'La date de début est requise.')]
#[Assert\Date(message: 'La date de début doit être au format YYYY-MM-DD.')]
public ?string $startDate = null;
#[Assert\NotBlank(message: 'La date de fin est requise.')]
#[Assert\Date(message: 'La date de fin doit être au format YYYY-MM-DD.')]
public ?string $endDate = null;
#[Assert\NotBlank(message: 'Le libellé est requis.')]
#[Assert\Length(min: 2, max: 100, minMessage: 'Le libellé doit faire au moins 2 caractères.', maxMessage: 'Le libellé ne peut dépasser 100 caractères.')]
public ?string $label = null;
public ?string $description = null;
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Infrastructure\Api\Processor\AddPedagogicalDayProcessor;
use App\Administration\Infrastructure\Api\Processor\ConfigureCalendarProcessor;
use App\Administration\Infrastructure\Api\Provider\CalendarProvider;
use App\Administration\Infrastructure\Api\Provider\IsSchoolDayProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion du calendrier scolaire.
*
* @see FR80 - Configurer jours fériés et vacances scolaires
*/
#[ApiResource(
shortName: 'Calendar',
operations: [
new Get(
uriTemplate: '/academic-years/{academicYearId}/calendar',
provider: CalendarProvider::class,
name: 'get_calendar',
),
new Put(
uriTemplate: '/academic-years/{academicYearId}/calendar',
read: false,
processor: ConfigureCalendarProcessor::class,
name: 'configure_calendar',
),
new Post(
uriTemplate: '/academic-years/{academicYearId}/calendar/import-official',
read: false,
processor: ConfigureCalendarProcessor::class,
name: 'import_official_holidays',
),
new Post(
uriTemplate: '/academic-years/{academicYearId}/calendar/pedagogical-day',
read: false,
processor: AddPedagogicalDayProcessor::class,
name: 'add_pedagogical_day',
),
new Get(
uriTemplate: '/academic-years/{academicYearId}/calendar/is-school-day/{date}',
provider: IsSchoolDayProvider::class,
name: 'is_school_day',
),
],
)]
final class CalendarResource
{
#[ApiProperty(identifier: true)]
public ?string $academicYearId = null;
#[Assert\Choice(choices: ['A', 'B', 'C'], message: 'La zone scolaire doit être A, B ou C.')]
public ?string $zone = null;
/** @var CalendarEntryItem[]|null */
public ?array $entries = null;
// Write-only for import-official
#[ApiProperty(readable: false)]
public ?string $importZone = null;
// Write-only for pedagogical-day
#[ApiProperty(readable: false)]
#[Assert\Date(message: 'La date doit être au format YYYY-MM-DD.')]
public ?string $date = null;
#[ApiProperty(readable: false)]
#[Assert\Length(min: 2, max: 100)]
public ?string $label = null;
#[ApiProperty(readable: false)]
public ?string $description = null;
// Read-only for is-school-day
#[ApiProperty(writable: false)]
public ?bool $isSchoolDay = null;
#[ApiProperty(writable: false)]
public ?string $reason = null;
public static function fromCalendar(SchoolCalendar $calendar, string $academicYearId): self
{
$resource = new self();
$resource->academicYearId = $academicYearId;
$resource->zone = $calendar->zone?->value;
$resource->entries = [];
foreach ($calendar->entries() as $entry) {
$item = new CalendarEntryItem();
$item->id = (string) $entry->id;
$item->type = $entry->type->value;
$item->startDate = $entry->startDate->format('Y-m-d');
$item->endDate = $entry->endDate->format('Y-m-d');
$item->label = $entry->label;
$item->description = $entry->description;
$resource->entries[] = $item;
}
return $resource;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Repository\UserRepository;
use function array_filter;
use function count;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Throwable;
use Twig\Environment;
/**
* Notifie les enseignants par email lors de l'ajout d'une journée pédagogique.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class NotifyTeachersPedagogicalDayHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private UserRepository $userRepository,
private LoggerInterface $logger,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(JourneePedagogiqueAjoutee $event): void
{
$allUsers = $this->userRepository->findAllByTenant($event->tenantId);
$teachers = array_filter(
$allUsers,
static fn ($user) => $user->aLeRole(Role::PROF),
);
if (count($teachers) === 0) {
$this->logger->info('Journée pédagogique ajoutée — aucun enseignant à notifier', [
'tenant_id' => (string) $event->tenantId,
'date' => $event->date->format('Y-m-d'),
'label' => $event->label,
]);
return;
}
$html = $this->twig->render('emails/pedagogical_day_notification.html.twig', [
'date' => $event->date->format('d/m/Y'),
'label' => $event->label,
]);
$sent = 0;
foreach ($teachers as $teacher) {
try {
$email = (new Email())
->from($this->fromEmail)
->to((string) $teacher->email)
->subject('Journée pédagogique — ' . $event->label)
->html($html);
$this->mailer->send($email);
++$sent;
} catch (Throwable $e) {
$this->logger->warning('Échec envoi notification journée pédagogique', [
'teacher_email' => (string) $teacher->email,
'error' => $e->getMessage(),
]);
}
}
$this->logger->info('Notifications journée pédagogique envoyées', [
'tenant_id' => (string) $event->tenantId,
'date' => $event->date->format('Y-m-d'),
'label' => $event->label,
'emails_sent' => $sent,
'teachers_total' => count($teachers),
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineSchoolCalendarRepository implements SchoolCalendarRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(SchoolCalendar $calendar): void
{
$this->connection->transactional(function () use ($calendar): void {
$tenantId = (string) $calendar->tenantId;
$academicYearId = (string) $calendar->academicYearId;
$this->connection->executeStatement(
'DELETE FROM school_calendar_entries WHERE tenant_id = :tenant_id AND academic_year_id = :academic_year_id',
[
'tenant_id' => $tenantId,
'academic_year_id' => $academicYearId,
],
);
foreach ($calendar->entries() as $entry) {
$this->connection->executeStatement(
'INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, zone, created_at)
VALUES (:id, :tenant_id, :academic_year_id, :entry_type, :start_date, :end_date, :label, :description, :zone, :created_at)',
[
'id' => (string) $entry->id,
'tenant_id' => $tenantId,
'academic_year_id' => $academicYearId,
'entry_type' => $entry->type->value,
'start_date' => $entry->startDate->format('Y-m-d'),
'end_date' => $entry->endDate->format('Y-m-d'),
'label' => $entry->label,
'description' => $entry->description,
'zone' => $calendar->zone?->value,
'created_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM),
],
);
}
});
}
#[Override]
public function findByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): ?SchoolCalendar
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_calendar_entries
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
ORDER BY start_date ASC',
[
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
],
);
if ($rows === []) {
return null;
}
$entries = array_map(fn (array $row): CalendarEntry => $this->hydrateEntry($row), $rows);
/** @var string|null $zone */
$zone = $rows[0]['zone'];
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $zone !== null ? SchoolZone::from($zone) : null,
entries: $entries,
);
}
#[Override]
public function getByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): SchoolCalendar
{
$calendar = $this->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
throw CalendrierNonTrouveException::pourTenantEtAnnee($tenantId, $academicYearId);
}
return $calendar;
}
/**
* @param array<string, mixed> $row
*/
private function hydrateEntry(array $row): CalendarEntry
{
/** @var string $id */
$id = $row['id'];
/** @var string $entryType */
$entryType = $row['entry_type'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string $endDate */
$endDate = $row['end_date'];
/** @var string $label */
$label = $row['label'];
/** @var string|null $description */
$description = $row['description'];
return new CalendarEntry(
id: CalendarEntryId::fromString($id),
type: CalendarEntryType::from($entryType),
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
label: $label,
description: $description,
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemorySchoolCalendarRepository implements SchoolCalendarRepository
{
/** @var array<string, SchoolCalendar> Indexé par tenant:year */
private array $calendars = [];
#[Override]
public function save(SchoolCalendar $calendar): void
{
$this->calendars[$this->key($calendar->tenantId, $calendar->academicYearId)] = $calendar;
}
#[Override]
public function findByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): ?SchoolCalendar
{
return $this->calendars[$this->key($tenantId, $academicYearId)] ?? null;
}
#[Override]
public function getByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): SchoolCalendar
{
$calendar = $this->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
throw CalendrierNonTrouveException::pourTenantEtAnnee($tenantId, $academicYearId);
}
return $calendar;
}
private function key(TenantId $tenantId, AcademicYearId $academicYearId): string
{
return $tenantId . ':' . $academicYearId;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Voter pour les autorisations sur le calendrier scolaire.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
* - PROF, VIE_SCOLAIRE, SECRETARIAT : lecture seule
*
* @extends Voter<string, null>
*/
final class CalendarVoter extends Voter
{
public const string VIEW = 'CALENDAR_VIEW';
public const string CONFIGURE = 'CALENDAR_CONFIGURE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CONFIGURE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CONFIGURE => $this->canConfigure($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::PROF->value,
Role::VIE_SCOLAIRE->value,
Role::SECRETARIAT->value,
]);
}
/**
* @param string[] $roles
*/
private function canConfigure(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use DateTimeImmutable;
use function sprintf;
/**
* Calcule les jours fériés français pour une année scolaire donnée.
*
* Couvre la période septembre → août (ex: "2024-2025" → sept 2024 à août 2025).
* Les dates mobiles (Pâques, Ascension, Pentecôte) sont calculées via l'algorithme de Butcher.
*/
final readonly class FrenchPublicHolidaysCalculator
{
/**
* @return list<array{date: string, label: string}>
*/
public function pourAnneeScolaire(string $academicYear): array
{
[$startYear, $endYear] = $this->parseAcademicYear($academicYear);
$holidays = [];
// Jours fériés de la première année (sept → déc)
$holidays[] = ['date' => "$startYear-11-01", 'label' => 'Toussaint'];
$holidays[] = ['date' => "$startYear-11-11", 'label' => 'Armistice'];
$holidays[] = ['date' => "$startYear-12-25", 'label' => 'Noël'];
// Jours fériés de la deuxième année (jan → août)
$holidays[] = ['date' => "$endYear-01-01", 'label' => "Jour de l'an"];
// Pâques et dates mobiles (basées sur l'année civile de Pâques = endYear)
$easter = $this->easterDate($endYear);
$holidays[] = [
'date' => $easter->modify('+1 day')->format('Y-m-d'),
'label' => 'Lundi de Pâques',
];
$holidays[] = ['date' => "$endYear-05-01", 'label' => 'Fête du travail'];
$holidays[] = ['date' => "$endYear-05-08", 'label' => 'Victoire 1945'];
$holidays[] = [
'date' => $easter->modify('+39 days')->format('Y-m-d'),
'label' => 'Ascension',
];
$holidays[] = [
'date' => $easter->modify('+50 days')->format('Y-m-d'),
'label' => 'Lundi de Pentecôte',
];
$holidays[] = ['date' => "$endYear-07-14", 'label' => 'Fête nationale'];
$holidays[] = ['date' => "$endYear-08-15", 'label' => 'Assomption'];
// Trier par date
usort($holidays, static fn (array $a, array $b): int => $a['date'] <=> $b['date']);
return $holidays;
}
/**
* @return array{int, int}
*/
private function parseAcademicYear(string $academicYear): array
{
$parts = explode('-', $academicYear);
return [(int) $parts[0], (int) $parts[1]];
}
/**
* Calcule la date de Pâques via l'algorithme de Butcher (Anonymous Gregorian).
*/
private function easterDate(int $year): DateTimeImmutable
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
}

View File

@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use function array_merge;
use DateTimeImmutable;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use InvalidArgumentException;
use function is_dir;
use function json_decode;
use function json_encode;
use const JSON_PRETTY_PRINT;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
use const LOCK_EX;
use function mkdir;
use Override;
use function preg_match;
use Psr\Log\LoggerInterface;
use RuntimeException;
use function sprintf;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
/**
* Fournit les données officielles du calendrier scolaire depuis un fichier JSON local.
*
* Si le fichier n'existe pas pour l'année demandée, les données sont automatiquement
* récupérées depuis l'API data.education.gouv.fr puis sauvegardées en cache local.
*/
final readonly class JsonOfficialCalendarProvider implements OfficialCalendarProvider
{
private const string API_BASE_URL = 'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records';
public function __construct(
private string $dataDirectory,
private HttpClientInterface $httpClient,
private FrenchPublicHolidaysCalculator $holidaysCalculator,
private LoggerInterface $logger,
) {
}
#[Override]
public function joursFeries(string $academicYear): array
{
$data = $this->loadData($academicYear);
return array_map(
static fn (array $holiday): CalendarEntry => new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable($holiday['date']),
endDate: new DateTimeImmutable($holiday['date']),
label: $holiday['label'],
),
$data['holidays'],
);
}
#[Override]
public function vacancesParZone(SchoolZone $zone, string $academicYear): array
{
$data = $this->loadData($academicYear);
$vacations = $data['vacations'][$zone->value] ?? [];
return array_map(
static fn (array $vacation): CalendarEntry => new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable($vacation['start']),
endDate: new DateTimeImmutable($vacation['end']),
label: $vacation['label'],
),
$vacations,
);
}
#[Override]
public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array
{
return array_merge(
$this->joursFeries($academicYear),
$this->vacancesParZone($zone, $academicYear),
);
}
/**
* @return array{holidays: list<array{date: string, label: string}>, vacations: array<string, list<array{start: string, end: string, label: string}>>}
*/
private function loadData(string $academicYear): array
{
if (preg_match('/^\d{4}-\d{4}$/', $academicYear) !== 1) {
throw new InvalidArgumentException(sprintf(
'Format d\'année scolaire invalide : "%s". Attendu : "YYYY-YYYY".',
$academicYear,
));
}
$filePath = sprintf('%s/official-holidays-%s.json', $this->dataDirectory, $academicYear);
$content = @file_get_contents($filePath);
if ($content === false) {
$this->fetchAndSave($academicYear, $filePath);
$content = file_get_contents($filePath);
if ($content === false) {
throw new RuntimeException(sprintf(
'Impossible de lire le fichier calendrier : %s',
$filePath,
));
}
}
/** @var array{holidays: list<array{date: string, label: string}>, vacations: array<string, list<array{start: string, end: string, label: string}>>} $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
return $data;
}
private function fetchAndSave(string $academicYear, string $filePath): void
{
$this->logger->info('Fichier calendrier absent, récupération depuis l\'API gouv.fr', [
'academic_year' => $academicYear,
]);
try {
$vacations = $this->fetchVacationsFromApi($academicYear);
} catch (Throwable $e) {
throw new RuntimeException(sprintf(
'Impossible de récupérer le calendrier %s depuis l\'API : %s',
$academicYear,
$e->getMessage(),
), previous: $e);
}
$holidays = $this->holidaysCalculator->pourAnneeScolaire($academicYear);
$data = [
'academic_year' => $academicYear,
'holidays' => $holidays,
'vacations' => $vacations,
];
$directory = dirname($filePath);
if (!is_dir($directory)) {
mkdir($directory, 0o755, true);
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
file_put_contents($filePath, $json, LOCK_EX);
$this->logger->info('Calendrier {year} sauvegardé', [
'year' => $academicYear,
'path' => $filePath,
]);
}
/**
* @return array<string, list<array{start: string, end: string, label: string}>>
*/
private function fetchVacationsFromApi(string $academicYear): array
{
$where = sprintf(
'annee_scolaire="%s" AND (zones="Zone A" OR zones="Zone B" OR zones="Zone C")',
$academicYear,
);
$response = $this->httpClient->request('GET', self::API_BASE_URL, [
'query' => [
'where' => $where,
'select' => 'description,start_date,end_date,zones',
'limit' => 100,
],
'timeout' => 10,
]);
/** @var array{results: list<array{description: string, start_date: string, end_date: string, zones: string}>} $data */
$data = $response->toArray();
// Grouper par zone et dédupliquer
$vacationsByZone = ['A' => [], 'B' => [], 'C' => []];
$seen = [];
foreach ($data['results'] as $record) {
$zone = match ($record['zones']) {
'Zone A' => 'A',
'Zone B' => 'B',
'Zone C' => 'C',
default => null,
};
if ($zone === null) {
continue;
}
// Les dates API sont en ISO 8601 (ex: "2024-12-20T23:00:00+00:00")
// start_date utilise la convention "veille à 23h UTC" → +1 jour pour obtenir le 1er jour de vacances
// end_date représente déjà le dernier jour de vacances (pas de décalage)
$startDate = (new DateTimeImmutable($record['start_date']))->modify('+1 day')->format('Y-m-d');
$endDate = (new DateTimeImmutable($record['end_date']))->format('Y-m-d');
$label = $record['description'];
$key = "$zone|$label|$startDate|$endDate";
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$vacationsByZone[$zone][] = [
'start' => $startDate,
'end' => $endDate,
'label' => $label,
];
}
// Trier chaque zone par date de début
foreach ($vacationsByZone as &$vacations) {
usort($vacations, static fn (array $a, array $b): int => $a['start'] <=> $b['start']);
}
return $vacationsByZone;
}
}