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:
2026-02-05 20:42:31 +01:00
parent 8e09e0abf1
commit 0d5a097c4c
50 changed files with 5882 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la création d'une matière.
*/
final readonly class MatiereCreee implements DomainEvent
{
public function __construct(
public SubjectId $subjectId,
public TenantId $tenantId,
public SubjectName $name,
public SubjectCode $code,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->subjectId->value;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la modification d'une matière.
*/
final readonly class MatiereModifiee implements DomainEvent
{
public function __construct(
public SubjectId $subjectId,
public TenantId $tenantId,
public SubjectName $ancienNom,
public SubjectName $nouveauNom,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->subjectId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la suppression/archivage d'une matière.
*/
final readonly class MatiereSupprimee implements DomainEvent
{
public function __construct(
public SubjectId $subjectId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->subjectId->value;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class SubjectCodeInvalideException extends RuntimeException
{
public static function pourFormat(string $value, int $min, int $max): self
{
return new self(sprintf(
'Le code de matière "%s" doit contenir entre %d et %d caractères alphanumériques majuscules (ex: "MATH", "FR", "EPS").',
$value,
$min,
$max,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class SubjectColorInvalideException extends RuntimeException
{
public static function pourFormat(string $value): self
{
return new self(sprintf(
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
$value,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectCode;
use RuntimeException;
use function sprintf;
final class SubjectDejaExistanteException extends RuntimeException
{
public static function avecCode(SubjectCode $code): self
{
return new self(sprintf(
'Une matière avec le code "%s" existe déjà dans cet établissement.',
(string) $code,
));
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class SubjectNameInvalideException extends RuntimeException
{
public static function pourLongueur(string $value, int $min, int $max): self
{
return new self(sprintf(
'Le nom de matière "%s" doit contenir entre %d et %d caractères.',
$value,
$min,
$max,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectId;
use RuntimeException;
use function sprintf;
final class SubjectNonSupprimableException extends RuntimeException
{
public static function avecNotes(SubjectId $id): self
{
return new self(sprintf(
'La matière "%s" ne peut pas être supprimée car des notes y sont associées.',
(string) $id,
));
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectId;
use RuntimeException;
use function sprintf;
final class SubjectNotFoundException extends RuntimeException
{
public static function withId(SubjectId $id): self
{
return new self(sprintf('Matière "%s" non trouvée.', (string) $id));
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
use App\Administration\Domain\Event\MatiereCreee;
use App\Administration\Domain\Event\MatiereModifiee;
use App\Administration\Domain\Event\MatiereSupprimee;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant une matière enseignée.
*
* Une matière appartient à un établissement (tenant) et une école.
* Elle a un nom, un code court unique et une couleur optionnelle pour l'affichage.
*
* @see FR74: Structurer l'offre pédagogique
*/
final class Subject extends AggregateRoot
{
public private(set) ?string $description = null;
public private(set) DateTimeImmutable $updatedAt;
public private(set) ?DateTimeImmutable $deletedAt = null;
private function __construct(
public private(set) SubjectId $id,
public private(set) TenantId $tenantId,
public private(set) SchoolId $schoolId,
public private(set) SubjectName $name,
public private(set) SubjectCode $code,
public private(set) ?SubjectColor $color,
public private(set) SubjectStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
/**
* Crée une nouvelle matière.
*/
public static function creer(
TenantId $tenantId,
SchoolId $schoolId,
SubjectName $name,
SubjectCode $code,
?SubjectColor $color,
DateTimeImmutable $createdAt,
): self {
$subject = new self(
id: SubjectId::generate(),
tenantId: $tenantId,
schoolId: $schoolId,
name: $name,
code: $code,
color: $color,
status: SubjectStatus::ACTIVE,
createdAt: $createdAt,
);
$subject->recordEvent(new MatiereCreee(
subjectId: $subject->id,
tenantId: $subject->tenantId,
name: $subject->name,
code: $subject->code,
occurredOn: $createdAt,
));
return $subject;
}
/**
* Renomme la matière.
*/
public function renommer(SubjectName $nouveauNom, DateTimeImmutable $at): void
{
if ($this->name->equals($nouveauNom)) {
return;
}
$ancienNom = $this->name;
$this->name = $nouveauNom;
$this->updatedAt = $at;
$this->recordEvent(new MatiereModifiee(
subjectId: $this->id,
tenantId: $this->tenantId,
ancienNom: $ancienNom,
nouveauNom: $nouveauNom,
occurredOn: $at,
));
}
/**
* Change le code de la matière.
*
* Note: L'unicité du code doit être vérifiée par l'Application Layer
* avant d'appeler cette méthode.
*/
public function changerCode(SubjectCode $nouveauCode, DateTimeImmutable $at): void
{
if ($this->code->equals($nouveauCode)) {
return;
}
$this->code = $nouveauCode;
$this->updatedAt = $at;
}
/**
* Change la couleur de la matière.
*/
public function changerCouleur(?SubjectColor $couleur, DateTimeImmutable $at): void
{
// Compare colors, considering null cases
$sameColor = ($this->color === null && $couleur === null)
|| ($this->color !== null && $couleur !== null && $this->color->equals($couleur));
if ($sameColor) {
return;
}
$this->color = $couleur;
$this->updatedAt = $at;
}
/**
* Ajoute ou modifie la description de la matière.
*/
public function decrire(?string $description, DateTimeImmutable $at): void
{
if ($this->description === $description) {
return;
}
$this->description = $description;
$this->updatedAt = $at;
}
/**
* Archive la matière (soft delete).
*
* Note: La vérification des notes associées doit être faite par l'Application Layer
* via une Query avant d'appeler cette méthode.
*/
public function archiver(DateTimeImmutable $at): void
{
if ($this->status === SubjectStatus::ARCHIVED) {
return;
}
$this->status = SubjectStatus::ARCHIVED;
$this->deletedAt = $at;
$this->updatedAt = $at;
$this->recordEvent(new MatiereSupprimee(
subjectId: $this->id,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Vérifie si la matière est active.
*/
public function estActive(): bool
{
return $this->status === SubjectStatus::ACTIVE;
}
/**
* Vérifie si la matière peut être utilisée.
*/
public function peutEtreUtilisee(): bool
{
return $this->status->peutEtreUtilisee();
}
/**
* Reconstitue un Subject depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
SubjectId $id,
TenantId $tenantId,
SchoolId $schoolId,
SubjectName $name,
SubjectCode $code,
?SubjectColor $color,
SubjectStatus $status,
?string $description,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
?DateTimeImmutable $deletedAt,
): self {
$subject = new self(
id: $id,
tenantId: $tenantId,
schoolId: $schoolId,
name: $name,
code: $code,
color: $color,
status: $status,
createdAt: $createdAt,
);
$subject->description = $description;
$subject->updatedAt = $updatedAt;
$subject->deletedAt = $deletedAt;
return $subject;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
use function assert;
use function preg_match;
use function strtoupper;
use function trim;
/**
* Value Object représentant le code court d'une matière.
*
* Contraintes :
* - Entre 2 et 10 caractères
* - Lettres majuscules et chiffres uniquement
* - Exemple: "MATH", "FR", "HG", "EPS"
*/
final class SubjectCode
{
private const int MIN_LENGTH = 2;
private const int MAX_LENGTH = 10;
private const string PATTERN = '/^[A-Z0-9]{2,10}$/';
public function __construct(
/** @var non-empty-string */
public private(set) string $value {
set(string $value) {
$normalized = strtoupper(trim($value));
if (preg_match(self::PATTERN, $normalized) !== 1) {
throw SubjectCodeInvalideException::pourFormat($value, self::MIN_LENGTH, self::MAX_LENGTH);
}
// After validation, $normalized is guaranteed to be non-empty (MIN_LENGTH >= 2)
assert($normalized !== '');
$this->value = $normalized;
}
},
) {
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
use App\Administration\Domain\Exception\SubjectColorInvalideException;
use function assert;
use function preg_match;
use function strtoupper;
use function trim;
/**
* Value Object représentant la couleur d'une matière (format hexadécimal).
*
* Contraintes :
* - Format #RRGGBB
* - Exemple: "#3B82F6" (bleu), "#EF4444" (rouge)
*/
final class SubjectColor
{
private const string PATTERN = '/^#[0-9A-F]{6}$/';
public function __construct(
/** @var non-empty-string */
public private(set) string $value {
set(string $value) {
$normalized = strtoupper(trim($value));
if (preg_match(self::PATTERN, $normalized) !== 1) {
throw SubjectColorInvalideException::pourFormat($value);
}
// After validation, $normalized is guaranteed to be non-empty (#RRGGBB = 7 chars)
assert($normalized !== '');
$this->value = $normalized;
}
},
) {
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
use App\Shared\Domain\EntityId;
final readonly class SubjectId extends EntityId
{
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
use App\Administration\Domain\Exception\SubjectNameInvalideException;
use function assert;
use function mb_strlen;
use function trim;
/**
* Value Object représentant le nom d'une matière.
*
* Contraintes :
* - Entre 2 et 100 caractères
* - Non vide après trim
*/
final class SubjectName
{
private const int MIN_LENGTH = 2;
private const int MAX_LENGTH = 100;
public function __construct(
/** @var non-empty-string */
public private(set) string $value {
set(string $value) {
$trimmed = trim($value);
$length = mb_strlen($trimmed);
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw SubjectNameInvalideException::pourLongueur($value, self::MIN_LENGTH, self::MAX_LENGTH);
}
// After validation, $trimmed is guaranteed to be non-empty (MIN_LENGTH >= 2)
assert($trimmed !== '');
$this->value = $trimmed;
}
},
) {
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* @return non-empty-string
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Subject;
/**
* Statut d'une matière.
*/
enum SubjectStatus: string
{
case ACTIVE = 'active';
case ARCHIVED = 'archived';
/**
* Vérifie si la matière peut être utilisée.
*/
public function peutEtreUtilisee(): bool
{
return $this === self::ACTIVE;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
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\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
interface SubjectRepository
{
public function save(Subject $subject): void;
/**
* @throws \App\Administration\Domain\Exception\SubjectNotFoundException
*/
public function get(SubjectId $id): Subject;
public function findById(SubjectId $id): ?Subject;
/**
* Recherche une matière par code dans un tenant et une école.
*/
public function findByCode(
SubjectCode $code,
TenantId $tenantId,
SchoolId $schoolId,
): ?Subject;
/**
* Retourne toutes les matières actives d'un tenant et une école.
*
* @return Subject[]
*/
public function findActiveByTenantAndSchool(
TenantId $tenantId,
SchoolId $schoolId,
): array;
/**
* Supprime une matière du repository.
*/
public function delete(SubjectId $id): void;
}