feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Les administrateurs devaient recréer manuellement l'emploi du temps chaque semaine. Cette implémentation introduit un système de récurrence hebdomadaire avec gestion des exceptions par occurrence, permettant de modifier ou annuler un cours spécifique sans affecter les autres semaines. Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class DateExceptionInvalideException extends RuntimeException
|
||||
{
|
||||
public static function pourSlotEtDate(ScheduleSlotId $slotId, DateTimeImmutable $date): self
|
||||
{
|
||||
return new self(
|
||||
"La date {$date->format('Y-m-d')} n'est pas valide pour le créneau $slotId "
|
||||
. '(mauvais jour de la semaine ou hors bornes de récurrence).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Représentation résolue d'un créneau pour une date donnée.
|
||||
*
|
||||
* Combine le créneau template (récurrent) avec ses éventuelles exceptions
|
||||
* pour donner l'état réel du cours à une date précise.
|
||||
*/
|
||||
final readonly class ResolvedScheduleSlot
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public TenantId $tenantId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public TimeSlot $timeSlot,
|
||||
public ?string $room,
|
||||
public DateTimeImmutable $date,
|
||||
public bool $isModified,
|
||||
public ?ScheduleExceptionId $exceptionId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un créneau résolu à partir du template (pas d'exception).
|
||||
*/
|
||||
public static function fromSlot(ScheduleSlot $slot, DateTimeImmutable $date): self
|
||||
{
|
||||
return new self(
|
||||
slotId: $slot->id,
|
||||
tenantId: $slot->tenantId,
|
||||
classId: $slot->classId,
|
||||
subjectId: $slot->subjectId,
|
||||
teacherId: $slot->teacherId,
|
||||
dayOfWeek: $slot->dayOfWeek,
|
||||
timeSlot: $slot->timeSlot,
|
||||
room: $slot->room,
|
||||
date: $date,
|
||||
isModified: false,
|
||||
exceptionId: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un créneau résolu avec une exception (modification ponctuelle).
|
||||
*/
|
||||
public static function fromSlotWithException(
|
||||
ScheduleSlot $slot,
|
||||
ScheduleException $exception,
|
||||
): self {
|
||||
return new self(
|
||||
slotId: $slot->id,
|
||||
tenantId: $slot->tenantId,
|
||||
classId: $slot->classId,
|
||||
subjectId: $slot->subjectId,
|
||||
teacherId: $exception->newTeacherId ?? $slot->teacherId,
|
||||
dayOfWeek: $slot->dayOfWeek,
|
||||
timeSlot: $exception->newTimeSlot ?? $slot->timeSlot,
|
||||
room: $exception->newRoom ?? $slot->room,
|
||||
date: $exception->exceptionDate,
|
||||
isModified: true,
|
||||
exceptionId: $exception->id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Représente une exception ponctuelle sur un créneau récurrent.
|
||||
*
|
||||
* Une exception peut être une annulation (cancelled) ou une modification
|
||||
* (modified) pour une date précise. Cela permet de gérer les changements
|
||||
* ponctuels sans altérer la semaine type.
|
||||
*
|
||||
* @see FR27: Définir une semaine type qui se répète automatiquement
|
||||
*/
|
||||
final class ScheduleException
|
||||
{
|
||||
private function __construct(
|
||||
public private(set) ScheduleExceptionId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) ScheduleSlotId $slotId,
|
||||
public private(set) DateTimeImmutable $exceptionDate,
|
||||
public private(set) ScheduleExceptionType $type,
|
||||
public private(set) ?TimeSlot $newTimeSlot,
|
||||
public private(set) ?string $newRoom,
|
||||
public private(set) ?UserId $newTeacherId,
|
||||
public private(set) ?string $reason,
|
||||
public private(set) UserId $createdBy,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule un cours récurrent pour une date donnée.
|
||||
*/
|
||||
public static function annuler(
|
||||
TenantId $tenantId,
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $exceptionDate,
|
||||
?string $reason,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
return new self(
|
||||
id: ScheduleExceptionId::generate(),
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $exceptionDate,
|
||||
type: ScheduleExceptionType::CANCELLED,
|
||||
newTimeSlot: null,
|
||||
newRoom: null,
|
||||
newTeacherId: null,
|
||||
reason: $reason,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie un cours récurrent pour une date donnée.
|
||||
*
|
||||
* Les champs null signifient "pas de changement par rapport au créneau original".
|
||||
*/
|
||||
public static function modifier(
|
||||
TenantId $tenantId,
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $exceptionDate,
|
||||
?TimeSlot $newTimeSlot,
|
||||
?string $newRoom,
|
||||
?UserId $newTeacherId,
|
||||
?string $reason,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
return new self(
|
||||
id: ScheduleExceptionId::generate(),
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $exceptionDate,
|
||||
type: ScheduleExceptionType::MODIFIED,
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: $newRoom,
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: $reason,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->type === ScheduleExceptionType::CANCELLED;
|
||||
}
|
||||
|
||||
public function isModified(): bool
|
||||
{
|
||||
return $this->type === ScheduleExceptionType::MODIFIED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ScheduleExceptionId $id,
|
||||
TenantId $tenantId,
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $exceptionDate,
|
||||
ScheduleExceptionType $type,
|
||||
?TimeSlot $newTimeSlot,
|
||||
?string $newRoom,
|
||||
?UserId $newTeacherId,
|
||||
?string $reason,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
return new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $exceptionDate,
|
||||
type: $type,
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: $newRoom,
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: $reason,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ScheduleExceptionId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
enum ScheduleExceptionType: string
|
||||
{
|
||||
case MODIFIED = 'modified';
|
||||
case CANCELLED = 'cancelled';
|
||||
}
|
||||
@@ -37,6 +37,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
public private(set) ?string $room,
|
||||
public private(set) bool $isRecurring,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) ?DateTimeImmutable $recurrenceStart = null,
|
||||
public private(set) ?DateTimeImmutable $recurrenceEnd = null,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
@@ -54,6 +56,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
?string $room,
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $now,
|
||||
?DateTimeImmutable $recurrenceStart = null,
|
||||
?DateTimeImmutable $recurrenceEnd = null,
|
||||
): self {
|
||||
$room = $room !== '' ? $room : null;
|
||||
|
||||
@@ -68,6 +72,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $now,
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
$slot->recordEvent(new CoursCree(
|
||||
@@ -133,6 +139,44 @@ final class ScheduleSlot extends AggregateRoot
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Termine la récurrence à une date donnée (pour le scénario "modifier toutes les futures").
|
||||
*/
|
||||
public function terminerRecurrenceLe(DateTimeImmutable $date, DateTimeImmutable $at): void
|
||||
{
|
||||
$this->recurrenceEnd = $date;
|
||||
$this->updatedAt = $at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau récurrent est actif à une date donnée.
|
||||
*
|
||||
* Un créneau est actif si : récurrent, même jour de la semaine,
|
||||
* et la date est dans les bornes de récurrence.
|
||||
*/
|
||||
public function isActiveOnDate(DateTimeImmutable $date): bool
|
||||
{
|
||||
if (!$this->isRecurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DayOfWeek::from((int) $date->format('N')) !== $this->dayOfWeek) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dateOnly = $date->format('Y-m-d');
|
||||
|
||||
if ($this->recurrenceStart !== null && $dateOnly < $this->recurrenceStart->format('Y-m-d')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->recurrenceEnd !== null && $dateOnly > $this->recurrenceEnd->format('Y-m-d')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour.
|
||||
*/
|
||||
@@ -159,6 +203,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
bool $isRecurring,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
?DateTimeImmutable $recurrenceStart = null,
|
||||
?DateTimeImmutable $recurrenceEnd = null,
|
||||
): self {
|
||||
$slot = new self(
|
||||
id: $id,
|
||||
@@ -171,6 +217,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
room: $room,
|
||||
isRecurring: $isRecurring,
|
||||
createdAt: $createdAt,
|
||||
recurrenceStart: $recurrenceStart,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
);
|
||||
|
||||
$slot->updatedAt = $updatedAt;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface ScheduleExceptionRepository
|
||||
{
|
||||
public function save(ScheduleException $exception): void;
|
||||
|
||||
public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException;
|
||||
|
||||
public function findForSlotAndDate(
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $date,
|
||||
TenantId $tenantId,
|
||||
): ?ScheduleException;
|
||||
|
||||
/**
|
||||
* @return array<ScheduleException>
|
||||
*/
|
||||
public function findForSlotBetweenDates(
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate,
|
||||
TenantId $tenantId,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* @return array<ScheduleException>
|
||||
*/
|
||||
public function findForDateRange(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate,
|
||||
): array;
|
||||
|
||||
public function delete(ScheduleExceptionId $id, TenantId $tenantId): void;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ interface ScheduleSlotRepository
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user