feat: Permettre la création et modification de l'emploi du temps des classes
L'administration a besoin de construire et maintenir les emplois du temps hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont pas en conflit (même créneau, classes différentes) et que les affectations enseignant-matière-classe sont respectées. Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot), la détection de conflits (classe, enseignant, salle) avec possibilité de forcer, la validation des affectations côté serveur (AC2), l'intégration calendrier pour les jours bloqués, une vue mobile-first avec onglets jour par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
final readonly class CreateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring = true,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(CreateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$timeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: DayOfWeek::from($command->dayOfWeek),
|
||||
timeSlot: $timeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $command->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
|
||||
$conflicts = $this->conflictDetector->detectConflicts($slot, $tenantId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
final readonly class DeleteScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DeleteScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScheduleSlot Le slot supprimé (pour dispatch events)
|
||||
*/
|
||||
public function __invoke(DeleteScheduleSlotCommand $command): ScheduleSlot
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$slot->supprimer($this->clock->now());
|
||||
$this->repository->delete($slotId, $tenantId);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
final readonly class UpdateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(UpdateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$dayOfWeek = DayOfWeek::from($command->dayOfWeek);
|
||||
$newTimeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
// Détecte les conflits avant de modifier le slot.
|
||||
// On crée un slot temporaire avec les nouvelles valeurs pour la détection.
|
||||
$preview = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
$conflicts = $this->conflictDetector->detectConflicts($preview, $tenantId, $slotId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$slot->modifier(
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
at: $this->clock->now(),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
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 qu'un enseignant est affecté à une classe/matière.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface EnseignantAffectationChecker
|
||||
{
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class BlockedDateDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public string $reason,
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
|
||||
* pour une plage de dates donnée.
|
||||
*
|
||||
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetBlockedDatesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<BlockedDateDto> */
|
||||
public function __invoke(GetBlockedDatesQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($query->academicYearId);
|
||||
|
||||
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
|
||||
|
||||
$startDate = new DateTimeImmutable($query->startDate);
|
||||
$endDate = new DateTimeImmutable($query->endDate);
|
||||
$oneDay = new DateInterval('P1D');
|
||||
|
||||
$blockedDates = [];
|
||||
$current = $startDate;
|
||||
|
||||
while ($current <= $endDate) {
|
||||
$dayOfWeek = (int) $current->format('N');
|
||||
$dateStr = $current->format('Y-m-d');
|
||||
|
||||
if ($dayOfWeek >= 6) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
||||
type: 'weekend',
|
||||
);
|
||||
} elseif ($calendar !== null) {
|
||||
$entry = $calendar->trouverEntreePourDate($current);
|
||||
|
||||
if ($entry !== null) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $entry->label,
|
||||
type: $entry->type->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$current = $current->add($oneDay);
|
||||
}
|
||||
|
||||
return $blockedDates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class GetBlockedDatesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetScheduleSlotsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ScheduleSlotDto> */
|
||||
public function __invoke(GetScheduleSlotsQuery $query): array
|
||||
{
|
||||
try {
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$slots = $this->repository->findByClass(ClassId::fromString($query->classId), $tenantId);
|
||||
|
||||
if ($query->teacherId !== null) {
|
||||
$teacherId = UserId::fromString($query->teacherId);
|
||||
$slots = array_values(array_filter(
|
||||
$slots,
|
||||
static fn (ScheduleSlot $slot) => $slot->teacherId->equals($teacherId),
|
||||
));
|
||||
}
|
||||
} elseif ($query->teacherId !== null) {
|
||||
$slots = $this->repository->findByTeacher(UserId::fromString($query->teacherId), $tenantId);
|
||||
} else {
|
||||
$slots = [];
|
||||
}
|
||||
} catch (InvalidUuidStringException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(ScheduleSlotDto::fromDomain(...), $slots);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
final readonly class GetScheduleSlotsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $classId = null,
|
||||
public ?string $teacherId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ScheduleSlotDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(ScheduleSlot $slot): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $slot->id,
|
||||
classId: (string) $slot->classId,
|
||||
subjectId: (string) $slot->subjectId,
|
||||
teacherId: (string) $slot->teacherId,
|
||||
dayOfWeek: $slot->dayOfWeek->value,
|
||||
startTime: $slot->timeSlot->startTime,
|
||||
endTime: $slot->timeSlot->endTime,
|
||||
room: $slot->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
createdAt: $slot->createdAt,
|
||||
updatedAt: $slot->updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
backend/src/Scolarite/Domain/Event/CoursCree.php
Normal file
43
backend/src/Scolarite/Domain/Event/CoursCree.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
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\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursCree implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
43
backend/src/Scolarite/Domain/Event/CoursModifie.php
Normal file
43
backend/src/Scolarite/Domain/Event/CoursModifie.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
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\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursModifie implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
36
backend/src/Scolarite/Domain/Event/CoursSupprime.php
Normal file
36
backend/src/Scolarite/Domain/Event/CoursSupprime.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class CoursSupprime implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->slotId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class CreneauHoraireInvalideException extends RuntimeException
|
||||
{
|
||||
public static function finAvantDebut(string $startTime, string $endTime): self
|
||||
{
|
||||
return new self("L'heure de fin ($endTime) doit être après l'heure de début ($startTime).");
|
||||
}
|
||||
|
||||
public static function dureeTropCourte(int $minutes): self
|
||||
{
|
||||
return new self("La durée du créneau ($minutes min) est inférieure au minimum de 5 minutes.");
|
||||
}
|
||||
|
||||
public static function formatInvalide(string $time): self
|
||||
{
|
||||
return new self("Le format de l'heure '$time' est invalide. Format attendu : HH:MM.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnseignantNonAffecteException extends RuntimeException
|
||||
{
|
||||
public static function pourClasseEtMatiere(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
): self {
|
||||
return new self(
|
||||
"L'enseignant ($teacherId) n'est pas affecté à la classe ($classId) pour la matière ($subjectId).",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use RuntimeException;
|
||||
|
||||
final class ScheduleSlotNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function avecId(ScheduleSlotId $id): self
|
||||
{
|
||||
return new self("Créneau d'emploi du temps introuvable : $id.");
|
||||
}
|
||||
}
|
||||
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
enum DayOfWeek: int
|
||||
{
|
||||
case MONDAY = 1;
|
||||
case TUESDAY = 2;
|
||||
case WEDNESDAY = 3;
|
||||
case THURSDAY = 4;
|
||||
case FRIDAY = 5;
|
||||
case SATURDAY = 6;
|
||||
case SUNDAY = 7;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MONDAY => 'Lundi',
|
||||
self::TUESDAY => 'Mardi',
|
||||
self::WEDNESDAY => 'Mercredi',
|
||||
self::THURSDAY => 'Jeudi',
|
||||
self::FRIDAY => 'Vendredi',
|
||||
self::SATURDAY => 'Samedi',
|
||||
self::SUNDAY => 'Dimanche',
|
||||
};
|
||||
}
|
||||
}
|
||||
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
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\Event\CoursCree;
|
||||
use App\Scolarite\Domain\Event\CoursModifie;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un créneau dans l'emploi du temps.
|
||||
*
|
||||
* Un créneau lie une classe, une matière et un enseignant à un jour de la semaine
|
||||
* et un horaire. Le créneau peut être récurrent (hebdomadaire) ou ponctuel.
|
||||
*
|
||||
* @see FR26: Créer et modifier l'emploi du temps des classes
|
||||
*/
|
||||
final class ScheduleSlot extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ScheduleSlotId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) ClassId $classId,
|
||||
public private(set) SubjectId $subjectId,
|
||||
public private(set) UserId $teacherId,
|
||||
public private(set) DayOfWeek $dayOfWeek,
|
||||
public private(set) TimeSlot $timeSlot,
|
||||
public private(set) ?string $room,
|
||||
public private(set) bool $isRecurring,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau créneau dans l'emploi du temps.
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$slot = new self(
|
||||
id: ScheduleSlotId::generate(),
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$slot->recordEvent(new CoursCree(
|
||||
slotId: $slot->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $now,
|
||||
));
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie les propriétés du créneau.
|
||||
*/
|
||||
public function modifier(
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
DateTimeImmutable $at,
|
||||
): void {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$this->classId = $classId;
|
||||
$this->subjectId = $subjectId;
|
||||
$this->teacherId = $teacherId;
|
||||
$this->dayOfWeek = $dayOfWeek;
|
||||
$this->timeSlot = $timeSlot;
|
||||
$this->room = $room;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new CoursModifie(
|
||||
slotId: $this->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre l'événement de suppression avant le hard-delete par le repository.
|
||||
*/
|
||||
public function supprimer(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->recordEvent(new CoursSupprime(
|
||||
slotId: $this->id,
|
||||
classId: $this->classId,
|
||||
subjectId: $this->subjectId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour.
|
||||
*/
|
||||
public function conflictsAvec(self $other): bool
|
||||
{
|
||||
return $this->dayOfWeek === $other->dayOfWeek
|
||||
&& $this->timeSlot->overlaps($other->timeSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un ScheduleSlot depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ScheduleSlotId $id,
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$slot = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$slot->updatedAt = $updatedAt;
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ScheduleSlotId extends EntityId
|
||||
{
|
||||
}
|
||||
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
|
||||
use function explode;
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* Value Object représentant un créneau horaire (heure début + heure fin).
|
||||
*
|
||||
* Format attendu : "HH:MM" (24h).
|
||||
* Contraintes : fin > début, durée minimum 5 minutes.
|
||||
*/
|
||||
final readonly class TimeSlot
|
||||
{
|
||||
private const string TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/';
|
||||
private const int MINIMUM_DURATION_MINUTES = 5;
|
||||
|
||||
public function __construct(
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
) {
|
||||
if (preg_match(self::TIME_PATTERN, $startTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($startTime);
|
||||
}
|
||||
|
||||
if (preg_match(self::TIME_PATTERN, $endTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($endTime);
|
||||
}
|
||||
|
||||
if ($endTime <= $startTime) {
|
||||
throw CreneauHoraireInvalideException::finAvantDebut($startTime, $endTime);
|
||||
}
|
||||
|
||||
$duration = $this->computeDurationInMinutes($startTime, $endTime);
|
||||
|
||||
if ($duration < self::MINIMUM_DURATION_MINUTES) {
|
||||
throw CreneauHoraireInvalideException::dureeTropCourte($duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau chevauche un autre créneau.
|
||||
*
|
||||
* Deux créneaux adjacents (fin de l'un = début de l'autre) ne se chevauchent pas.
|
||||
*/
|
||||
public function overlaps(self $other): bool
|
||||
{
|
||||
return $this->startTime < $other->endTime && $other->startTime < $this->endTime;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->startTime === $other->startTime
|
||||
&& $this->endTime === $other->endTime;
|
||||
}
|
||||
|
||||
public function durationInMinutes(): int
|
||||
{
|
||||
return $this->computeDurationInMinutes($this->startTime, $this->endTime);
|
||||
}
|
||||
|
||||
private function computeDurationInMinutes(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMin] = explode(':', $start);
|
||||
[$endHour, $endMin] = explode(':', $end);
|
||||
|
||||
return ((int) $endHour * 60 + (int) $endMin) - ((int) $startHour * 60 + (int) $startMin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface ScheduleSlotRepository
|
||||
{
|
||||
public function save(ScheduleSlot $slot): void;
|
||||
|
||||
/** @throws ScheduleSlotNotFoundException */
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot;
|
||||
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot;
|
||||
|
||||
public function delete(ScheduleSlotId $id, TenantId $tenantId): void;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array;
|
||||
}
|
||||
23
backend/src/Scolarite/Domain/Service/ScheduleConflict.php
Normal file
23
backend/src/Scolarite/Domain/Service/ScheduleConflict.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
|
||||
/**
|
||||
* Représente un conflit détecté entre deux créneaux.
|
||||
*/
|
||||
final readonly class ScheduleConflict
|
||||
{
|
||||
/**
|
||||
* @param 'class'|'teacher'|'room' $type
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public ScheduleSlot $conflictingSlot,
|
||||
public string $description,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Détecte les conflits de créneaux dans l'emploi du temps.
|
||||
*
|
||||
* Vérifie les conflits enseignant (même enseignant, même créneau horaire)
|
||||
* et les conflits de salle (même salle, même créneau horaire).
|
||||
*/
|
||||
final readonly class ScheduleConflictDetector
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ScheduleConflict>
|
||||
*/
|
||||
public function detectConflicts(
|
||||
ScheduleSlot $slot,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$conflicts = [];
|
||||
|
||||
$classConflicts = $this->repository->findOverlappingForClass(
|
||||
$slot->classId,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($classConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'class',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "La classe a déjà un cours le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
|
||||
$teacherConflicts = $this->repository->findOverlappingForTeacher(
|
||||
$slot->teacherId,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($teacherConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'teacher',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "L'enseignant est déjà occupé le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
|
||||
if ($slot->room !== null) {
|
||||
$roomConflicts = $this->repository->findOverlappingForRoom(
|
||||
$slot->room,
|
||||
$slot->dayOfWeek,
|
||||
$slot->timeSlot->startTime,
|
||||
$slot->timeSlot->endTime,
|
||||
$tenantId,
|
||||
$excludeId,
|
||||
);
|
||||
|
||||
foreach ($roomConflicts as $conflicting) {
|
||||
$conflicts[] = new ScheduleConflict(
|
||||
type: 'room',
|
||||
conflictingSlot: $conflicting,
|
||||
description: "La salle {$slot->room} est déjà occupée le {$conflicting->dayOfWeek->label()} de {$conflicting->timeSlot->startTime} à {$conflicting->timeSlot->endTime}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour créer un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class CreateScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new CreateScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
classId: $data->classId ?? '',
|
||||
subjectId: $data->subjectId ?? '',
|
||||
teacherId: $data->teacherId ?? '',
|
||||
dayOfWeek: $data->dayOfWeek ?? 1,
|
||||
startTime: $data->startTime ?? '',
|
||||
endTime: $data->endTime ?? '',
|
||||
room: $data->room,
|
||||
isRecurring: $data->isRecurring ?? true,
|
||||
forceConflicts: $data->forceConflicts ?? false,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$slot = $result['slot'];
|
||||
/** @var array<ScheduleConflict> $conflicts */
|
||||
$conflicts = $result['conflicts'];
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
$resource = ScheduleSlotResource::fromDomain($slot);
|
||||
|
||||
if ($conflicts !== []) {
|
||||
$resource->conflicts = array_map(
|
||||
static fn (ScheduleConflict $c) => [
|
||||
'type' => $c->type,
|
||||
'description' => $c->description,
|
||||
'slotId' => (string) $c->conflictingSlot->id,
|
||||
],
|
||||
$conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (CreneauHoraireInvalideException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, null>
|
||||
*/
|
||||
final readonly class DeleteScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à supprimer des créneaux.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new DeleteScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
);
|
||||
|
||||
$slot = ($this->handler)($command);
|
||||
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
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\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier un créneau d'emploi du temps.
|
||||
*
|
||||
* @implements ProcessorInterface<ScheduleSlotResource, ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class UpdateScheduleSlotProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateScheduleSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleSlotResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::EDIT)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$command = new UpdateScheduleSlotCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
classId: $data->classId ?? '',
|
||||
subjectId: $data->subjectId ?? '',
|
||||
teacherId: $data->teacherId ?? '',
|
||||
dayOfWeek: $data->dayOfWeek ?? 1,
|
||||
startTime: $data->startTime ?? '',
|
||||
endTime: $data->endTime ?? '',
|
||||
room: $data->room,
|
||||
forceConflicts: $data->forceConflicts ?? false,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$slot = $result['slot'];
|
||||
/** @var array<ScheduleConflict> $conflicts */
|
||||
$conflicts = $result['conflicts'];
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
foreach ($slot->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
$resource = ScheduleSlotResource::fromDomain($slot);
|
||||
|
||||
if ($conflicts !== []) {
|
||||
$resource->conflicts = array_map(
|
||||
static fn (ScheduleConflict $c) => [
|
||||
'type' => $c->type,
|
||||
'description' => $c->description,
|
||||
'slotId' => (string) $c->conflictingSlot->id,
|
||||
],
|
||||
$conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
} catch (CreneauHoraireInvalideException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\BlockedDateResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer les dates bloquées (jours fériés, vacances, etc.).
|
||||
*
|
||||
* @implements ProviderInterface<BlockedDateResource>
|
||||
*/
|
||||
final readonly class BlockedDateCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetBlockedDatesHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<BlockedDateResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter les dates bloquées.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var array<string, string> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (!isset($filters['startDate'], $filters['endDate'])) {
|
||||
throw new BadRequestHttpException('Les paramètres startDate et endDate sont requis.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = new GetBlockedDatesQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
startDate: (string) $filters['startDate'],
|
||||
endDate: (string) $filters['endDate'],
|
||||
);
|
||||
|
||||
$dtos = ($this->handler)($query);
|
||||
|
||||
return array_map(BlockedDateResource::fromDto(...), $dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsHandler;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsQuery;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
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;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer l'emploi du temps avec filtrage par classe ou enseignant.
|
||||
*
|
||||
* @implements ProviderInterface<ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class ScheduleSlotCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetScheduleSlotsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ScheduleSlotResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var array<string, string> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
$query = new GetScheduleSlotsQuery(
|
||||
tenantId: $tenantId,
|
||||
classId: isset($filters['classId']) ? (string) $filters['classId'] : null,
|
||||
teacherId: isset($filters['teacherId']) ? (string) $filters['teacherId'] : null,
|
||||
);
|
||||
|
||||
$dtos = ($this->handler)($query);
|
||||
|
||||
return array_map(ScheduleSlotResource::fromDto(...), $dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer un créneau d'emploi du temps par son ID.
|
||||
*
|
||||
* @implements ProviderInterface<ScheduleSlotResource>
|
||||
*/
|
||||
final readonly class ScheduleSlotItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ScheduleSlotResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps.");
|
||||
}
|
||||
|
||||
/** @var string|null $slotId */
|
||||
$slotId = $uriVariables['id'] ?? null;
|
||||
if ($slotId === null) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
$slot = $this->repository->get(ScheduleSlotId::fromString($slotId), $tenantId);
|
||||
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
}
|
||||
|
||||
return ScheduleSlotResource::fromDomain($slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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\GetBlockedDates\BlockedDateDto;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\BlockedDateCollectionProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'BlockedDate',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/blocked-dates',
|
||||
provider: BlockedDateCollectionProvider::class,
|
||||
name: 'get_blocked_dates',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BlockedDateResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $date = null;
|
||||
|
||||
public ?string $reason = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public static function fromDto(BlockedDateDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->date = $dto->date;
|
||||
$resource->reason = $dto->reason;
|
||||
$resource->type = $dto->type;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?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\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Scolarite\Application\Query\GetScheduleSlots\ScheduleSlotDto;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\CreateScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\DeleteScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\UpdateScheduleSlotProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ScheduleSlotCollectionProvider;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ScheduleSlotItemProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion de l'emploi du temps.
|
||||
*
|
||||
* @see Story 4.1 - Création et Modification de l'Emploi du Temps
|
||||
* @see FR26 - Créer et modifier l'emploi du temps des classes
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ScheduleSlot',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/slots',
|
||||
provider: ScheduleSlotCollectionProvider::class,
|
||||
name: 'get_schedule_slots',
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
name: 'get_schedule_slot',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/schedule/slots',
|
||||
processor: CreateScheduleSlotProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_schedule_slot',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
processor: UpdateScheduleSlotProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_schedule_slot',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/schedule/slots/{id}',
|
||||
provider: ScheduleSlotItemProvider::class,
|
||||
processor: DeleteScheduleSlotProcessor::class,
|
||||
name: 'delete_schedule_slot',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ScheduleSlotResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])]
|
||||
public ?string $classId = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])]
|
||||
public ?string $subjectId = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'enseignant est requis.", groups: ['create'])]
|
||||
public ?string $teacherId = null;
|
||||
|
||||
#[Assert\NotNull(message: 'Le jour de la semaine est requis.', groups: ['create'])]
|
||||
#[Assert\Range(min: 1, max: 7, notInRangeMessage: 'Le jour doit être compris entre 1 (lundi) et 7 (dimanche).')]
|
||||
public ?int $dayOfWeek = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'heure de début est requise.", groups: ['create'])]
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $startTime = null;
|
||||
|
||||
#[Assert\NotBlank(message: "L'heure de fin est requise.", groups: ['create'])]
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $endTime = null;
|
||||
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le nom de la salle ne peut pas dépasser {{ limit }} caractères.')]
|
||||
public ?string $room = null;
|
||||
|
||||
public ?bool $isRecurring = null;
|
||||
|
||||
/**
|
||||
* Si true, forcer la création/modification malgré les conflits.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $forceConflicts = null;
|
||||
|
||||
/**
|
||||
* Conflits détectés lors de la création/modification.
|
||||
* Renvoyé en réponse si des conflits existent.
|
||||
*
|
||||
* @var array<array{type: string, description: string, slotId: string}>|null
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?array $conflicts = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromDomain(ScheduleSlot $slot): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $slot->id;
|
||||
$resource->classId = (string) $slot->classId;
|
||||
$resource->subjectId = (string) $slot->subjectId;
|
||||
$resource->teacherId = (string) $slot->teacherId;
|
||||
$resource->dayOfWeek = $slot->dayOfWeek->value;
|
||||
$resource->startTime = $slot->timeSlot->startTime;
|
||||
$resource->endTime = $slot->timeSlot->endTime;
|
||||
$resource->room = $slot->room;
|
||||
$resource->isRecurring = $slot->isRecurring;
|
||||
$resource->createdAt = $slot->createdAt;
|
||||
$resource->updatedAt = $slot->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public static function fromDto(ScheduleSlotDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->classId = $dto->classId;
|
||||
$resource->subjectId = $dto->subjectId;
|
||||
$resource->teacherId = $dto->teacherId;
|
||||
$resource->dayOfWeek = $dto->dayOfWeek;
|
||||
$resource->startTime = $dto->startTime;
|
||||
$resource->endTime = $dto->endTime;
|
||||
$resource->room = $dto->room;
|
||||
$resource->isRecurring = $dto->isRecurring;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?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\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ScheduleSlot $slot): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :created_at, :updated_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
class_id = EXCLUDED.class_id,
|
||||
subject_id = EXCLUDED.subject_id,
|
||||
teacher_id = EXCLUDED.teacher_id,
|
||||
day_of_week = EXCLUDED.day_of_week,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
room = EXCLUDED.room,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $slot->id,
|
||||
'tenant_id' => (string) $slot->tenantId,
|
||||
'class_id' => (string) $slot->classId,
|
||||
'subject_id' => (string) $slot->subjectId,
|
||||
'teacher_id' => (string) $slot->teacherId,
|
||||
'day_of_week' => $slot->dayOfWeek->value,
|
||||
'start_time' => $slot->timeSlot->startTime,
|
||||
'end_time' => $slot->timeSlot->endTime,
|
||||
'room' => $slot->room,
|
||||
'is_recurring' => $slot->isRecurring ? 'true' : 'false',
|
||||
'created_at' => $slot->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $slot->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot === null) {
|
||||
throw ScheduleSlotNotFoundException::avecId($id);
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM schedule_slots 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 delete(ScheduleSlotId $id, TenantId $tenantId): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM schedule_slots WHERE id = :id AND tenant_id = :tenant_id',
|
||||
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM schedule_slots
|
||||
WHERE class_id = :class_id AND tenant_id = :tenant_id
|
||||
ORDER BY day_of_week, start_time',
|
||||
['class_id' => (string) $classId, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM schedule_slots
|
||||
WHERE teacher_id = :teacher_id AND tenant_id = :tenant_id
|
||||
ORDER BY day_of_week, start_time',
|
||||
['teacher_id' => (string) $teacherId, 'tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND class_id = :class_id
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'class_id' => (string) $classId,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND teacher_id = :teacher_id
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'teacher_id' => (string) $teacherId,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$sql = 'SELECT * FROM schedule_slots
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND room = :room
|
||||
AND day_of_week = :day_of_week
|
||||
AND start_time < :end_time
|
||||
AND end_time > :start_time';
|
||||
$params = [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'room' => $room,
|
||||
'day_of_week' => $dayOfWeek->value,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id != :exclude_id';
|
||||
$params['exclude_id'] = (string) $excludeId;
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||
|
||||
return $this->hydrateMany($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<ScheduleSlot>
|
||||
*/
|
||||
private function hydrateMany(array $rows): array
|
||||
{
|
||||
return array_map($this->hydrate(...), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): ScheduleSlot
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string $subjectId */
|
||||
$subjectId = $row['subject_id'];
|
||||
/** @var string $teacherId */
|
||||
$teacherId = $row['teacher_id'];
|
||||
/** @var int $dayOfWeek */
|
||||
$dayOfWeek = $row['day_of_week'];
|
||||
/** @var string $startTime */
|
||||
$startTime = $row['start_time'];
|
||||
/** @var string $endTime */
|
||||
$endTime = $row['end_time'];
|
||||
/** @var string|null $room */
|
||||
$room = $row['room'];
|
||||
/** @var bool $isRecurring */
|
||||
$isRecurring = $row['is_recurring'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return ScheduleSlot::reconstitute(
|
||||
id: ScheduleSlotId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
classId: ClassId::fromString($classId),
|
||||
subjectId: SubjectId::fromString($subjectId),
|
||||
teacherId: UserId::fromString($teacherId),
|
||||
dayOfWeek: DayOfWeek::from((int) $dayOfWeek),
|
||||
timeSlot: new TimeSlot($startTime, $endTime),
|
||||
room: $room,
|
||||
isRecurring: (bool) $isRecurring,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_values;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemoryScheduleSlotRepository implements ScheduleSlotRepository
|
||||
{
|
||||
/** @var array<string, ScheduleSlot> */
|
||||
private array $byId = [];
|
||||
|
||||
#[Override]
|
||||
public function save(ScheduleSlot $slot): void
|
||||
{
|
||||
$this->byId[(string) $slot->id] = $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ScheduleSlotId $id, TenantId $tenantId): ScheduleSlot
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot === null) {
|
||||
throw ScheduleSlotNotFoundException::avecId($id);
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ScheduleSlotId $id, TenantId $tenantId): ?ScheduleSlot
|
||||
{
|
||||
$slot = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($slot !== null && !$slot->tenantId->equals($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ScheduleSlotId $id, TenantId $tenantId): void
|
||||
{
|
||||
$slot = $this->findById($id, $tenantId);
|
||||
|
||||
if ($slot !== null) {
|
||||
unset($this->byId[(string) $id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->classId->equals($classId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->teacherId->equals($teacherId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForClass(
|
||||
ClassId $classId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->classId->equals($classId)
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForTeacher(
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->teacherId->equals($teacherId)
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findOverlappingForRoom(
|
||||
string $room,
|
||||
DayOfWeek $dayOfWeek,
|
||||
string $startTime,
|
||||
string $endTime,
|
||||
TenantId $tenantId,
|
||||
?ScheduleSlotId $excludeId = null,
|
||||
): array {
|
||||
$timeSlot = new TimeSlot($startTime, $endTime);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
|
||||
&& $s->room === $room
|
||||
&& $s->dayOfWeek === $dayOfWeek
|
||||
&& $s->timeSlot->overlaps($timeSlot)
|
||||
&& ($excludeId === null || !$s->id->equals($excludeId)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
|
||||
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 l'emploi du temps.
|
||||
*
|
||||
* Seuls ADMIN et SUPER_ADMIN peuvent gérer l'EDT.
|
||||
* PROF et VIE_SCOLAIRE peuvent le consulter.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class ScheduleSlotVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'SCHEDULE_VIEW';
|
||||
public const string CREATE = 'SCHEDULE_CREATE';
|
||||
public const string EDIT = 'SCHEDULE_EDIT';
|
||||
public const string DELETE = 'SCHEDULE_DELETE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CREATE,
|
||||
self::EDIT,
|
||||
self::DELETE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
|
||||
}
|
||||
|
||||
#[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),
|
||||
self::CREATE, self::EDIT, self::DELETE => $this->canManage($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param string[] $roles */
|
||||
private function canManage(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Port\TeacherAssignmentChecker;
|
||||
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\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Vérifie l'affectation enseignant en résolvant automatiquement l'année académique courante.
|
||||
*/
|
||||
final readonly class CurrentYearEnseignantAffectationChecker implements EnseignantAffectationChecker
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherAssignmentChecker $assignmentChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool {
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->assignmentChecker->estAffecte(
|
||||
$teacherId,
|
||||
$classId,
|
||||
$subjectId,
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user