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\Application\Command\DesignateReplacement;
|
||||
|
||||
final readonly class DesignateReplacementCommand
|
||||
{
|
||||
/**
|
||||
* @param array<array{classId: string, subjectId: string}> $classes
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $replacedTeacherId,
|
||||
public string $replacementTeacherId,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
public array $classes,
|
||||
public ?string $reason,
|
||||
public string $createdBy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DesignateReplacement;
|
||||
|
||||
use App\Administration\Domain\Exception\TenantMismatchException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DesignateReplacementHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherReplacementRepository $replacementRepository,
|
||||
private UserRepository $userRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(DesignateReplacementCommand $command): TeacherReplacement
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$replacedTeacherId = UserId::fromString($command->replacedTeacherId);
|
||||
$replacementTeacherId = UserId::fromString($command->replacementTeacherId);
|
||||
|
||||
// Valider l'existence, le tenant et le rôle des enseignants
|
||||
$replacedTeacher = $this->userRepository->get($replacedTeacherId);
|
||||
if (!$replacedTeacher->tenantId->equals($tenantId)) {
|
||||
throw TenantMismatchException::pourUtilisateur($replacedTeacherId, $tenantId);
|
||||
}
|
||||
if (!$replacedTeacher->aLeRole(Role::PROF)) {
|
||||
throw UtilisateurNonEnseignantException::pourUtilisateur($replacedTeacherId);
|
||||
}
|
||||
|
||||
$replacementTeacher = $this->userRepository->get($replacementTeacherId);
|
||||
if (!$replacementTeacher->tenantId->equals($tenantId)) {
|
||||
throw TenantMismatchException::pourUtilisateur($replacementTeacherId, $tenantId);
|
||||
}
|
||||
if (!$replacementTeacher->aLeRole(Role::PROF)) {
|
||||
throw UtilisateurNonEnseignantException::pourUtilisateur($replacementTeacherId);
|
||||
}
|
||||
|
||||
$classes = array_map(
|
||||
static fn (array $pair) => new ClassSubjectPair(
|
||||
ClassId::fromString($pair['classId']),
|
||||
SubjectId::fromString($pair['subjectId']),
|
||||
),
|
||||
$command->classes,
|
||||
);
|
||||
|
||||
$replacement = TeacherReplacement::designer(
|
||||
tenantId: $tenantId,
|
||||
replacedTeacherId: $replacedTeacherId,
|
||||
replacementTeacherId: $replacementTeacherId,
|
||||
startDate: new DateTimeImmutable($command->startDate),
|
||||
endDate: new DateTimeImmutable($command->endDate),
|
||||
classes: $classes,
|
||||
reason: $command->reason,
|
||||
createdBy: UserId::fromString($command->createdBy),
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->replacementRepository->save($replacement);
|
||||
|
||||
return $replacement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\EndReplacement;
|
||||
|
||||
final readonly class EndReplacementCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $replacementId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\EndReplacement;
|
||||
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class EndReplacementHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherReplacementRepository $replacementRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(EndReplacementCommand $command): void
|
||||
{
|
||||
$replacement = $this->replacementRepository->get(
|
||||
TeacherReplacementId::fromString($command->replacementId),
|
||||
TenantId::fromString($command->tenantId),
|
||||
);
|
||||
|
||||
$replacement->terminer($this->clock->now());
|
||||
$this->replacementRepository->save($replacement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetActiveReplacements;
|
||||
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetActiveReplacementsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherReplacementRepository $replacementRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ReplacementDto> */
|
||||
public function __invoke(GetActiveReplacementsQuery $query): array
|
||||
{
|
||||
$replacements = $this->replacementRepository->findActiveByTenant(
|
||||
TenantId::fromString($query->tenantId),
|
||||
$this->clock->now(),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn ($r) => ReplacementDto::fromDomain($r),
|
||||
$replacements,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetActiveReplacements;
|
||||
|
||||
final readonly class GetActiveReplacementsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetActiveReplacements;
|
||||
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ReplacementDto
|
||||
{
|
||||
/** @param array<array{classId: string, subjectId: string}> $classes */
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $replacedTeacherId,
|
||||
public string $replacementTeacherId,
|
||||
public DateTimeImmutable $startDate,
|
||||
public DateTimeImmutable $endDate,
|
||||
public string $status,
|
||||
public array $classes,
|
||||
public ?string $reason,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(TeacherReplacement $replacement): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $replacement->id,
|
||||
replacedTeacherId: (string) $replacement->replacedTeacherId,
|
||||
replacementTeacherId: (string) $replacement->replacementTeacherId,
|
||||
startDate: $replacement->startDate,
|
||||
endDate: $replacement->endDate,
|
||||
status: $replacement->status->value,
|
||||
classes: array_map(
|
||||
static fn ($pair) => [
|
||||
'classId' => (string) $pair->classId,
|
||||
'subjectId' => (string) $pair->subjectId,
|
||||
],
|
||||
$replacement->classes,
|
||||
),
|
||||
reason: $replacement->reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetReplacedClassesForTeacherHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherReplacementRepository $replacementRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ReplacedClassDto> */
|
||||
public function __invoke(GetReplacedClassesForTeacherQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
$replacements = $this->replacementRepository->findActiveByReplacementTeacher(
|
||||
UserId::fromString($query->replacementTeacherId),
|
||||
$tenantId,
|
||||
$this->clock->now(),
|
||||
);
|
||||
|
||||
if ($replacements === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classNames = [];
|
||||
foreach ($this->classRepository->findAllActiveByTenant($tenantId) as $class) {
|
||||
$classNames[(string) $class->id] = (string) $class->name;
|
||||
}
|
||||
|
||||
$subjectNames = [];
|
||||
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
|
||||
$subjectNames[(string) $subject->id] = (string) $subject->name;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($replacements as $replacement) {
|
||||
foreach ($replacement->classes as $pair) {
|
||||
$classId = (string) $pair->classId;
|
||||
$subjectId = (string) $pair->subjectId;
|
||||
|
||||
$result[] = new ReplacedClassDto(
|
||||
replacementId: (string) $replacement->id,
|
||||
replacedTeacherId: (string) $replacement->replacedTeacherId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
className: $classNames[$classId] ?? $classId,
|
||||
subjectName: $subjectNames[$subjectId] ?? $subjectId,
|
||||
startDate: $replacement->startDate,
|
||||
endDate: $replacement->endDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
|
||||
|
||||
final readonly class GetReplacedClassesForTeacherQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $replacementTeacherId,
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ReplacedClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $replacementId,
|
||||
public string $replacedTeacherId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $className,
|
||||
public string $subjectName,
|
||||
public DateTimeImmutable $startDate,
|
||||
public DateTimeImmutable $endDate,
|
||||
) {
|
||||
}
|
||||
}
|
||||
37
backend/src/Scolarite/Domain/Event/RemplacementDesigne.php
Normal file
37
backend/src/Scolarite/Domain/Event/RemplacementDesigne.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class RemplacementDesigne implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public TeacherReplacementId $replacementId,
|
||||
public UserId $replacedTeacherId,
|
||||
public UserId $replacementTeacherId,
|
||||
public DateTimeImmutable $startDate,
|
||||
public DateTimeImmutable $endDate,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->replacementId->value;
|
||||
}
|
||||
}
|
||||
37
backend/src/Scolarite/Domain/Event/RemplacementTermine.php
Normal file
37
backend/src/Scolarite/Domain/Event/RemplacementTermine.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class RemplacementTermine implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public TeacherReplacementId $replacementId,
|
||||
public UserId $replacedTeacherId,
|
||||
public UserId $replacementTeacherId,
|
||||
public DateTimeImmutable $startDate,
|
||||
public DateTimeImmutable $endDate,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->replacementId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class DatesRemplacementInvalidesException extends DomainException
|
||||
{
|
||||
public static function finAvantDebut(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La date de début (%s) doit être antérieure à la date de fin (%s).',
|
||||
$startDate->format('Y-m-d'),
|
||||
$endDate->format('Y-m-d'),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class RemplacementDejaTermineException extends DomainException
|
||||
{
|
||||
public static function withId(TeacherReplacementId $id): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le remplacement "%s" est déjà terminé.',
|
||||
$id,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class RemplacementNotFoundException extends DomainException
|
||||
{
|
||||
public static function withId(TeacherReplacementId $id): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le remplacement avec l\'ID "%s" n\'a pas été trouvé.',
|
||||
$id,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class RemplacementSameTeacherException extends DomainException
|
||||
{
|
||||
public static function create(): self
|
||||
{
|
||||
return new self('L\'enseignant remplacé et le remplaçant ne peuvent pas être la même personne.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class UtilisateurNonEnseignantException extends DomainException
|
||||
{
|
||||
public static function pourUtilisateur(UserId $userId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'utilisateur « %s » n\'a pas le rôle enseignant.',
|
||||
$userId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface TeacherReplacementRepository
|
||||
{
|
||||
public function save(TeacherReplacement $replacement): void;
|
||||
|
||||
/** @throws RemplacementNotFoundException */
|
||||
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement;
|
||||
|
||||
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement;
|
||||
|
||||
public function findActiveReplacement(
|
||||
UserId $replacementTeacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
DateTimeImmutable $at,
|
||||
TenantId $tenantId,
|
||||
): ?TeacherReplacement;
|
||||
|
||||
/** @return array<TeacherReplacement> */
|
||||
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array;
|
||||
|
||||
/** @return array<TeacherReplacement> */
|
||||
public function findActiveByReplacementTeacher(
|
||||
UserId $replacementTeacherId,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $at,
|
||||
): array;
|
||||
|
||||
/** @return array<TeacherReplacement> */
|
||||
public function findExpired(DateTimeImmutable $at): array;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Domain\Exception\TenantMismatchException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementCommand;
|
||||
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementHandler;
|
||||
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
|
||||
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
|
||||
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
|
||||
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<TeacherReplacementResource, TeacherReplacementResource>
|
||||
*/
|
||||
final readonly class CreateTeacherReplacementProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DesignateReplacementHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TeacherReplacementResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un remplacement.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new DesignateReplacementCommand(
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
replacedTeacherId: $data->replacedTeacherId ?? '',
|
||||
replacementTeacherId: $data->replacementTeacherId ?? '',
|
||||
startDate: $data->startDate ?? '',
|
||||
endDate: $data->endDate ?? '',
|
||||
classes: $data->classes ?? [],
|
||||
reason: $data->reason,
|
||||
createdBy: $user->userId(),
|
||||
);
|
||||
|
||||
$replacement = ($this->handler)($command);
|
||||
|
||||
foreach ($replacement->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return TeacherReplacementResource::fromDomain($replacement);
|
||||
} catch (RemplacementSameTeacherException|DatesRemplacementInvalidesException|TenantMismatchException|UtilisateurNonEnseignantException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (UserNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (InvalidUuidStringException $e) {
|
||||
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
|
||||
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
|
||||
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
|
||||
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
|
||||
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<TeacherReplacementResource, null>
|
||||
*/
|
||||
final readonly class EndTeacherReplacementProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EndReplacementHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer un remplacement.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string $id */
|
||||
$id = $uriVariables['id'];
|
||||
|
||||
try {
|
||||
($this->handler)(new EndReplacementCommand(
|
||||
replacementId: $id,
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
));
|
||||
} catch (RemplacementNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (RemplacementDejaTermineException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherHandler;
|
||||
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ReplacedClassResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* Fournit les classes du remplaçant connecté (GET /api/me/replaced-classes).
|
||||
*
|
||||
* @implements ProviderInterface<ReplacedClassResource>
|
||||
*/
|
||||
final readonly class ReplacedClassesProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetReplacedClassesForTeacherHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ReplacedClassResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$query = new GetReplacedClassesForTeacherQuery(
|
||||
replacementTeacherId: $user->userId(),
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
ReplacedClassResource::fromDto(...),
|
||||
($this->handler)($query),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
|
||||
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<TeacherReplacementResource>
|
||||
*/
|
||||
final readonly class TeacherReplacementItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherReplacementRepository $replacementRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer les remplacements.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string $id */
|
||||
$id = $uriVariables['id'];
|
||||
$tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId());
|
||||
|
||||
$replacement = $this->replacementRepository->findById(
|
||||
TeacherReplacementId::fromString($id),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($replacement === null) {
|
||||
throw new NotFoundHttpException('Remplacement non trouvé.');
|
||||
}
|
||||
|
||||
return TeacherReplacementResource::fromDomain($replacement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsHandler;
|
||||
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsQuery;
|
||||
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
|
||||
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<TeacherReplacementResource>
|
||||
*/
|
||||
final readonly class TeacherReplacementsCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetActiveReplacementsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<TeacherReplacementResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les remplacements.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$query = new GetActiveReplacementsQuery(
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
);
|
||||
|
||||
$dtos = ($this->handler)($query);
|
||||
|
||||
return array_map(
|
||||
static fn (ReplacementDto $dto) => TeacherReplacementResource::fromDto($dto),
|
||||
$dtos,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\ReplacedClassDto;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ReplacedClassesProvider;
|
||||
|
||||
/**
|
||||
* Classes/matières pour lesquelles l'enseignant connecté est remplaçant.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ReplacedClass',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/me/replaced-classes',
|
||||
provider: ReplacedClassesProvider::class,
|
||||
name: 'get_replaced_classes',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ReplacedClassResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $id;
|
||||
|
||||
public string $replacementId;
|
||||
|
||||
public string $replacedTeacherId;
|
||||
|
||||
public string $classId;
|
||||
|
||||
public string $subjectId;
|
||||
|
||||
public string $className;
|
||||
|
||||
public string $subjectName;
|
||||
|
||||
public string $startDate;
|
||||
|
||||
public string $endDate;
|
||||
|
||||
public static function fromDto(ReplacedClassDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->replacementId . '_' . $dto->classId . '_' . $dto->subjectId;
|
||||
$resource->replacementId = $dto->replacementId;
|
||||
$resource->replacedTeacherId = $dto->replacedTeacherId;
|
||||
$resource->classId = $dto->classId;
|
||||
$resource->subjectId = $dto->subjectId;
|
||||
$resource->className = $dto->className;
|
||||
$resource->subjectName = $dto->subjectName;
|
||||
$resource->startDate = $dto->startDate->format('Y-m-d');
|
||||
$resource->endDate = $dto->endDate->format('Y-m-d');
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\CreateTeacherReplacementProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\EndTeacherReplacementProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementItemProvider;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementsCollectionProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion des remplacements enseignants.
|
||||
*
|
||||
* @see Story 2.9 - Désignation Remplaçants Temporaires
|
||||
* @see FR9 - Désigner remplaçant temporaire
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'TeacherReplacement',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/teacher-replacements',
|
||||
provider: TeacherReplacementsCollectionProvider::class,
|
||||
name: 'get_teacher_replacements',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/teacher-replacements',
|
||||
processor: CreateTeacherReplacementProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_teacher_replacement',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/teacher-replacements/{id}',
|
||||
provider: TeacherReplacementItemProvider::class,
|
||||
processor: EndTeacherReplacementProcessor::class,
|
||||
name: 'end_teacher_replacement',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class TeacherReplacementResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant remplacé est requis.', groups: ['create'])]
|
||||
public ?string $replacedTeacherId = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'L\'identifiant du remplaçant est requis.', groups: ['create'])]
|
||||
public ?string $replacementTeacherId = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La date de début est requise.', groups: ['create'])]
|
||||
public ?string $startDate = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La date de fin est requise.', groups: ['create'])]
|
||||
public ?string $endDate = null;
|
||||
|
||||
/** @var array<array{classId: string, subjectId: string}>|null */
|
||||
#[Assert\NotBlank(message: 'Au moins une classe/matière est requise.', groups: ['create'])]
|
||||
#[Assert\All(
|
||||
constraints: [
|
||||
new Assert\Collection(
|
||||
fields: [
|
||||
'classId' => [
|
||||
new Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create']),
|
||||
new Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create']),
|
||||
],
|
||||
'subjectId' => [
|
||||
new Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create']),
|
||||
new Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create']),
|
||||
],
|
||||
],
|
||||
allowExtraFields: false,
|
||||
allowMissingFields: false,
|
||||
groups: ['create'],
|
||||
),
|
||||
],
|
||||
groups: ['create'],
|
||||
)]
|
||||
public ?array $classes = null;
|
||||
|
||||
public ?string $reason = null;
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public ?DateTimeImmutable $endedAt = null;
|
||||
|
||||
public static function fromDomain(TeacherReplacement $replacement): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $replacement->id;
|
||||
$resource->replacedTeacherId = (string) $replacement->replacedTeacherId;
|
||||
$resource->replacementTeacherId = (string) $replacement->replacementTeacherId;
|
||||
$resource->startDate = $replacement->startDate->format('Y-m-d');
|
||||
$resource->endDate = $replacement->endDate->format('Y-m-d');
|
||||
$resource->classes = array_map(
|
||||
static fn ($pair) => [
|
||||
'classId' => (string) $pair->classId,
|
||||
'subjectId' => (string) $pair->subjectId,
|
||||
],
|
||||
$replacement->classes,
|
||||
);
|
||||
$resource->reason = $replacement->reason;
|
||||
$resource->status = $replacement->status->value;
|
||||
$resource->createdAt = $replacement->createdAt;
|
||||
$resource->endedAt = $replacement->endedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public static function fromDto(ReplacementDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->replacedTeacherId = $dto->replacedTeacherId;
|
||||
$resource->replacementTeacherId = $dto->replacementTeacherId;
|
||||
$resource->startDate = $dto->startDate->format('Y-m-d');
|
||||
$resource->endDate = $dto->endDate->format('Y-m-d');
|
||||
$resource->classes = $dto->classes;
|
||||
$resource->reason = $dto->reason;
|
||||
$resource->status = $dto->status;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Console;
|
||||
|
||||
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
|
||||
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
|
||||
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
|
||||
use function count;
|
||||
|
||||
use Override;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Termine automatiquement les remplacements dont la date de fin est dépassée.
|
||||
*
|
||||
* CRON: 0 0 * * * php bin/console app:end-expired-replacements
|
||||
*
|
||||
* @see Story 2.9 - AC3: Fin automatique remplacement
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:end-expired-replacements',
|
||||
description: 'Termine les remplacements dont la date de fin est dépassée',
|
||||
)]
|
||||
final class EndExpiredReplacementsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeacherReplacementRepository $replacementRepository,
|
||||
private readonly EndReplacementHandler $endReplacementHandler,
|
||||
private readonly Clock $clock,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Fin automatique des remplacements expirés');
|
||||
|
||||
$expiredReplacements = $this->replacementRepository->findExpired($this->clock->now());
|
||||
|
||||
if ($expiredReplacements === []) {
|
||||
$io->success('Aucun remplacement expiré à traiter.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('%d remplacement(s) expiré(s) trouvé(s)', count($expiredReplacements)));
|
||||
|
||||
$endedCount = 0;
|
||||
|
||||
foreach ($expiredReplacements as $replacement) {
|
||||
try {
|
||||
($this->endReplacementHandler)(new EndReplacementCommand(
|
||||
replacementId: (string) $replacement->id,
|
||||
tenantId: (string) $replacement->tenantId,
|
||||
));
|
||||
|
||||
$io->writeln(sprintf(
|
||||
' ✅ Remplacement %s terminé (remplaçant: %s)',
|
||||
$replacement->id,
|
||||
$replacement->replacementTeacherId,
|
||||
));
|
||||
|
||||
$this->logger->info('Remplacement terminé automatiquement', [
|
||||
'replacement_id' => (string) $replacement->id,
|
||||
'tenant_id' => (string) $replacement->tenantId,
|
||||
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
|
||||
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
|
||||
]);
|
||||
|
||||
++$endedCount;
|
||||
} catch (RemplacementDejaTermineException $e) {
|
||||
$io->warning(sprintf(
|
||||
' ⚠ Remplacement %s déjà terminé, ignoré',
|
||||
$replacement->id,
|
||||
));
|
||||
|
||||
$this->logger->info('Remplacement déjà terminé, ignoré lors du traitement automatique', [
|
||||
'replacement_id' => (string) $replacement->id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$io->error(sprintf(
|
||||
'Erreur pour le remplacement %s : %s',
|
||||
$replacement->id,
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
$this->logger->error('Erreur lors de la terminaison automatique du remplacement', [
|
||||
'replacement_id' => (string) $replacement->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d remplacement(s) terminé(s) avec succès.', $endedCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineTeacherReplacementRepository implements TeacherReplacementRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(TeacherReplacement $replacement): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, ended_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :replaced_teacher_id, :replacement_teacher_id, :start_date, :end_date, :status, :reason, :created_by, :created_at, :ended_at, :updated_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
ended_at = EXCLUDED.ended_at,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $replacement->id,
|
||||
'tenant_id' => (string) $replacement->tenantId,
|
||||
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
|
||||
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
|
||||
'start_date' => $replacement->startDate->format('Y-m-d'),
|
||||
'end_date' => $replacement->endDate->format('Y-m-d'),
|
||||
'status' => $replacement->status->value,
|
||||
'reason' => $replacement->reason,
|
||||
'created_by' => (string) $replacement->createdBy,
|
||||
'created_at' => $replacement->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'ended_at' => $replacement->endedAt?->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $replacement->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
|
||||
// Upsert replacement_classes
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM replacement_classes WHERE replacement_id = :replacement_id',
|
||||
['replacement_id' => (string) $replacement->id],
|
||||
);
|
||||
|
||||
foreach ($replacement->classes as $pair) {
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO replacement_classes (replacement_id, class_id, subject_id) VALUES (:replacement_id, :class_id, :subject_id)',
|
||||
[
|
||||
'replacement_id' => (string) $replacement->id,
|
||||
'class_id' => (string) $pair->classId,
|
||||
'subject_id' => (string) $pair->subjectId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
|
||||
{
|
||||
$replacement = $this->findById($id, $tenantId);
|
||||
|
||||
if ($replacement === null) {
|
||||
throw RemplacementNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $replacement;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM teacher_replacements WHERE id = :id AND tenant_id = :tenant_id',
|
||||
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveReplacement(
|
||||
UserId $replacementTeacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
DateTimeImmutable $at,
|
||||
TenantId $tenantId,
|
||||
): ?TeacherReplacement {
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT tr.* FROM teacher_replacements tr
|
||||
JOIN replacement_classes rc ON rc.replacement_id = tr.id
|
||||
WHERE tr.tenant_id = :tenant_id
|
||||
AND tr.replacement_teacher_id = :replacement_teacher_id
|
||||
AND rc.class_id = :class_id
|
||||
AND rc.subject_id = :subject_id
|
||||
AND tr.status = :status
|
||||
AND tr.start_date <= :at
|
||||
AND tr.end_date >= :at',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'replacement_teacher_id' => (string) $replacementTeacherId,
|
||||
'class_id' => (string) $classId,
|
||||
'subject_id' => (string) $subjectId,
|
||||
'status' => ReplacementStatus::ACTIVE->value,
|
||||
'at' => $at->format('Y-m-d'),
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM teacher_replacements
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND status = :status
|
||||
AND start_date <= :at
|
||||
AND end_date >= :at
|
||||
ORDER BY start_date ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'status' => ReplacementStatus::ACTIVE->value,
|
||||
'at' => $at->format('Y-m-d'),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByReplacementTeacher(
|
||||
UserId $replacementTeacherId,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $at,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM teacher_replacements
|
||||
WHERE replacement_teacher_id = :replacement_teacher_id
|
||||
AND tenant_id = :tenant_id
|
||||
AND status = :status
|
||||
AND start_date <= :at
|
||||
AND end_date >= :at
|
||||
ORDER BY created_at ASC',
|
||||
[
|
||||
'replacement_teacher_id' => (string) $replacementTeacherId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'status' => ReplacementStatus::ACTIVE->value,
|
||||
'at' => $at->format('Y-m-d'),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findExpired(DateTimeImmutable $at): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM teacher_replacements
|
||||
WHERE status = :status
|
||||
AND end_date < :at
|
||||
ORDER BY end_date ASC',
|
||||
[
|
||||
'status' => ReplacementStatus::ACTIVE->value,
|
||||
'at' => $at->format('Y-m-d'),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
/** @return array<ClassSubjectPair> */
|
||||
private function loadClasses(string $replacementId): array
|
||||
{
|
||||
return $this->loadClassesBatch([$replacementId])[$replacementId] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $replacementIds
|
||||
*
|
||||
* @return array<string, list<ClassSubjectPair>>
|
||||
*/
|
||||
private function loadClassesBatch(array $replacementIds): array
|
||||
{
|
||||
if ($replacementIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($replacementIds), '?'));
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT replacement_id, class_id, subject_id FROM replacement_classes WHERE replacement_id IN ($placeholders)",
|
||||
array_values($replacementIds),
|
||||
);
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $replacementId */
|
||||
$replacementId = $row['replacement_id'];
|
||||
/** @var string $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string $subjectId */
|
||||
$subjectId = $row['subject_id'];
|
||||
|
||||
$grouped[$replacementId][] = new ClassSubjectPair(
|
||||
ClassId::fromString($classId),
|
||||
SubjectId::fromString($subjectId),
|
||||
);
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<TeacherReplacement>
|
||||
*/
|
||||
private function hydrateMany(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = array_map(static function (array $row): string {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
|
||||
return $id;
|
||||
}, $rows);
|
||||
$classesByReplacement = $this->loadClassesBatch($ids);
|
||||
|
||||
return array_map(
|
||||
function (array $row) use ($classesByReplacement) {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
|
||||
return $this->hydrate($row, $classesByReplacement[$id] ?? []);
|
||||
},
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<ClassSubjectPair>|null $classes Pre-loaded classes (null = load individually)
|
||||
*/
|
||||
private function hydrate(array $row, ?array $classes = null): TeacherReplacement
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $replacedTeacherId */
|
||||
$replacedTeacherId = $row['replaced_teacher_id'];
|
||||
/** @var string $replacementTeacherId */
|
||||
$replacementTeacherId = $row['replacement_teacher_id'];
|
||||
/** @var string $startDate */
|
||||
$startDate = $row['start_date'];
|
||||
/** @var string $endDate */
|
||||
$endDate = $row['end_date'];
|
||||
/** @var string $status */
|
||||
$status = $row['status'];
|
||||
/** @var string|null $reason */
|
||||
$reason = $row['reason'];
|
||||
/** @var string $createdBy */
|
||||
$createdBy = $row['created_by'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string|null $endedAt */
|
||||
$endedAt = $row['ended_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return TeacherReplacement::reconstitute(
|
||||
id: TeacherReplacementId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
replacedTeacherId: UserId::fromString($replacedTeacherId),
|
||||
replacementTeacherId: UserId::fromString($replacementTeacherId),
|
||||
startDate: new DateTimeImmutable($startDate),
|
||||
endDate: new DateTimeImmutable($endDate),
|
||||
status: ReplacementStatus::from($status),
|
||||
classes: $classes ?? $this->loadClasses($id),
|
||||
reason: $reason,
|
||||
createdBy: UserId::fromString($createdBy),
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
endedAt: $endedAt !== null ? new DateTimeImmutable($endedAt) : null,
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
|
||||
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
|
||||
final class InMemoryTeacherReplacementRepository implements TeacherReplacementRepository
|
||||
{
|
||||
/** @var array<string, TeacherReplacement> */
|
||||
private array $byId = [];
|
||||
|
||||
#[Override]
|
||||
public function save(TeacherReplacement $replacement): void
|
||||
{
|
||||
$this->byId[(string) $replacement->id] = $replacement;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
|
||||
{
|
||||
$replacement = $this->findById($id, $tenantId);
|
||||
|
||||
if ($replacement === null) {
|
||||
throw RemplacementNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $replacement;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
|
||||
{
|
||||
$replacement = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($replacement !== null && !$replacement->tenantId->equals($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $replacement;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveReplacement(
|
||||
UserId $replacementTeacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
DateTimeImmutable $at,
|
||||
TenantId $tenantId,
|
||||
): ?TeacherReplacement {
|
||||
$pair = new ClassSubjectPair($classId, $subjectId);
|
||||
|
||||
foreach ($this->byId as $replacement) {
|
||||
if ($replacement->tenantId->equals($tenantId)
|
||||
&& $replacement->replacementTeacherId->equals($replacementTeacherId)
|
||||
&& $replacement->isActive($at)
|
||||
&& $replacement->couvreClasseMatiere($pair)
|
||||
) {
|
||||
return $replacement;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
|
||||
&& $r->isActive($at),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByReplacementTeacher(
|
||||
UserId $replacementTeacherId,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $at,
|
||||
): array {
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
|
||||
&& $r->replacementTeacherId->equals($replacementTeacherId)
|
||||
&& $r->isActive($at),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findExpired(DateTimeImmutable $at): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (TeacherReplacement $r) => $r->status === ReplacementStatus::ACTIVE
|
||||
&& $r->isExpired($at),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur les remplacements enseignants.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
|
||||
* - ENSEIGNANT : lecture seule (ses propres remplacements actifs/passifs)
|
||||
* - Autres rôles : pas d'accès
|
||||
*
|
||||
* @extends Voter<string, TeacherReplacement|null>
|
||||
*/
|
||||
final class TeacherReplacementVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'TEACHER_REPLACEMENT_VIEW';
|
||||
public const string CREATE = 'TEACHER_REPLACEMENT_CREATE';
|
||||
public const string DELETE = 'TEACHER_REPLACEMENT_DELETE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CREATE,
|
||||
self::DELETE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subject === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $subject instanceof TeacherReplacement;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles, $user, $subject),
|
||||
self::CREATE => $this->canCreate($roles),
|
||||
self::DELETE => $this->canDelete($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canView(array $roles, SecurityUser $user, mixed $subject): bool
|
||||
{
|
||||
if ($this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasAnyRole($roles, [Role::PROF->value])) {
|
||||
return $this->isInvolvedTeacher($user, $subject);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isInvolvedTeacher(SecurityUser $user, mixed $subject): bool
|
||||
{
|
||||
if (!$subject instanceof TeacherReplacement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) $subject->replacedTeacherId === $user->userId()
|
||||
|| (string) $subject->replacementTeacherId === $user->userId();
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canCreate(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canDelete(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user