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