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

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