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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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é',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user