feat: Permettre la création et modification de l'emploi du temps des classes
L'administration a besoin de construire et maintenir les emplois du temps hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont pas en conflit (même créneau, classes différentes) et que les affectations enseignant-matière-classe sont respectées. Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot), la détection de conflits (classe, enseignant, salle) avec possibilité de forcer, la validation des affectations côté serveur (AC2), l'intégration calendrier pour les jours bloqués, une vue mobile-first avec onglets jour par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
29
backend/src/Scolarite/Domain/Model/Schedule/DayOfWeek.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
enum DayOfWeek: int
|
||||
{
|
||||
case MONDAY = 1;
|
||||
case TUESDAY = 2;
|
||||
case WEDNESDAY = 3;
|
||||
case THURSDAY = 4;
|
||||
case FRIDAY = 5;
|
||||
case SATURDAY = 6;
|
||||
case SUNDAY = 7;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MONDAY => 'Lundi',
|
||||
self::TUESDAY => 'Mardi',
|
||||
self::WEDNESDAY => 'Mercredi',
|
||||
self::THURSDAY => 'Jeudi',
|
||||
self::FRIDAY => 'Vendredi',
|
||||
self::SATURDAY => 'Samedi',
|
||||
self::SUNDAY => 'Dimanche',
|
||||
};
|
||||
}
|
||||
}
|
||||
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
180
backend/src/Scolarite/Domain/Model/Schedule/ScheduleSlot.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\CoursCree;
|
||||
use App\Scolarite\Domain\Event\CoursModifie;
|
||||
use App\Scolarite\Domain\Event\CoursSupprime;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un créneau dans l'emploi du temps.
|
||||
*
|
||||
* Un créneau lie une classe, une matière et un enseignant à un jour de la semaine
|
||||
* et un horaire. Le créneau peut être récurrent (hebdomadaire) ou ponctuel.
|
||||
*
|
||||
* @see FR26: Créer et modifier l'emploi du temps des classes
|
||||
*/
|
||||
final class ScheduleSlot extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ScheduleSlotId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) ClassId $classId,
|
||||
public private(set) SubjectId $subjectId,
|
||||
public private(set) UserId $teacherId,
|
||||
public private(set) DayOfWeek $dayOfWeek,
|
||||
public private(set) TimeSlot $timeSlot,
|
||||
public private(set) ?string $room,
|
||||
public private(set) bool $isRecurring,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau créneau dans l'emploi du temps.
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$slot = new self(
|
||||
id: ScheduleSlotId::generate(),
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$slot->recordEvent(new CoursCree(
|
||||
slotId: $slot->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $now,
|
||||
));
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie les propriétés du créneau.
|
||||
*/
|
||||
public function modifier(
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
DateTimeImmutable $at,
|
||||
): void {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
$this->classId = $classId;
|
||||
$this->subjectId = $subjectId;
|
||||
$this->teacherId = $teacherId;
|
||||
$this->dayOfWeek = $dayOfWeek;
|
||||
$this->timeSlot = $timeSlot;
|
||||
$this->room = $room;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new CoursModifie(
|
||||
slotId: $this->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
startTime: $timeSlot->startTime,
|
||||
endTime: $timeSlot->endTime,
|
||||
room: $room,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre l'événement de suppression avant le hard-delete par le repository.
|
||||
*/
|
||||
public function supprimer(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->recordEvent(new CoursSupprime(
|
||||
slotId: $this->id,
|
||||
classId: $this->classId,
|
||||
subjectId: $this->subjectId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour.
|
||||
*/
|
||||
public function conflictsAvec(self $other): bool
|
||||
{
|
||||
return $this->dayOfWeek === $other->dayOfWeek
|
||||
&& $this->timeSlot->overlaps($other->timeSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un ScheduleSlot depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ScheduleSlotId $id,
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
UserId $teacherId,
|
||||
DayOfWeek $dayOfWeek,
|
||||
TimeSlot $timeSlot,
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$slot = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $timeSlot,
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$slot->updatedAt = $updatedAt;
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ScheduleSlotId extends EntityId
|
||||
{
|
||||
}
|
||||
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
74
backend/src/Scolarite/Domain/Model/Schedule/TimeSlot.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
|
||||
|
||||
use function explode;
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* Value Object représentant un créneau horaire (heure début + heure fin).
|
||||
*
|
||||
* Format attendu : "HH:MM" (24h).
|
||||
* Contraintes : fin > début, durée minimum 5 minutes.
|
||||
*/
|
||||
final readonly class TimeSlot
|
||||
{
|
||||
private const string TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/';
|
||||
private const int MINIMUM_DURATION_MINUTES = 5;
|
||||
|
||||
public function __construct(
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
) {
|
||||
if (preg_match(self::TIME_PATTERN, $startTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($startTime);
|
||||
}
|
||||
|
||||
if (preg_match(self::TIME_PATTERN, $endTime) !== 1) {
|
||||
throw CreneauHoraireInvalideException::formatInvalide($endTime);
|
||||
}
|
||||
|
||||
if ($endTime <= $startTime) {
|
||||
throw CreneauHoraireInvalideException::finAvantDebut($startTime, $endTime);
|
||||
}
|
||||
|
||||
$duration = $this->computeDurationInMinutes($startTime, $endTime);
|
||||
|
||||
if ($duration < self::MINIMUM_DURATION_MINUTES) {
|
||||
throw CreneauHoraireInvalideException::dureeTropCourte($duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau chevauche un autre créneau.
|
||||
*
|
||||
* Deux créneaux adjacents (fin de l'un = début de l'autre) ne se chevauchent pas.
|
||||
*/
|
||||
public function overlaps(self $other): bool
|
||||
{
|
||||
return $this->startTime < $other->endTime && $other->startTime < $this->endTime;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->startTime === $other->startTime
|
||||
&& $this->endTime === $other->endTime;
|
||||
}
|
||||
|
||||
public function durationInMinutes(): int
|
||||
{
|
||||
return $this->computeDurationInMinutes($this->startTime, $this->endTime);
|
||||
}
|
||||
|
||||
private function computeDurationInMinutes(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMin] = explode(':', $start);
|
||||
[$endHour, $endMin] = explode(':', $end);
|
||||
|
||||
return ((int) $endHour * 60 + (int) $endMin) - ((int) $startHour * 60 + (int) $startMin);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user