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