feat: Gestion des matières scolaires
Les établissements ont besoin de définir leur référentiel de matières pour pouvoir ensuite les associer aux enseignants et aux classes. Cette fonctionnalité permet aux administrateurs de créer, modifier et archiver les matières avec leurs propriétés (nom, code court, couleur). L'architecture suit le pattern DDD avec des Value Objects utilisant les property hooks PHP 8.5 pour garantir l'immutabilité et la validation. L'isolation multi-tenant est assurée par vérification dans les handlers.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ArchiveSubject;
|
||||
|
||||
/**
|
||||
* Command pour archiver une matière.
|
||||
*/
|
||||
final readonly class ArchiveSubjectCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $subjectId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ArchiveSubject;
|
||||
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour archiver une matière.
|
||||
*
|
||||
* Note: La vérification des notes associées doit être faite par le Processor API
|
||||
* avant d'appeler ce handler (via HasGradesForSubjectQuery).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ArchiveSubjectHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ArchiveSubjectCommand $command): Subject
|
||||
{
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
|
||||
$subject = $this->subjectRepository->get($subjectId);
|
||||
|
||||
// Vérifier que la matière appartient au tenant (défense en profondeur)
|
||||
if (!$subject->tenantId->equals($tenantId)) {
|
||||
throw SubjectNotFoundException::withId($subjectId);
|
||||
}
|
||||
|
||||
$subject->archiver($this->clock->now());
|
||||
|
||||
$this->subjectRepository->save($subject);
|
||||
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateSubject;
|
||||
|
||||
/**
|
||||
* Command pour créer une nouvelle matière.
|
||||
*/
|
||||
final readonly class CreateSubjectCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public string $name,
|
||||
public string $code,
|
||||
public ?string $color,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateSubject;
|
||||
|
||||
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function assert;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour créer une nouvelle matière.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateSubjectHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateSubjectCommand $command): Subject
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
|
||||
// Value Objects validate input; assert non-empty for PHPStan
|
||||
assert($command->code !== '');
|
||||
assert($command->name !== '');
|
||||
$code = new SubjectCode($command->code);
|
||||
|
||||
// Vérifier l'unicité du code dans le tenant et l'école
|
||||
$existingSubject = $this->subjectRepository->findByCode($code, $tenantId, $schoolId);
|
||||
if ($existingSubject !== null) {
|
||||
throw SubjectDejaExistanteException::avecCode($code);
|
||||
}
|
||||
|
||||
$color = null;
|
||||
if ($command->color !== null) {
|
||||
assert($command->color !== '');
|
||||
$color = new SubjectColor($command->color);
|
||||
}
|
||||
|
||||
$subject = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName($command->name),
|
||||
code: $code,
|
||||
color: $color,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->subjectRepository->save($subject);
|
||||
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateSubject;
|
||||
|
||||
/**
|
||||
* Command pour modifier une matière existante.
|
||||
*/
|
||||
final readonly class UpdateSubjectCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $subjectId,
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public ?string $name = null,
|
||||
public ?string $code = null,
|
||||
public ?string $color = null,
|
||||
public ?string $description = null,
|
||||
public bool $clearColor = false,
|
||||
public bool $clearDescription = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateSubject;
|
||||
|
||||
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function assert;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour modifier une matière existante.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateSubjectHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateSubjectCommand $command): Subject
|
||||
{
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$subject = $this->subjectRepository->get($subjectId);
|
||||
|
||||
// Vérifier que la matière appartient au tenant (défense en profondeur)
|
||||
if (!$subject->tenantId->equals($tenantId)) {
|
||||
throw SubjectNotFoundException::withId($subjectId);
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if ($command->name !== null) {
|
||||
assert($command->name !== '');
|
||||
$subject->renommer(new SubjectName($command->name), $now);
|
||||
}
|
||||
|
||||
// Mise à jour du code
|
||||
if ($command->code !== null) {
|
||||
assert($command->code !== '');
|
||||
$newCode = new SubjectCode($command->code);
|
||||
|
||||
// Vérifier l'unicité du code (sauf si c'est le même)
|
||||
if (!$subject->code->equals($newCode)) {
|
||||
$existingSubject = $this->subjectRepository->findByCode($newCode, $tenantId, $schoolId);
|
||||
if ($existingSubject !== null && !$existingSubject->id->equals($subjectId)) {
|
||||
throw SubjectDejaExistanteException::avecCode($newCode);
|
||||
}
|
||||
|
||||
$subject->changerCode($newCode, $now);
|
||||
}
|
||||
}
|
||||
|
||||
// Mise à jour de la couleur
|
||||
if ($command->clearColor) {
|
||||
$subject->changerCouleur(null, $now);
|
||||
} elseif ($command->color !== null) {
|
||||
assert($command->color !== '');
|
||||
$subject->changerCouleur(new SubjectColor($command->color), $now);
|
||||
}
|
||||
|
||||
// Mise à jour de la description
|
||||
if ($command->clearDescription) {
|
||||
$subject->decrire(null, $now);
|
||||
} elseif ($command->description !== null) {
|
||||
$subject->decrire($command->description, $now);
|
||||
}
|
||||
|
||||
$this->subjectRepository->save($subject);
|
||||
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer les matières actives d'un tenant.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetSubjectsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SubjectDto[]
|
||||
*/
|
||||
public function __invoke(GetSubjectsQuery $query): array
|
||||
{
|
||||
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
|
||||
TenantId::fromString($query->tenantId),
|
||||
SchoolId::fromString($query->schoolId),
|
||||
);
|
||||
|
||||
// TODO: Récupérer les comptages d'enseignants et de classes
|
||||
// quand les modules Affectations seront implémentés (T7)
|
||||
|
||||
return array_map(
|
||||
static fn ($subject) => SubjectDto::fromDomain(
|
||||
$subject,
|
||||
teacherCount: 0, // Placeholder - T7
|
||||
classCount: 0, // Placeholder - T7
|
||||
),
|
||||
$subjects,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
/**
|
||||
* Query pour récupérer les matières actives d'un tenant et d'une école.
|
||||
*/
|
||||
final readonly class GetSubjectsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO pour représenter une matière dans les réponses de query.
|
||||
*/
|
||||
final readonly class SubjectDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public string $code,
|
||||
public ?string $color,
|
||||
public ?string $description,
|
||||
public string $status,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
public int $teacherCount = 0,
|
||||
public int $classCount = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(
|
||||
Subject $subject,
|
||||
int $teacherCount = 0,
|
||||
int $classCount = 0,
|
||||
): self {
|
||||
return new self(
|
||||
id: (string) $subject->id,
|
||||
name: (string) $subject->name,
|
||||
code: (string) $subject->code,
|
||||
color: $subject->color !== null ? (string) $subject->color : null,
|
||||
description: $subject->description,
|
||||
status: $subject->status->value,
|
||||
createdAt: $subject->createdAt,
|
||||
updatedAt: $subject->updatedAt,
|
||||
teacherCount: $teacherCount,
|
||||
classCount: $classCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user