feat: Désignation de remplaçants temporaires avec corrections sécurité

Permet aux administrateurs de désigner un enseignant remplaçant pour
un autre enseignant absent, sur des classes et matières précises, pour
une période donnée. Le dashboard enseignant affiche les remplacements
actifs avec les noms de classes/matières au lieu des identifiants bruts.

Inclut les corrections de la code review :
- Requête findActiveByTenant qui excluait les remplacements en cours
  mais incluait les futurs (manquait start_date <= :at)
- Validation tenant et rôle enseignant dans le handler de désignation
  pour empêcher l'affectation cross-tenant ou de non-enseignants
- Validation structurée du payload classes (Assert\Collection + UUID)
  pour éviter les erreurs serveur sur payloads malformés
- API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
2026-02-16 14:32:37 +01:00
parent fdc26eb334
commit c856dfdcda
63 changed files with 7694 additions and 236 deletions

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
final readonly class ClassSubjectPair
{
public function __construct(
public ClassId $classId,
public SubjectId $subjectId,
) {
}
public function equals(self $other): bool
{
return $this->classId->equals($other->classId)
&& $this->subjectId->equals($other->subjectId);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
enum ReplacementStatus: string
{
case ACTIVE = 'active';
case ENDED = 'ended';
public function estActive(): bool
{
return $this === self::ACTIVE;
}
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Actif',
self::ENDED => 'Terminé',
};
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\RemplacementDesigne;
use App\Scolarite\Domain\Event\RemplacementTermine;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Aggregate Root représentant le remplacement temporaire d'un enseignant.
*
* Un administrateur désigne un remplaçant pour un enseignant absent, avec des dates
* de début/fin et un périmètre de classes/matières. Le remplaçant peut saisir de
* nouvelles notes et lire les notes passées (lecture seule).
*
* @see FR9: Désigner remplaçant temporaire
*/
final class TeacherReplacement extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
/** @param array<ClassSubjectPair> $classes */
private function __construct(
public private(set) TeacherReplacementId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $replacedTeacherId,
public private(set) UserId $replacementTeacherId,
public private(set) DateTimeImmutable $startDate,
public private(set) DateTimeImmutable $endDate,
public private(set) ReplacementStatus $status,
public private(set) array $classes,
public private(set) ?string $reason,
public private(set) UserId $createdBy,
public private(set) DateTimeImmutable $createdAt,
public private(set) ?DateTimeImmutable $endedAt,
) {
$this->updatedAt = $createdAt;
}
/** @param array<ClassSubjectPair> $classes */
public static function designer(
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $now,
): self {
if ($replacedTeacherId->equals($replacementTeacherId)) {
throw RemplacementSameTeacherException::create();
}
if ($endDate < $startDate) {
throw DatesRemplacementInvalidesException::finAvantDebut($startDate, $endDate);
}
if ($classes === []) {
throw new InvalidArgumentException('Au moins une paire classe/matière est requise.');
}
$replacement = new self(
id: TeacherReplacementId::generate(),
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: ReplacementStatus::ACTIVE,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $now,
endedAt: null,
);
$replacement->recordEvent(new RemplacementDesigne(
replacementId: $replacement->id,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
occurredOn: $now,
));
return $replacement;
}
public function isActive(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now >= $this->startDate
&& $now <= $this->endDate;
}
public function isExpired(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now > $this->endDate;
}
public function terminer(DateTimeImmutable $at): void
{
if ($this->status === ReplacementStatus::ENDED) {
throw RemplacementDejaTermineException::withId($this->id);
}
$this->status = ReplacementStatus::ENDED;
$this->endedAt = $at;
$this->updatedAt = $at;
$this->recordEvent(new RemplacementTermine(
replacementId: $this->id,
replacedTeacherId: $this->replacedTeacherId,
replacementTeacherId: $this->replacementTeacherId,
startDate: $this->startDate,
endDate: $this->endDate,
occurredOn: $at,
));
}
public function couvreClasseMatiere(ClassSubjectPair $pair): bool
{
foreach ($this->classes as $classSubjectPair) {
if ($classSubjectPair->equals($pair)) {
return true;
}
}
return false;
}
/**
* Reconstitue un TeacherReplacement depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*
* @param array<ClassSubjectPair> $classes
*/
public static function reconstitute(
TeacherReplacementId $id,
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
ReplacementStatus $status,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $createdAt,
?DateTimeImmutable $endedAt,
DateTimeImmutable $updatedAt,
): self {
$replacement = new self(
id: $id,
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: $status,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $createdAt,
endedAt: $endedAt,
);
$replacement->updatedAt = $updatedAt;
return $replacement;
}
}

View File

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