feat: Permettre la création et modification de l'emploi du temps des classes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-03 13:54:53 +01:00
parent 1db8a7a0b2
commit d103b34023
53 changed files with 6382 additions and 1 deletions

View File

@@ -175,6 +175,16 @@ services:
App\Scolarite\Domain\Repository\TeacherReplacementRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository
# Schedule (Story 4.1 - Emploi du temps)
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
App\Scolarite\Domain\Service\ScheduleConflictDetector:
autowire: true
App\Scolarite\Application\Port\EnseignantAffectationChecker:
alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker
# Super Admin Repositories (Story 2.10 - Multi-établissements)
App\SuperAdmin\Domain\Repository\SuperAdminRepository:
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302091704 extends AbstractMigration
{
public function getDescription(): string
{
return 'Créer la table schedule_slots pour l\'emploi du temps';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE schedule_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
class_id UUID NOT NULL,
subject_id UUID NOT NULL,
teacher_id UUID NOT NULL,
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
start_time VARCHAR(5) NOT NULL,
end_time VARCHAR(5) NOT NULL,
room VARCHAR(50),
is_recurring BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_times CHECK (end_time > start_time),
CONSTRAINT fk_schedule_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE CASCADE,
CONSTRAINT fk_schedule_subject FOREIGN KEY (subject_id) REFERENCES subjects(id) ON DELETE CASCADE,
CONSTRAINT fk_schedule_teacher FOREIGN KEY (teacher_id) REFERENCES users(id) ON DELETE CASCADE
)
SQL);
$this->addSql('CREATE INDEX idx_schedule_tenant ON schedule_slots(tenant_id)');
$this->addSql('CREATE INDEX idx_schedule_class ON schedule_slots(class_id)');
$this->addSql('CREATE INDEX idx_schedule_teacher ON schedule_slots(teacher_id)');
$this->addSql('CREATE INDEX idx_schedule_day ON schedule_slots(day_of_week)');
$this->addSql('CREATE INDEX idx_schedule_teacher_day ON schedule_slots(tenant_id, teacher_id, day_of_week)');
$this->addSql('CREATE INDEX idx_schedule_room_day ON schedule_slots(tenant_id, room, day_of_week) WHERE room IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE schedule_slots');
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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];
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}

View File

@@ -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,
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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);
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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.");
}
}

View File

@@ -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).",
);
}
}

View File

@@ -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.");
}
}

View 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',
};
}
}

View 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;
}
}

View File

@@ -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
{
}

View 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);
}
}

View File

@@ -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;
}

View 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,
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}
}

View File

@@ -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é.');
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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),
);
}
}

View File

@@ -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)),
));
}
}

View File

