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,
) {
}
}