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:
2026-02-06 12:00:29 +01:00
parent 0d5a097c4c
commit f19d0ae3ef
69 changed files with 5201 additions and 121 deletions

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ConfigurePeriods;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ConfigurePeriodsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryPeriodConfigurationRepository $repository;
private ConfigurePeriodsHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-31 10:00:00');
}
};
$eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$this->handler = new ConfigurePeriodsHandler($this->repository, $clock, $eventBus);
}
#[Test]
public function itConfiguresTrimesterPeriods(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
$config = ($this->handler)($command);
self::assertSame(PeriodType::TRIMESTER, $config->type);
self::assertCount(3, $config->periods);
self::assertSame('T1', $config->periods[0]->label);
self::assertSame('T2', $config->periods[1]->label);
self::assertSame('T3', $config->periods[2]->label);
}
#[Test]
public function itConfiguresSemesterPeriods(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'semester',
startYear: 2025,
);
$config = ($this->handler)($command);
self::assertSame(PeriodType::SEMESTER, $config->type);
self::assertCount(2, $config->periods);
self::assertSame('S1', $config->periods[0]->label);
self::assertSame('S2', $config->periods[1]->label);
}
#[Test]
public function itPersistsConfiguration(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
($this->handler)($command);
$saved = $this->repository->findByAcademicYear(
\App\Shared\Domain\Tenant\TenantId::fromString(self::TENANT_ID),
\App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($saved);
self::assertSame(PeriodType::TRIMESTER, $saved->type);
}
#[Test]
public function itRejectsDoubleConfiguration(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
($this->handler)($command);
$this->expectException(PeriodesDejaConfigureesException::class);
($this->handler)($command);
}
#[Test]
public function itAllowsDifferentTenantsToConfigureSameYear(): void
{
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
($this->handler)(new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
));
$config = ($this->handler)(new ConfigurePeriodsCommand(
tenantId: $otherTenantId,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'semester',
startYear: 2025,
));
self::assertSame(PeriodType::SEMESTER, $config->type);
}
}