feat: Affectation des enseignants aux classes et matières

Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.

Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
This commit is contained in:
2026-02-13 20:22:39 +01:00
parent 73a473ec93
commit 88e7f319db
61 changed files with 6484 additions and 52 deletions

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
final readonly class AssignTeacherCommand
{
public function __construct(
public string $tenantId,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private Clock $clock,
) {
}
public function __invoke(AssignTeacherCommand $command): TeacherAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$teacherId = UserId::fromString($command->teacherId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence des entités référencées (throws NotFoundException)
$this->userRepository->get($teacherId);
$this->classRepository->get($classId);
$this->subjectRepository->get($subjectId);
// Vérifier l'unicité du triplet enseignant × classe × matière
$existing = $this->assignmentRepository->findByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($existing !== null) {
throw AffectationDejaExistanteException::pourTriple($teacherId, $classId, $subjectId);
}
// Vérifier si une affectation retirée existe pour ce même triplet.
// Si oui, la réactiver au lieu d'en créer une nouvelle (évite la violation
// de la contrainte UNIQUE qui couvre tous les statuts).
$removed = $this->assignmentRepository->findRemovedByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($removed !== null) {
$removed->reactiver($this->clock->now());
$this->assignmentRepository->save($removed);
return $removed;
}
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $this->clock->now(),
);
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveAssignment;
final readonly class RemoveAssignmentCommand
{
public function __construct(
public string $assignmentId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RemoveAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private Clock $clock,
) {
}
public function __invoke(RemoveAssignmentCommand $command): TeacherAssignment
{
$assignmentId = TeacherAssignmentId::fromString($command->assignmentId);
$tenantId = TenantId::fromString($command->tenantId);
$assignment = $this->assignmentRepository->get($assignmentId, $tenantId);
$assignment->retirer($this->clock->now());
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour vérifier si un enseignant est affecté à une classe/matière.
*
* Utilisé par les Voters (GradeVoter, HomeworkVoter) pour autoriser
* la saisie de notes ou devoirs uniquement aux enseignants affectés.
*
* @see AC2: Peut saisir notes pour cette matière dans cette classe
* @see AC4: Bloquer saisie si affectation retirée
*/
interface TeacherAssignmentChecker
{
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): bool;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
public function __invoke(GetAssignmentQuery $query): ?TeacherAssignmentDto
{
$assignment = $this->assignmentRepository->findByTeacherClassSubject(
UserId::fromString($query->teacherId),
ClassId::fromString($query->classId),
SubjectId::fromString($query->subjectId),
AcademicYearId::fromString($query->academicYearId),
TenantId::fromString($query->tenantId),
);
if ($assignment === null) {
return null;
}
return TeacherAssignmentDto::fromDomain($assignment);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
final readonly class GetAssignmentQuery
{
public function __construct(
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentsForTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetAssignmentsForTeacherQuery $query): array
{
$teacherId = UserId::fromString($query->teacherId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByTeacher($teacherId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
final readonly class GetAssignmentsForTeacherQuery
{
public function __construct(
public string $teacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use DateTimeImmutable;
final readonly class TeacherAssignmentDto
{
public function __construct(
public string $id,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $status,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public DateTimeImmutable $createdAt,
) {
}
public static function fromDomain(TeacherAssignment $assignment): self
{
return new self(
id: (string) $assignment->id,
teacherId: (string) $assignment->teacherId,
classId: (string) $assignment->classId,
subjectId: (string) $assignment->subjectId,
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetTeachersForClass;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetTeachersForClassHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetTeachersForClassQuery $query): array
{
$classId = ClassId::fromString($query->classId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByClass($classId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetTeachersForClass;
final readonly class GetTeachersForClassQuery
{
public function __construct(
public string $classId,
public string $tenantId,
) {
}
}