feat: Gestion des classes scolaires

Permet aux administrateurs de créer, modifier et supprimer des classes
pour organiser les élèves par niveau. L'archivage soft-delete préserve
l'historique tout en masquant les classes obsolètes.

Inclut la validation des noms (2-50 caractères), les niveaux scolaires
du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
2026-02-05 15:24:29 +01:00
parent b45ef735db
commit 8e09e0abf1
54 changed files with 5099 additions and 5 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ArchiveClass;
/**
* Command pour archiver (soft delete) une classe scolaire.
*/
final readonly class ArchiveClassCommand
{
public function __construct(
public string $classId,
) {
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ArchiveClass;
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
/**
* Handler pour archiver (soft delete) une classe scolaire.
*
* Vérifie qu'aucun élève n'est affecté à la classe avant de permettre l'archivage.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ArchiveClassHandler
{
public function __construct(
private ClassRepository $classRepository,
private MessageBusInterface $queryBus,
private Clock $clock,
) {
}
public function __invoke(ArchiveClassCommand $command): SchoolClass
{
$classId = ClassId::fromString($command->classId);
$class = $this->classRepository->get($classId);
// Vérifier s'il y a des élèves affectés
$envelope = $this->queryBus->dispatch(new HasStudentsInClassQuery($command->classId));
/** @var HandledStamp|null $handledStamp */
$handledStamp = $envelope->last(HandledStamp::class);
if ($handledStamp !== null) {
/** @var int $studentCount */
$studentCount = $handledStamp->getResult();
if ($studentCount > 0) {
throw ClasseNonSupprimableException::carElevesAffectes($classId, $studentCount);
}
}
$class->archiver($this->clock->now());
$this->classRepository->save($class);
return $class;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateClass;
/**
* Command pour créer une nouvelle classe scolaire.
*/
final readonly class CreateClassCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public string $academicYearId,
public string $name,
public ?string $level,
public ?int $capacity,
) {
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateClass;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour créer une nouvelle classe scolaire.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateClassHandler
{
public function __construct(
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(CreateClassCommand $command): SchoolClass
{
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$name = new ClassName($command->name);
// Vérifier l'unicité du nom dans le tenant et l'année scolaire
$existingClass = $this->classRepository->findByName($name, $tenantId, $academicYearId);
if ($existingClass !== null) {
throw ClasseDejaExistanteException::avecNom($name);
}
$class = SchoolClass::creer(
tenantId: $tenantId,
schoolId: SchoolId::fromString($command->schoolId),
academicYearId: $academicYearId,
name: $name,
level: $command->level !== null ? SchoolLevel::from($command->level) : null,
capacity: $command->capacity,
createdAt: $this->clock->now(),
);
$this->classRepository->save($class);
return $class;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateClass;
/**
* Résultat de la création d'une classe.
*/
final readonly class CreateClassResult
{
public function __construct(
public string $classId,
public string $name,
public ?string $level,
public ?int $capacity,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateClass;
/**
* Command pour modifier une classe scolaire existante.
*/
final readonly class UpdateClassCommand
{
public function __construct(
public string $classId,
public ?string $name = null,
public ?string $level = null,
public ?int $capacity = null,
public ?string $description = null,
public bool $clearLevel = false,
public bool $clearCapacity = false,
public bool $clearDescription = false,
) {
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateClass;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour modifier une classe scolaire existante.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateClassHandler
{
public function __construct(
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(UpdateClassCommand $command): SchoolClass
{
$class = $this->classRepository->get(ClassId::fromString($command->classId));
$now = $this->clock->now();
if ($command->name !== null) {
$newName = new ClassName($command->name);
// Vérifier l'unicité du nouveau nom (sauf si c'est le même)
if (!$class->name->equals($newName)) {
$existingClass = $this->classRepository->findByName(
$newName,
$class->tenantId,
$class->academicYearId,
);
if ($existingClass !== null && !$existingClass->id->equals($class->id)) {
throw ClasseDejaExistanteException::avecNom($newName);
}
}
$class->renommer($newName, $now);
}
if ($command->level !== null) {
$class->changerNiveau(SchoolLevel::from($command->level), $now);
} elseif ($command->clearLevel) {
$class->changerNiveau(null, $now);
}
if ($command->capacity !== null) {
$class->changerCapacite($command->capacity, $now);
} elseif ($command->clearCapacity) {
$class->changerCapacite(null, $now);
}
if ($command->description !== null) {
$class->decrire($command->description, $now);
} elseif ($command->clearDescription) {
$class->decrire(null, $now);
}
$this->classRepository->save($class);
return $class;
}
}