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:
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO pour représenter une classe dans les réponses de query.
|
||||
*/
|
||||
final readonly class ClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public ?string $level,
|
||||
public ?int $capacity,
|
||||
public string $status,
|
||||
public ?string $description,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(SchoolClass $class): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $class->id,
|
||||
name: (string) $class->name,
|
||||
level: $class->level?->value,
|
||||
capacity: $class->capacity,
|
||||
status: $class->status->value,
|
||||
description: $class->description,
|
||||
createdAt: $class->createdAt,
|
||||
updatedAt: $class->updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer les classes actives d'un tenant.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetClassesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ClassDto[]
|
||||
*/
|
||||
public function __invoke(GetClassesQuery $query): array
|
||||
{
|
||||
$classes = $this->classRepository->findActiveByTenantAndYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn ($class) => ClassDto::fromDomain($class),
|
||||
$classes,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
/**
|
||||
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
|
||||
*/
|
||||
final readonly class GetClassesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
|
||||
* Pour l'instant, retourne toujours 0 (aucun élève).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasStudentsInClassHandler
|
||||
{
|
||||
public function __invoke(HasStudentsInClassQuery $query): int
|
||||
{
|
||||
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
|
||||
// Pour l'instant, retourne 0 (permet l'archivage)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
/**
|
||||
* Query pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Retourne le nombre d'élèves affectés (0 si aucun).
|
||||
*/
|
||||
final readonly class HasStudentsInClassQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user