@@ -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;
}
}

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\Command\CreateScheduleSlot\CreateScheduleSlotCommand;
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotHandler;
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\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CreateScheduleSlotHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private CreateScheduleSlotHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 10:00:00');
}
};
$this->handler = new CreateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createAlwaysAssignedChecker(),
$clock,
);
}
#[Test]
public function createsSlotSuccessfully(): void
{
$result = ($this->handler)($this->createCommand());
/** @var ScheduleSlot $slot */
$slot = $result['slot'];
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
self::assertSame('08:00', $slot->timeSlot->startTime);
self::assertSame('09:00', $slot->timeSlot->endTime);
self::assertEmpty($result['conflicts']);
}
#[Test]
public function savesSlotToRepository(): void
{
$result = ($this->handler)($this->createCommand());
/** @var ScheduleSlot $slot */
$slot = $result['slot'];
$found = $this->repository->findById($slot->id, TenantId::fromString(self::TENANT_ID));
self::assertNotNull($found);
self::assertTrue($found->id->equals($slot->id));
}
#[Test]
public function detectsConflictsWithoutSaving(): void
{
// Créer un slot existant
($this->handler)($this->createCommand());
// Même enseignant, même créneau, classe différente
$result = ($this->handler)(new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:30',
endTime: '09:30',
room: null,
));
self::assertNotEmpty($result['conflicts']);
// Le slot conflictuel ne devrait PAS être sauvegardé
$allSlots = $this->repository->findByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(1, $allSlots);
}
#[Test]
public function forceSavesSlotWithConflicts(): void
{
($this->handler)($this->createCommand());
$result = ($this->handler)(new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:30',
endTime: '09:30',
room: null,
forceConflicts: true,
));
self::assertNotEmpty($result['conflicts']);
// Malgré les conflits, le slot est sauvegardé
$allSlots = $this->repository->findByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(2, $allSlots);
}
#[Test]
public function rejectsUnassignedTeacher(): void
{
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 10:00:00');
}
};
$handler = new CreateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createNeverAssignedChecker(),
$clock,
);
$this->expectException(EnseignantNonAffecteException::class);
($handler)($this->createCommand());
}
private function createCommand(): CreateScheduleSlotCommand
{
return new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
);
}
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return true;
}
};
}
private function createNeverAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return false;
}
};
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\DeleteScheduleSlot;
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\Command\DeleteScheduleSlot\DeleteScheduleSlotCommand;
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotHandler;
use App\Scolarite\Domain\Event\CoursSupprime;
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\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DeleteScheduleSlotHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private DeleteScheduleSlotHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 15:00:00');
}
};
$this->handler = new DeleteScheduleSlotHandler($this->repository, $clock);
}
#[Test]
public function deletesSlotFromRepository(): void
{
$slot = $this->createAndSaveSlot();
$tenantId = TenantId::fromString(self::TENANT_ID);
($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
));
self::assertNull($this->repository->findById($slot->id, $tenantId));
}
#[Test]
public function returnsSlotWithCoursSupprime(): void
{
$slot = $this->createAndSaveSlot();
$slot->pullDomainEvents(); // Clear creation event
$deletedSlot = ($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
));
$events = $deletedSlot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursSupprime::class, $events[0]);
}
#[Test]
public function throwsWhenSlotNotFound(): void
{
$this->expectException(ScheduleSlotNotFoundException::class);
($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: '550e8400-e29b-41d4-a716-446655440099',
));
}
private function createAndSaveSlot(): ScheduleSlot
{
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\Command\UpdateScheduleSlot\UpdateScheduleSlotCommand;
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotHandler;
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\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateScheduleSlotHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string OTHER_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private UpdateScheduleSlotHandler $handler;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 15:00:00');
}
};
$this->handler = new UpdateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createAlwaysAssignedChecker(),
$this->clock,
);
}
#[Test]
public function updatesSlotProperties(): void
{
$slot = $this->createAndSaveSlot();
$newSubjectId = '550e8400-e29b-41d4-a716-446655440031';
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: $newSubjectId,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::WEDNESDAY->value,
startTime: '10:00',
endTime: '11:00',
room: 'Salle 301',
));
/** @var ScheduleSlot $updated */
$updated = $result['slot'];
self::assertTrue($updated->subjectId->equals(SubjectId::fromString($newSubjectId)));
self::assertSame(DayOfWeek::WEDNESDAY, $updated->dayOfWeek);
self::assertSame('10:00', $updated->timeSlot->startTime);
self::assertSame('Salle 301', $updated->room);
self::assertEmpty($result['conflicts']);
}
#[Test]
public function updatesClassId(): void
{
$slot = $this->createAndSaveSlot();
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::OTHER_CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
/** @var ScheduleSlot $updated */
$updated = $result['slot'];
self::assertTrue($updated->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
self::assertFalse($updated->classId->equals(ClassId::fromString(self::CLASS_ID)));
}
#[Test]
public function persistsClassIdChange(): void
{
$slot = $this->createAndSaveSlot();
($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::OTHER_CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
$persisted = $this->repository->get($slot->id, TenantId::fromString(self::TENANT_ID));
self::assertTrue($persisted->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
}
#[Test]
public function excludesSelfFromConflictDetection(): void
{
$slot = $this->createAndSaveSlot();
// Modifier le slot sans changer le créneau → pas de conflit avec lui-même
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
));
self::assertEmpty($result['conflicts']);
}
#[Test]
public function rejectsUnassignedTeacher(): void
{
$slot = $this->createAndSaveSlot();
$handler = new UpdateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createNeverAssignedChecker(),
$this->clock,
);
$this->expectException(EnseignantNonAffecteException::class);
($handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
}
private function createAndSaveSlot(): ScheduleSlot
{
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return true;
}
};
}
private function createNeverAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return false;
}
};
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetBlockedDatesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemorySchoolCalendarRepository $calendarRepository;
private GetBlockedDatesHandler $handler;
protected function setUp(): void
{
$this->calendarRepository = new InMemorySchoolCalendarRepository();
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
}
#[Test]
public function returnsWeekendsAsBlocked(): void
{
// 2026-03-02 est un lundi, 2026-03-08 est un dimanche
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-08',
));
$weekendDates = array_filter($result, static fn ($d) => $d->type === 'weekend');
self::assertCount(2, $weekendDates);
$dates = array_map(static fn ($d) => $d->date, array_values($weekendDates));
self::assertContains('2026-03-07', $dates);
self::assertContains('2026-03-08', $dates);
}
#[Test]
public function returnsHolidaysAsBlocked(): void
{
$calendar = $this->createCalendarWithHoliday(
new DateTimeImmutable('2026-03-04'),
'Jour de test',
);
$this->calendarRepository->save($calendar);
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$holidays = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::HOLIDAY->value);
self::assertCount(1, $holidays);
$holiday = array_values($holidays)[0];
self::assertSame('2026-03-04', $holiday->date);
self::assertSame('Jour de test', $holiday->reason);
}
#[Test]
public function returnsEmptyForWeekWithNoBlockedDates(): void
{
// Lundi à vendredi sans calendrier configuré
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
self::assertEmpty($result);
}
#[Test]
public function returnsVacationsAsBlocked(): void
{
$calendar = $this->createCalendarWithVacation(
new DateTimeImmutable('2026-03-02'),
new DateTimeImmutable('2026-03-06'),
'Vacances de printemps',
);
$this->calendarRepository->save($calendar);
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$vacations = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::VACATION->value);
self::assertCount(5, $vacations);
}
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: $date,
endDate: $date,
label: $label,
));
return $calendar;
}
private function createCalendarWithVacation(
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
string $label,
): SchoolCalendar {
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: $startDate,
endDate: $endDate,
label: $label,
));
return $calendar;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetScheduleSlots;
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\Query\GetScheduleSlots\GetScheduleSlotsHandler;
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsQuery;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetScheduleSlotsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_A = '550e8400-e29b-41d4-a716-446655440020';
private const string CLASS_B = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_A = '550e8400-e29b-41d4-a716-446655440010';
private const string TEACHER_B = '550e8400-e29b-41d4-a716-446655440011';
private InMemoryScheduleSlotRepository $repository;
private GetScheduleSlotsHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->handler = new GetScheduleSlotsHandler($this->repository);
}
#[Test]
public function returnsSlotsFilteredByClass(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
));
self::assertCount(1, $result);
self::assertSame(self::CLASS_A, $result[0]->classId);
}
#[Test]
public function returnsSlotsFilteredByTeacher(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: self::TEACHER_A,
));
self::assertCount(1, $result);
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
}
#[Test]
public function returnsSlotsFilteredByClassAndTeacher(): void
{
// Classe A, enseignant A
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
// Classe A, enseignant B
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
// Classe B, enseignant A
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::WEDNESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
teacherId: self::TEACHER_A,
));
self::assertCount(1, $result);
self::assertSame(self::CLASS_A, $result[0]->classId);
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
}
#[Test]
public function returnsEmptyWhenClassAndTeacherDontMatch(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
teacherId: self::TEACHER_B,
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyWhenNoFilters(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyForInvalidClassIdUuid(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: 'not-a-valid-uuid',
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyForInvalidTeacherIdUuid(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: 'invalid',
));
self::assertCount(0, $result);
}
#[Test]
public function teacherFilterReturnsAllClassesForTeacher(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::TUESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: self::TEACHER_A,
));
self::assertCount(2, $result);
}
private function createSlot(
string $classId,
string $teacherId,
DayOfWeek $day = DayOfWeek::MONDAY,
): ScheduleSlot {
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString($teacherId),
dayOfWeek: $day,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ScheduleSlotTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function creerCreatesSlotWithCorrectProperties(): void
{
$slot = $this->createSlot();
self::assertTrue($slot->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
self::assertTrue($slot->subjectId->equals(SubjectId::fromString(self::SUBJECT_ID)));
self::assertTrue($slot->teacherId->equals(UserId::fromString(self::TEACHER_ID)));
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
self::assertSame('08:00', $slot->timeSlot->startTime);
self::assertSame('09:00', $slot->timeSlot->endTime);
self::assertNull($slot->room);
self::assertTrue($slot->isRecurring);
}
#[Test]
public function creerRecordsCoursCreeEvent(): void
{
$slot = $this->createSlot();
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursCree::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function creerWithRoomSetsRoom(): void
{
$slot = $this->createSlot(room: 'Salle 101');
self::assertSame('Salle 101', $slot->room);
}
#[Test]
public function modifierUpdatesProperties(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$newSubjectId = SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031');
$newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011');
$newTimeSlot = new TimeSlot('10:00', '11:00');
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440021');
$slot->modifier(
classId: $newClassId,
subjectId: $newSubjectId,
teacherId: $newTeacherId,
dayOfWeek: DayOfWeek::TUESDAY,
timeSlot: $newTimeSlot,
room: 'Salle 202',
at: $now,
);
self::assertTrue($slot->classId->equals($newClassId));
self::assertTrue($slot->subjectId->equals($newSubjectId));
self::assertTrue($slot->teacherId->equals($newTeacherId));
self::assertSame(DayOfWeek::TUESDAY, $slot->dayOfWeek);
self::assertSame('10:00', $slot->timeSlot->startTime);
self::assertSame('11:00', $slot->timeSlot->endTime);
self::assertSame('Salle 202', $slot->room);
self::assertEquals($now, $slot->updatedAt);
}
#[Test]
public function modifierRecordsCoursModifieEvent(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$slot->modifier(
classId: $slot->classId,
subjectId: $slot->subjectId,
teacherId: $slot->teacherId,
dayOfWeek: DayOfWeek::TUESDAY,
timeSlot: new TimeSlot('10:00', '11:00'),
room: null,
at: $now,
);
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursModifie::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function supprimerRecordsCoursSupprime(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$slot->supprimer($now);
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursSupprime::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = ScheduleSlotId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$classId = ClassId::fromString(self::CLASS_ID);
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$timeSlot = new TimeSlot('14:00', '15:30');
$createdAt = new DateTimeImmutable('2026-03-01 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-02 14:00:00');
$slot = ScheduleSlot::reconstitute(
id: $id,
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
dayOfWeek: DayOfWeek::FRIDAY,
timeSlot: $timeSlot,
room: 'Salle 305',
isRecurring: false,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($slot->id->equals($id));
self::assertTrue($slot->tenantId->equals($tenantId));
self::assertTrue($slot->classId->equals($classId));
self::assertTrue($slot->subjectId->equals($subjectId));
self::assertTrue($slot->teacherId->equals($teacherId));
self::assertSame(DayOfWeek::FRIDAY, $slot->dayOfWeek);
self::assertSame('14:00', $slot->timeSlot->startTime);
self::assertSame('15:30', $slot->timeSlot->endTime);
self::assertSame('Salle 305', $slot->room);
self::assertFalse($slot->isRecurring);
self::assertEquals($createdAt, $slot->createdAt);
self::assertEquals($updatedAt, $slot->updatedAt);
self::assertEmpty($slot->pullDomainEvents());
}
private function createSlot(?string $room = null): ScheduleSlot
{
return ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TimeSlotTest extends TestCase
{
#[Test]
public function createsValidTimeSlot(): void
{
$timeSlot = new TimeSlot('08:00', '09:00');
self::assertSame('08:00', $timeSlot->startTime);
self::assertSame('09:00', $timeSlot->endTime);
}
#[Test]
public function throwsWhenEndTimeBeforeStartTime(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('10:00', '09:00');
}
#[Test]
public function throwsWhenEndTimeEqualsStartTime(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('08:00', '08:00');
}
#[Test]
public function throwsWhenDurationLessThanFiveMinutes(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('08:00', '08:04');
}
#[Test]
public function acceptsFiveMinuteDuration(): void
{
$timeSlot = new TimeSlot('08:00', '08:05');
self::assertSame('08:00', $timeSlot->startTime);
self::assertSame('08:05', $timeSlot->endTime);
}
#[Test]
public function overlapsReturnsTrueWhenTimesOverlap(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:30', '09:30');
self::assertTrue($slot1->overlaps($slot2));
self::assertTrue($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsFalseWhenTimesDoNotOverlap(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertFalse($slot1->overlaps($slot2));
self::assertFalse($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsTrueWhenOneContainsOther(): void
{
$slot1 = new TimeSlot('08:00', '12:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertTrue($slot1->overlaps($slot2));
self::assertTrue($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsFalseWhenAdjacent(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertFalse($slot1->overlaps($slot2));
}
#[Test]
public function equalsReturnsTrueForSameTimes(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:00', '09:00');
self::assertTrue($slot1->equals($slot2));
}
#[Test]
public function equalsReturnsFalseForDifferentTimes(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:00', '10:00');
self::assertFalse($slot1->equals($slot2));
}
#[Test]
public function durationInMinutesReturnsCorrectValue(): void
{
$timeSlot = new TimeSlot('08:00', '09:30');
self::assertSame(90, $timeSlot->durationInMinutes());
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Service;
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\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ScheduleConflictDetectorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private ScheduleConflictDetector $detector;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->detector = new ScheduleConflictDetector($this->repository);
}
#[Test]
public function detectsClassConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
// Même classe, même créneau, enseignant différent
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
$types = array_map(static fn ($c) => $c->type, $conflicts);
self::assertContains('class', $types);
}
#[Test]
public function noClassConflictWhenDifferentClass(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsTeacherConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
self::assertSame('teacher', $conflicts[0]->type);
}
#[Test]
public function noConflictWhenDifferentDay(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::TUESDAY,
startTime: '08:00',
endTime: '09:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function noConflictWhenAdjacentTimes(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '09:00',
endTime: '10:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsRoomConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
room: 'Salle 101',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
self::assertSame('room', $conflicts[0]->type);
}
#[Test]
public function noRoomConflictWhenNoRoom(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function excludesCurrentSlotWhenUpdating(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$conflicts = $this->detector->detectConflicts($existingSlot, $tenantId, $existingSlot->id);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsBothTeacherAndRoomConflicts(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
room: 'Salle 101',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
$types = array_map(static fn ($c) => $c->type, $conflicts);
self::assertContains('teacher', $types);
self::assertContains('room', $types);
}
private function createSlot(
string $teacherId = self::TEACHER_ID,
string $classId = self::CLASS_ID,
DayOfWeek $dayOfWeek = DayOfWeek::MONDAY,
string $startTime = '08:00',
string $endTime = '09:00',
?string $room = null,
): ScheduleSlot {
return ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString($teacherId),
dayOfWeek: $dayOfWeek,
timeSlot: new TimeSlot($startTime, $endTime),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
}
}

View File

@@ -0,0 +1,524 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-schedule-admin@example.com';
const ADMIN_PASSWORD = 'ScheduleTest123';
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
const TEACHER_PASSWORD = 'ScheduleTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function cleanupScheduleData() {
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist yet
}
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
function cleanupCalendarEntries() {
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
function seedBlockedDate(date: string, label: string, type: string) {
const { academicYearId } = resolveDeterministicIds();
runSql(
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, created_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', '${type}', '${date}', '${date}', '${label}', NOW()) ` +
`ON CONFLICT DO NOTHING`
);
}
function getWeekdayInCurrentWeek(isoDay: number): string {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
const target = new Date(monday);
target.setDate(monday.getDate() + (isoDay - 1));
return target.toISOString().split('T')[0]!;
}
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function waitForScheduleReady(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
timeout: 15000
});
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
className?: string;
dayValue?: string;
startTime?: string;
endTime?: string;
room?: string;
} = {}
) {
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
await dialog.locator('#slot-end').fill(endTime);
if (room) {
await dialog.locator('#slot-room').fill(room);
}
}
test.describe('Schedule Management - Modification & Conflicts & Calendar (Story 4.1)', () => {
// Tests share database state (same tenant, users, slots) so they must run sequentially
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test classes exist
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure test subjects exist
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
cleanupScheduleData();
cleanupCalendarEntries();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
cleanupCalendarEntries();
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// AC3: Slot Modification & Deletion
// ==========================================================================
test.describe('AC3: Slot Modification & Deletion', () => {
test('clicking a slot opens edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// First create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog);
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the created slot
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
// Edit modal should appear
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
await expect(
editDialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
});
test('can delete a slot via edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '2', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot to edit
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click delete button
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
// Confirm deletion
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
// Modal should close and slot should disappear
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).not.toBeVisible({ timeout: 10000 });
});
test('can modify a slot and see updated data in grid', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create initial slot with room
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'A101' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify initial slot with room A101
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await expect(slotCard.getByText('A101')).toBeVisible();
// Click to open edit modal
await slotCard.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
dialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
// Change room to B202
await dialog.locator('#slot-room').clear();
await dialog.locator('#slot-room').fill('B202');
// Submit modification
await dialog.getByRole('button', { name: /modifier/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify success and updated data
await expect(page.getByText('Créneau modifié.')).toBeVisible({ timeout: 5000 });
const updatedSlot = page.locator('.slot-card').first();
await expect(updatedSlot.getByText('B202')).toBeVisible();
});
});
// ==========================================================================
// AC4: Conflict Detection
// ==========================================================================
test.describe('AC4: Conflict Detection', () => {
test('displays conflict warning when creating slot with same teacher at overlapping time', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Wednesday 10:00-11:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '3', startTime: '10:00', endTime: '11:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with DIFFERENT class but SAME teacher at same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '3',
startTime: '10:00',
endTime: '11:00'
});
// Submit - should trigger conflict detection
await dialog.getByRole('button', { name: /créer/i }).click();
// Conflict warning should appear inside the dialog
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/conflits détectés/i)).toBeVisible();
// Force checkbox should be available
await expect(dialog.getByText(/forcer la création/i)).toBeVisible();
// Dialog should still be open (not closed)
await expect(dialog).toBeVisible();
});
test('can force creation despite detected conflict', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Thursday 14:00-15:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '4', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with different class, same teacher, same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '4',
startTime: '14:00',
endTime: '15:00'
});
// First submit - triggers conflict warning
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
// Check force checkbox
await dialog.locator('.force-checkbox input[type="checkbox"]').check();
// Submit again with force enabled
await dialog.getByRole('button', { name: /créer/i }).click();
// Modal should close - slot created despite conflict
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message should appear
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// AC5: Calendar Respect (Blocked Days)
// ==========================================================================
test.describe('AC5: Calendar Respect', () => {
test('time validation prevents end before start', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Set end time before start time
await dialog.locator('#slot-start').fill('10:00');
await dialog.locator('#slot-end').fill('09:00');
// Error message should appear
await expect(
dialog.getByText(/l'heure de fin doit être après/i)
).toBeVisible();
// Submit should be disabled
await expect(dialog.getByRole('button', { name: /créer/i })).toBeDisabled();
});
test('blocked day is visually marked in the grid', async ({ page }) => {
// Seed a holiday on Wednesday of current week
const wednesdayDate = getWeekdayInCurrentWeek(3);
seedBlockedDate(wednesdayDate, 'Jour férié test', 'holiday');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// The third day-column (Wednesday) should have the blocked class
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 10000 });
// Should display the reason badge in the header
await expect(page.getByText('Jour férié test')).toBeVisible();
});
test('cannot create a slot on a blocked day', async ({ page }) => {
// Seed a vacation on Tuesday of current week
const tuesdayDate = getWeekdayInCurrentWeek(2);
seedBlockedDate(tuesdayDate, 'Vacances test', 'vacation');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Tuesday column should be blocked
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(1)).toHaveClass(/day-blocked/, { timeout: 10000 });
// Attempt to click a time cell in the blocked day — dialog should NOT open
// Use dispatchEvent to bypass pointer-events: none
await dayColumns.nth(1).locator('.time-cell').first().dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
});
});
});

View File

@@ -0,0 +1,417 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-schedule-admin@example.com';
const ADMIN_PASSWORD = 'ScheduleTest123';
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
const TEACHER_PASSWORD = 'ScheduleTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function cleanupScheduleData() {
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist yet
}
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function waitForScheduleReady(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
timeout: 15000
});
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
className?: string;
dayValue?: string;
startTime?: string;
endTime?: string;
room?: string;
} = {}
) {
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
await dialog.locator('#slot-end').fill(endTime);
if (room) {
await dialog.locator('#slot-room').fill(room);
}
}
test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', () => {
// Tests share database state (same tenant, users, assignments) so they must run sequentially
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test class exists
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure second test class exists (for conflict tests across classes)
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure test subjects exist
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
cleanupScheduleData();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// Navigation
// ==========================================================================
test.describe('Navigation', () => {
test('schedule link appears in admin navigation under Organisation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /organisation/i }).hover();
const navLink = nav.getByRole('menuitem', { name: /emploi du temps/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});
test('can navigate to schedule page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps/i })
).toBeVisible({ timeout: 15000 });
});
});
// ==========================================================================
// AC1: Schedule Grid
// ==========================================================================
test.describe('AC1: Schedule Grid', () => {
test('displays weekly grid with day columns', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Check day headers are present
await expect(page.getByText('Lundi')).toBeVisible();
await expect(page.getByText('Mardi')).toBeVisible();
await expect(page.getByText('Mercredi')).toBeVisible();
await expect(page.getByText('Jeudi')).toBeVisible();
await expect(page.getByText('Vendredi')).toBeVisible();
});
test('has class filter dropdown', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const classFilter = page.locator('#filter-class');
await expect(classFilter).toBeVisible();
// Should have at least the placeholder option + one class
const options = classFilter.locator('option');
await expect(options).not.toHaveCount(1, { timeout: 10000 });
});
test('has teacher filter dropdown', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const teacherFilter = page.locator('#filter-teacher');
await expect(teacherFilter).toBeVisible();
});
});
// ==========================================================================
// AC2: Slot Creation
// ==========================================================================
test.describe('AC2: Slot Creation', () => {
test('clicking on a time cell opens creation modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Click on a time cell in the grid
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
// Modal should appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
dialog.getByRole('heading', { name: /nouveau créneau/i })
).toBeVisible();
});
test('creation form has required fields', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Check required form fields
await expect(dialog.locator('#slot-subject')).toBeVisible();
await expect(dialog.locator('#slot-teacher')).toBeVisible();
await expect(dialog.locator('#slot-day')).toBeVisible();
await expect(dialog.locator('#slot-start')).toBeVisible();
await expect(dialog.locator('#slot-end')).toBeVisible();
await expect(dialog.locator('#slot-room')).toBeVisible();
// Submit button should be disabled when fields are empty
const submitButton = dialog.getByRole('button', { name: /créer/i });
await expect(submitButton).toBeDisabled();
});
test('can close creation modal with cancel button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click cancel
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can close creation modal with Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Press Escape
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can create a slot and see it in the grid', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'A101' });
// Submit
const submitButton = dialog.getByRole('button', { name: /créer/i });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Slot card should appear in the grid
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Should show room on the slot card
await expect(page.locator('.slot-card').getByText('A101')).toBeVisible();
});
test('filters subjects and teachers by class assignment', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds();
// Clear all assignments, seed exactly one: teacher → class 6A → first subject
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c, (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' ORDER BY name LIMIT 1) s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select class E2E-Schedule-6A (triggers loadAssignments for this class)
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
// Subject dropdown should be filtered to only the assigned subject
// (auto-retry handles the async assignment loading)
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions).toHaveCount(1, { timeout: 15000 });
// Teacher dropdown should only show the assigned teacher
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
});
});
});

View File

@@ -61,6 +61,11 @@
<span class="action-label">Remplacements</span>
<span class="action-hint">Enseignants absents</span>
</a>
<a class="action-card" href="/admin/schedule">
<span class="action-icon">🕐</span>
<span class="action-label">Emploi du temps</span>
<span class="action-hint">Cours et créneaux</span>
</a>
<a class="action-card" href="/admin/academic-year/periods">
<span class="action-icon">📅</span>
<span class="action-label">Périodes scolaires</span>

View File

@@ -49,7 +49,8 @@
{ href: '/admin/classes', label: 'Classes' },
{ href: '/admin/subjects', label: 'Matières' },
{ href: '/admin/assignments', label: 'Affectations' },
{ href: '/admin/replacements', label: 'Remplacements' }
{ href: '/admin/replacements', label: 'Remplacements' },
{ href: '/admin/schedule', label: 'Emploi du temps' }
]
},
{

File diff suppressed because it is too large Load Diff