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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user