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,176 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
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\Model\Subject\SubjectStatus;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineSubjectRepository implements SubjectRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(Subject $subject): void
{
$data = [
'id' => (string) $subject->id,
'tenant_id' => (string) $subject->tenantId,
'school_id' => (string) $subject->schoolId,
'name' => (string) $subject->name,
'code' => (string) $subject->code,
'color' => $subject->color !== null ? (string) $subject->color : null,
'status' => $subject->status->value,
'description' => $subject->description,
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
];
$exists = $this->findById($subject->id) !== null;
if ($exists) {
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
} else {
$this->connection->insert('subjects', $data);
}
}
#[Override]
public function get(SubjectId $id): Subject
{
$subject = $this->findById($id);
if ($subject === null) {
throw SubjectNotFoundException::withId($id);
}
return $subject;
}
#[Override]
public function findById(SubjectId $id): ?Subject
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM subjects WHERE id = :id',
['id' => (string) $id],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByCode(
SubjectCode $code,
TenantId $tenantId,
SchoolId $schoolId,
): ?Subject {
$row = $this->connection->fetchAssociative(
'SELECT * FROM subjects
WHERE tenant_id = :tenant_id
AND school_id = :school_id
AND code = :code
AND deleted_at IS NULL',
[
'tenant_id' => (string) $tenantId,
'school_id' => (string) $schoolId,
'code' => (string) $code,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTenantAndSchool(
TenantId $tenantId,
SchoolId $schoolId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM subjects
WHERE tenant_id = :tenant_id
AND school_id = :school_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'school_id' => (string) $schoolId,
'status' => SubjectStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(SubjectId $id): void
{
$this->connection->delete('subjects', ['id' => (string) $id]);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): Subject
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $schoolId */
$schoolId = $row['school_id'];
/** @var non-empty-string $name */
$name = $row['name'];
/** @var non-empty-string $code */
$code = $row['code'];
/** @var non-empty-string|null $color */
$color = $row['color'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $description */
$description = $row['description'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
/** @var string|null $deletedAt */
$deletedAt = $row['deleted_at'];
return Subject::reconstitute(
id: SubjectId::fromString($id),
tenantId: TenantId::fromString($tenantId),
schoolId: SchoolId::fromString($schoolId),
name: new SubjectName($name),
code: new SubjectCode($code),
color: $color !== null ? new SubjectColor($color) : null,
status: SubjectStatus::from($status),
description: $description,
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
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\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectStatus;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use function mb_strtolower;
use Override;
final class InMemorySubjectRepository implements SubjectRepository
{
/** @var array<string, Subject> Indexed by ID */
private array $byId = [];
/** @var array<string, Subject> Indexed by tenant:school:code */
private array $byTenantSchoolCode = [];
/** @var array<string, string> Maps subject ID to its current code key */
private array $codeKeyById = [];
#[Override]
public function save(Subject $subject): void
{
$subjectIdStr = (string) $subject->id;
// If subject already exists, remove the old code key (handles code changes)
if (isset($this->codeKeyById[$subjectIdStr])) {
$oldKey = $this->codeKeyById[$subjectIdStr];
unset($this->byTenantSchoolCode[$oldKey]);
}
$newKey = $this->codeKey($subject->code, $subject->tenantId, $subject->schoolId);
$this->byId[$subjectIdStr] = $subject;
$this->byTenantSchoolCode[$newKey] = $subject;
$this->codeKeyById[$subjectIdStr] = $newKey;
}
#[Override]
public function get(SubjectId $id): Subject
{
$subject = $this->findById($id);
if ($subject === null) {
throw SubjectNotFoundException::withId($id);
}
return $subject;
}
#[Override]
public function findById(SubjectId $id): ?Subject
{
return $this->byId[(string) $id] ?? null;
}
#[Override]
public function findByCode(
SubjectCode $code,
TenantId $tenantId,
SchoolId $schoolId,
): ?Subject {
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
if ($subject !== null && $subject->deletedAt !== null) {
return null;
}
return $subject;
}
#[Override]
public function findActiveByTenantAndSchool(
TenantId $tenantId,
SchoolId $schoolId,
): array {
$result = [];
foreach ($this->byId as $subject) {
if ($subject->tenantId->equals($tenantId)
&& $subject->schoolId->equals($schoolId)
&& $subject->status === SubjectStatus::ACTIVE
) {
$result[] = $subject;
}
}
return $result;
}
#[Override]
public function delete(SubjectId $id): void
{
$subjectIdStr = (string) $id;
if (isset($this->byId[$subjectIdStr])) {
if (isset($this->codeKeyById[$subjectIdStr])) {
unset($this->byTenantSchoolCode[$this->codeKeyById[$subjectIdStr]]);
unset($this->codeKeyById[$subjectIdStr]);
}
unset($this->byId[$subjectIdStr]);
}
}
private function codeKey(SubjectCode $code, TenantId $tenantId, SchoolId $schoolId): string
{
return $tenantId . ':' . $schoolId . ':' . mb_strtolower((string) $code, 'UTF-8');
}
}