feat: Gestion des périodes scolaires
L'administration d'un établissement nécessite de découper l'année scolaire en trimestres ou semestres avant de pouvoir saisir les notes et générer les bulletins. Ce module permet de configurer les périodes par année scolaire (current/previous/next résolus en UUID v5 déterministes), de modifier les dates individuelles avec validation anti-chevauchement, et de consulter la période en cours avec le décompte des jours restants. Les dates par défaut de février s'adaptent aux années bissextiles. Le repository utilise UPSERT transactionnel pour garantir l'intégrité lors du changement de mode (trimestres ↔ semestres). Les domain events de Subject sont étendus pour couvrir toutes les mutations (code, couleur, description) en plus du renommage.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ConfigurePeriods;
|
||||
|
||||
/**
|
||||
* Command pour configurer les périodes d'une année scolaire (trimestres ou semestres).
|
||||
*/
|
||||
final readonly class ConfigurePeriodsCommand
|
||||
{
|
||||
/**
|
||||
* @param string $periodType 'trimester' ou 'semester'
|
||||
* @param int $startYear Année de début (ex: 2025 pour 2025-2026)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public string $periodType,
|
||||
public int $startYear,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ConfigurePeriods;
|
||||
|
||||
use App\Administration\Domain\Event\PeriodesConfigurees;
|
||||
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handler pour configurer les périodes d'une année scolaire.
|
||||
*
|
||||
* Crée les périodes par défaut (trimestres ou semestres) pour l'année donnée.
|
||||
* Refuse si des périodes existent déjà.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ConfigurePeriodsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ConfigurePeriodsCommand $command): PeriodConfiguration
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$periodType = PeriodType::from($command->periodType);
|
||||
|
||||
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
|
||||
if ($existing !== null) {
|
||||
throw PeriodesDejaConfigureesException::pourAnnee($command->academicYearId);
|
||||
}
|
||||
|
||||
$configuration = DefaultPeriods::forType($periodType, $command->startYear);
|
||||
|
||||
$this->repository->save($tenantId, $academicYearId, $configuration);
|
||||
|
||||
$this->eventBus->dispatch(new PeriodesConfigurees(
|
||||
academicYearId: $academicYearId,
|
||||
tenantId: $tenantId,
|
||||
periodType: $periodType,
|
||||
periodCount: count($configuration->periods),
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdatePeriod;
|
||||
|
||||
/**
|
||||
* Command pour modifier les dates d'une période existante.
|
||||
*/
|
||||
final readonly class UpdatePeriodCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public int $sequence,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
public bool $confirmImpact = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdatePeriod;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Event\PeriodeModifiee;
|
||||
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
|
||||
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Handler pour modifier les dates d'une période scolaire.
|
||||
*
|
||||
* Reconstruit la PeriodConfiguration avec la période modifiée,
|
||||
* ce qui déclenche la revalidation complète (chevauchement, contiguïté).
|
||||
* Si la période contient des notes, une confirmation explicite est requise.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdatePeriodHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private GradeExistenceChecker $gradeExistenceChecker,
|
||||
private Clock $clock,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdatePeriodCommand $command): PeriodConfiguration
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
|
||||
if ($existing === null) {
|
||||
throw PeriodesNonConfigureesException::pourAnnee($command->academicYearId);
|
||||
}
|
||||
|
||||
$found = false;
|
||||
$targetLabel = '';
|
||||
$updatedPeriods = [];
|
||||
foreach ($existing->periods as $period) {
|
||||
if ($period->sequence === $command->sequence) {
|
||||
$found = true;
|
||||
$targetLabel = $period->label;
|
||||
$updatedPeriods[] = new AcademicPeriod(
|
||||
sequence: $period->sequence,
|
||||
label: $period->label,
|
||||
startDate: new DateTimeImmutable($command->startDate),
|
||||
endDate: new DateTimeImmutable($command->endDate),
|
||||
);
|
||||
} else {
|
||||
$updatedPeriods[] = $period;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
throw PeriodeNonTrouveeException::pourSequence($command->sequence, $command->academicYearId);
|
||||
}
|
||||
|
||||
if (!$command->confirmImpact) {
|
||||
$hasGrades = $this->gradeExistenceChecker->hasGradesInPeriod(
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$command->sequence,
|
||||
);
|
||||
|
||||
if ($hasGrades) {
|
||||
throw PeriodeAvecNotesException::confirmationRequise($targetLabel);
|
||||
}
|
||||
}
|
||||
|
||||
$newConfiguration = new PeriodConfiguration($existing->type, $updatedPeriods);
|
||||
|
||||
$this->repository->save($tenantId, $academicYearId, $newConfiguration);
|
||||
|
||||
$this->eventBus->dispatch(new PeriodeModifiee(
|
||||
academicYearId: $academicYearId,
|
||||
tenantId: $tenantId,
|
||||
periodSequence: $command->sequence,
|
||||
periodLabel: $targetLabel,
|
||||
occurredOn: $this->clock->now(),
|
||||
));
|
||||
|
||||
return $newConfiguration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour vérifier l'existence de notes dans une période.
|
||||
*
|
||||
* Implémenté par le module Notes/Évaluations (Epic 6).
|
||||
* L'implémentation par défaut retourne false tant que le module n'existe pas.
|
||||
*/
|
||||
interface GradeExistenceChecker
|
||||
{
|
||||
public function hasGradesInPeriod(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
int $periodSequence,
|
||||
): bool;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer la configuration des périodes d'une année scolaire.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetPeriodsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private PeriodConfigurationRepository $repository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(GetPeriodsQuery $query): ?PeriodsResultDto
|
||||
{
|
||||
$config = $this->repository->findByAcademicYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
if ($config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$currentPeriod = $config->currentPeriod($now);
|
||||
|
||||
$periodDtos = array_map(
|
||||
static fn ($period) => PeriodDto::fromDomain($period, $now),
|
||||
$config->periods,
|
||||
);
|
||||
|
||||
$currentPeriodDto = $currentPeriod !== null
|
||||
? PeriodDto::fromDomain($currentPeriod, $now)
|
||||
: null;
|
||||
|
||||
return new PeriodsResultDto(
|
||||
type: $config->type->value,
|
||||
periods: $periodDtos,
|
||||
currentPeriod: $currentPeriodDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
/**
|
||||
* Query pour récupérer la configuration des périodes d'une année scolaire.
|
||||
*/
|
||||
final readonly class GetPeriodsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO pour représenter une période scolaire dans les réponses de query.
|
||||
*/
|
||||
final readonly class PeriodDto
|
||||
{
|
||||
public function __construct(
|
||||
public int $sequence,
|
||||
public string $label,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
public bool $isCurrent,
|
||||
public int $daysRemaining,
|
||||
public bool $isPast,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(AcademicPeriod $period, DateTimeImmutable $now): self
|
||||
{
|
||||
return new self(
|
||||
sequence: $period->sequence,
|
||||
label: $period->label,
|
||||
startDate: $period->startDate->format('Y-m-d'),
|
||||
endDate: $period->endDate->format('Y-m-d'),
|
||||
isCurrent: $period->containsDate($now),
|
||||
daysRemaining: $period->daysRemaining($now),
|
||||
isPast: $period->isPast($now),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetPeriods;
|
||||
|
||||
/**
|
||||
* DTO encapsulant la configuration complète des périodes.
|
||||
*/
|
||||
final readonly class PeriodsResultDto
|
||||
{
|
||||
/**
|
||||
* @param PeriodDto[] $periods
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public array $periods,
|
||||
public ?PeriodDto $currentPeriod,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasGradesInPeriod;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier la présence de notes dans une période.
|
||||
*
|
||||
* Délègue au port GradeExistenceChecker qui sera implémenté par le module Notes
|
||||
* quand il existera. Pour l'instant, retourne toujours false (pas de notes).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasGradesInPeriodHandler
|
||||
{
|
||||
public function __construct(
|
||||
private GradeExistenceChecker $gradeExistenceChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(HasGradesInPeriodQuery $query): bool
|
||||
{
|
||||
return $this->gradeExistenceChecker->hasGradesInPeriod(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
$query->periodSequence,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasGradesInPeriod;
|
||||
|
||||
/**
|
||||
* Query pour vérifier si des notes existent dans une période donnée.
|
||||
*
|
||||
* Utilisée pour avertir l'administrateur lors de la modification
|
||||
* des dates d'une période passée contenant des évaluations.
|
||||
*/
|
||||
final readonly class HasGradesInPeriodQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public int $periodSequence,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user