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,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
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\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineClassRepository implements ClassRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($class->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
|
||||
} else {
|
||||
$this->connection->insert('school_classes', $data);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ClassId $id): SchoolClass
|
||||
{
|
||||
$class = $this->findById($id);
|
||||
|
||||
if ($class === null) {
|
||||
throw ClasseNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassId $id): ?SchoolClass
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_classes WHERE id = :id',
|
||||
['id' => (string) $id],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByName(
|
||||
ClassName $name,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): ?SchoolClass {
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_classes
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND name = :name
|
||||
AND deleted_at IS NULL',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'name' => (string) $name,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndYear(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM school_classes
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND status = :status
|
||||
ORDER BY name ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'status' => ClassStatus::ACTIVE->value,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ClassId $id): void
|
||||
{
|
||||
$this->connection->delete('school_classes', ['id' => (string) $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): SchoolClass
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $schoolId */
|
||||
$schoolId = $row['school_id'];
|
||||
/** @var string $academicYearId */
|
||||
$academicYearId = $row['academic_year_id'];
|
||||
/** @var string $name */
|
||||
$name = $row['name'];
|
||||
/** @var string|null $level */
|
||||
$level = $row['level'];
|
||||
/** @var int|string|null $capacity */
|
||||
$capacity = $row['capacity'];
|
||||
/** @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 SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
academicYearId: AcademicYearId::fromString($academicYearId),
|
||||
name: new ClassName($name),
|
||||
level: $level !== null ? SchoolLevel::from($level) : null,
|
||||
capacity: $capacity !== null ? (int) $capacity : null,
|
||||
status: ClassStatus::from($status),
|
||||
description: $description,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryClassRepository implements ClassRepository
|
||||
{
|
||||
/** @var array<string, SchoolClass> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, SchoolClass> Indexed by tenant:year:name */
|
||||
private array $byTenantYearName = [];
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
// If class already exists, remove the old name key (handles renames)
|
||||
$existingClass = $this->byId[(string) $class->id] ?? null;
|
||||
if ($existingClass !== null) {
|
||||
$oldKey = $this->nameKey($existingClass->name, $existingClass->tenantId, $existingClass->academicYearId);
|
||||
unset($this->byTenantYearName[$oldKey]);
|
||||
}
|
||||
|
||||
$this->byId[(string) $class->id] = $class;
|
||||
$this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)] = $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ClassId $id): SchoolClass
|
||||
{
|
||||
$class = $this->findById($id);
|
||||
|
||||
if ($class === null) {
|
||||
throw ClasseNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassId $id): ?SchoolClass
|
||||
{
|
||||
return $this->byId[(string) $id] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByName(
|
||||
ClassName $name,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): ?SchoolClass {
|
||||
return $this->byTenantYearName[$this->nameKey($name, $tenantId, $academicYearId)] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndYear(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($this->byId as $class) {
|
||||
if ($class->tenantId->equals($tenantId)
|
||||
&& $class->academicYearId->equals($academicYearId)
|
||||
&& $class->status === ClassStatus::ACTIVE
|
||||
) {
|
||||
$result[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ClassId $id): void
|
||||
{
|
||||
$class = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($class !== null) {
|
||||
unset($this->byId[(string) $id]);
|
||||
unset($this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function nameKey(ClassName $name, TenantId $tenantId, AcademicYearId $academicYearId): string
|
||||
{
|
||||
return $tenantId . ':' . $academicYearId . ':' . mb_strtolower((string) $name, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user