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,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'],
};
}
}